非同期調整プリミティブを構築する

ManualResetEventSlimCountdownEventBarrierなどの同期調整プリミティブは、待機中に呼び出し元スレッドをブロックします。 非同期コードでは、スレッドをブロックすると、他の作業を行っている可能性のあるリソースが無駄になります。 TaskCompletionSourceを使用して、呼び出し元がブロックする代わりにawaitできる非同期の代替手段を構築します。

TaskCompletionSource は、SetResult()SetException または SetCanceled を呼び出すことで手動で完了する Task を生成します。 そのタスクを待機するコードは、スレッドをブロックせずに中断し、ソースの完了時に再開します。 このパターンは、この記事のすべてのプリミティブの構成要素を形成します。

この記事のプリミティブは、教育的な実装です。 運用環境のスロットリングと相互除外の場合、「非同期セマフォ、ロック、およびおよびリーダー/ライターの調整」で説明されている組み込みの型を使用します。 作成するすべての TaskCompletionSource を常に完了します。ガイダンスについては、「 タスクを完了 する」を参照してください。

非同期の手動リセット イベント

手動リセット イベントは、非シグナル状態で開始されます。 呼び出し側はイベントを待機し、別のパーティがイベントをシグナルを送信 (設定) すると、すべての待機者が再開します。 イベントは、明示的にリセットするまで通知された状態を維持します。 同期に相当するものは ManualResetEventSlim。 .NET ランタイムは、TaskCompletionSource<TResult>を 1 ショット ブロードキャスト シグナリングに直接提供します。 リセット ラッパーを構築するのではなく、サイクルごとに新しいインスタンスを作成します。

TaskCompletionSource はそれ自体が 1 回限り手動リセット イベントです。 Task は、 Set* メソッドを呼び出してから、すべての awaiters が再開するまで不完全です。 新しいResetでスワップするTaskCompletionSourceメソッドを追加すると、再利用可能な非同期手動リセット イベントが発生します。

// Educational only — use TaskCompletionSource<T> directly instead of this sample implementation; create a new instance each cycle.
public class AsyncManualResetEvent
{
    private volatile TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

    public Task WaitAsync() => _tcs.Task;

    public void Set() => _tcs.TrySetResult();

    public void Reset()
    {
        while (true)
        {
            TaskCompletionSource tcs = _tcs;
            if (!tcs.Task.IsCompleted ||
                Interlocked.CompareExchange(
                    ref _tcs,
                    new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously),
                    tcs) == tcs)
            {
                return;
            }
        }
    }
}
' Educational only — use TaskCompletionSource(Of T) directly instead of this sample implementation; create a new instance each cycle.
Public Class AsyncManualResetEvent
    Private _tcs As TaskCompletionSource = New TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)

    Public Function WaitAsync() As Task
        Return _tcs.Task
    End Function

    Public Sub [Set]()
        _tcs.TrySetResult()
    End Sub

    Public Sub Reset()
        Do
            Dim tcs As TaskCompletionSource = _tcs
            If Not tcs.Task.IsCompleted OrElse
               Interlocked.CompareExchange(
                   _tcs,
                   New TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously),
                   tcs) Is tcs Then
                Return
            End If
        Loop
    End Sub
End Class

主要な実装の詳細:

  • コンストラクターは TaskCreationOptions.RunContinuationsAsynchronously を渡して、 Set が呼び出し元のスレッドで待機者の継続を同期的に実行しないようにします。 このフラグがないと、 Set が予期しない時間ブロックされる可能性があります。
  • Reset は、現在の TaskCompletionSource が既に完了している場合にのみ、CompareExchange を使用して新しい TaskCompletionSource と切り替えます。 このアトミック スワップにより、待機者が既に受信したタスクが孤立化するのを防ぎます。

次の例は、2 つのタスクがイベントを通じてどのように連携するかを示しています。

