Menu

C#のlockステートメント:競合状態、Interlocked、デッドロック

lockステートメントは、一度に1つのスレッドだけがコードのブロックを実行できるようにします。競合状態がカウンターを壊す様子を確認し、lockで直し、どのオブジェクトでロックすべきかを学び、単純なカウンターにはInterlockedを使い、ロックの順序によるデッドロックを避け、中でawaitするコードにはSemaphoreSlimを使います。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

複数のスレッドが同じデータを同時に読み書きすると、更新が失われたり、途中の状態が見えたりすることがあります。lock ステートメントは、コードのブロックを相互排他にします。1つのスレッドがその中にいる間、同じオブジェクトの lock に到達した他のすべてのスレッドは順番を待ちます。

問題:競合状態

4つのタスクが、それぞれ共有のカウンターに100,000を足します。答えは400,000になるはずです。

出力例:

Expected 400000, got 245609

合計はたいてい足りず、しかも実行のたびに不足する量が異なります(シングルコアのマシンではたまに正しくなることもあり、それがこの種のバグを見つけにくくしています)。count++ は1つの操作に見えますが、count を読む、1を足す、書き戻す、の3つです。2つのスレッドがどちらも500を読み、どちらも501を計算し、どちらも501を書き込むと、1回分の加算が消えます。

直し方:lock

読み取り、変更、書き込みを、共有のオブジェクトに対する lock で囲みます。

出力:

Expected 400000, got 400000

これで一度に1つのスレッドしかブロックの中に入れないので、次のスレッドが値を読む前に、各加算が完了します。ロックは可視性も保証します。ロックに入るスレッドは、前の保持者がロックを出る前に行ったすべての書き込みを見ることができます。

この保護が働くのは、count へのすべてのアクセスが同じロックを通る場合だけです。プログラムのどこかに1つでもロックなしの count++ があれば、競合が戻ってきます。

lockが何にコンパイルされるか

lock は Monitor クラスの省略形で、ブロックが例外を投げてもロックが解放されるように try/finally を伴います。

lock (sync)
{
    count++;
}

// is compiled roughly as:
bool taken = false;
try
{
    Monitor.Enter(sync, ref taken);
    count++;
}
finally
{
    if (taken) Monitor.Exit(sync);
}

Monitor には、永遠に待つ代わりにタイムアウトで諦める TryEnter(obj, timeout) や、スレッド間で合図を送るための Wait/Pulse もあります。すでにロックを保持しているスレッドは同じロックに再び入れる(ロックは再入可能)ので、ロックしたメソッドから、同じオブジェクトでロックする別のメソッドを呼べます。

ロックオブジェクトの選び方

lock (...) の中のオブジェクトは、スレッドが合意するための目印にすぎません。ルールは次のとおりです。

  • object 型のprivateで読み取り専用のフィールドを使う。 private readonly object sync = new object();。privateなので外部のコードが同じロックを取れず、readonly なのでスレッドがロックを保持している間に目印が差し替えられることもありません。
  • 決して lock (this) にしない。 オブジェクトへの参照を持つ誰もがそれでロックでき、そのコードが自分のコードをブロックしたり、デッドロックを起こしたりします。
  • 文字列や typeof(...) で決してロックしない。 文字列リテラルはインターンされ(プロセス内のすべての "orders" が同じオブジェクト)、Type オブジェクトはアプリ全体で共有されるので、無関係なコードが同じロックを奪い合うことになりかねません。
  • 値型で決してロックしない。 int に対する lock (count) はコンパイルできず(エラーCS0185)、手でボックス化すると毎回新しいオブジェクトが作られるので、何もロックしません。
  • 一貫性を保つ必要があるデータの組ごとに1つのロックを使う。 静的なデータにはstaticなロックフィールドを、インスタンスごとのデータにはインスタンスのフィールドを使います。

複数ステップの操作を保護する

lock が最も必要になるのは、確認と動作を一緒に行わなければならない場面です。スレッドセーフな口座の例で、確認してから動作するパターンと、2つのフィールドの一貫したスナップショットの両方を示します。

出力:

10 left after 33 withdrawals

ロックがないと、2つのスレッドがどちらも残高40を見て、どちらもチェックを通過し、どちらも30を引き出して、口座がマイナス20になりかねません。Summary メソッドもロックしているので、引き出し後の balance と引き出し前の withdrawals の回数を一緒に報告することはありません。

Dictionary<TKey, TValue> と List<T> もスレッドセーフではありません。同時の書き込みは、更新を失うだけでなく内部の配列を壊すことがあります。ロックで保護するか、ConcurrentDictionary<TKey, TValue> などの System.Collections.Concurrent の型を使います。

単一の値にはInterlocked

共有の状態が1つの整数か1つの参照で、更新が1ステップなら、Interlocked クラスがロックなしでアトミックに処理します。

出力:

Visits: 10000
Bytes: 5120000
Peak: 10000

Increment、Decrement、Add、Exchange は、プロセッサのアトミックな操作(x64では1つの命令)に対応しています。CompareExchange(ref location, newValue, expected) は、その場所がまだ expected を保持している場合にだけ書き込み、見つけた値を返すので、あらゆる更新を再試行のループとして組み立てられます。2つの変数に同時に触れるものには、やはり lock が必要です。

