C# volatileとは?スレッドセーフに使う方法とlock・Interlockedとの違いを解説

はじめに

C#のvolatileは、複数のスレッドから共有フィールドを読み書きするときに、コンパイラやCPUによる特定の最適化や命令の並べ替えを抑制するキーワードです。停止フラグや状態通知など、単純な値の受け渡しに利用できます。

ただし、volatileを付けただけで、すべての処理がスレッドセーフになるわけではありません。インクリメント、条件判定後の更新、複数フィールドの一括変更などには、Interlockedlockが必要です。Microsoftも、多くのケースではvolatileより、保証内容が明確なInterlockedlock、上位レベルの同期機構を検討するよう案内しています。Microsoft Learn

この記事では、C#のvolatileが保証すること、保証しないこと、宣言方法、実装例、lockInterlockedとの違いを解説します。

1. C#のvolatileとは

1-1. volatileキーワードの役割

volatileは、フィールドが複数のスレッドから変更される可能性をコンパイラに伝えるキーワードです。

次のように、フィールド宣言に付けて使用します。

C#
private volatile bool _stopRequested;

通常のフィールドでは、コンパイラやCPUが処理を高速化するために、値をレジスタへ保持したり、メモリアクセスの順序を変更したりすることがあります。

volatileを付けたフィールドへのアクセスにはvolatile読み取り・volatile書き込みの規則が適用され、特定の命令の並べ替えが制限されます。

1-2. マルチスレッドでvolatileが必要になる理由

単一スレッドのプログラムでは、基本的にコードに書かれた処理順序を前提として動作を考えられます。しかし、複数のスレッドが同じデータへアクセスする場合、各スレッドが異なるCPUコアで実行されることがあります。

その結果、次のような問題が発生する可能性があります。

  • 書き込んだ値が、別スレッドの処理に期待した順序で反映されない

  • フィールドの値がレジスタなどに保持され、繰り返し読み直されない

  • コンパイラやCPUが、プログラムの意味を変えない範囲で命令を並べ替える

  • 別スレッドが、関連するデータの更新前の状態を参照する

volatileは、こうした最適化の一部を制限し、スレッド間で単純な状態を通知するために使用されます。

1-3. volatileが保証する「可視性」とメモリアクセスの順序

volatile書き込みでは、その書き込みより前に記述されたメモリ操作が、volatile書き込みより後ろへ移動することを防ぎます。

volatile読み取りでは、その読み取りより後に記述されたメモリ操作が、volatile読み取りより前へ移動することを防ぎます。これは一般に、書き込み側のリリース操作、読み取り側のアクワイア操作として説明されます。Microsoft Learn

たとえば、次のコードを考えます。

C#
private int _result;
private volatile bool _completed;

public void Produce()
{
_result = 100;
_completed = true;
}

public void Consume()
{
if (_completed)
{
Console.WriteLine(_result);
}
}

読み取り側が_completed == trueを観測した場合、先に実行された_result = 100も、その順序関係に基づいて読み取れるようになります。

ただし、volatile書き込みを行った直後に、別のCPUで実行中のスレッドが必ず即座に最新値を取得する、という強い保証ではありません。また、すべてのスレッドがvolatile書き込みを完全に同じ順序で観測することも保証されません。Microsoft Learn+1

1-4. volatileを付ければスレッドセーフになるわけではない

volatileが主に提供するのは、単一フィールドへの読み取り・書き込みに関する可視性と順序制御です。

次の処理は、volatileを付けても安全になりません。

C#
private volatile int _count;

public void Increment()
{
_count++;
}

_count++は、内部的には次のような複数の処理で構成されます。

C#
int current = _count;
int next = current + 1;
_count = next;

複数スレッドが同じ値を読み取ってから書き戻すと、更新が失われます。volatileは代入以外の複合操作に原子性を与えず、レースコンディションも防止しません。Microsoft Learn

2. C#のvolatileの仕組み

2-1. CPUキャッシュとコンパイラ最適化による問題

現代のCPUは、メインメモリへのアクセス回数を減らすために、キャッシュやレジスタを利用します。また、コンパイラやCPUは、処理結果が変わらないと判断した範囲で命令を並べ替えることがあります。

たとえば、次のループがあるとします。

C#
private bool _running = true;

public void Run()
{
while (_running)
{
DoWork();
}
}

別スレッドが_running = falseを実行しても、同期されていない共有データへのアクセスには、スレッド間通信として必要な順序保証がありません。

C#
public void Stop()
{
_running = false;
}

このようなコードでは、volatileやほかの同期機構を用いて、共有状態へのアクセス方法を明示する必要があります。

2-2. volatile読み取りとvolatile書き込みの動作