public static class AsyncManualResetEventDemo
{
    public static async Task RunAsync()
    {
        var gate = new AsyncManualResetEvent();

        Task waiter = Task.Run(async () =>
        {
            Console.WriteLine("Waiter: waiting for signal...");
            await gate.WaitAsync();
            Console.WriteLine("Waiter: signal received!");
        });

        await Task.Delay(100);
        Console.WriteLine("Signaler: setting the event.");
        gate.Set();

        await waiter;
    }
}
Public Module AsyncManualResetEventDemo
    Public Async Function RunAsync() As Task
        Dim gate As New AsyncManualResetEvent()

        Dim waiter As Task = Task.Run(Async Function()
            Console.WriteLine("Waiter: waiting for signal...")
            Await gate.WaitAsync()
            Console.WriteLine("Waiter: signal received!")
        End Function)

        Await Task.Delay(100)
        Console.WriteLine("Signaler: setting the event.")
        gate.Set()

        Await waiter
    End Function
End Module

非同期自動リセット イベント

自動リセット イベントは手動リセット イベントに似ていますが、1 つの待機者を解放すると、自動的に非シグナル状態に戻ります。 イベントが通知されたときに複数の呼び出し側が待機している場合は、1 つの待機者のみが再開します。 同期に相当するものは AutoResetEvent。 .NET ランタイムには、シングル ウェイター非同期シグナリング用の SemaphoreSlim が含まれています。 0を最大1に設定して初期化し、WaitAsyncを呼び出して待機し、Releaseでシグナルを送ります。

各シグナルは待機者を 1 つだけ解放するため、個々に完了できるように、 TaskCompletionSource インスタンスのコレクション (待機者ごとに 1 つ) が必要です。

// Educational only — use SemaphoreSlim(0, 1) instead of this sample implementation: call WaitAsync() to wait and Release() to signal.
public class AsyncAutoResetEvent
{
    private readonly Queue<TaskCompletionSource> _waiters = new();
    private bool _signaled;

    public Task WaitAsync()
    {
        lock (_waiters)
        {
            if (_signaled)
            {
                _signaled = false;
                return Task.CompletedTask;
            }
            else
            {
                var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
                _waiters.Enqueue(tcs);
                return tcs.Task;
            }
        }
    }

    public void Set()
    {
        TaskCompletionSource? toRelease = null;

        lock (_waiters)
        {
            if (_waiters.Count > 0)
            {
                toRelease = _waiters.Dequeue();
            }
            else if (!_signaled)
            {
                _signaled = true;
            }
        }

        toRelease?.TrySetResult();
    }
}
' Educational only — use SemaphoreSlim(0, 1) instead of this sample implementation: call WaitAsync() to wait and Release() to signal.
Public Class AsyncAutoResetEvent
    Private ReadOnly _waiters As New Queue(Of TaskCompletionSource)()
    Private _signaled As Boolean

    Public Function WaitAsync() As Task
        SyncLock _waiters
            If _signaled Then
                _signaled = False
                Return Task.CompletedTask
            Else
                Dim tcs As New TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
                _waiters.Enqueue(tcs)
                Return tcs.Task
            End If
        End SyncLock
    End Function

    Public Sub [Set]()
        Dim toRelease As TaskCompletionSource = Nothing

        SyncLock _waiters
            If _waiters.Count > 0 Then
                toRelease = _waiters.Dequeue()
            ElseIf Not _signaled Then
                _signaled = True
            End If
        End SyncLock

        toRelease?.TrySetResult()
    End Sub
End Class

主要な実装の詳細:

  • Set メソッドは、ロックTaskCompletionSourceにある (TCS) を完了します。 ロック内で TCS を完了すると、ロックが保持されている間に同期継続が実行され、デッドロックや予期しない再入が発生する可能性があります。
  • Setが呼び出され、待機者がキューに登録されていない場合は、次のWaitAsync呼び出しがすぐに完了するようにシグナルが格納されます。

次の例は、イベントを通じてコンシューマーに通知するプロデューサーを示しています。

