C# Interlockedの使い方完全ガイド|lockなしで安全にカウンター更新・排他制御する方法

はじめに

C#で複数のスレッドやTaskから同じ変数を更新すると、値の取りこぼしや二重実行といった不具合が発生することがあります。アクセス数や処理件数を数えるだけなら単純なcount++で十分に見えますが、マルチスレッド環境では安全ではありません。

このような単一変数への更新を、lockを使わずスレッドセーフに実行するためのクラスがSystem.Threading.Interlockedです。IncrementAddExchangeCompareExchangeなどを利用すると、カウンター更新、値の交換、条件付き更新、簡易的な排他制御をアトミックに実行できます。Microsoft Learn+1

ただし、Interlockedを使えば、どのような処理でも安全になるわけではありません。本記事では基本的な使い方だけでなく、lockvolatileとの違い、CASループ、非同期処理での注意点、よくある間違い、性能検証まで解説します。

1. C#のInterlockedとは?スレッドセーフな原子操作の基礎

1-1. Interlockedは共有変数をアトミックに更新するクラス

Interlockedは、複数のスレッドからアクセスされる共有変数に対して、分割できない操作を提供する静的クラスです。

代表的なメソッドは次のとおりです。

  • Increment:1加算する

  • Decrement:1減算する

  • Add:任意の値を加算する

  • Exchange:値を無条件に交換する

  • CompareExchange:現在値が期待値と一致した場合だけ交換する

  • Read:64ビット整数をアトミックに読み取る

  • AndOr:ビットフラグをアトミックに変更する

これらの操作は、更新途中の状態をほかのスレッドから観測されない形で実行されます。

1-2. アトミック操作とは「途中で割り込まれない処理」

アトミック操作とは、外部から見たときに1回の不可分な操作として実行される処理です。

たとえば、ある値を10から11へ変更する処理がアトミックであれば、別のスレッドから見えるのは更新前の10か更新後の11だけです。「値を読み取ったが、まだ書き戻していない」といった中間状態を利用されません。

ただし、アトミックであるのはInterlockedに渡した1回の操作です。その前後に記述した条件判定やデータベース更新まで、自動的に一体化されるわけではありません。

1-3. count++がマルチスレッド環境で安全ではない理由

次のコードは、単一スレッドでは問題ありません。

C#
count++;

しかし、内部的な考え方としては次の3段階に分かれます。

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

複数スレッドが同時に実行すると、それぞれが同じ更新前の値を読み取る可能性があります。その場合、2回インクリメントしたにもかかわらず、結果が1しか増えません。

Microsoftのドキュメントでも、通常のインクリメントは値の読み込み、加算、書き戻しという複数の手順を必要とし、その途中で別のスレッドが実行されると更新が失われる可能性が説明されています。Microsoft Learn

1-4. 競合状態(レースコンディション)が発生する仕組み

たとえば、countの初期値が100で、スレッドAとスレッドBが同時にcount++を実行したとします。

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

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

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

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

本来の結果は102ですが、実際には101になります。

このように、実行タイミングや順序によって結果が変化する問題を競合状態、またはレースコンディションと呼びます。再現条件がスレッドのスケジューリングに依存するため、開発環境では発生せず、本番環境の高負荷時だけ発生することもあります。

1-5. Interlockedで安全に操作できる主なデータ型

現在の.NETでは、メソッドによって対応範囲は異なりますが、主に次の値を操作できます。

  • intuint

  • longulong

  • floatdouble

  • IntPtrUIntPtr

  • オブジェクト参照

  • 一部の小さい整数型

  • 一部のジェネリック型や列挙型

実務では、カウンターにはintまたはlong、状態フラグにはint、オブジェクトの差し替えには参照型を使うケースが一般的です。すべてのメソッドがすべての型に対応しているわけではないため、利用する.NETのバージョンと対象メソッドのオーバーロードを確認してください。Microsoft Learn+1

1-6. Interlockedを利用するための名前空間と基本構文

InterlockedSystem.Threading名前空間にあります。

C#
using System.Threading;

基本構文では、更新対象をrefで渡します。

C#
private int _count;

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

refを付けることで、変数の現在値ではなく、更新対象となる変数そのものをメソッドへ渡します。

2. Interlockedとlockの違い・使い分け

2-1. Interlockedは単一変数の更新に適した軽量な同期手段

Interlockedは、単一変数に対する単純な操作に向いています。

C#
Interlocked.Increment(ref _count);
Interlocked.Add(ref _total, amount);
Interlocked.Exchange(ref _status, 1);

カウンターを増やす、フラグを切り替える、参照を差し替えるといった処理なら、短く明確に実装できます。

2-2. lockは複数の処理をひとまとまりで保護する仕組み

lockは、コードブロック全体を排他的に実行します。

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

public bool TryPurchase(int quantity)
{
lock (_gate)
{
if (_stock < quantity)
{
return false;
}

_stock -= quantity;
SavePurchaseHistory(quantity);
return true;
}
}

在庫の確認、減算、履歴保存をひとまとまりとして保護できます。複数の共有変数や複数の処理の整合性を維持する場合は、lockのほうが適切です。

.NET 9およびC# 13以降では、同期専用オブジェクトとしてSystem.Threading.Lockを使用する方法も推奨されています。以前の環境では、ほかの用途に使わない専用のobjectを用意します。Microsoft Learn

2-3. Interlockedとlockのコード・動作・性能比較

同じカウンター更新でも、コードは次のように異なります。

C#
private int _interlockedCount;

public void IncrementWithInterlocked()
{
Interlocked.Increment(ref _interlockedCount);
}
C#
private readonly object _gate = new();
private int _lockedCount;

public void IncrementWithLock()
{
lock (_gate)
{
_lockedCount++;
}
}

Interlockedは1変数への特定の操作だけをアトミックに実行します。lockはロックの取得から解放まで、ブロック内の任意の処理を保護します。

単純な加算ではInterlockedが高速になりやすいものの、性能差は競合率、CPU、ランタイム、処理内容によって変わります。複雑な処理を無理にCASループへ変換すると、lockより遅くなる場合もあります。