volatile読み取りには、後続のメモリ操作を読み取りより前へ移動させない性質があります。

C#
if (_ready)
{
UseData(_data);
}

_readyがvolatileフィールドであれば、_dataの読み取りを_readyの読み取りより前へ移動することが制限されます。

volatile書き込みには、先行するメモリ操作を書き込みより後へ移動させない性質があります。

C#
_data = CreateData();
_ready = true;

_readyがvolatileフィールドであれば、_dataへの書き込みが_ready = trueより後ろへ移動することが制限されます。

2-3. メモリバリアと命令の並べ替え

メモリバリアとは、メモリ操作の順序を制御するための仕組みです。volatileアクセスでは、実行環境やCPUアーキテクチャに応じて、必要なメモリバリア相当の処理が使用されます。

ただし、volatile読み取り・書き込みは、すべての命令の並べ替えを全面的に禁止する完全なバリアではありません。

重要なのは、次の方向の順序です。

  • volatile書き込みより前の操作を、その書き込みより後ろへ移動させない

  • volatile読み取りより後の操作を、その読み取りより前へ移動させない

この一方向の順序保証を利用することで、準備済みフラグや新しい参照の公開といった実装が可能になります。

2-4. volatileでは保証されない処理の原子性

原子性とは、ある処理が途中で分割されず、ほかのスレッドから一つの操作として観測される性質です。

単純な読み取りや書き込みが原子的でも、次のような複合処理全体が原子的になるわけではありません。

C#
_count++;
_balance -= amount;

if (_status == Status.Waiting)
{
_status = Status.Running;
}

これらは読み取り、計算・判定、書き込みという複数の手順を含みます。

単一変数の更新を原子的に行いたい場合はInterlocked、複数の処理を一つの不可分な処理として保護したい場合はlockを使用します。

3. C#でvolatileを宣言する方法

3-1. volatileフィールドの基本構文

基本構文は次のとおりです。

C#
アクセス修飾子 volatile  フィールド名;

具体例は次のとおりです。

C#
public class Worker
{
private volatile bool _stopRequested;
private volatile int _status;
private volatile object? _currentTask;
}

volatileはフィールドに対して指定する修飾子です。通常は外部から直接変更されないよう、privateフィールドとして宣言します。

3-2. volatileを指定できる型

C#でvolatileを指定できる主な型は次のとおりです。

  • 参照型

  • sbyte

  • byte

  • short

  • ushort

  • int

  • uint

  • char

  • float

  • bool

  • 対応する整数型を基底型とする列挙型

  • IntPtrUIntPtr

  • 参照型に制約されたジェネリック型パラメーター

  • unsafeコンテキスト内のポインター型

ポインターをvolatileにすることはできますが、ポインターが指すオブジェクトそのものをvolatileにすることはできません。Microsoft Learn

3-3. long・double・構造体などにvolatileを指定できない理由

次の宣言はコンパイルエラーになります。

C#
private volatile long _total;
private volatile double _average;
private volatile DateTime _updatedAt;

longdoubleは64ビット型であり、C#のフィールド修飾子としてのvolatileが対象とする型には含まれていません。32ビット環境も含めて通常のアクセスが常に不可分になるとは限らないためです。

64ビット値にvolatileアクセスを行う必要がある場合は、Volatile.ReadVolatile.Writeを使用できます。

C#
private long _total;

public long Total
{
get => Volatile.Read(ref _total);
}

public void SetTotal(long value)
{
Volatile.Write(ref _total, value);
}

Volatileクラスは、longdoubleを含む一部の64ビット型について、32ビット環境でも原子的なvolatile読み取り・書き込みを提供します。Microsoft Learn

構造体は複数のフィールドから構成される可能性があり、構造体全体を一回の原子的な操作として扱えるとは限らないため、volatileフィールドにはできません。構造体全体の整合性が必要な場合は、lockや不変な参照型への差し替えを検討します。

3-4. ローカル変数・プロパティ・配列要素にvolatileを指定できるか

volatileを指定できるのはフィールドです。ローカル変数には指定できません。

C#
public void Execute()
{
// volatile bool completed; // コンパイルエラー
}

自動実装プロパティにも直接指定できません。

C#
// public volatile bool IsRunning { get; set; } // コンパイルエラー

必要な場合は、バッキングフィールドをvolatileにします。

C#
private volatile bool _isRunning;

public bool IsRunning
{
get => _isRunning;
private set => _isRunning = value;
}

配列変数をvolatileフィールドとして宣言することはできますが、volatileになるのは配列への参照です。各配列要素へのアクセスがvolatileになるわけではありません。

C#
private volatile int[] _values = new int[10];