public static class AsyncAutoResetEventDemo
{
    public static async Task RunAsync()
    {
        var autoEvent = new AsyncAutoResetEvent();

        Task consumer = Task.Run(async () =>
        {
            for (int i = 0; i < 3; i++)
            {
                await autoEvent.WaitAsync();
                Console.WriteLine($"Consumer: received signal {i + 1}");
            }
        });

        for (int i = 0; i < 3; i++)
        {
            await Task.Delay(50);
            Console.WriteLine($"Producer: sending signal {i + 1}");
            autoEvent.Set();
        }

        await consumer;
    }
}
Public Module AsyncAutoResetEventDemo
    Public Async Function RunAsync() As Task
        Dim autoEvent As New AsyncAutoResetEvent()

        Dim consumer As Task = Task.Run(Async Function()
            For i As Integer = 0 To 2
                Await autoEvent.WaitAsync()
                Console.WriteLine($"Consumer: received signal {i + 1}")
            Next
        End Function)

        For i As Integer = 0 To 2
            Await Task.Delay(50)
            Console.WriteLine($"Producer: sending signal {i + 1}")
            autoEvent.Set()
        Next

        Await consumer
    End Function
End Module

非同期カウントダウン イベント

カウントダウン イベントは、待機者が続行できるようになる前に、指定された数のシグナルを待機します。 このパターンは、N 個の操作を開始し、すべての N 個の完了を待機するフォーク/結合シナリオに役立ちます。 同期に相当するものは CountdownEvent。 .NET ランタイムは、固定された一連のタスクとのフォーク/結合の調整にWhenAllを提供します。 代わりに使用してください。

前のセクションの AsyncManualResetEvent をアトミック カウンターで構成して、非同期バージョンをビルドします。

// Educational only — use Task.WhenAll() instead of this sample implementation to coordinate a fixed set of tasks.
public class AsyncCountdownEvent
{
    private readonly AsyncManualResetEvent _event = new();
    private int _count;

    public AsyncCountdownEvent(int initialCount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(initialCount, nameof(initialCount));
        _count = initialCount;
    }

    public Task WaitAsync() => _event.WaitAsync();

    public void Signal()
    {
        if (_count <= 0)
            throw new InvalidOperationException("The event is already signaled.");

        int newCount = Interlocked.Decrement(ref _count);

        if (newCount == 0)
            _event.Set();
        else if (newCount < 0)
            throw new InvalidOperationException("Too many signals.");
    }

    public Task SignalAndWait()
    {
        Signal();
        return WaitAsync();
    }
}
' Educational only — use Task.WhenAll() instead of this sample implementation to coordinate a fixed set of tasks.
Public Class AsyncCountdownEvent
    Private ReadOnly _event As New AsyncManualResetEvent()
    Private _count As Integer

    Public Sub New(initialCount As Integer)
        If initialCount <= 0 Then Throw New ArgumentOutOfRangeException(NameOf(initialCount))
        _count = initialCount
    End Sub

    Public Function WaitAsync() As Task
        Return _event.WaitAsync()
    End Function

    Public Sub Signal()
        If _count <= 0 Then
            Throw New InvalidOperationException("The event is already signaled.")
        End If

        Dim newCount As Integer = Interlocked.Decrement(_count)

        If newCount = 0 Then
            _event.Set()
        ElseIf newCount < 0 Then
            Throw New InvalidOperationException("Too many signals.")
        End If
    End Sub

    Public Function SignalAndWait() As Task
        Signal()
        Return WaitAsync()
    End Function
End Class

Signalメソッドは、Decrementを使用してカウントをアトミックにデクリメントします。 カウントが 0 に達すると、内部イベントが設定され、すべての待機中のスレッドが実行を再開します。

次の例では、カウントダウン イベントを使用して、3 つの同時操作を待機します。

public static class AsyncCountdownEventDemo
{
    public static async Task RunAsync()
    {
        var countdown = new AsyncCountdownEvent(3);

        for (int i = 1; i <= 3; i++)
        {
            int id = i;
            _ = Task.Run(async () =>
            {
                await Task.Delay(id * 30);
                Console.WriteLine($"Worker {id}: done.");
                countdown.Signal();
            });
        }

        await countdown.WaitAsync();
        Console.WriteLine("All workers finished.");
    }
}
Public Module AsyncCountdownEventDemo
    Public Async Function RunAsync() As Task
        Dim countdown As New AsyncCountdownEvent(3)

        For i As Integer = 1 To 3
            Dim id As Integer = i
            Dim backgroundTask As Task = Task.Run(Async Function()
                Await Task.Delay(id * 30)
                Console.WriteLine($"Worker {id}: done.")
                countdown.Signal()
            End Function)
        Next

        Await countdown.WaitAsync()
        Console.WriteLine("All workers finished.")
    End Function