2-4. Interlockedだけでは排他制御できない処理

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

C#
if (_stock >= quantity)
{
Interlocked.Add(ref _stock, -quantity);
}

読み取り時点では在庫が足りていても、減算する前に別のスレッドが在庫を変更する可能性があります。

また、次のように2つの変数を個別に更新しても、両者の整合性は保証されません。

C#
Interlocked.Add(ref _balance, -amount);
Interlocked.Add(ref _reservedAmount, amount);

1回目と2回目の間に別のスレッドから観測されれば、合計値が一時的に不正になります。

2-5. lockなしで安全に実装できるケース

次のような処理は、Interlockedだけで実装しやすいケースです。

  • アクセス数の加算

  • 完了件数の加算

  • 実行中フラグの取得

  • 最大値の記録

  • 最新オブジェクトの差し替え

  • 初期化済み状態への一方向の遷移

  • 1つのビットフラグ集合の更新

重要なのは、共有状態を単一変数として表現できることです。

2-6. lockを選ぶべきケース

次の場合は、まずlockを検討します。

  • 複数の共有変数を同時に更新する

  • コレクションの複数操作を一体として扱う

  • 条件確認と更新の間に別スレッドを入れたくない

  • 複雑なビジネスルールを保護する

  • CASループにするとコードが読みにくくなる

  • 競合時に再試行する計算コストが大きい

可読性と正しさを優先し、性能問題が計測で確認されてから最適化するのが基本です。

2-7. volatile・Interlocked・lockの違い

volatileまたはVolatile.ReadVolatile.Writeは、主に読み書きの可視性やメモリ操作の並べ替えを制御するために使います。複合更新をアトミックにはしません。

C#
private volatile bool _stopping;

この変数をほかのスレッドから読み取る用途には利用できますが、次の処理は安全になりません。

C#
private volatile int _count;

// 安全ではない
_count++;

Interlockedは単一変数の読み取りと更新を不可分に実行します。lockは複数の処理を含むクリティカルセクションを排他的に実行します。

Volatile.ReadVolatile.Writeは、必要に応じてメモリ操作の並べ替えを防ぐ読み書きを提供しますが、インクリメントのような複合操作にはInterlockedlockが必要です。Microsoft Learn+1

3. Interlocked.Increment・Decrementでカウンターを安全に更新する

3-1. Incrementでカウンターを1増やす基本コード

Interlocked.Incrementは、指定した変数を1増やします。

C#
private int _requestCount;

public void RecordRequest()
{
Interlocked.Increment(ref _requestCount);
}

複数スレッドから同時に呼び出されても、各インクリメントが失われません。

3-2. Incrementの戻り値は更新後の値

Incrementは更新後の値を返します。

C#
int current = Interlocked.Increment(ref _requestCount);

Console.WriteLine($"今回のリクエスト番号: {current}");

初期値が10なら、戻り値は11です。更新前の値ではない点に注意してください。Microsoft Learn

3-3. Decrementでカウンターを1減らす基本コード

Interlocked.Decrementは、値を1減らして更新後の値を返します。

C#
private int _remainingJobs = 100;

public void CompleteJob()
{
int remaining = Interlocked.Decrement(ref _remainingJobs);

Console.WriteLine($"残り件数: {remaining}");
}

残り件数、利用中リソース数、参照数などの管理に利用できます。

3-4. 複数スレッドからアクセス数を集計する実装例

C#
public sealed class AccessCounter
{
private long _count;

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

public long GetCount()
{
return Interlocked.Read(ref _count);
}
}

外部から直接フィールドを変更できないようにし、更新方法をクラス内へ閉じ込めると、誤って++を使う事故を防げます。

3-5. Task.WhenAllを使った並列カウンターの動作確認

C#
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

public static class Program
{
public static async Task Main()
{
int count = 0;

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

await Task.WhenAll(tasks);

Console.WriteLine(count); // 1,000,000
}
}

100個のタスクがそれぞれ1万回更新するため、最終結果は100万になります。

3-6. ++・--を使った場合との結果比較

先ほどのコードを次のように変更すると、結果が100万未満になる可能性があります。

C#
count++;

ただし、毎回必ず誤った結果になるとは限りません。CPUコア数、最適化、実行タイミングによっては期待値と一致することもあります。

一致したから安全なのではなく、競合がたまたま結果へ現れなかっただけです。

3-7. intとlongのカウンターを扱う際の注意点

intの最大値は約21億です。高頻度のアクセス数や長期間稼働するサービスでは、longを選んだほうが安全です。

C#
private long _requestCount;

public long Increment()
{
return Interlocked.Increment(ref _requestCount);
}

ただし、longも無限ではありません。カウンターが上限へ到達する可能性、リセット方法、永続化方法を設計しておく必要があります。

4. Interlocked.Addで任意の値を加算・減算する

4-1. Addで共有変数に任意の数値を加算する方法

Interlocked.Addを使うと、1以外の値を加算できます。

C#
private long _processedBytes;

public void AddProcessedBytes(int bytes)
{
Interlocked.Add(ref _processedBytes, bytes);
}

Incrementを何度も呼び出すより、合計値を1回で追加するほうが意図も明確です。

4-2. 負の値を指定して減算する方法

負の値を渡せば減算できます。

C#
private int _stock = 100;

public void ReduceStock(int quantity)
{
Interlocked.Add(ref _stock, -quantity);
}

ただし、このコードは在庫がマイナスにならないことを保証しません。単に指定値をアトミックに加算しているだけです。

4-3. Addの戻り値から更新後の値を取得する方法

Addの戻り値は更新後の値です。

C#
long total = Interlocked.Add(ref _processedBytes, bytes);

Console.WriteLine($"累計処理量: {total}");

ExchangeCompareExchangeは更新前の値を返すため、戻り値の意味を混同しないようにしてください。Addは加算後の新しい値を返します。Microsoft Learn

4-4. 複数タスクの処理件数を合計する実装例

C#
long totalProcessed = 0;

