タスク ベースの非同期パターンの実装

タスク ベースの非同期パターン (TAP) は、3 つの方法 (Visual Studio の C# および Visual Basic コンパイラを使用する方法、手動で行う方法、またはコンパイラと手動による方法を組み合わせた方法) で実装できます。 以下のセクションでは、それぞれの方法について詳しく説明します。 TAP パターンを使用し、計算主体の非同期操作と I/O バインドの非同期操作の両方を実装できます。 [ワークロード] セクションでは、操作の各種類を確認します。

TAP メソッドを生成する

コンパイラを使用する

.NET Framework 4.5 以降、async キーワード (Visual Basic では Async) を使用して属性設定されているメソッドは、非同期メソッドと見なされ、TAP を使用して非同期にメソッドを実装するために必要となる変換が C# コンパイラおよび Visual Basic コンパイラによって行われます。 非同期メソッドは、System.Threading.Tasks.Task オブジェクトまたは System.Threading.Tasks.Task<TResult> オブジェクトを返す必要があります。 後者の場合、関数の本体は TResult を返す必要があり、コンパイラによって、結果として得られるタスク オブジェクトでこの結果が利用可能になっていることが確認されます。 同様に、メソッド本体で処理されない例外は、出力タスクにマーシャリングされ、結果として得られるタスクが TaskStatus.Faulted 状態で終了する原因となります。 この規則に対する例外は、OperationCanceledException (または派生型) がハンドルされない場合で、結果として得られるタスクは TaskStatus.Canceled 状態で終了します。

手動で TAP メソッドを生成する

TAP パターンは、実装の制御を強化するために手動で実装することができます。 コンパイラは、System.Threading.Tasks 名前空間から公開されるパブリック アクセス機能および System.Runtime.CompilerServices 名前空間でサポートされている型に依存します。 TAP を実装するには、TaskCompletionSource<TResult> オブジェクトを作成して非同期操作を実行し、それが完了したら、SetResultSetException、または SetCanceled メソッド、またはそのいずれかのメソッドの Try バージョンを呼び出します。 TAP メソッドを手動で実装する場合には、表現されている非同期操作の完了時に、結果として得られるタスクを完了する必要があります。 次に例を示します。

public static Task<int> ReadTask(this Stream stream, byte[] buffer, int offset, int count, object state)
{
    var tcs = new TaskCompletionSource<int>();
    stream.BeginRead(buffer, offset, count, ar =>
    {
        try { tcs.SetResult(stream.EndRead(ar)); }
        catch (Exception exc) { tcs.SetException(exc); }
    }, state);
    return tcs.Task;
}
<Extension()>
Public Function ReadTask(stream As Stream, buffer() As Byte,
                         offset As Integer, count As Integer,
                         state As Object) As Task(Of Integer)
    Dim tcs As New TaskCompletionSource(Of Integer)()
    stream.BeginRead(buffer, offset, count, Sub(ar)
                                                Try
                                                    tcs.SetResult(stream.EndRead(ar))
                                                Catch exc As Exception
                                                    tcs.SetException(exc)
                                                End Try
                                            End Sub, state)
    Return tcs.Task
End Function

ハイブリッド手法

TAP パターンは手動で実装しても、コア ロジックはコンパイラの実装に委譲すると便利な場合があります。 たとえば、例外を System.Threading.Tasks.Task オブジェクトを使用して公開するのではなく、メソッドの直接の呼び出し元にエスケープするように、コンパイラが生成した非同期メソッドの外部で引数を検証する場合は、ハイブリッド手法を使用します。

public Task<int> MethodAsync(string input)
{
    if (input == null) throw new ArgumentNullException("input");
    return MethodAsyncInternal(input);
}

private async Task<int> MethodAsyncInternal(string input)
{

   // code that uses await goes here

   return value;
}
Public Function MethodAsync(input As String) As Task(Of Integer)
    If input Is Nothing Then Throw New ArgumentNullException("input")

    Return MethodAsyncInternal(input)
End Function

Private Async Function MethodAsyncInternal(input As String) As Task(Of Integer)

    ' code that uses await goes here

    return value
End Function

また、このような委譲が便利なもう 1 つのケースとして、高速パスの最適化を実装し、キャッシュされたタスクを返す場合を挙げられます。

作業負荷

計算主体および I/O バインドの非同期操作は、いずれも、TAP のメソッドとして実装することができます。 ただし、TAP メソッドがライブラリから公開される場合には、TAP メソッドは、I/O バインド操作 (計算が含まれていても、純粋な計算ではない) を含む作業負荷にのみ指定する必要があります。 メソッドが純粋に計算主体の場合、同期実装としてのみ公開してください。 そのメソッドを使用するコードによって、別のスレッドに作業をオフロードするため、または並列化を実現するためにその同期メソッドの呼び出しをタスク内にラップするかどうかが選択されます。 メソッドが I/O バインドの場合、非同期実装としてのみ公開してください。

計算主体のタスク

System.Threading.Tasks.Task クラスは、計算を集中的に行う操作の表現に適しています。 既定では、このクラスは、ThreadPool クラス内の特別なサポートを利用します。また、いつ、どこで、どのように非同期計算を実行するかを細かく制御することもできます。

計算主体のタスクは、次の方法で生成できます。

  • .NET Framework 4.5 以降のバージョンでは (.NET Core と .NET 5 以降を含む)、TaskFactory.StartNew へのショートカットとして静的 Task.Run メソッドを使用します。 スレッド プールをターゲットとする計算主体のタスクを簡単に起動するには、Run を使用します。 これが計算主体のタスクで推奨される開始方法です。 タスクによりきめの細かい制御を行う場合のみ StartNew を直接使用します。

  • .NET Framework 4 では、デリゲート (通常は TaskFactory.StartNew または Action<T>) の非同期実行を許容する Func<TResult> メソッドを使用します。 Action<T> のデリゲートを指定する場合、メソッドはデリゲートの非同期実行を表す System.Threading.Tasks.Task オブジェクトを返します。 Func<TResult> のデリゲートを指定する場合、メソッドは System.Threading.Tasks.Task<TResult> オブジェクトを返します。 StartNew メソッドのオーバーロードは、キャンセル トークン (CancellationToken)、タスクの作成オプション (TaskCreationOptions)、およびタスク スケジューラ (TaskScheduler) を受け取ります。 たとえば、Factory など、現在のタスク スケジューラをターゲットとするファクトリ インスタンスを、Task クラスの静的プロパティ (Task.Factory.StartNew(…)) として使用できます。

  • タスクを個別に生成およびスケジュールする場合は、Task 型のコンストラクターと Start メソッドを使用します。 パブリック メソッドは、既に開始されているタスクのみを返す必要があります。

  • Task.ContinueWith メソッドのオーバーロードを使用します。 このメソッドは、別のタスクが完了したときにスケジュールされる新しいタスクを作成します。 一部の ContinueWith オーバーロードは、キャンセル トークン、継続オプション、およびタスク スケジューラを受け取るため、継続タスクのスケジュール設定と実行を細かく制御することができます。

  • TaskFactory.ContinueWhenAll メソッドと TaskFactory.ContinueWhenAny メソッドを使用します。 これらのメソッドは、指定された一連のタスクのすべてまたは一部の完了時にスケジュールされる新しいタスクを作成します。 これらのメソッドには、こうしたタスクのスケジュール設定と実行を制御するためのオーバーロードも用意されています。

計算主体のタスクでは、実行開始前に取り消し要求を受信した場合に、スケジュール済みのタスクがシステムによって実行されないようにすることができます。 したがって、キャンセル トークン (CancellationToken オブジェクト) を指定すると、そのトークンを監視する非同期コードにそのトークンを渡すことができます。 また、StartNewRun など、前述のメソッドのいずれかにそのトークンを指定し、Task ランタイムによって、そのトークンが監視されるようにもできます。

たとえば、イメージをレンダリングする非同期メソッドを考えます。 タスクの本体によってキャンセル トークンをポーリングすることで、レンダリングの実行中に取り消し要求を受信した場合にコードを早期に終了できるようにします。 また、取り消し要求がレンダリングの開始前に発生した場合、レンダリング処理が行われないようにします。

internal Task<Bitmap> RenderAsync(
              ImageData data, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        var bmp = new Bitmap(data.Width, data.Height);
        for(int y=0; y<data.Height; y++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            for(int x=0; x<data.Width; x++)
            {
                // render pixel [x,y] into bmp
            }
        }
        return bmp;
    }, cancellationToken);
}
Friend Function RenderAsync(data As ImageData, cancellationToken As _
                            CancellationToken) As Task(Of Bitmap)
    Return Task.Run(Function()
                        Dim bmp As New Bitmap(data.Width, data.Height)
                        For y As Integer = 0 to data.Height - 1
                            cancellationToken.ThrowIfCancellationRequested()
                            For x As Integer = 0 To data.Width - 1
                                ' render pixel [x,y] into bmp
                            Next
                        Next
                        Return bmp
                    End Function, cancellationToken)