End Module

非同期バリア

バリアは、複数のラウンドにわたって固定された参加者のセットを調整します。 各参加者は、現在のラウンドの作業が完了したときに通知し、他のすべての参加者が完了するまで待機します。 最後の参加者が信号を出すと、すべての参加者が再開し、次のラウンドのためにバリアがリセットされます。 同期に相当するものは Barrier。 .NET ランタイムは、マルチラウンド非同期同期WhenAllを提供します。 ループと組み合わせて、ラウンドごとに 1 つの WhenAll 呼び出しを行います。

// Educational only — use Task.WhenAll() in a loop instead of this sample implementation, one call per round.
public class AsyncBarrier
{
    private readonly int _participantCount;
    private int _remainingParticipants;
    private TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

    public AsyncBarrier(int participantCount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(participantCount, nameof(participantCount));
        _remainingParticipants = _participantCount = participantCount;
    }

    public Task SignalAndWait()
    {
        TaskCompletionSource tcs = _tcs;

        if (Interlocked.Decrement(ref _remainingParticipants) == 0)
        {
            _remainingParticipants = _participantCount;
            _tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
            tcs.SetResult();
        }

        return tcs.Task;
    }
}
' Educational only — use Task.WhenAll() in a loop instead of this sample implementation, one call per round.
Public Class AsyncBarrier
    Private ReadOnly _participantCount As Integer
    Private _remainingParticipants As Integer
    Private _tcs As TaskCompletionSource = New TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)

    Public Sub New(participantCount As Integer)
        If participantCount <= 0 Then Throw New ArgumentOutOfRangeException(NameOf(participantCount))
        _participantCount = participantCount
        _remainingParticipants = participantCount
    End Sub

    Public Function SignalAndWait() As Task
        Dim tcs As TaskCompletionSource = _tcs

        If Interlocked.Decrement(_remainingParticipants) = 0 Then
            _remainingParticipants = _participantCount
            _tcs = New TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
            tcs.SetResult()
        End If

        Return tcs.Task
    End Function
End Class

主要な実装の詳細:

  • 共有 TaskCompletionSourceを完了する前に、メソッドはカウントをリセットし、次のラウンドの新しい TaskCompletionSource でスワップします。 この順序により、待機者が再開すると、バリアは次のラウンドに向けて既に準備が整います。
  • すべての参加者が同じ Taskを共有します。 このサンプルでは、RunContinuationsAsynchronously を使用して TaskCompletionSource を作成するため、バリアを完了するスレッドでインラインで実行するのではなく、継続が非同期的に再開されます。

次の例では、3 人の参加者が 2 ラウンドのバリアを通過します。

public static class AsyncBarrierDemo
{
    public static async Task RunAsync()
    {
        var barrier = new AsyncBarrier(3);
        int rounds = 2;

        Task[] participants = Enumerable.Range(1, 3).Select(id => Task.Run(async () =>
        {
            for (int round = 1; round <= rounds; round++)
            {
                Console.WriteLine($"Participant {id}: working on round {round}");
                await Task.Delay(id * 20);
                Console.WriteLine($"Participant {id}: finished round {round}, waiting at barrier");
                await barrier.SignalAndWait();
            }
        })).ToArray();

        await Task.WhenAll(participants);
        Console.WriteLine("All rounds complete.");
    }
}
Public Module AsyncBarrierDemo
    Public Async Function RunAsync() As Task
        Dim barrier As New AsyncBarrier(3)
        Dim rounds As Integer = 2

        Dim participants As Task() = Enumerable.Range(1, 3).Select(
            Function(id) Task.Run(Async Function()
                For round As Integer = 1 To rounds
                    Console.WriteLine($"Participant {id}: working on round {round}")
                    Await Task.Delay(id * 20)
                    Console.WriteLine($"Participant {id}: finished round {round}, waiting at barrier")
                    Await barrier.SignalAndWait()
                Next
            End Function)).ToArray()

        Await Task.WhenAll(participants)
        Console.WriteLine("All rounds complete.")
    End Function
End Module

こちらも参照ください