Task[] tasks = Enumerable.Range(0, 20)
.Select(workerId => Task.Run(() =>
{
int processedByWorker = ProcessItems(workerId);
Interlocked.Add(ref totalProcessed, processedByWorker);
}))
.ToArray();

await Task.WhenAll(tasks);

Console.WriteLine($"合計: {totalProcessed}");

static int ProcessItems(int workerId)
{
return 100 + workerId;
}

各タスク内でローカル集計し、最後にまとめてAddすると、共有変数へのアクセス回数を減らせます。

4-5. 金額・在庫数・残量の更新に使う際の注意点

単純な統計値ならAddで問題ありませんが、業務上の残高や在庫には条件が伴うことが多くあります。

たとえば、「残高が支払額以上の場合だけ引き落とす」という処理は、単純なAddだけでは実装できません。条件付き更新にはCompareExchangeループ、またはlockが必要です。

また、金額を小数で保持する必要がある場合、decimalに対する直接的なInterlocked.Addはありません。金額を最小通貨単位のlongで保持する設計や、lockの利用を検討します。

4-6. 複数項目の整合性が必要な処理にAddを使えない理由

次の2回の加算は、それぞれ個別にはアトミックです。

C#
Interlocked.Add(ref _accountA, -amount);
Interlocked.Add(ref _accountB, amount);

しかし、口座Aから口座Bへの振替全体はアトミックではありません。1回目の後、2回目の前に例外が発生すれば、資金が消えた状態になります。

複数項目を1つのトランザクションとして扱う必要がある場合は、lockやデータベーストランザクションなど、より広い範囲を保護できる仕組みを使います。

5. Interlocked.Exchangeで値を安全に置き換える

5-1. Exchangeの基本構文と戻り値

Exchangeは、共有変数を指定値へ交換し、交換前の値を返します。

C#
int oldValue = Interlocked.Exchange(ref _status, 1);

_statusには1が設定され、oldValueには設定前の値が入ります。Microsoft Learn+1

5-2. 共有変数を新しい値に置き換える実装例

C#
private int _currentWorkerId;

public int ChangeWorker(int newWorkerId)
{
return Interlocked.Exchange(ref _currentWorkerId, newWorkerId);
}

戻り値を使うと、以前の担当IDを取得しながら新しいIDへ切り替えられます。

5-3. フラグを0と1で管理する方法

boolの代わりにintの0と1で状態を表すと、Interlockedを使いやすくなります。

C#
private int _running;

public bool IsRunning => Volatile.Read(ref _running) == 1;

一般的に、0を未実行、1を実行中として扱います。

5-4. 二重実行を防止する簡易ガードの実装

C#
private int _running;

public void Run()
{
int previous = Interlocked.Exchange(ref _running, 1);

if (previous == 1)
{
return;
}

try
{
ExecuteCore();
}
finally
{
Interlocked.Exchange(ref _running, 0);
}
}

複数スレッドが同時にRunを呼んでも、0を1へ変更した1つのスレッドだけが処理を実行します。

5-5. 値を取得しながらカウンターをリセットする方法

一定期間の件数を取得してゼロへ戻す処理にも使えます。

C#
private long _intervalCount;

public long GetAndReset()
{
return Interlocked.Exchange(ref _intervalCount, 0);
}

取得とリセットが1回のアトミック操作になるため、その間に発生した更新を失いません。

5-6. Exchangeによる排他制御の限界

Exchangeを使った実行中フラグは、簡易的な「実行できれば実行し、使用中なら諦める」処理に向いています。

一方、次の機能はありません。

  • 順番待ち

  • 公平性

  • タイムアウト

  • 非同期の待機

  • 所有スレッドの記録

  • 再入制御

  • 自動解放

高度な排他制御が必要なら、lockSemaphoreSlimMutexなどを選びます。

5-7. 処理終了時にフラグを確実に戻すためのtry-finally

フラグの取得後に例外が発生しても、必ず解放できるようにします。

C#
if (Interlocked.Exchange(ref _running, 1) != 0)
{
return;
}

try
{
ExecuteCore();
}
finally
{
Interlocked.Exchange(ref _running, 0);
}

finallyがなければ、1度の例外によってフラグが永久に1のままとなり、その後の処理がすべて拒否される可能性があります。

6. Interlocked.CompareExchangeで条件付き更新を実装する

6-1. CompareExchangeの引数と戻り値

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

C#
int original = Interlocked.CompareExchange(
ref location,
newValue,
expectedValue);

引数の意味は次のとおりです。

C#
Interlocked.CompareExchange(
ref 更新対象,
交換後の値,
期待する現在値);

戻り値は、交換に成功したかどうかにかかわらず、操作直前の値です。Microsoft Learn

6-2. 「現在値が期待値と同じ場合だけ更新する」仕組み

次のコードは、_statusが0の場合だけ1へ変更します。

C#
Interlocked.CompareExchange(ref _status, 1, 0);

_statusがすでに1なら変更しません。比較と交換が1回のアトミック操作として実行されるため、「確認した直後に別スレッドが変更する」という競合を防げます。

6-3. 更新に成功したかを判定する方法

戻り値と期待値を比較します。

C#
bool succeeded =
Interlocked.CompareExchange(ref _status, 1, 0) == 0;

戻り値が0なら、操作直前の値が期待値0だったため、1への変更に成功しています。

6-4. 値を変更せずに現在値を読み取るテクニック

CompareExchangeで同じ値を比較・設定すると、実質的に値を変更せず現在値を取得できます。

C#
int current = Interlocked.CompareExchange(ref _status, 0, 0);

_statusが0なら0を設定し、0以外なら何も変更しません。どちらの場合も戻り値から操作前の値を取得できます。

単純な読み取りにはVolatile.Readのほうが意図を表しやすいため、この方法は古いコードやCASパターンで見かけるテクニックとして理解しておくとよいでしょう。

6-5. 初回の1回だけ処理を実行する実装例

C#
private int _initialized;

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

InitializeCore();
}

ただし、InitializeCoreが失敗しても_initializedは1のままです。失敗時に再試行したい場合は、未初期化、初期化中、初期化済み、失敗といった複数状態を設計する必要があります。

6-6. 参照型オブジェクトを安全に差し替える方法

