この記事は、C# Advent Calendar 2025 16日目の記事です。
はじめに
マルチスレッドで共有変数を扱うと、たまに起きるやつ
- カウンタが合わない
- 「一度だけ実行」のはずが二度走る
- 停止フラグを立てたのに、別スレッドが止まらない(っぽい)
こういう時にまず出番になるのがこの2つ
-
Interlocked:共有変数の更新を 原子的(途中で割り込めない) にする -
Volatile:共有変数の読み書きを 他スレッドから"ちゃんと見える" ようにする(順序や最新性)
ここで大事なのは、2つは役割が違う ことです。
まず結論 - 使い分け
Interlocked と Volatile は似た場面で登場しますが、役割は別物です。
Volatile は“見える”、Interlocked は“壊れない”。
まずは図と表で全体像だけ押さえます。
| やりたいこと | 使うもの |
|---|---|
count++ みたいな 更新(Read→Modify→Write) を安全に |
Interlocked |
| 停止フラグみたいに 「書く側」と「読む側」が分かれている 単純な共有 | Volatile.Read/Write |
| 「複数の値の整合性」を守りたい |
lock(Interlocked/Volatileだけで頑張らない) |
Interlocked/Volatile は "単一の共有値" に強い道具です。複数の値をセットで守りたいなら lock が素直です。
ざっくりなイメージ
count=10 を両スレッドが read → それぞれ +1 → どちらも 11 を write してしまい、期待値12に届かない。
① count++ は1命令じゃない
count++ は実質こうです。
- count を読む
- +1 する
- count に書く
複数スレッドが同時にやると、同じ値を読んで、同じ結果を書いてしまう(=増分が消える)ことがあります。
→ ここを 1回の"原子操作" にするのが Interlocked.Increment。
② "見える" と "安全に更新できる" は別
Volatileは「書いた値が読めるようにする」寄り(可視性)Interlockedは「更新そのものを壊さない」寄り(原子性)
Volatile を使っても count++ が安全にはならない、のはこのためです。
※Volatile.Read/Write は acquire/release(半フェンス)相当の制約を与えます。一方で、Interlocked 系の操作は一般により強いメモリバリア(フェンス)を伴うため、同期用途で使われます。
動作確認 - 検証用コンソールアプリ
実行手順
dotnet new console -n InterlockedVolatileDemo
cd InterlockedVolatileDemo
# Program.cs を下のコードに差し替え
dotnet run -c Release
Release 推奨(最適化が効いて、挙動の差が見えやすいです)
Program.cs
Volatile のデモは CPU/JIT の最適化に左右されるので「非Volatileが必ず止まらない」とは限りません。ただ「止まる/止まらないが環境依存になり得る」ことがポイントです。
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
public static class Program
{
// Volatileデモ用:ローカルキャプチャではなくフィールドで明示
private static int _stop;
public static void Main()
{
Console.WriteLine($".NET: {Environment.Version}");
Console.WriteLine($"CPU : {Environment.ProcessorCount} cores");
Console.WriteLine();
LostUpdateDemo();
Console.WriteLine(new string('-', 60));
StartOnceDemo();
Console.WriteLine(new string('-', 60));
VolatileStopFlagDemo();
Console.WriteLine(new string('-', 60));
Console.WriteLine("Done.");
}
// 1) count++ が壊れる(ロストアップデート)→ Interlocked で直す
private static void LostUpdateDemo()
{
Console.WriteLine("[1] Lost update demo: count++ vs Interlocked.Increment");
Console.WriteLine(" count++ は『読む→足す→書く』の3段階なので、同時実行で増分が消えることがある。");
int workers = Math.Max(2, Environment.ProcessorCount);
int itersPerWorker = 100_000;
int expected = workers * itersPerWorker;
// NG: count++
{
int count = 0;
var tasks = new Task[workers];
for (int w = 0; w < workers; w++)
{
tasks[w] = Task.Run(() =>
{
for (int i = 0; i < itersPerWorker; i++)
{
count++; // 競合で増分が消える可能性
}
});
}
Task.WaitAll(tasks);
Console.WriteLine($"count++ expected={expected}, actual={count}");
}
// OK: Interlocked.Increment
{
int count = 0;
var tasks = new Task[workers];
for (int w = 0; w < workers; w++)
{
tasks[w] = Task.Run(() =>
{
for (int i = 0; i < itersPerWorker; i++)
{
Interlocked.Increment(ref count); // 原子的に +1
}
});
}
Task.WaitAll(tasks);
Console.WriteLine($"Interlocked.Inc expected={expected}, actual={count}");
}
}
// 2) "一度だけ実行したい" → Exchange / CompareExchange の定番
private static void StartOnceDemo()
{
Console.WriteLine("[2] Start-once demo: unsafe flag vs Interlocked.Exchange");
Console.WriteLine(" if (flag == 0) { flag = 1; } は同時に通る可能性がある。");
Console.WriteLine(" Exchange は『0→1にしたのが自分か?』を原子的に判定できる。");
Console.WriteLine(" ※NG例は環境/タイミング次第で 1 に見えることもあります。");
int workers = 64;
// NG: 非原子的なフラグ操作
{
int started = 0;
int startCalls = 0;
var tasks = new Task[workers];
for (int i = 0; i < workers; i++)
{
tasks[i] = Task.Run(() =>
{
Thread.SpinWait(500);
if (started == 0)
{
started = 1;
Interlocked.Increment(ref startCalls);
}
});
}
Task.WaitAll(tasks);
Console.WriteLine($"unsafe flag startCalls={startCalls} (1 のはずが 2+ になることがある)");
}
// OK: Exchange で 0→1 を一人だけにする
{
int started = 0;
int startCalls = 0;
var tasks = new Task[workers];
for (int i = 0; i < workers; i++)
{
tasks[i] = Task.Run(() =>
{
Thread.SpinWait(500);
if (Interlocked.Exchange(ref started, 1) == 0)
{
Interlocked.Increment(ref startCalls);
}
});
}
Task.WaitAll(tasks);
Console.WriteLine($"Interlocked.Exch startCalls={startCalls} (必ず 1)");
}
}
// 3) 停止フラグ:Volatile.Read/Write で "見え方" を安定させる
private static void VolatileStopFlagDemo()
{
Console.WriteLine("[3] Volatile demo: stop flag visibility");
Console.WriteLine(" 別スレッドの書き込みが『見える』保証を強めるのが Volatile。");
Console.WriteLine(" ただし挙動はCPU/JITに依存するので、このデモは再現性が環境によって変わる。");
// A) plain: 止まることも多いが、環境依存の不安定さが残る
{
_stop = 0;
var sw = Stopwatch.StartNew();
var t = Task.Run(() =>
{
while (_stop == 0) { Thread.SpinWait(1); }
});
Thread.Sleep(200);
_stop = 1;
bool finished = t.Wait(1000);
Console.WriteLine($"plain read/write finished={finished}, elapsed={sw.ElapsedMilliseconds}ms");
}
// B) Volatile: 見え方を意図的にする
{
_stop = 0;
var sw = Stopwatch.StartNew();
var t = Task.Run(() =>
{
while (Volatile.Read(ref _stop) == 0) { Thread.SpinWait(1); }
});
Thread.Sleep(200);
Volatile.Write(ref _stop, 1);
bool finished = t.Wait(1000);
Console.WriteLine($"Volatile R/W finished={finished}, elapsed={sw.ElapsedMilliseconds}ms");
}
Console.WriteLine(" 注意: Volatile は『更新を安全にする』道具ではない。count++ は Interlocked。");
}
}
※このデモは「Volatile を使う意図」を示すもので、再現性そのものを保証するものではありません。
ここだけ覚えればOK(実務の早見表)
| 用途 | コード |
|---|---|
| カウンタ |
Interlocked.Increment(ref count) / Add
|
| 一度だけ実行 |
Interlocked.Exchange(ref started, 1) == 0(条件付きなら CompareExchange) |
| 停止フラグ |
Volatile.Read / Volatile.Write
|
| 複数値の整合性 | lock |
CompareExchange の補足
Exchange は無条件に書き換えますが、「今の値が○○のときだけ書き換えたい」なら CompareExchange です。
int state = 0;
// state が 0 のときだけ 1 にしたい(成功したら 0 が返る)
if (Interlocked.CompareExchange(ref state, 1, 0) == 0)
{
// 自分が遷移させた
}
volatile キーワード
volatile キーワードは、そのフィールドへのアクセスに対して、暗黙的に Acquire/Release のメモリバリアを挿入する 点で Volatile.Read/Write とほぼ同等ですが
- 対応型が限定される(構造体などは不可)
- 読み/書きのどちらが Volatile かコード上で分かりにくい
- 明示的なバリアの粒度を選べない
などの理由から、近年の .NET では、Volatile.Read/Write を明示的に使った方が意図が明確 かなと思います。
まとめ
Interlocked は「更新を壊さない」、Volatile は「見え方を安定させる」。
迷ったら「単一の値なら Interlocked/Volatile、複数の整合性なら lock」で安全側に倒すのが手堅いです。