End Function

計算主体のタスクは、次の条件のうち最低でも 1 つが true の場合に Canceled 状態で終了します。

  • 取り消し要求は、タスクが CancellationToken 状態に遷移する前に、引数として作成メソッド (StartNewRun など) に渡される Running オブジェクトを使用して受け取ります。

  • このようなタスクの本体では、OperationCanceledException 例外がハンドルされなくなります。この例外にはタスクに渡されたのと同じ CancellationToken が含まれていて、このトークンは取り消しが要求されていることを示します。

別の例外がそのタスク本体でハンドルされないと、タスクは Faulted 状態で終了し、タスクでの待機または結果へのアクセスが試みられると例外をスローします。

I/O バインドのタスク

スレッドの実行全体に対してスレッドによって直接サポートされないタスクを作成するには、TaskCompletionSource<TResult> 型を使用します。 この型は、関連する Task インスタンスを返す Task<TResult> プロパティを公開します。 このタスクの有効期間は、TaskCompletionSource<TResult>SetResultSetExceptionSetCanceled バリアントなどの TrySet メソッドによって制御されます。

指定時間が経過すると完了するタスクを作成する場合を考えてみます。 たとえば、ユーザー インターフェイスでアクティビティを遅延させる場合などです。 System.Threading.Timer クラスには、一定時間経過後にデリゲートを非同期に呼び出す機能、また、TaskCompletionSource<TResult> を使用して、タイマーの前に Task<TResult> を配置する機能が用意されています。次に例を示します。