C#
private Configuration? _configuration;

public bool TrySetInitialConfiguration(Configuration value)
{
Configuration? original = Interlocked.CompareExchange(
ref _configuration,
value,
null);

return original is null;
}

_configurationnullの場合だけ、指定したオブジェクトを設定します。

参照型に対するCompareExchangeは、通常、オブジェクトの内容ではなく参照の一致を基準に比較します。Microsoft Learn

6-7. 遅延初期化にCompareExchangeを使う際の注意点

次の実装では、公開されるインスタンスは1つですが、生成処理自体は複数回実行される可能性があります。

C#
private ExpensiveService? _service;

public ExpensiveService GetService()
{
ExpensiveService? current = Volatile.Read(ref _service);

if (current is not null)
{
return current;
}

var created = new ExpensiveService();

return Interlocked.CompareExchange(
ref _service,
created,
null) ?? created;
}

複数スレッドが同時にnew ExpensiveService()を実行し、1つだけが採用される可能性があります。

コンストラクターに副作用がある、生成コストが高い、例外の扱いが重要といった場合は、通常はLazy<T>を利用したほうが安全で読みやすくなります。

7. CompareExchangeループで複雑な値をアトミックに更新する

7-1. CompareExchangeループ(CASループ)とは

CASはCompare-And-Swap、またはCompare-And-Setの略です。

現在値を読み取り、新しい値を計算し、現在値が読み取り時点から変わっていない場合だけ更新します。別スレッドによって変更されていた場合は、最新値を読み直して再試行します。

7-2. 読み取り・計算・条件付き交換を繰り返す基本パターン

C#
while (true)
{
int current = Volatile.Read(ref _value);
int next = CalculateNext(current);

int observed = Interlocked.CompareExchange(
ref _value,
next,
current);

if (observed == current)
{
break;
}
}

observed == currentなら交換成功です。一致しなければ、別のスレッドが先に更新したためループを繰り返します。

7-3. double型の値を安全に加算する実装例

C#
public static double AtomicAdd(ref double location, double delta)
{
while (true)
{
double current = Volatile.Read(ref location);
double next = current + delta;

double observed = Interlocked.CompareExchange(
ref location,
next,
current);

long observedBits = BitConverter.DoubleToInt64Bits(observed);
long currentBits = BitConverter.DoubleToInt64Bits(current);

if (observedBits == currentBits)
{
return next;
}
}
}

ビット列で比較しているのは、NaNでは通常の==比較が成立しないためです。

浮動小数点数には丸め誤差もあるため、金額の集計にはdoubleではなく、最小通貨単位をlongで保持する設計などを検討してください。

7-4. 最大値・最小値を安全に更新する実装例

C#
public static int UpdateMax(ref int location, int candidate)
{
while (true)
{
int current = Volatile.Read(ref location);

if (candidate <= current)
{
return current;
}

int observed = Interlocked.CompareExchange(
ref location,
candidate,
current);

if (observed == current)
{
return candidate;
}
}
}

候補値が現在値以下なら更新不要です。現在値より大きい場合だけCASを試みます。

最小値の場合は比較条件を反対にします。

7-5. 更新競合が起きた場合に再試行が必要な理由

たとえば、現在値が100で、スレッドAとBがそれぞれ110と120への更新を計算したとします。

スレッドAが先に110へ更新すると、スレッドBが「現在値は100のはず」という条件で120へ交換しようとしても失敗します。

そこでスレッドBは現在値110を読み直し、必要な計算を再実行します。この再試行がなければ、更新に失敗したまま処理が終了します。

7-6. CASループ内で副作用のある処理を実行してはいけない理由

CASループは複数回実行される可能性があります。

C#
while (true)
{
SendEmail(); // 実行してはいけない
// CAS処理
}

競合によって3回再試行されれば、メールも3回送信されます。

CASループ内では、読み取った値から新しい値を計算するだけの、副作用がない処理を行います。ログ出力、ファイル書き込み、API呼び出し、データベース更新などは、CAS成功後に実行します。

7-7. 競合が多い環境で性能が低下するケース

競合が少ない場合、CASループは短時間で成功します。一方、多数のスレッドが同じ変数を更新すると、失敗と再試行が増えます。

計算処理が重い場合は、失敗するたびに再計算コストも発生します。高競合環境では、lockのほうがCPU消費を抑えられる場合や、スレッドごとのローカル集計後に合算するほうが速い場合があります。

7-8. ABA問題の概要と対策が必要になるケース

ABA問題とは、値がAからBへ変化した後、再びAへ戻ったため、CASから見ると「変更されていない」ように見える問題です。

単純な数値カウンターでは問題にならないことが多いものの、ロックフリーなスタックやリンクリストでは重要です。

対策には次のような方法があります。

  • 値とバージョン番号をセットで管理する

  • 世代番号を付ける

  • 不変オブジェクトを使う

  • 専用の並行コレクションを利用する

  • 独自のロックフリー構造を避ける

高度なロックフリー構造は、メモリ管理やABA問題まで含めて設計する必要があります。

8. Interlockedでlockなしの簡易排他制御を実装する

8-1. 0と1の状態フラグで処理権を獲得する仕組み

0を未使用、1を使用中とし、0から1への変更に成功したスレッドだけが処理権を取得します。

C#
private int _state;

取得に失敗したスレッドは、処理を諦めるか、一定時間待って再試行します。

8-2. Exchangeを使った非待機型ロックの実装例

C#
private int _busy;

public bool TryExecute()
{
if (Interlocked.Exchange(ref _busy, 1) != 0)
{
return false;
}

try
{
ExecuteCore();
return true;
}
finally
{
Interlocked.Exchange(ref _busy, 0);
}
}

使用中なら待機せずfalseを返します。定期処理の重複スキップなどに利用できます。

8-3. CompareExchangeを使ったTryEnter形式の実装例

C#
private int _lockState;

public bool TryEnter()
{
return Interlocked.CompareExchange(
ref _lockState,
1,
0) == 0;
}

解放処理は次のようにします。

C#
public void Exit()
{
Interlocked.Exchange(ref _lockState, 0);
}

