【C#】非同期で排他制御できるSemaphoreSlimクラスの使い方

最近使用する回数が増えてきた非同期 (async/await) 対応の排他制御の紹介をしたいと思います。

はじめに

同時実行数に制限がある排他制御を行うため SemaphoreSlim クラスを使います。

このクラスはプロセス内に限定した排他制御を行いたい時(すなわち、別のプロセス間で実行中のプログラムと排他制御をしない場合)軽量かつ非同期処理が利用できます。

逆に、プロセス間で横断的な排他制御が必要な場合(例えば、OS上の共有資源への排他アクセスが必要などの)機能要件がある場合は、SemaphoreSlim ではなく Semaphore や Mutex(ほかにManualResetEvent, AutoResetEvent)を使用する使い分けが必要です。

確認環境

この記事は以下の環境で動作確認をしています。

  • .NET8
  • Visual Studio 2022

.NET8 の構文を使用しているため古い環境ではエラーが発生します。

SemaphoreSlimクラス

SemaphoreSlim クラスを使った排他制御実装例です。

大抵の場合、クラス内にメンバーとして SemaphoreSlim を配置する定型的(ボイラープレート的)な使用方法が多いのでここではコピペできる形で紹介します。

// using System.Threading;

public class LockSample : IDisposable
{
    // ★排他制御用のオブジェクト
    readonly SemaphoreSlim _semaphore = new(1, 1); // 同時実行数=1で初期化

    // 排他制御が必要な処理
    public async Task Foo(TimeSpan timeout, CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this); // 破棄済みの時は例外

        bool isOk = false;
        try
        {
            // 非同期 + タイムアウトあり + キャンセルありで待機する
            isOk = await _semaphore.WaitAsync(timeout, ct);
            if (!isOk)
            {
                // 1) タイムアウトしたときはこっち
                // 2) キャンセルされたときはOperationCanceledExceptionでここでは処理しない
                // 3) Disposeされると待機が強制終了してObjectDisposedExceptionがthrowされる
                throw new TimeoutException("Wait async timed out");
            }

            // ★ここに排他的な処理の記述
            // Console.WriteLine("Start");
            // await Task.Delay(2000, ct);
            // Console.WriteLine("End");
        }
        finally
        {
            if (isOk)
            {
                _semaphore.Release();
            }
        }
    }

    // 破棄済みかどうかのフラグ
    // true: 破棄済み / false: まだ
    bool _disposed;

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        _semaphore.Dispose(); // 使い終わったら解放する
        GC.SuppressFinalize(this);
    }
}

// 使い方
static void Main(string[] args)
{
    LockSample sample = new LockSample();

    using CancellationTokenSource cts = new();

    // 同時実行数1のところに3つの処理を同時に起動
    _ = sample.Foo(TimeSpan.FromSeconds(10), cts.Token);
    _ = sample.Foo(TimeSpan.FromSeconds(10), cts.Token); // ↑が終わるまで待たされる
    _ = sample.Foo(TimeSpan.FromSeconds(10), cts.Token); // ↑が終わるまで待たされる

    Console.ReadLine();
    Console.WriteLine("exit");
}
以下のように1つずつ実行される
> [Start]
> [End]
> [Start]
> [End]
> [Start]
> [End]

この記事を見てる人に説明は不要かもしれませんが、インスタンスを作成するときの「new(1, 1)」は ( 初期カウント値, 最大カウント値 ) で、内部的にカウンターの数値を持っていて、Wait / WaitAsync するとカウンタを 1つ減らす、0 の時は 1に戻るまで待機、Release すると +1 で 0 ~ 最大カウント値の間で初期カウント値の値が変化します。

なのでコメントの通り上記例の同時実行数は 1となり、同時実行数を =3 にする場合、new(3,3)、のように記述します。

1つだけの場合、昔からある以下の構文の高機能版のようなイメージです。

public void Foo()
{
    lock(_lockObj)
    {
        // 
    }
}

余談ですが、この構文、lock構文で待機を始めると途中で中止できない & タイムアウトが設定できない、UI スレッドで使用するときは停止する可能性があるので注意が必要と(昔からある構文故に)若干最近の .NET の用途で適合しない時があるのでそういう場合も SemaphoreSlim で置き換えが検討できます。

Wait/WaitAsyncのオーバーロード一覧

本当は CancellationToken のみでタイムアウトも制御できますが、TimeSpan による待機のタイムアウトが CancellationToken とは別に指定できるためオーバーロードの種類が 同期 x 非同期 x タイムアウト(TimeSpan x ミリ秒指定) x キャンセル で数が多めのため以下の通り整理します。

メソッド名 種類 説明
void Wait( ) 同期 引数なしでキャンセル不可
void Wait( CancellationToken ) 同期 キャンセルするまで待機
bool Wait( TimeSpan ) 同期 待機時間指定あり
bool Wait( TimeSpan, CancellationToken ) 同期 待機時間とキャンセル両方
bool Wait( int ) 同期 ミリ秒指定の待機時間指定あり
bool Wait( int , CancellationToken ) 同期 待機時間(ms)とキャンセル両方
Task WaitAsync( ) 非同期 引数なしでキャンセル不可
Task WaitAsync( CancellationToken ) 非同期 キャンセルするまで待機
Task<bool> WaitAsync( int ) 非同期 ミリ秒指定の待機時間指定あり
Task<bool> WaitAsync( TimeSpan ) 非同期 待機時間指定あり
Task<bool> WaitAsync( TimeSpan , CancellationToken ) 非同期 待機時間とキャンセル両方
Task<bool> WaitAsync( int, CancellationToken ) 非同期 待機時間(ms)とキャンセル両方

タイムアウトを指定する場合戻り値が bool で true が成功 / false がタイムアウト、キャンセルできる場合 OperationCanceledException で処理を行います。

関連記事

takap-tech.com