配列要素にvolatileアクセスを行う場合は、Volatile.ReadVolatile.Writeを使用します。

C#
Volatile.Write(ref _values[0], 100);
int value = Volatile.Read(ref _values[0]);

3-5. static volatileを使用する場合の注意点

static volatileは、アプリケーション内の複数インスタンスから共有される静的フィールドにvolatileアクセスを適用します。

C#
public static class ApplicationState
{
private static volatile bool _shuttingDown;

public static bool IsShuttingDown => _shuttingDown;

public static void BeginShutdown()
{
_shuttingDown = true;
}
}

ただし、staticを付けても原子性や排他制御が追加されるわけではありません。むしろ、アプリケーション全体からアクセスされるため、競合する範囲が広くなります。

静的フィールドを使用する場合は、次の点を明確にします。

  • どのスレッドが書き込むのか

  • 複数スレッドから書き込まれる可能性があるか

  • 値を初期状態へ戻す必要があるか

  • テスト間で状態が残らないか

  • 単純なフラグ以上の整合性が必要ではないか

4. volatileをスレッドセーフに使う実装例

4-1. bool型の停止フラグにvolatileを使う例

volatileの代表的な用途は、ワーカースレッドへ停止要求を通知するフラグです。

C#
public sealed class Worker
{
private volatile bool _stopRequested;

public void Run()
{
while (!_stopRequested)
{
DoWork();
}

Cleanup();
}

public void Stop()
{
_stopRequested = true;
}

private static void DoWork()
{
Thread.Sleep(10);
}

private static void Cleanup()
{
}
}

この例では、書き込み側はfalseからtrueへ値を変更するだけで、読み取り側も値を確認するだけです。

ただし、ループ内の処理が長時間ブロックする場合、フラグを確認するまで停止できません。また、実際のアプリケーションではCancellationTokenのほうが適している場合が多くあります。

4-2. 別スレッドから状態変更を通知する例

処理結果を用意した後、準備完了フラグを更新する例です。

C#
public sealed class ResultHolder
{
private string? _result;
private volatile bool _ready;

public void Produce()
{
string result = LoadResult();

_result = result;
_ready = true;
}

public string? TryGetResult()
{
if (!_ready)
{
return null;
}

return _result;
}

private static string LoadResult()
{
return "completed";
}
}

書き込み側では、先に_resultを設定し、最後にvolatileフィールドの_readytrueにします。

読み取り側が_ready == trueを観測した後で_resultを読み取ることで、データを公開する順序を作っています。

このパターンを使うときは、フラグを立てた後に公開済みデータを再変更しないなど、設計上の制約を明確にする必要があります。

4-3. 参照型の差し替えを通知する例

設定値やスナップショットを不変オブジェクトとして作り、参照ごと差し替える方法です。

C#
public sealed record AppSettings(
int TimeoutSeconds,
bool LoggingEnabled);

public sealed class SettingsProvider
{
private volatile AppSettings _current =
new(30, false);

public AppSettings Current => _current;

public void Update(int timeoutSeconds, bool loggingEnabled)
{
var next = new AppSettings(
timeoutSeconds,
loggingEnabled);

_current = next;
}
}

この方法では、読み取り側は一つのオブジェクト参照を取得します。AppSettingsが不変であれば、取得後に内部状態が途中で変化することもありません。

一方、可変オブジェクトの参照をvolatileにしても、そのオブジェクト内部の変更は保護されません。

C#
private volatile List<string> _items = new();

// List内部の操作はスレッドセーフにならない
_items.Add("item");

volatileなのは_itemsという参照の読み書きであり、List<T>.Addではありません。

4-4. volatileが適している「単純な読み取り・書き込み」の条件

volatileを採用できる可能性があるのは、次の条件を満たす場合です。

  • 共有する状態が基本的に一つのフィールドで表現できる

  • 操作が単純な読み取りまたは代入で完了する

  • 読み取り・判定・更新を一つの操作として実行する必要がない

  • 複数フィールドの整合性を保つ必要がない

  • 値の変更を待つ間、ブロックや非同期通知を必要としない

  • メモリ順序の意味を説明できる

  • すべてのアクセス経路を管理できる

少しでも複合的な状態遷移が含まれる場合は、Interlockedlockを選んだほうが安全です。

4-5. CancellationTokenを使うべきケース

停止要求やキャンセル通知には、通常はCancellationTokenを優先します。

C#
public static async Task RunAsync(
CancellationToken cancellationToken)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();

await ProcessAsync(cancellationToken);
}
}

private static Task ProcessAsync(
CancellationToken cancellationToken)
{
return Task.Delay(100, cancellationToken);
}