CompareExchangeは取得に失敗した場合、値を書き換えない点で意図が明確です。

8-4. 排他処理を解放する正しい方法

取得に成功した場合だけ解放し、必ずfinallyで実行します。

C#
if (!TryEnter())
{
return;
}

try
{
ExecuteCore();
}
finally
{
Exit();
}

取得に失敗したスレッドが誤ってExitすると、別スレッドが使用中であるにもかかわらず、状態を0へ戻してしまいます。

8-5. スピン待機が必要な場合のSpinWaitとの組み合わせ

非常に短い処理の終了を待つ場合は、SpinWaitと組み合わせられます。

C#
public void Enter(CancellationToken cancellationToken)
{
var spinner = new SpinWait();

while (Interlocked.CompareExchange(
ref _lockState,
1,
0) != 0)
{
cancellationToken.ThrowIfCancellationRequested();
spinner.SpinOnce();
}
}

スピン待機は、ロックがすぐ解放されると予想できる場合に限って検討します。

8-6. ビジーループでCPU使用率が上がる問題

次のような空ループは避けます。

C#
while (Interlocked.CompareExchange(ref _lockState, 1, 0) != 0)
{
}

待機中もCPUを使い続けるため、競合が長引くとCPU使用率が大きく上昇します。

待機時間が予測できない場合は、lockSemaphoreSlimなど、実行権が得られるまで効率的に待機できる手段を選びます。

8-7. 再入可能なlockとの違い

C#の通常のlockは、同じスレッドが同じロックへ再び入ることができます。

一方、0と1だけで作った簡易ロックには所有者という概念がありません。同じスレッドが再度取得しようとしても失敗し、待機方式なら自分自身の解放を待ち続ける可能性があります。

独自実装を通常のlockと同等だと考えないことが重要です。

8-8. 公平性・待機・キャンセルが必要なら別の同期手段を選ぶ

簡易ロックでは、待機している順番に処理権を渡す保証がありません。特定スレッドが何度も負け続ける可能性もあります。

次の機能が必要なら別の同期手段を検討します。

  • 同期的な排他制御:lock

  • 非同期の排他制御:SemaphoreSlim

  • プロセス間同期:Mutex

  • 複数リーダーと単一ライター:ReaderWriterLockSlim

  • スレッド間シグナル:AutoResetEventなど

9. Interlocked.Readと安全な読み取り

9-1. Interlocked.Readの用途と基本構文

Interlocked.Readは、longまたはulongをアトミックに読み取ります。

C#
private long _total;

public long GetTotal()
{
return Interlocked.Read(ref _total);
}

主に32ビット環境で、64ビット値が途中の状態として読み取られることを防ぐ目的があります。

9-2. 64ビット整数をアトミックに読み取る理由

64ビット環境では、正しくアラインされた64ビット値の読み取りは通常アトミックです。一方、32ビット環境では、64ビット値を2回に分けて読み取る可能性があります。

その途中で別スレッドが値を更新すると、上位と下位が異なる時点の値になる可能性があります。Interlocked.Readは、このような破損した読み取りを防ぎます。Microsoft Learn

9-3. CompareExchangeを使って現在値を読み取る方法

longでも、次のように読み取れます。

C#
long current = Interlocked.CompareExchange(
ref _total,
0,
0);

現在値が0なら同じ0を設定し、0以外なら変更しません。戻り値から操作前の値を取得できます。

可読性を考えると、単純な読み取りにはInterlocked.ReadまたはVolatile.Readを使うほうが自然です。

9-4. 通常の読み取りとVolatile.Readの違い

通常の読み取りは次のとおりです。

C#
int current = _status;

Volatile.Readは、ほかのメモリ操作との並べ替えを制限する読み取りです。

C#
int current = Volatile.Read(ref _status);

単純な状態フラグの公開やCASループの現在値取得では、Volatile.Readを使うと同期の意図が明確になります。Microsoft Learn

9-5. 読み取りだけをInterlockedにしても安全にならないケース

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

C#
long current = Interlocked.Read(ref _balance);

if (current >= amount)
{
Interlocked.Add(ref _balance, -amount);
}

読み取りと減算は別々の操作です。その間に別スレッドが残高を変更できます。

各操作が個別にアトミックでも、処理全体がアトミックとは限りません。

9-6. 読み取りと更新を一連の処理として扱う際の注意点

条件に基づいて更新する場合は、CompareExchangeループを使います。

C#
public bool TryWithdraw(ref long balance, long amount)
{
while (true)
{
long current = Interlocked.Read(ref balance);

if (current < amount)
{
return false;
}

long next = current - amount;

long observed = Interlocked.CompareExchange(
ref balance,
next,
current);

if (observed == current)
{
return true;
}
}
}

ただし、残高更新以外に履歴保存や外部処理も必要なら、lockやデータベーストランザクションを使うべきです。

10. Interlocked.And・Orでビットフラグを安全に更新する

10-1. ビットフラグを共有状態として管理する方法

複数の状態を1つの整数へ格納できます。

C#
[Flags]
public enum WorkerState
{
None = 0,
Started = 1 << 0,
Paused = 1 << 1,
Stopping = 1 << 2,
Faulted = 1 << 3
}

private int _state;

各状態に異なるビットを割り当てます。

10-2. Interlocked.Orでフラグを追加する実装例

C#
public void AddState(WorkerState state)
{
Interlocked.Or(ref _state, (int)state);
}

たとえばStartedが設定済みの状態へPausedを追加しても、既存のStartedは残ります。

Interlocked.Orの戻り値は更新後ではなく、操作前の値です。Microsoft Learn

10-3. Interlocked.Andでフラグを解除する実装例

フラグを解除する場合は、対象ビットを反転したマスクとANDを取ります。

C#
public void RemoveState(WorkerState state)
{
Interlocked.And(ref _state, ~(int)state);
}

Pausedだけを解除し、ほかのフラグは維持できます。

10-4. 複数スレッドが同時にフラグを変更するケース

通常の代入では、更新を失う可能性があります。

C#
// 安全ではない
_state |= (int)WorkerState.Started;

