C#のlockはどれを選ぶ?.NET 9のSystem.Threading.Lockとobjectロックの違い・性能・選び方を徹底解説

同じ 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/awaitlock の本体で 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()) { ... } と等価な中間コードが生成されます。

選択指針(まとめ)

  1. 既存コード・ライブラリとの互換性が最優先:迷わず object を使う。
  2. 高スループット&短い臨界区間、かつ 同期メソッドで完結:System.Threading.Lock を検討。
  3. async/await を含むロジック:Lock は使えない(CS9217)。SemaphoreSlim 等の非同期対応プリミティブに切り替える。
  4. ボックス化・キャスト に注意:Lock を object に格納・渡すと特別扱いが外れる(CS9216)。変数・引数・フィールドの静的型を常に Lock に保つ。
  5. 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 + MonitorMonitor.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 へ)

  1. プロジェクトのターゲットを .NET 9 に更新し、言語バージョンを C# 13 に設定。
  2. 性能がボトルネックのホットパスを特定(プロファイラ/ETW/Benchmark)。
  3. 該当箇所の private readonly object _gate = new(); を private readonly System.Threading.Lock _gate = new(); へ置換。
  4. 型注釈を最後まで Lock のままに保つ(引数・戻り値・フィールド・ローカル)。
    ※ 一瞬でも object に入れると CS9216。
  5. async メソッドでは Lock を使わない(ビルド時に CS9217 で検出される)。
  6. 条件待機を使っている箇所は Monitor を維持。混用しない。
  7. ビルド後に 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&lt;string&gt; 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&lt;byte[]&gt; 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。このシンプルな判別で大半のケースを安全にカバーできます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次