CancellationTokenには、次の利点があります。

  • asyncawaitと自然に連携できる

  • キャンセル可能なAPIへトークンを伝播できる

  • ThrowIfCancellationRequestedを利用できる

  • コールバックを登録できる

  • タイムアウトと組み合わせられる

  • 複数処理へ同じキャンセル要求を通知できる

単純な専用スレッドのフラグとしてはvolatile boolが使える場合もありますが、非同期処理や複数コンポーネントをまたぐキャンセルにはCancellationTokenが適しています。

5. volatileでスレッドセーフにならない処理

5-1. volatile変数のインクリメントが安全ではない理由

次のコードはスレッドセーフではありません。

C#
private volatile int _count;

public void Increment()
{
_count++;
}

たとえば、二つのスレッドが同時に実行すると、次の流れになる可能性があります。

  1. スレッドAが0を読み取る

  2. スレッドBも0を読み取る

  3. スレッドAが1を書き込む

  4. スレッドBも1を書き込む

2回インクリメントしたにもかかわらず、結果は1です。

安全にカウントするには、次のようにInterlocked.Incrementを使用します。

C#
private int _count;

public void Increment()
{
Interlocked.Increment(ref _count);
}

5-2. 読み取り・判定・書き込みを伴う複合処理の競合

次のコードも安全ではありません。

C#
private volatile int _stock = 1;

public bool TryPurchase()
{
if (_stock > 0)
{
_stock--;
return true;
}

return false;
}

複数スレッドが同時に_stock > 0を確認すると、在庫が一つしかないのに、複数の購入処理が成功する可能性があります。

この処理は、確認と更新を一つの不可分な操作として実行する必要があります。

C#
private readonly object _gate = new();
private int _stock = 1;

public bool TryPurchase()
{
lock (_gate)
{
if (_stock <= 0)
{
return false;
}

_stock--;
return true;
}
}

5-3. 複数フィールド間の整合性を保証できない理由

volatileは、一つのフィールドに対するアクセスへ適用されます。複数フィールドを一つのまとまりとして保護できません。

C#
private volatile int _x;
private volatile int _y;

public void Update()
{
_x = 100;
_y = 200;
}

読み取り側が、_xの新しい値と_yの古い値を組み合わせて取得する可能性を、二つのvolatile宣言だけで全面的に防ぐことはできません。

複数の値を一貫した状態で扱うには、次のいずれかを検討します。

  • 同じlockで読み書きを保護する

  • 不変オブジェクトへまとめて参照を差し替える

  • 用途に適したスレッドセーフなデータ構造を使う

5-4. チェックしてから更新する処理で発生するレースコンディション

「まだ処理されていなければ処理済みにする」というコードも注意が必要です。

C#
private volatile bool _processed;

public void ProcessOnce()
{
if (!_processed)
{
_processed = true;
Execute();
}
}

二つのスレッドが同時にfalseを読み取ると、Executeが2回実行されます。

一度だけ実行したい場合は、Interlocked.CompareExchangeを利用できます。

C#
private int _processed;

public void ProcessOnce()
{
if (Interlocked.CompareExchange(
ref _processed,
1,
0) != 0)
{
return;
}

Execute();
}

5-5. volatileに関するよくある誤解

volatileには、次のような誤解があります。

「値へのアクセスがすべて原子的になる」

単純な対応型の読み取り・書き込みと、++や条件付き更新のような複合操作は別です。

「常に絶対的な最新値を取得できる」

volatileアクセスには可視性と順序に関する保証がありますが、別CPUへの即時反映や、全スレッドで共通の一意な書き込み順序まで保証するものではありません。

「オブジェクト参照をvolatileにすれば、内部も安全になる」

volatileになるのは参照の差し替えです。参照先のオブジェクトが持つ可変状態は保護されません。

「lockより高速なので置き換えられる」

volatilelockでは保証内容が異なります。高速化を目的にlockvolatileへ置き換えると、競合バグが発生する可能性があります。

6. volatileとlockの違い

6-1. lockが保証する排他制御と原子性

lockは、指定したロックを取得したスレッドだけが、対象ブロックを実行できるようにします。

C#
private readonly object _gate = new();
private int _count;

public void Increment()
{
lock (_gate)
{
_count++;
}
}

同じロックオブジェクトを使用するほかのスレッドは、ロックが解放されるまで待機します。そのため、_count++のような複合処理全体を保護できます。lockブロックは、一度に最大一つのスレッドだけが実行することを保証します。Microsoft Learn

6-2. volatileではなくlockを使うべきケース

次のような場合はlockが適しています。

  • 複数フィールドをまとめて更新する

  • 条件を確認してから値を変更する

  • コレクションへの複数操作を保護する

  • 不変条件を維持する必要がある

  • 複数行の処理を一つの単位として実行する

  • 読み取り中に書き込みが行われてはならない

  • 同期処理の正しさを分かりやすくしたい