スレッドAがStarted、スレッドBがPausedを同時に追加すると、一方の更新が上書きされる可能性があります。

Interlocked.Orなら、それぞれのOR演算がアトミックに実行されます。

10-5. enumのFlags属性と組み合わせる際の注意点

Interlockedへ渡すには、列挙値を基になる整数型へ変換します。

C#
Interlocked.Or(ref _state, (int)WorkerState.Started);

読み取り時も整数から列挙型へ変換します。

C#
public WorkerState State =>
(WorkerState)Volatile.Read(ref _state);

各列挙値には重複しない2の累乗を割り当ててください。

10-6. ビット演算による状態管理が複雑化しやすい理由

ビットフラグは複数状態を1変数として更新できる利点がありますが、状態遷移のルールが複雑になると理解しにくくなります。

たとえば、「停止中は開始状態を設定できない」「障害状態では一時停止を解除できない」といった条件は、単純なAndOrだけでは保証できません。

状態間の制約が多い場合は、状態を1つの列挙値で表現し、CompareExchangeで遷移させる方法や、lockで状態機械全体を保護する方法を検討します。

11. Interlockedをasync・await環境で使う方法

11-1. Taskを使う処理でも共有変数の競合は発生する

asyncメソッドは常に別スレッドで動くわけではありません。しかし、複数の非同期処理が並行して進み、同じ変数へアクセスすれば競合は発生します。

C#
await Task.WhenAll(
ProcessAsync(),
ProcessAsync(),
ProcessAsync());

各処理が共有カウンターを更新するなら、Interlockedなどの同期が必要です。

11-2. 非同期処理の完了件数を安全にカウントする実装例

C#
private int _completedCount;

public async Task ProcessAsync(Item item)
{
await HandleAsync(item);

int completed = Interlocked.Increment(ref _completedCount);

Console.WriteLine($"完了件数: {completed}");
}

await後に別のスレッドで継続される可能性があっても、カウンター更新は安全です。

11-3. asyncメソッドの二重実行を防止する方法

C#
private int _running;

public async Task RunAsync()
{
if (Interlocked.CompareExchange(ref _running, 1, 0) != 0)
{
return;
}

try
{
await ExecuteCoreAsync();
}
finally
{
Interlocked.Exchange(ref _running, 0);
}
}

この実装は、実行中の呼び出しを待たせず、単にスキップします。

11-4. Interlockedで待機を伴う非同期排他制御はできない

Interlockedには、処理権が得られるまで非同期に待つ機能がありません。

スピンループで待つとCPUを消費し、Thread.Sleepで待つとスレッドをブロックします。非同期メソッドでは、処理権を効率的に待てるSemaphoreSlim.WaitAsyncが適しています。

11-5. awaitを含む排他処理ではSemaphoreSlimを使う

C#
private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task RunExclusivelyAsync(
CancellationToken cancellationToken)
{
await _semaphore.WaitAsync(cancellationToken);

try
{
await ExecuteCoreAsync();
}
finally
{
_semaphore.Release();
}
}

SemaphoreSlim.WaitAsyncは、セマフォへ入れるまで非同期に待機でき、キャンセルトークンにも対応します。Microsoft Learn+1

なお、lockブロック内ではawaitを使用できません。Microsoft Learn

11-6. InterlockedとCancellationTokenを組み合わせる際の注意点

キャンセルされたからといって、すでに取得したフラグが自動解放されるわけではありません。

C#
if (Interlocked.CompareExchange(ref _running, 1, 0) != 0)
{
return;
}

try
{
cancellationToken.ThrowIfCancellationRequested();
await ExecuteCoreAsync(cancellationToken);
}
finally
{
Interlocked.Exchange(ref _running, 0);
}

キャンセル例外が発生しても、finallyで必ず状態を戻します。

12. 実践例:Interlockedを使ったスレッドセーフな処理

12-1. APIリクエスト数を集計するカウンター

C#
public sealed class RequestMetrics
{
private long _totalRequests;

public void RecordRequest()
{
Interlocked.Increment(ref _totalRequests);
}

public long TotalRequests =>
Interlocked.Read(ref _totalRequests);
}

HTTPリクエストごとに呼び出すだけで、複数リクエストの同時処理にも対応できます。

12-2. バックグラウンド処理の実行中フラグ

C#
private int _backgroundRunning;

public async Task<bool> TryRunBackgroundAsync()
{
if (Interlocked.CompareExchange(
ref _backgroundRunning,
1,
0) != 0)
{
return false;
}

try
{
await RunBackgroundCoreAsync();
return true;
}
finally
{
Interlocked.Exchange(ref _backgroundRunning, 0);
}
}

一定間隔で起動される処理が前回分と重複するのを防げます。

12-3. 一定件数ごとに処理を実行するカウンター

C#
private int _processedCount;
private const int BatchSize = 1_000;

public void RecordProcessed()
{
int count = Interlocked.Increment(ref _processedCount);

if (count % BatchSize == 0)
{
OnBatchCompleted(count);
}
}

1,000、2,000、3,000といった節目の値は1つのスレッドだけが取得するため、それぞれ1回だけOnBatchCompletedが呼ばれます。

ただし、異なる節目の処理が同時並行になる可能性はあります。OnBatchCompleted自体の排他制御が必要かどうかは別途検討してください。

12-4. 複数タスクの完了数と残り件数を管理する方法

C#
private int _completed;
private int _remaining;

public async Task RunAllAsync(IReadOnlyList<Item> items)
{
_completed = 0;
_remaining = items.Count;

Task[] tasks = items
.Select(ProcessOneAsync)
.ToArray();

await Task.WhenAll(tasks);
}

private async Task ProcessOneAsync(Item item)
{
try
{
await ProcessAsync(item);
}
finally
{
int completed = Interlocked.Increment(ref _completed);
int remaining = Interlocked.Decrement(ref _remaining);

Console.WriteLine(
$"完了: {completed}, 残り: {remaining}");
}
}

_completed_remainingは個別には正しく更新されます。ただし、2つの値を同時点の完全に整合したスナップショットとして取得できるわけではありません。