public static Task<DateTimeOffset> Delay(int millisecondsTimeout)
{
    TaskCompletionSource<DateTimeOffset> tcs = null;
    Timer timer = null;

    timer = new Timer(delegate
    {
        timer.Dispose();
        tcs.TrySetResult(DateTimeOffset.UtcNow);
    }, null, Timeout.Infinite, Timeout.Infinite);

    tcs = new TaskCompletionSource<DateTimeOffset>(timer);
    timer.Change(millisecondsTimeout, Timeout.Infinite);
    return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of DateTimeOffset)
    Dim tcs As TaskCompletionSource(Of DateTimeOffset) = Nothing
    Dim timer As Timer = Nothing

    timer = New Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(DateTimeOffset.UtcNow)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of DateTimeOffset)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

この用途には、別の非同期メソッドで使用できる Task.Delay メソッドが用意されています。たとえば、これは次のような非同期でのポーリング ループの実装に使用できます。

public static async Task Poll(Uri url, CancellationToken cancellationToken,
                              IProgress<bool> progress)
{
    while(true)
    {
        await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
        bool success = false;
        try
        {
            await DownloadStringAsync(url);
            success = true;
        }
        catch { /* ignore errors */ }
        progress.Report(success);
    }
}
Public Async Function Poll(url As Uri, cancellationToken As CancellationToken,
                           progress As IProgress(Of Boolean)) As Task
    Do While True
        Await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)
        Dim success As Boolean = False
        Try
            await DownloadStringAsync(url)
            success = true
        Catch
            ' ignore errors
        End Try
        progress.Report(success)
    Loop
End Function

TaskCompletionSource<TResult> クラスには、非ジェネリック クラスがありません。 ただし、Task<TResult> から Task が派生されるため、単純にタスクを返す I/O バインド メソッドに対してジェネリックな TaskCompletionSource<TResult> オブジェクトを使用できます。 これを行うには、ソースをダミー TResult と共に使用します (Boolean は適切な既定の選択肢ですが、Task のユーザーがそれを Task<TResult> にダウンキャストすることが懸念される場合は、代わりにプライベートな TResult を使用することもできます)。 たとえば、前の例の Delay メソッドは、結果として得られるオフセット (Task<DateTimeOffset>) と共に現在時間を返します。 このような結果値が不要である場合、メソッドのコードは次のように記述できます (戻り値の型と TrySetResult の引数が変化することに注意してください)。

public static Task<bool> Delay(int millisecondsTimeout)
{
     TaskCompletionSource<bool> tcs = null;
     Timer timer = null;

     timer = new Timer(delegate
     {
         timer.Dispose();
         tcs.TrySetResult(true);
     }, null, Timeout.Infinite, Timeout.Infinite);

     tcs = new TaskCompletionSource<bool>(timer);
     timer.Change(millisecondsTimeout, Timeout.Infinite);
     return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of Boolean)
    Dim tcs As TaskCompletionSource(Of Boolean) = Nothing
    Dim timer As Timer = Nothing

    Timer = new Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(True)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of Boolean)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

計算主体のタスクと I/O バインドのタスクの混在

非同期メソッドは、計算主体の操作や I/O バインド操作に限らず、この 2 つを混在させた操作を表現できます。 実際、複数の非同期操作は、しばしばより規模の大きな混合操作に合成されます。 たとえば、前の例の RenderAsync メソッドでは、いくつかの入力 imageData に基づいて、計算を集中的に行う操作を実行してイメージをレンダリングしました。 この imageData は、非同期にアクセスする Web サービスから次のように取得される場合があります。

public async Task<Bitmap> DownloadDataAndRenderImageAsync(
    CancellationToken cancellationToken)
{
    var imageData = await DownloadImageDataAsync(cancellationToken);
    return await RenderAsync(imageData, cancellationToken);
}
Public Async Function DownloadDataAndRenderImageAsync(
             cancellationToken As CancellationToken) As Task(Of Bitmap)
    Dim imageData As ImageData = Await DownloadImageDataAsync(cancellationToken)
    Return Await RenderAsync(imageData, cancellationToken)
End Function

また、この例では、単一のキャンセル トークンが複数の非同期操作でどのようにスレッド化されるかも示します。 詳細については、「タスク ベースの非同期パターンの利用」のキャンセルの使用セクションをご覧ください。

関連項目