volatileで実現できるか判断が難しい処理は、まずlockで正しく実装するのが安全です。

6-3. 複数の値を一貫した状態で更新するコード例

口座残高と更新回数を、同じ状態として管理する例です。

C#
public sealed class Account
{
private readonly object _gate = new();

private decimal _balance;
private int _version;

public void Deposit(decimal amount)
{
lock (_gate)
{
_balance += amount;
_version++;
}
}

public (decimal Balance, int Version) GetSnapshot()
{
lock (_gate)
{
return (_balance, _version);
}
}
}

読み取りと書き込みの両方で同じロックを使用するため、Balanceだけが更新済みでVersionは更新前、という中間状態を取得しません。

6-4. volatileとlockのパフォーマンス上の違い

一般に、単純なvolatile読み取り・書き込みは、ロックの取得と解放より軽量になる可能性があります。しかし、実際の性能はCPU、競合の頻度、処理内容、.NETランタイムの最適化などによって変わります。

lockには、次のようなコストが発生する可能性があります。

  • ロック取得と解放

  • 競合時の待機

  • スレッドのスケジューリング

  • ロック競合によるスループット低下

一方、volatileにも命令の並べ替えを制限するコストがあり、CPU最適化の自由度を下げることがあります。

保証内容が異なるため、単純に「volatileのほうが高速」と考えて置き換えてはいけません。まず正しい同期方法を選び、必要な場合にベンチマークやプロファイリングを行います。

6-5. lock使用時に注意したいデッドロックとロック範囲

lockを使用するときは、ロック範囲を必要最小限にします。

C#
private readonly object _gate = new();

public void Update()
{
var data = LoadData();

lock (_gate)
{
Apply(data);
}
}

時間のかかるI/Oや外部API呼び出しまでロック内に含めると、ほかのスレッドを長時間待たせる原因になります。

また、複数のロックを異なる順番で取得すると、デッドロックが発生する可能性があります。

ロック対象には、外部コードから取得できない専用オブジェクトを使います。

C#
private readonly object _gate = new();

this、文字列、Typeオブジェクトなどは、別のコードからもロックされる可能性があるため避けます。.NET 9およびC# 13以降では、利用環境に応じて専用のSystem.Threading.Lockも選択できます。Microsoft Learn

7. volatileとInterlockedの違い

7-1. Interlockedが保証するアトミック操作

Interlockedは、共有変数に対して単純なアトミック操作を提供するクラスです。

代表的なメソッドには、次のものがあります。

  • Increment

  • Decrement

  • Add

  • Exchange

  • CompareExchange

  • Read

Interlockedは、加算、インクリメント、デクリメント、交換、比較に基づく条件付き交換などを、不可分な操作として実行します。Microsoft Learn

7-2. Interlocked.Incrementで安全にカウントする例

複数スレッドからカウンターを増やす場合は、Interlocked.Incrementを使用します。

C#
public sealed class RequestCounter
{
private int _count;

public int Increment()
{
return Interlocked.Increment(ref _count);
}

public int Current
{
get => Volatile.Read(ref _count);
}
}

Interlocked.Incrementは、値を読み取り、1を加算し、結果を書き戻す処理をアトミックに実行します。Microsoft Learn

7-3. Interlocked.Exchangeで値を置き換える例

Interlocked.Exchangeは、新しい値を設定し、変更前の値を返します。

C#
public sealed class ConnectionHolder
{
private IDisposable? _connection;

public void Replace(IDisposable? next)
{
IDisposable? previous =
Interlocked.Exchange(
ref _connection,
next);

previous?.Dispose();
}
}

参照の取得と差し替えが一つのアトミック操作になるため、複数スレッドが同時に交換を試みても、各呼び出しは交換前の参照を正しく取得できます。Microsoft Learn

ただし、交換後に実行するDisposeまで原子的になるわけではありません。アトミックなのはExchangeによる値の交換部分です。

7-4. Interlocked.CompareExchangeを使った条件付き更新

CompareExchangeは、現在値が期待値と一致した場合だけ、新しい値へ置き換えます。

C#
public sealed class Initializer
{
private int _initialized;

public void Initialize()
{
int previous = Interlocked.CompareExchange(
ref _initialized,
1,
0);

if (previous != 0)
{
return;
}

InitializeCore();
}

private static void InitializeCore()
{
}
}

_initialized0の場合だけ1へ更新されます。更新に成功した一つのスレッドだけが初期化処理へ進みます。

CompareExchangeは、比較と置換を一つのアトミック操作として実行し、変更前の値を返します。Microsoft Learn