12-5. 最新オブジェクトをアトミックに差し替える方法

C#
private Configuration _current =
Configuration.CreateDefault();

public Configuration Replace(Configuration next)
{
return Interlocked.Exchange(ref _current, next);
}

public Configuration GetCurrent()
{
return Volatile.Read(ref _current);
}

Configurationを不変オブジェクトとして設計すれば、読み手は取得した参照を安全に利用しやすくなります。

12-6. 最大同時処理数の計測に使う方法

C#
private int _currentExecutions;
private int _maxExecutions;

public async Task ExecuteAsync()
{
int current = Interlocked.Increment(
ref _currentExecutions);

UpdateMax(ref _maxExecutions, current);

try
{
await ExecuteCoreAsync();
}
finally
{
Interlocked.Decrement(ref _currentExecutions);
}
}

private static void UpdateMax(
ref int maximum,
int candidate)
{
while (true)
{
int current = Volatile.Read(ref maximum);

if (candidate <= current)
{
return;
}

if (Interlocked.CompareExchange(
ref maximum,
candidate,
current) == current)
{
return;
}
}
}

負荷試験や運用監視で、実際に同時実行された最大数を記録できます。

12-7. 一度だけ初期化処理を実行する方法

単純な初期化済みフラグなら次のように実装できます。

C#
private int _initialized;

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

InitializeCore();
}

ただし、初期化失敗時の再試行や、ほかのスレッドが初期化完了を待つ必要がある場合は不十分です。そのような用途にはLazy<T>Taskの共有、SemaphoreSlimなどを検討します。

13. Interlockedでよくある間違いと注意点

13-1. Interlockedを使えばメソッド全体が安全になるという誤解

次のメソッドでアトミックなのは、Incrementの1行だけです。

C#
public void Process()
{
Prepare();
Interlocked.Increment(ref _count);
Save();
}

PrepareSaveが共有状態へアクセスするなら、別の同期が必要です。

13-2. チェックしてから更新する処理を分離する問題

C#
if (Volatile.Read(ref _state) == 0)
{
Interlocked.Exchange(ref _state, 1);
}

読み取りと交換の間に別スレッドが同じ処理を実行できます。

条件付き更新は1回のCompareExchangeにまとめます。

C#
bool acquired =
Interlocked.CompareExchange(ref _state, 1, 0) == 0;

13-3. 複数の共有変数を個別に更新して整合性が崩れる問題

C#
Interlocked.Increment(ref _successCount);
Interlocked.Add(ref _successAmount, amount);

成功件数と成功金額を同じ時点で読み取ると、一方だけ更新済みの場合があります。

両者を必ず一体として扱うなら、lockで保護するか、2つの値を保持する不変オブジェクトをCASで差し替える方法を検討します。

13-4. 戻り値が更新前か更新後かを取り違えるミス

主な戻り値は次のように覚えます。

  • Increment:更新後

  • Decrement:更新後

  • Add:更新後

  • Exchange:更新前

  • CompareExchange:更新前

  • AndOr:更新前

特にExchangeCompareExchangeの戻り値は、成功判定に利用されるため重要です。Microsoft Learn+3Microsoft Learn+3Microsoft Learn+3

13-5. Exchangeのフラグを例外発生時に戻し忘れるミス

次の実装では、ExecuteCoreが例外を投げるとフラグを戻せません。

C#
Interlocked.Exchange(ref _running, 1);
ExecuteCore();
Interlocked.Exchange(ref _running, 0);

必ずtry-finallyを使います。

C#
Interlocked.Exchange(ref _running, 1);

try
{
ExecuteCore();
}
finally
{
Interlocked.Exchange(ref _running, 0);
}

13-6. CompareExchangeループの再試行条件を誤るミス

成功判定では、戻り値を読み取り時点の値と比較します。

C#
int observed = Interlocked.CompareExchange(
ref _value,
next,
current);

if (observed == current)
{
// 成功
}

observed == nextではありません。交換前から偶然nextと同じ値だった場合など、誤判定の原因になります。

13-7. オーバーフローを見落とす問題

高頻度カウンターでは、いつか型の上限へ到達します。

C#
private int _count;

長期間稼働するサービスではlongを使う、一定期間ごとにExchangeで取得・リセットする、監視値を差分集計するなどの設計が必要です。

オーバーフロー後も大小比較を続ける最大値・世代番号・残り件数では、特に重大な不具合につながります。

13-8. false sharingによって性能が低下するケース

異なるスレッドが別々の変数を更新していても、その変数が同じCPUキャッシュライン上にあると、キャッシュの取り合いが起こることがあります。これをfalse sharingと呼びます。

高頻度カウンターを複数並べた場合に発生しやすく、Interlockedの呼び出し自体よりキャッシュ同期がボトルネックになることもあります。

対策としては、次の方法があります。

  • スレッドやパーティションごとにカウンターを分ける

  • 更新頻度の高いフィールドを別オブジェクトへ分離する

  • ローカル集計して最後に合算する

  • 実測せずに安易なパディングへ頼らない

13-9. lock-freeとwait-freeを同じ意味で扱わない

ロックを使っていないからといって、すべてのスレッドが一定回数以内に処理を完了できるとは限りません。

CASループでは、あるスレッドが何度も競合に負け、再試行し続ける可能性があります。このようなアルゴリズムはロックフリーであっても、各スレッドの完了を保証するウェイトフリーとは限りません。

用語だけで性能や進行保証を判断せず、実際のアルゴリズムを確認する必要があります。

14. Interlockedの性能を検証する

14-1. Interlockedがlockより高速になりやすい理由

Interlockedは、単一変数への特定操作に限定されています。一般的なロック管理や任意のコードブロック実行を必要としないため、単純なカウンターではオーバーヘッドが小さくなりやすい傾向があります。

ただし、競合するとCPUキャッシュラインの所有権移動が発生します。スレッド数を増やせば無制限に性能が向上するわけではありません。

14-2. 単一スレッドと複数スレッドでの違い

単一スレッドのベンチマークでは、同期手段の基本コストしか分かりません。

