【C#】System.Threading.Lockの使い方とobject lockとの性能比較

.NET 9 から同期的な排他制御用の System.Threading.Lock というクラスが追加されました。

元々存在する lock 構文はそのままに、lock 用に用意していた object などをほぼそのまま置き換えられる上、より高速に動作するということなので使い方の確認と性能の比較を行いたいと思います。

確認環境

  • Visual Studio 2026
  • .NET 10
  • Windows11(マルチプラットフォームは未考慮)

計測には BenchmarkDotNet(ver 0.15.8)を使用しています。

System.Threading.Lock

特に難しいことはなく C# に存在する lock 構文と組み合わせて利用できます。

using System.Threading;

// lock構文で使うオブジェクトの宣言
Lock _lockObj = new();

lock(_lockObj) // ★ここにLockオブジェクトのインスタンスを指定する
{
    // ★スレッドセーフな区間
}

もともと lock の括弧の中ってインスタンスオブジェクトなら自由に指定できていたのですが、その中に指定する専用のクラスが追加された形になります。

何が違うのか?

元々、以下のように専用の lock オブジェクトを確保して lock で使用するのが一般的な形でした。

class Example
{
    // ロック専用のオブジェクトをフィールドに持つ
    readonly object _lockObj = new();

    public void Foo()
    {
        lock (_lockObj)
        {
            // スレッドセーフな区間
        }
    }
}

で、これがどんな風に展開されていたかと言うと以下の通りで Monitor.Enter / Monitor.Exit を組み合わせた処理になっていました。

//lock (_lockObj)
//{
//    // スレッドセーフな区間
//}
// ↓↓↓
object __lockObj = _lockObj;
bool __lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(__lockObj, ref __lockWasTaken);

    // スレッドセーフな区間
}
finally
{
    if (__lockWasTaken)
    {
        System.Threading.Monitor.Exit(__lockObj);
    }
}

で、これが今回の System.Threading.Lock を使用するとその時だけ展開のされ方が専用のものに置き換わり

class Example
{
    // ロック専用のオブジェクトをフィールドに持つ
    readonly System.Threading.Lock _lockObj = new();

    public void Foo()
    {
        lock (_lockObj)
        {
            // スレッドセーフな区間
        }
    }
}

以下のように展開されます。

public void Foo()
{
    //lock (_lockObj)
    //{
    //    // スレッドセーフな区間
    //}
    // ↓↓↓
    using (_lockObj.EnterScope())
    {
        // スレッドセーフな区間
    }
    // ↓↓↓
    // usingは更に以下のように展開される
    //Lock.Scope scope = _lockObj.EnterScope();
    //try
    //{
    //    // スレッドセーフな区間
    //}
    //finally
    //{
    //    scope.Dispose();
    //}
}

EnterScope から返されるオブジェクトは Dispose パターンを持つ ref struct なので using スコープを抜ける時に Dispose され排他制御区間が終了します。

一般的な作法の場合そのまま置き換えられるので実装コスト的には相当軽く変更できます。

パフォーマンス計測

計測用コード

既存の lock(System.Object) より lock(System.Threading.Lock) のほうが高速といわれているためパフォーマンスを以下の通り計測してみました。

using System.Runtime.CompilerServices;
using System.Threading;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

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

[SimpleJob(RuntimeMoniker.Net10_0)]
[MemoryDiagnoser]
[RankColumn]
public class Test
{
    [Params(100000)]
    public int Loops { get; set; }

    // ---- Case1 ----

    int _p1;
    public int Property1
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        get => _p1;
        [MethodImpl(MethodImplOptions.NoInlining)]
        set => _p1 = value;
    }

    [Benchmark(Baseline = true)]
    public int Case1()
    {
        int value = 0;
        for (int i = 0; i < Loops; i++)
        {
            Property1 = i;
            value = Property1;
        }
        return value;
    }

    // ---- Case2 ----

    readonly object _lockObj2 = new();
    int _p2;
    public int Property2
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        get
        {
            lock (_lockObj2)
            {
                return _p2;
            }
        }
        [MethodImpl(MethodImplOptions.NoInlining)]
        set
        {
            lock (_lockObj2)
            {
                _p2 = value;
            }
        }
    }

    [Benchmark]
    public int Case2()
    {
        int value = 0;
        for (int i = 0; i < Loops; i++)
        {
            Property2 = i;
            value = Property2;
        }
        return value;
    }

    // ---- Case3 ----

    readonly Lock _lockObj3 = new();
    int _p3;
    public int Property3
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        get
        {
            lock (_lockObj3)
            {
                return _p3;
            }
        }
        [MethodImpl(MethodImplOptions.NoInlining)]
        set
        {
            lock (_lockObj3)
            {
                _p3 = value;
            }
        }
    }

    [Benchmark]
    public int Case3()
    {
        int value = 0;
        for (int i = 0; i < Loops; i++)
        {
            Property3 = i;
            value = Property3;
        }
        return value;
    }
}

計測結果

計測 PC は、CPU: Ryzen 9 5900X、メモリ: DDR4-3200 96GB 環境でReleaseビルドをコマンドラインから実行しています。

| Method | Loops  | Mean       | Error   | StdDev  | Ratio | RatioSD | Rank | Allocated | Alloc Ratio |
|------- |------- |-----------:|--------:|--------:|------:|--------:|-----:|----------:|------------:|
| Case1  | 100000 |   196.9 us | 3.29 us | 3.08 us |  1.00 |    0.02 |    1 |         - |          NA |
| Case2  | 100000 | 1,123.3 us | 6.94 us | 6.49 us |  5.71 |    0.09 |    3 |         - |          NA |
| Case3  | 100000 |   933.2 us | 5.61 us | 5.24 us |  4.74 |    0.08 |    2 |         - |          NA |

素のプロパティが高速なのは当たり前ですが、Case2 と Case3 比較で約17%高速 ということが分かります。

結果は環境により大きく変動するので常にこの数値が出るとは限りませんが System.Threading.Lock のほうが公式の意図通り高速に動作しているようです。

またこの Lock のほうが .NET の新しい標準という側面があり、利用は既存構文と比較しても差が少ないため .NET 9 以降であれば同期的な排他制御に使うロックオブジェクトは System.Threading.Lock を優先して利用していく、で良いと思いました。*1

参考サイト

Microsoft系のサイトはすぐリンクが切れるのでもしリンクが切れていたらタイトルで検索してください。

Microsoft Learn:lock ステートメント

learn.microsoft.com

Microsoft Learn:C# 13 の新機能

learn.microsoft.com

*1:VisualStudio だと既存の書き方してもサジェストにリファクタの提案が出てくるのでボタン押せば自動で新しいほうに直してくれます