7-5. volatileよりInterlockedを選ぶべきケース

次の場合はInterlockedが適しています。

  • カウンターを増減する

  • 数値を加算する

  • 値をアトミックに交換する

  • 一度だけ状態を変更する

  • 現在値が期待値と一致した場合だけ更新する

  • ロックを使わず単一変数を更新したい

ただし、複数変数の整合性や、複数行にわたる処理全体はInterlockedだけでは保護しにくいため、lockを検討します。

8. volatileキーワードとVolatileクラスの違い

8-1. Volatile.ReadとVolatile.Writeの使い方

System.Threading.Volatileクラスは、特定の読み取りまたは書き込みにvolatileアクセスを適用します。

C#
private int _status;

public int GetStatus()
{
return Volatile.Read(ref _status);
}

public void SetStatus(int value)
{
Volatile.Write(ref _status, value);
}

volatileキーワードは、そのフィールドへの通常のアクセスをすべてvolatileアクセスとして扱います。

C#
private volatile int _status;

一方、Volatile.ReadVolatile.Writeは、呼び出した一回のメモリアクセスだけに作用します。

8-2. 配列要素にvolatileアクセスを行う方法

配列要素にはvolatileキーワードを直接指定できません。Volatileクラスを使用します。

C#
public sealed class StateTable
{
private readonly int[] _states = new int[100];

public int Read(int index)
{
return Volatile.Read(
ref _states[index]);
}

public void Write(int index, int value)
{
Volatile.Write(
ref _states[index],
value);
}
}

配列そのものの参照をvolatileにする方法とは意味が異なります。

C#
private volatile int[] _states = new int[100];

この宣言でvolatileになるのは_statesという配列参照の読み書きであり、各要素ではありません。配列要素へのvolatileアクセスにはVolatile.ReadVolatile.Writeを使用します。Microsoft Learn

8-3. volatileフィールドをref引数として渡す際の注意点

volatileフィールドを通常のref引数やout引数としてメソッドへ渡すと、そのメソッド内ではvolatileフィールドとして扱われません。

C#
private volatile int _status;

private static void Update(ref int value)
{
value = 1;
}

public void Execute()
{
Update(ref _status);
}

このようなコードでは、コンパイラ警告CS0420が発生することがあります。

volatileフィールドをrefまたはoutで通常のメソッドへ渡すべきではありません。例外として、Interlockedなど、参照を受け取って必要な同期を行うAPIへ渡すケースがあります。Microsoft Learn

同じフィールドにInterlockedVolatileを使用する設計では、フィールド自体からvolatileを外し、アクセス箇所で同期方法を明示したほうが分かりやすい場合があります。

8-4. volatileキーワードとVolatileクラスの使い分け

volatileキーワードが適しているのは、対応型のフィールドへのすべてのアクセスをvolatileにしたい場合です。

C#
private volatile bool _ready;

Volatileクラスが適しているのは、次のような場合です。

  • 配列要素へvolatileアクセスしたい

  • longdoubleへvolatileアクセスしたい

  • 特定のアクセスだけ同期方法を明示したい

  • フィールド宣言を変更できない

  • 共通コード内でvolatile操作を明確に表現したい

Volatile.ReadVolatile.Writeを使う場合は、対象フィールドに対するすべてのアクセス経路で同期方法を統一する必要があります。一部だけ通常アクセスにすると、設計意図が崩れます。Microsoft Learn

9. volatile・lock・Interlockedの使い分け

9-1. 可視性だけを確保したい場合はvolatile

一つのフラグや参照を単純に公開し、読み取り側がその状態を確認するだけなら、volatileを使用できる場合があります。

代表例は次のとおりです。

  • 停止要求フラグ

  • 準備完了フラグ

  • 不変オブジェクトへの参照の差し替え

  • 単純な状態コードの通知

ただし、読み取り後に条件付き更新を行う場合は、可視性だけでは不十分です。

9-2. 単一変数をアトミックに更新したい場合はInterlocked

一つの変数に対する計算や条件付き更新にはInterlockedを使用します。

C#
Interlocked.Increment(ref _count);
Interlocked.Add(ref _total, amount);
Interlocked.Exchange(ref _state, next);
Interlocked.CompareExchange(ref _state, next, expected);

処理内容をInterlockedの一つのメソッドで表現できる場合は、lockを使うより簡潔に実装できることがあります。

9-3. 複数の処理やデータを保護したい場合はlock

複数のフィールド、コレクション、不変条件、条件判定をまとめて保護する場合はlockを使用します。

C#
lock (_gate)
{
if (_balance >= amount)
{
_balance -= amount;
_history.Add(amount);
_version++;
}
}

