同じ lock でも、ロック対象に new object() を使うのか、.NET 9 / C# 13 で追加された System.Threading.Lock を使うのかで、生成されるコードも制約も変わります。本記事では違いを体系的に整理し、設計指針・移行チェックリスト・ベンチマーク例まで含めて、現場で迷わない選択基準を提示します。
1. C# における排他制御トークン
C# の lock ステートメントは、共有リソースへの同時実行を「同時に 1 スレッドだけが実行」となるように守る構文です。歴史的には object インスタンスをロック対象にするのが一般的でしたが、.NET 9 / C# 13 では System.Threading.Lock(以下「Lock」)が導入され、lock の対象として特別扱いされます。
重要な前提:
System.Threading.Lockは クラス(参照型)です。ロック範囲を表すEnterScope()の戻り値がref struct(スタック割り当て)で、これが高速な終了処理(Disposeによる解放)を担います。lockの対象がLockであると コンパイラが特別な下位コードを生成し、Monitorベースではない経路が使われます。対象がobjectなどの一般参照型なら従来どおりMonitor.Enter/Exitが使われます。
「object インスタンス」と「System.Threading.Lock クラス」の違いと選択指針
質問・課題の概要
「小さなクリティカルセクションを高頻度で叩く処理」「I/O を含む長めの処理」「非同期コード」など、用途によって最適なロック対象は変わります。ここでは性能差・制約・使い分けを分かりやすく比較します。
| 項目 | object を使う場合 | System.Threading.Lock を使う場合 |
|---|---|---|
| 型 | 参照型(任意の object) | 参照型クラス System.Threading.Lock(EnterScope() が ref struct を返す) |
| 実装経路 | Monitor.Enter/Exit を使用(JIT は一般的最適化) | コンパイラが using (lockObj.EnterScope()) 相当のコードを生成(スコープ終了のコストが小さい) |
| 性能の傾向 | 汎用的で安定。競合が激しいと待ちが増えがち | 競合の多い短時間ロックで有利という報告が多い(概ね 20–30% 高速の例)。実アプリではワークロード依存 |
| 再入(同一スレッドでの重ね取り) | 可(モニターは再入可能) | 可(Lock も再入可能) |
| async/await | lock の本体で await は禁止(コンパイラ エラー)が、async メソッド内での使用自体は可能(本体が同期なら可) | 禁止:Lock を対象にした lock は async メソッド / async ラムダでは使えない(CS9217)。await の有無に関係なく不可 |
| ボックス化・キャスト | 影響なし | 注意:Lock を object 等にキャストすると特別扱いが外れ、Monitor 経路に落ちるため警告(CS9216)。暗黙キャストやジェネリック型経由に注意 |
| 待機/通知との併用 | Monitor.Wait/Pulse が使える | 混用非推奨:Lock の臨界区間と Monitor.Wait/Pulse は互換ではない。条件変数が要るなら object+Monitor を選ぶ |
| 典型用途 | I/O 待ちを含む一般用途/非同期コードが絡む処理/既存コードとの互換性重視 | 高頻度・短時間のクリティカルセクション(ゲームループ、数値計算、メモリ操作のホットパスなど) |
| 導入条件 | .NET 4 以降など広範。制約ほぼなし | .NET 9 / C# 13 以降。ターゲットフレームワークと言語バージョン設定が必要 |
| 診断 | Monitor.IsEntered 等 | Lock.IsHeldByCurrentThread で保有判定可能 |
サンプルコード(最小例)
// object 版(従来)
private readonly object _gate = new();
private int _value;
void Increment()
{
lock (_gate)
{
_value++;
}
}
// Lock 版(.NET 9 / C# 13 以降)
private readonly System.Threading.Lock _gate2 = new();
private int _value2;
void IncrementFast()
{
lock (_gate2) // コンパイラが EnterScope() を用いる最適化を適用
{
_value2++;
}
}
補足:System.Threading.Lock はクラスであり、最適化は EnterScope() の戻り値(ref struct)により例外時も確実にロックが解放される点にあります。using (_gate2.EnterScope()) { ... } と等価な中間コードが生成されます。
選択指針(まとめ)
- 既存コード・ライブラリとの互換性が最優先:迷わず
objectを使う。 - 高スループット&短い臨界区間、かつ 同期メソッドで完結:
System.Threading.Lockを検討。 async/awaitを含むロジック:Lockは使えない(CS9217)。SemaphoreSlim等の非同期対応プリミティブに切り替える。- ボックス化・キャスト に注意:
Lockをobjectに格納・渡すと特別扱いが外れる(CS9216)。変数・引数・フィールドの静的型を常にLockに保つ。 Monitor.Wait/Pulseが必要:object ロックを選ぶ(Lock と Monitor の混用は不可)
実務でハマりがちな落とし穴と対策
async/await と Lock(CS9217)
Lock を対象にした lock は、async メソッド / async ラムダの中では宣言できません。await の有無は関係なくエラーです。理由はロック所有がスレッド単位であり、await 後の継続が別スレッドで実行されうるためです。非同期が絡む場合は設計を見直し、以下のいずれかに切り分けます。
- 臨界区間を同期ブロックに最小化し、
awaitは 外側で行う。 SemaphoreSlimやサードパーティの非同期ロック(いわゆる AsyncLock)へ置換する。
暗黙の object 化(CS9216)
Lock を object 型の変数・プロパティ・フィールド・ジェネリック引数経由で扱うと、lock 時に Monitor 経路にフォールバックします。これは性能低下だけでなく、Lock 前提のコード規約が破られる原因にもなります。以下は NG の例です。
// NG: Lock を object に格納(CS9216 警告)
object gate = new System.Threading.Lock();
lock (gate) { /* Monitor 経路になる */ }
// OK: 静的型を Lock に保つ
System.Threading.Lock gate2 = new();
lock (gate2) { /* EnterScope() 経路 */ }
Monitor.Wait/Pulse の混用
Lock は Monitor の条件変数(Wait/Pulse)と互換ではありません。待機や条件通知が設計要件なら、最初から object + Monitor(lock)を選ぶべきです。
公開ロックの禁止
ロック対象は 外部から触れない private フィールドに限定してください。lock(this)、lock(typeof(T))、lock で文字列リテラルを対象にする等は、他所のコードと干渉してデッドロックの温床になります。
readonly と初期化タイミング
Lock フィールドは readonly で宣言し、コンストラクターまたはフィールド初期化子で生成します。差し替えは避け、生存期間をクラスの寿命に一致させます。
API の形と「なぜ速いのか」
lock (x) の対象が Lock だと、コンパイラは using (x.EnterScope()) { ... } に相当する中間コードを生成します。EnterScope() が返す ref struct はスタックに確保され、スコープを抜けるときに Dispose() が確実に呼ばれます(例外時も保証)。この「スコープ化されたロック」が、ホットパスでの小さなオーバーヘッド削減につながります。
従来の object ロックでも通常はヒープ割り当てを伴いませんが、経路が Monitor 固有であるため、Lock と比べると手続き的オーバーヘッドや状態取得のパスが長くなりがちです。公開ベンチマークでは 20–30% 程度の高速化が観測された例が複数あります。ただし 効果はワークロード依存で、臨界区間が長い / I/O を含む / 争奪が少ないケースでは差が縮みます。
設計判断のためのクイックチャート
| シナリオ | 推奨ロック | 理由 |
|---|---|---|
| CPU バウンドで短い臨界区間(1–50 命令程度)を高頻度で保護 | System.Threading.Lock | スコープ化と最適化でオーバーヘッドが小さい |
非同期フロー(async/await)が関与 | SemaphoreSlim 等 | Lock は async メソッドで使用不可(CS9217) |
| 条件変数(待機/通知)が必要 | object + Monitor | Monitor.Wait/Pulse を利用可能 |
| 既存ライブラリとの互換性・API 安定性重視 | object | 広く互換・移行コスト最小 |
| 読み取りが圧倒的に多い(Read-Mostly) | ReaderWriterLockSlim | 同時読み取りを許可してスループット向上 |
| 単純なインクリメントやフラグ更新のみ | Interlocked | ロック不要で最小オーバーヘッド |
コード例:ホットパスの保護
public sealed class Counter
{
private readonly System.Threading.Lock _lock = new();
private int _value;
public void Add(int delta)
{
// TryEnter + バックオフで競合時のスピンを抑える例
if (!_lock.TryEnter())
{
// 軽い仕事を先に片付ける、Thread.Yield() する、などの戦略も可
_lock.Enter(); // ここで待機
}
try
{
_value += delta;
}
finally
{
_lock.Exit();
}
}
public int Snapshot
{
get
{
lock (_lock)
{
return _value;
}
}
}
}
同じクラスを object ロックで書くと以下の通りです。
public sealed class CounterObjectLock
{
private readonly object _gate = new();
private int _value;
public void Add(int delta)
{
// TryEnter 相当は Monitor.TryEnter を使用
bool taken = false;
try
{
System.Threading.Monitor.Enter(_gate, ref taken);
_value += delta;
}
finally
{
if (taken) System.Threading.Monitor.Exit(_gate);
}
}
public int Snapshot
{
get
{
lock (_gate) { return _value; }
}
}
}
NG パターン集
lock(this):外部コードが同じthisをロックでき、デッドロックや待機を招く。- 公開プロパティでロックを晒す:外部と内部で異なる規約が交錯する。
Lockをobjectに入れて渡す:CS9216。コンパイラの最適化が無効に。- async メソッドで
Lock:CS9217。構文的に禁止。 LockとMonitor.Wait/Pulseの混用:動作が一致しない。
移行チェックリスト(.NET 9 / C# 13 へ)
- プロジェクトのターゲットを .NET 9 に更新し、言語バージョンを C# 13 に設定。
- 性能がボトルネックのホットパスを特定(プロファイラ/ETW/Benchmark)。
- 該当箇所の
private readonly object _gate = new();をprivate readonly System.Threading.Lock _gate = new();へ置換。 - 型注釈を最後まで
Lockのままに保つ(引数・戻り値・フィールド・ローカル)。
※ 一瞬でもobjectに入れると CS9216。 - async メソッドでは
Lockを使わない(ビルド時に CS9217 で検出される)。 - 条件待機を使っている箇所は
Monitorを維持。混用しない。 - ビルド後に
CS9216とCS9217を 警告ではなくエラーとして扱う設定を推奨(品質ゲート)。
ベンチマーク例(概念実証)
以下は 競合を意図的に強めたインクリメントで Lock と object を比較する 概念実証コードです。実アプリとスケールは異なるため、必ず自分のコードで測ってください。
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Threading;
using System.Threading.Tasks;
[MemoryDiagnoser]
public class LockVsObjectBench
{
private readonly System.Threading.Lock _lock = new();
private readonly object _obj = new();
private int _x1, _x2;
[Params(1, 2, 4, 8, 16)]
public int Threads;
[Benchmark]
public void Lock_CriticalSection()
=> RunParallel(Threads, () =>
{
lock (_lock) { _x1++; }
});
[Benchmark]
public void Object_Monitor_CriticalSection()
=> RunParallel(Threads, () =>
{
lock (_obj) { _x2++; }
});
private static void RunParallel(int threads, Action body)
{
Parallel.For(0, threads * 10_000, new ParallelOptions { MaxDegreeOfParallelism = threads }, _ => body());
}
}
public class Program
{
public static void Main() => BenchmarkRunner.Run<LockVsObjectBench>();
}
読み方:Threads を増やすほど争奪が増え、臨界区間が短いほど差が見えやすくなります。I/O を含む場合は差が埋もれます。GC 割り当てはどちらも通常ゼロに近いはずですが、EnterScope() の ref struct により、スコープ終了コストが抑えられる傾向が期待できます。
どの同期プリミティブをいつ使う?(実務プレイブック)
System.Threading.Lock:ホットパスの短時間ロック。同期で完結。object+lock:汎用。条件待機が必要なときもこれ。SemaphoreSlim:非同期と相性抜群。スループットはロックより劣る場面も。ReaderWriterLockSlim:読み取り多数・書き込み少数。SpinLock:特殊用途。再入不可。運用難度高。Interlocked:単一フィールドの原子的更新。
監査・診断のヒント
- 静的解析ルール:
CS9216(Lockを異なる型としてロック)とCS9217(Lockを async で使用)を監視。ビルドを失敗させる設定が安全。 - イベントログ/トレース:臨界区間の長さをログし、P95/P99 を追跡。ホットパスだけ
Lockに切替える戦略が現実的。 - デッドロック回避:複数ロックを取る順序を 全経路で一貫させる。
Lockにも同じ原則が適用される。
よくある質問(FAQ)
Q. Lock は Monitor より常に速い?
A. いいえ。短い臨界区間+高競合で差が出やすい傾向はありますが、I/O や長い計算を含むと差は縮みます。まず計測しましょう。
Q. Lock を使うとメモリ割り当てはゼロ?
A. ロック自体は参照型なのでヒープに存在しますが、臨界区間の開始/終了に関しては ref struct のスコープで処理されます。実際の割り当て量はほぼ増えません。
Q. Lock を公開して他クラスから受け渡してもいい?
A. 非推奨。ロックの所有」を越境させると設計が脆くなり、型が object に変わる事故(CS9216)も誘発します。ロックは常に private に留めましょう。
Q. Lock と Monitor.Wait/Pulse を両立させたい
A. できません。条件待機が必要なら最初から object ロックを選びましょう。
Q. 再入はできますか?
A. はい。Lock も Monitor も同一スレッドによる再入は可能です。ただし、再入は臨界区間の設計不備を隠すことがあるため慎重に。
実装テンプレート集
パターン A:クラス内の共有資源を守る(推奨)
public sealed class Repository
{
private readonly System.Threading.Lock _gate = new();
private Dictionary<string, string> _cache = new();
```
public string? GetOrAdd(string key, Func<string> valueFactory)
{
// 速い判定はロック外で
if (_cache.TryGetValue(key, out var v)) return v;
lock (_gate)
{
if (_cache.TryGetValue(key, out v)) return v;
v = valueFactory();
_cache[key] = v;
return v;
}
}
```
}
パターン B:async と同期を切り分ける
public sealed class FileStore
{
private readonly object _gate = new(); // async が絡むので object を採用
private readonly Dictionary<string, byte[]> _map = new();
```
public async Task<byte[]> GetAsync(string path)
{
byte[] data;
lock (_gate)
{
if (_map.TryGetValue(path, out data)) return data;
}
// I/O はロックの外で
data = await File.ReadAllBytesAsync(path).ConfigureAwait(false);
lock (_gate)
{
// 二重取り込み防止
if (!_map.ContainsKey(path)) _map[path] = data;
return data;
}
}
```
}
パターン C:スコープ API を明示的に使う
public sealed class ScopedExample
{
private readonly System.Threading.Lock _lock = new();
```
public void Work()
{
using (_lock.EnterScope())
{
// クリティカルセクション
} // ここで Dispose() により解放
}
```
}
テスト容易性と保守性
- 局所化:ロック対象はクラスに閉じ込め、ユニットテストではクリティカルセクションの粒度を検証。
- 計測フック:臨界区間突入/退出で
IDisposableトークンを用意すると、スコープ時間の集計が容易。 - フェイルファスト:タイムアウト版(
TryEnter(timeout))で異常を検出しログに出す運用も有効。
結論
同期メソッドで完結する高頻度・短時間の臨界区間では System.Threading.Lock が有力な第一候補です。コンパイラが特別扱いすることでスコープ終了コストが小さく、ホットパスのスループット向上が期待できます。一方で、非同期フローや条件待機が絡む場面、あるいは互換性最優先のコードベースでは、従来の object ロック(Monitor)が引き続き適切です。
最後にもう一度だけ要点を。
- 高速化が効くのはホットパス:まずは計測。
- async と
Lockは不可:設計で切り分ける。 - 型の一貫性を保つ:
Lockをobjectに変換しない(CS9216)。 - Monitor の機能が必要なら Monitor:混用しない。
付録:チェック式ドキュメント(貼って使える)
// ✅ Yes/No でレビュー
// [ ] ホットパス(短い臨界区間)のみ Lock を使用している
// [ ] Lock を async メソッドで使っていない(CS9217)
// [ ] Lock 型のまま渡している(object にキャストしていない:CS9216)
// [ ] 条件待機が必要な箇所は Monitor を継続利用
// [ ] ロックは private readonly フィールドに限定
// [ ] 2 個以上のロックを取る順序は全経路で一貫
// [ ] 計測により効果(P95/P99)が確認できている
まとめ(実務的ワンライナー)
「非同期コードを含まない高負荷の同期処理」なら System.Threading.Lock、それ以外は従来どおり object。このシンプルな判別で大半のケースを安全にカバーできます。

コメント