実際の並行処理では、次の要素が加わります。

  • ロック競合

  • CAS失敗

  • CPUキャッシュの同期

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

  • コア数

  • NUMA構成

  • 処理時間のばらつき

単一スレッドと高競合環境の両方を測定する必要があります。

14-3. 競合率によって結果が変わる理由

共有変数へのアクセスが少なければ、Interlockedはほぼ1回で完了します。

一方、すべてのスレッドが同じ変数を高頻度で更新すると、その変数がボトルネックになります。CASループでは失敗回数が増え、Incrementでもキャッシュラインの取り合いが発生します。

実際のアプリケーションと異なる競合率で測定すると、参考にならない結果が出るため注意してください。

14-4. BenchmarkDotNetでIncrementとlockを比較する方法

BenchmarkDotNetを追加します。

Bash
dotnet add package BenchmarkDotNet

簡単な比較例は次のとおりです。

C#
using System.Threading;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class CounterBenchmark
{
private const int Operations = 100_000;

private readonly object _gate = new();

private int _interlockedCount;
private int _lockedCount;

[IterationSetup]
public void Setup()
{
_interlockedCount = 0;
_lockedCount = 0;
}

[Benchmark(Baseline = true)]
public void InterlockedIncrement()
{
Parallel.For(0, Operations, _ =>
{
Interlocked.Increment(
ref _interlockedCount);
});
}

[Benchmark]
public void LockIncrement()
{
Parallel.For(0, Operations, _ =>
{
lock (_gate)
{
_lockedCount++;
}
});
}
}

public static class Program
{
public static void Main(string[] args)
{
BenchmarkRunner.Run<CounterBenchmark>();
}
}

Release構成で実行します。

Bash
dotnet run -c Release

BenchmarkDotNetでは、Benchmark属性を付けたメソッドをBenchmarkRunnerで実行し、平均時間やばらつきなどを確認できます。benchmarkdotnet.org

14-5. ベンチマーク結果を読む際の注意点

前述の例にはParallel.Forのオーバーヘッドも含まれます。したがって、Interlocked.Increment単体の命令コストではなく、並列ワークロード全体の比較です。

正確に評価するには、次の条件を変えて測定します。

  • スレッド数

  • 1スレッド当たりの更新回数

  • 共有変数の数

  • 競合率

  • クリティカルセクション内の処理量

  • 実行環境のCPUコア数

  • .NETランタイムのバージョン

1回の結果だけで結論を出さず、平均値、誤差、標準偏差も確認します。

14-6. 高頻度カウンターで競合を分散させる設計

全スレッドが1つのカウンターを更新すると、そこが直列化ポイントになります。

更新を分散させる方法として、ワーカーごとのローカルカウンターを利用できます。

C#
long[] counters = new long[workerCount];

await Parallel.ForEachAsync(
items,
async (item, cancellationToken) =>
{
int workerIndex = GetWorkerIndex();
counters[workerIndex]++;

await ProcessAsync(item, cancellationToken);
});

long total = counters.Sum();

実際には、同じ配列要素を複数スレッドが更新しない設計や、false sharingへの配慮が必要です。

厳密なリアルタイム値が不要なら、スレッドローカル集計、バッチ集計、イベントキューへの記録なども有効です。

14-7. 可読性を犠牲にしてまでInterlockedを選ばない

単純なIncrementExchangeは読みやすい一方、複雑なCASループは保守が難しくなります。

次のような場合は、多少のオーバーヘッドがあってもlockが適切です。

  • 更新ルールが複雑

  • 例外処理を含む

  • 複数の状態を扱う

  • チーム内でCASコードの理解が難しい

  • 性能問題が計測されていない

同期処理では、速さよりも正しさが優先されます。

15. Interlockedとほかの同期手段の選び方

15-1. 単一変数の加算・交換ならInterlocked

同期手段は、保護したい範囲と必要な待機方法で選びます。

単一の数値を加算・減算するなら、Interlocked.IncrementDecrementAddが適しています。

C#
Interlocked.Increment(ref _count);
Interlocked.Add(ref _total, amount);

単一の値や参照を無条件に差し替えるなら、Exchangeを使います。

C#
Interlocked.Exchange(ref _status, 1);
Interlocked.Exchange(ref _configuration, next);

期待する現在値と一致した場合だけ更新するなら、CompareExchangeを使います。

C#
bool acquired =
Interlocked.CompareExchange(ref _state, 1, 0) == 0;

複数の共有変数や複数ステップをまとめて保護するなら、lockが適しています。

C#
lock (_gate)
{
Validate();
UpdateA();
UpdateB();
}

awaitを含む処理で、実行権が得られるまで待機するならSemaphoreSlimを使います。

C#
await _semaphore.WaitAsync(cancellationToken);

try
{
await ExecuteAsync();
}
finally
{
_semaphore.Release();
}

読み取りや書き込みの可視性を制御するだけなら、Volatile.ReadVolatile.Writeを検討します。ただし、count++のような複合更新には使えません。

判断に迷った場合は、次の基準で選ぶと整理しやすくなります。

  • 単一変数の加算・交換:Interlocked

  • 複数処理の同期的な排他制御:lock

  • 非同期で待機する排他制御:SemaphoreSlim

  • 単純な状態の公開:Volatile

  • スレッドセーフなコレクション操作:ConcurrentDictionaryなど

  • プロセスをまたぐ排他制御:Mutexなど

まとめ

C#のInterlockedは、単一の共有変数をアトミックに更新するための軽量な同期手段です。

count++は読み取り、加算、書き戻しに分かれるため、複数スレッドから同時に実行すると更新が失われる可能性があります。カウンターにはIncrementAddを使い、値の差し替えにはExchange、条件付き更新にはCompareExchangeを使います。

一方、Interlockedが保証するのは、原則として1回の操作だけです。複数変数の整合性、条件判定を含む複数ステップ、awaitを含む排他処理まで自動的に安全になるわけではありません。

単一変数の単純な更新ならInterlocked、複数処理をまとめて守るならlock、非同期に待機するならSemaphoreSlimというように、処理の性質に合わせて使い分けることが重要です。