この例では、残高の確認、減算、履歴追加、バージョン更新を一つの処理として保護しています。

9-4. CancellationTokenやスレッドセーフコレクションを選ぶケース

用途によっては、volatilelockInterlockedを直接組み合わせるより、目的別の仕組みを使うほうが安全です。

キャンセル通知にはCancellationTokenを使用します。

複数スレッドからコレクションへアクセスする場合は、次のような型を検討します。

  • ConcurrentDictionary<TKey, TValue>

  • ConcurrentQueue<T>

  • ConcurrentStack<T>

  • ConcurrentBag<T>

  • BlockingCollection<T>

  • Channel<T>

非同期処理で待機を伴う排他制御が必要な場合は、SemaphoreSlim.WaitAsyncなどを検討します。lockブロック内ではawaitを使用できません。Microsoft Learn

9-5. 要件別の選び方を比較表で確認

要件適した方法主な保証
boolフラグを読み書きするvolatile可視性と特定の順序制御
不変オブジェクトの参照を差し替えるvolatileまたはVolatile参照の公開と順序制御
配列要素へvolatileアクセスするVolatile.ReadVolatile.Write対象アクセスの順序制御
カウンターを増減するInterlocked単一操作の原子性
値をアトミックに交換するInterlocked.Exchange交換処理の原子性
条件付きで値を変更するInterlocked.CompareExchange比較と交換の原子性
複数フィールドを更新するlock排他制御と処理全体の保護
コレクションを共有するスレッドセーフコレクション用途別の並行アクセス制御
非同期処理をキャンセルするCancellationToken協調的キャンセル
非同期で排他制御するSemaphoreSlimなど非同期待機を伴う排他制御

10. C#のvolatileを使用するときの注意点

10-1. すべてのアクセス経路で同期方法を統一する

共有フィールドにVolatile.ReadVolatile.Writeを使う場合は、すべての読み書きで同じ方針を採用します。

C#
private int _status;

public int ReadStatus()
{
return Volatile.Read(ref _status);
}

public void WriteStatus(int status)
{
Volatile.Write(ref _status, status);
}

一部のメソッドだけ通常アクセスにすると、同期の前提が分かりにくくなります。

C#
public void UnsafeWriteStatus(int status)
{
_status = status;
}

フィールドをprivateにし、読み書きを限定したメソッドへ集約すると、アクセス方法を統一しやすくなります。

10-2. volatileを安易な高速化手段として使わない

lockを削除してvolatileへ変更しても、同じスレッドセーフ性は得られません。

次のような変更は危険です。

C#
// 変更前
lock (_gate)
{
if (_stock > 0)
{
_stock--;
}
}

// 危険な変更後
if (_stock > 0)
{
_stock--;
}

フィールドへvolatileを付けても、確認と減算を一つの処理として保護できません。

性能改善を行う場合は、次の順番で進めます。

  1. 必要な整合性を定義する

  2. 正しい同期方法で実装する

  3. 実際にボトルネックを計測する

  4. 保証を維持できる代替設計を検討する

  5. 負荷テストで正しさと性能を確認する

10-3. 再現しにくい競合バグをテストする方法

マルチスレッドの競合は、実行タイミングによって発生するため、通常の単体テストでは再現しにくい問題です。

次の方法を組み合わせます。

  • 多数のタスクを同時に実行する

  • 同じテストを繰り返す

  • CPUコア数の異なる環境で実行する

  • Thread.Yieldなどで実行順序を変化させる

  • 高負荷状態でテストする

  • タイムアウトを設定して停止しない問題を検出する

  • 最終値だけでなく、途中状態の不変条件も検証する

カウンターのテスト例は次のとおりです。

C#
[Fact]
public async Task Increment_IsThreadSafe()
{
var counter = new RequestCounter();

Task[] tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() =>
{
for (int i = 0; i < 1_000; i++)
{
counter.Increment();
}
}))
.ToArray();

await Task.WhenAll(tasks);

Assert.Equal(100_000, counter.Current);
}

一度成功しただけでは競合がない証明になりません。コード上の同期保証を確認したうえで、テストを補助的に使用します。

10-4. コードレビューで確認すべきポイント

volatileを使ったコードをレビューするときは、次の点を確認します。

  • 共有フィールドの型はvolatileに対応しているか

  • 操作は本当に単純な読み取り・書き込みだけか

  • ++--+=などを使用していないか

  • チェックしてから更新する処理がないか

  • 複数フィールドの整合性が必要ではないか

  • volatile参照先のオブジェクトが変更されていないか

  • すべてのアクセス経路が統一されているか

  • refout経由で保証が失われていないか

  • Interlockedlockのほうが意図を明確にできないか

  • CancellationTokenなどの専用機構を使うべきではないか

