Cisis - C##

ASP.NET Core 開発者のブログ

async な lock をしよう

Qiita から移行

--

C# では,非同期なメソッドでは lock が使えません.この記事ではそれでも lock したいときはどうするのっていうお話をします.

たとえば,こんなふうにダブルチェックロッキングしたいとしますね.

// これが複数のスレッドから非同期に呼ばれる
private static async ValueTask 条件満たしたらなんかするAsync()
{
    if (条件)
    {
        lock (_lockObj)
        {
            if (条件)
            {
                await なんかすごく重いIOバウンドなやつAsync();
            }
        }
    }
}

lock ステートメントは非同期メソッド内で使えないので,実際にはこのコードはコンパイルが通りません. じゃあどうしようか,といって,一番ラクな逃げ道は同期にしちゃうことです.カンタンカンタン.

// これが複数のスレッドから非同期に呼ばれる
private static void 条件満たしたらなんかする()
{
    if (条件)
    {
        lock (_lockObj)
        {
            if (条件)
            {
                なんかすごく重いIOバウンドなやつAsync().Wait();
            }
        }
    }
}

Task だって Wait しちゃえばただの同期,これはちゃんと動きます.でもここで「いやなんのための async なんだよ」ってなりますよね. lock したいだけなのに,そのために非同期の恩恵を捨て去る,そんなことやっちゃダメです.

じゃあ最新技術をこね回して難しいコード書くのかっていうとそんなことはなく,むしろ全く逆で古くからある技術を使います.そう,セマフォ です. セマフォっていうと OS の機能で,プロセス間の資源のアクセス制御に使うイメージですが,.NET にはプロセス内で利用するための SemaphoreSlim クラスがあります. セマフォといっても結局待たなきゃいけないでしょって話なんですが, SemaphoreSlim クラスには非同期で待てる WaitAsync() メソッドがあるわけです. これをつかうと完全に非同期な lock が実現できるわけですね. これはもうパターンが固定なので,ちょっと汎用的に AsyncLock なんていうクラスを作っておくとどこでも使えます. やってることもとてもカンタンなので,作り方さえ覚えてしまえばとっさのときにも書けます.

/// <summary>
/// async な文脈での lock を提供します.
/// Lock 開放のために,必ず処理の完了後に LockAsync が生成した IDisposable を Dispose してください.
/// </summary>
public sealed class AsyncLock
{
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

    public async Task<IDisposable> LockAsync()
    {
        await _semaphore.WaitAsync();
        return new Handler(_semaphore);
    }

    private sealed class Handler : IDisposable
    {
        private readonly SemaphoreSlim _semaphore;
        private bool _disposed = false;

        public Handler(SemaphoreSlim semaphore)
        {
            _semaphore = semaphore;
        }

        public void Dispose()
        {
            if (!_disposed)
            {
                _semaphore.Release();
                _disposed = true;
            }
        }
    }
}

面倒な人はこれをコピペでいいです.使うときは, lock 構文の代わりに using 構文を使います.これによって, semaphore の管理を忘れて lock という意味を持たせた見た目のコードを書けます.

たとえば,最初の例を書いてみるとこんな感じです.

private static readonly s_lock = new AsyncLock();
private static async Task 条件満たしたらなんかするAsync()
{
    if (条件)
    {
        using (await s_lock.LockAsync())
        {
            if (条件)
            {
                await なんかすごく重いIOバウンドなやつAsync();
            }
        }
    }
}

lock が using に変わっただけで,あとはあまり変わりません.でもこれはコンパイルも通るし,ちゃんと非同期でパフォーマンスよく動きます.

というわけで,いままで .Wait しちゃってた方,今日からは await しましょう!