デッドロック

デッドロックは、2つのスレッドが、それぞれ相手の必要とするロックを保持しているときに起きます。

// Thread 1                          // Thread 2
lock (accountA)                      lock (accountB)
{                                    {
    lock (accountB) { /* ... */ }        lock (accountA) { /* ... */ }
}                                    }

スレッド1が accountA を取り、スレッド2が accountB を取ると、それぞれが相手を永遠に待ちます。例外もタイムアウトもなく、プログラムは固まります。「送金元」をロックしてから「送金先」をロックする2つの口座間の送金は、逆方向の2つの送金が同時に実行されると、まさにこの状態になります。

標準的な対策:

  • 決まった全体の順序でロックする。 送金なら、お金がどちら向きに動くかに関係なく、IDの小さい口座を先にロックします。
  • ロックの保持は短くし、遅い処理(I/O、ログ出力、ネットワークの呼び出し)はロックの外で行います。
  • ロックを保持したまま未知のコードを呼ばない。 イベント、コールバック、仮想メソッドは、独自のロックを取るかもしれません。
  • 固まることが失敗より悪い場合は、タイムアウト付きの Monitor.TryEnter を使います。

lockの中でawaitしない:SemaphoreSlimを使う

lock ブロックの中では await が許されていません(コンパイラエラーCS1996)。await の後、メソッドは別のスレッドで続行するかもしれず、Monitor のロックは取ったスレッドが解放しなければならないからです。非同期コードでは、カウント1の SemaphoreSlim が非同期に対応したロックとして働きます。

出力:

5 saved, one at a time

WaitAsync と Release は必ず finally で組にします。そうしないと、例外によってゲートが閉じたままになります。SemaphoreSlim(3, 3) なら3つの呼び出し元を同時に通すので、レート制限のあるAPIへの同時リクエスト数を抑えるのに使えます。

System.Threading.Lock(.NET 9)

.NET 9とC# 13では、専用の System.Threading.Lock 型が追加されました。lock ステートメントのオブジェクトが Lock の場合、コンパイラは Monitor の代わりに、より高速な EnterScope APIを使います。

private readonly Lock sync = new Lock();

public void Add(decimal amount)
{
    lock (sync)          // uses Lock.EnterScope(), not Monitor
    {
        balance += amount;
    }
}

オブジェクトの選び方のルールは同じです。それより前のバージョンでは、private readonly object が正しい選択です。

よくある間違い

  • 一部のアクセスだけをロックする。 共有データのすべての読み書きで、同じロックを取らなければなりません。
  • lock (this)、lock (typeof(X))、lock ("name")。 外部のコードが同じロックを取れてしまいます。
  • ロックの中で遅いI/Oを行う。 他のすべてのスレッドが待たされます。ブロックは短く保ちます。
  • 場所によって2つのロックを異なる順序で取る。 典型的なデッドロックです。
  • await の周りに lock を使う。 コンパイルできません。SemaphoreSlim を使います。
  • 呼び出しごとのロック。 lock (new object()) は何も保護しません。オブジェクトは共有されていなければなりません。

よくある質問

C#のlockは何をしますか?

lock (obj) { ... } は、あるロックオブジェクトについて、一度に1つのスレッドだけがそのブロックを実行できるようにします。同じオブジェクトの lock に到達した2つ目のスレッドは、最初のスレッドがブロックを出るまで待ちます。lock は try/finally の中の Monitor.Enter と Monitor.Exit にコンパイルされるので、ブロックが例外を投げてもロックは解放されます。

C#ではどのオブジェクトでロックすべきですか?

専用のprivateなフィールドです:private readonly object _sync = new object();。参照型で、保護されたデータに触れるすべてのスレッドが共有し、クラスの外から到達できないものでなければなりません。this、Type(typeof(MyClass))、文字列で決してロックしてはいけません。他のコードが同じオブジェクトでロックして、自分のコードをブロックしたりデッドロックさせたりする可能性があるからです。

lockの代わりにInterlockedを使うべきなのはどんなときですか?

共有の状態が1つの数値か参照で、操作が1ステップのときです:Interlocked.Increment(ref count)、Interlocked.Add(ref total, x)、Interlocked.Exchange、CompareExchange。これらはハードウェアのアトミックな操作で、ロックより高速です。複数のフィールドやコレクションに触れるものには lock を使います。

C#のlockの中でawaitを使えますか?

使えません。awaitの後のコードはロックを保持しているスレッドとは別のスレッドで再開するかもしれないので、コンパイラは lock ブロックの中の await を拒否します(エラーCS1996)。代わりにカウント1の SemaphoreSlim を使います:await sem.WaitAsync(); try { ... } finally { sem.Release(); }。

lockでデッドロックはどのように起きますか?

スレッド1がロックAを保持してロックBを待ち、スレッド2がロックBを保持してロックAを待つ状態です。どちらも先に進めず、プログラムは例外もなく固まります。複数のロックを常に同じ全体の順序で取る、ロックを保持する時間をできるだけ短くする、ロックを保持したまま未知のコードを呼ばない、という方法で防ぎます。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める