特に、「なぜvolatileで正しいのか」を説明できないコードは、より強い同期方法へ変更することを検討します。

10-5. 判断に迷った場合にlockを優先する理由

lockは、保護対象とクリティカルセクションがコード上で明確です。

C#
lock (_gate)
{
// この範囲を一つのスレッドだけが実行する
}

一方、volatileを正しく使うには、原子性、可視性、命令の並べ替え、フィールド間の順序、アクセス経路などを理解する必要があります。

lockには競合時の待機コストがありますが、正しさを確認しやすく、複数の処理をまとめて保護できます。性能上の問題が計測で確認されていない段階では、理解しやすく安全な実装を優先することが重要です。

11. C#のvolatileに関するよくある質問

11-1. volatileを付けると必ず最新の値を取得できる?

volatileを付けると、そのフィールドへのアクセスにはvolatile読み取り・書き込みの規則が適用され、特定のキャッシュ利用や命令の並べ替えによる問題を防ぎます。

ただし、「別のCPUが書き込んだ値を、すべてのスレッドが同じ瞬間に必ず取得する」という意味ではありません。Microsoftのドキュメントでも、volatile読み取りが別プロセッサによる絶対的な最新値を取得することや、volatile書き込みが即座に全プロセッサへ表示されることは保証されないと説明されています。Microsoft Learn+1

実装では、「フラグが更新済みの値を観測できた場合、その前に公開されたデータも順序どおり観測できる」といった保証を利用します。

11-2. volatileは排他制御の代わりになる?

なりません。

volatileは、ほかのスレッドが同時に処理を実行することを禁止しません。複数スレッドが同時に同じコードへ入り、同じフィールドを読み書きできます。

排他制御が必要な場合はlockを使用します。

C#
lock (_gate)
{
UpdateSharedState();
}

11-3. volatile intの加算や減算はスレッドセーフ?

スレッドセーフではありません。

C#
private volatile int _count;

_count++;
_count--;
_count += 10;

これらは読み取り、計算、書き込みを含む複合処理です。

安全に更新するには、Interlockedを使用します。

C#
Interlocked.Increment(ref _count);
Interlocked.Decrement(ref _count);
Interlocked.Add(ref _count, 10);

この場合、フィールド自体をvolatileにせず、InterlockedVolatile.Readによってアクセス方法を明示する設計も検討できます。

11-4. volatileとasync・awaitは併用できる?

併用自体は可能ですが、volatileは非同期処理を制御する専用機能ではありません。

C#
private volatile bool _stopRequested;

public async Task RunAsync()
{
while (!_stopRequested)
{
await Task.Delay(100);
}
}

このコードは動作しますが、停止要求を通知する目的ならCancellationTokenのほうが適しています。

C#
public async Task RunAsync(
CancellationToken cancellationToken)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
}
}

また、lockブロック内ではawaitを使用できません。非同期で排他制御が必要な場合は、SemaphoreSlimなどを検討します。

11-5. volatileを使うと処理速度は低下する?

通常のフィールドアクセスと比べると、volatileアクセスはコンパイラやCPUの最適化を制限するため、一定のコストが発生する可能性があります。CPUアーキテクチャによっては、メモリバリア相当の命令も関係します。

ただし、実際の影響は処理内容や実行環境によって異なります。lockより常に速い、通常アクセスより大幅に遅い、と一律には判断できません。

性能だけを理由に同期を省略せず、まず正しい方法を選択します。性能が問題になった場合は、実際のワークロードに近い条件で計測します。

まとめ

C#のvolatileは、複数スレッドから共有フィールドへアクセスするときに、特定のコンパイラ最適化やメモリ操作の並べ替えを制限するキーワードです。

停止フラグ、準備完了フラグ、不変オブジェクト参照の差し替えなど、単純な読み取り・書き込みには利用できる場合があります。

一方、volatileは排他制御を提供せず、インクリメントや条件付き更新などの複合操作を原子的にしません。複数の値を一貫した状態で更新することもできません。

使い分けの基本は次のとおりです。

  • 可視性と順序だけが必要な単純なフィールドにはvolatile

  • 単一変数をアトミックに更新する場合はInterlocked

  • 複数の処理やデータをまとめて保護する場合はlock

  • 非同期処理の停止通知にはCancellationToken

  • 共有コレクションにはスレッドセーフコレクション

volatileを使用する際は、「付ければスレッドセーフになる」と考えず、必要な原子性、排他制御、データ間の整合性を整理することが重要です。判断に迷う場合は、まずlockや用途別の同期機構で正しく実装し、その後に必要性を確認しながら最適化しましょう。