TAP (Task-based Asynchronous Pattern) 非同期メソッドのガイドライン

  • 68
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

Task-based Asynchronous Pattern (TAP) と呼ばれる、最新の .NET Framework で推奨される非同期メソッド実装におけるデザインガイドラインをまとめました。にわかなのでマサカリ歓迎します。

Async および Await を使用した非同期プログラミング (C# および Visual Basic) も参照してください。
また、下記では .NETで非同期ライブラリを正しく実装する の内容を踏まえています。

メソッド名は ~Async もしくは ~TaskAsync とする

非同期操作を実行するメソッドは名前に Async サフィックスをつけます。
もし既に Async で終わる Task を返却しない同名メソッド(IAsyncResult などを返すメソッド)が存在している場合は TaskAsync とします。

var result = await httpClient.GetAsync("http://google.co.jp/");

TAP の非同期操作は、操作名の後に Async サフィックスが付きます。たとえば、取得 (Get) 操作の場合は GetAsync になります。 既に Async サフィックスの付いたメソッド名を含むクラスに TAP メソッドを追加する場合は、代わりに TaskAsync サフィックスを使用します。 たとえば、既にクラスに GetAsync メソッドが含まれている場合は、GetTaskAsync という名前を使用します。
タスク ベースの非同期パターン (TAP)

例外

イベントハンドラで非同期処理を実行する場合は従来のイベントハンドラ名で構いません。

private async void btnExecute_Click(object sender, EventArgs e)
{
    await foo.BarAsync();
    DoSomething();
}

戻り値は Task もしくは Task<T> とする

非同期操作を実行するメソッドの戻り値は Task もしくは Task<T> を戻り値とします。
戻り値に void は使用しません。

例外

イベントハンドラで非同期処理を実行する場合は void で構いません。
イベントハンドラ以外で void の非同期メソッドを作成してはいけません。

private async void btnExecute_Click(object sender, EventArgs e)
{
    await foo.BarAsync();
    DoSomething();
}

(参考) asyncの落とし穴Part3, async voidを避けるべき100億の理由

ライブラリとして提供するメソッドで Task.Run は使用しません

Task.Run を使用するかどうかは呼び出し元が決定できますし、するべきです。

すなわち、ライブラリが非同期メソッドを提供するのは主に内部でネイティブ非同期メソッドを使用する場合です。

public async Task<bool> NanikaHandan()
{
    var result = await _httpClient.GetAsync(_toaruUri).ConfigureAwait(false);
    var str = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    return str == _nanika;
}

ネイティブ非同期メソッドはスレッドプールを使った別スレッドによる非同期処理を目的としていません。
I/Oなどの待ちに対してスレッドを空けて同時実効性を高めることが目的のため、Task.Run を使用する非同期処理とは本質的に目的が異なります。

例外

.NETで非同期ライブラリを正しく実装する 内で触れられています。

ライブラリとして提供するメソッドで ConfigureAwait(false) を呼び出します

呼び出し元の UI で Task.Wait() を使用せざるを得ない場合にデッドロックになる現象を回避するためです。
これが有名な ConfigureAwait(false) 地獄です。有名なのかな……?

冒頭の記事のほか HttpClient詳解、或いは非同期の落とし穴について でも解説されています。

また、Web の場合(かつ処理の完了を待たないような書き方をするケースで)は SynchronizationContextnull になって NullReferenceException が発生するという問題もあります。こちらは asyncの落とし穴Part2, SynchronizationContextの向こう側 で解説されています。

ちなみに、この辺は過去に識者たちが改めて解説してくださいました。
ConfigureAwait - Togetterまとめ

(2016/02/06 追記) ConfigureAwait(false) をつけまくるかどうかについて

デッドロックを防ぐには以下の様な呼び出し方をする方法もあることを教えてもらいました。

Task.Run(async () => await hoge.FugaAsync()).Wait();

このようにすると hoge.FugaAsync() が戻そうとする先の SynchronizationContextTask.Run によって生成されたスレッドプールからのスレッドに対して向くため、Wait() するスレッドと互いに待ち合う形にならず、デッドロックを防止できます。

以下の様なメリット・デメリットが考えられます。

  • ライブラリ側が ConfigureAwait(false) をつけまくる
    • [○] 呼び出し元が Wait() を呼んでしまってもデッドロックしない
    • [△] ConfigureAwait(false) のつけ忘れがあるとデッドロックしてしまう
    • [○] 同期コンテキストを戻さない分、パフォーマンスが良い
    • [×] ソースコードにノイズが増えて見づらくなる(横に長くなるし)
  • 呼び出し元で Task.Run して回避する
    • [○] ライブラリ側が書きやすくなる
    • [×] パフォーマンス面では不利になる
    • [×] 呼び出し元が Wait() する際にきちんと理解して回避しないとデッドロックする

要はライブラリ側で考慮してあげるか、利用側が考慮して呼び出すかという責務の問題になります。

ライブラリ開発者、利用者がそれぞれどのような人たちなのか、考えられる Wait() せざるを得ない状況がどのくらいあるのか、等によって決めると良いかと思います。

(参考) Wait() をなるべく無くす工夫

catch 句で await が使えない問題

C#5.0以前では catch 句で await を使用することができません。

catch (HogeException ex)
{
    // C#5.0 以前では await できない
    await foo.HandleErrorAsync();
    throw;
}

そのため Wait() を呼び出さざるを得ないということもあるのではないでしょうか。

catch (HogeException ex)
{
    // 同期版メソッドが用意されていない場合など
    foo.HandleErrorAsync().Wait();
    throw;
}

しかし、ExceptionDispatchInfo を使用することで非同期メソッドを同期的に待機しなくて済むように工夫できるケースがあります。

ExceptionDispatchInfo info = null;
try
{
    ....
}
catch (HogeException ex)
{
    // 例外情報を捕捉
    info = ExceptionDispatchInfo.Capture(ex);
}

if (info != null)
{
    // catch の外で処理する
    await foo.HandleErrorAsync();
    // Throw() で例外情報を保ったまま再スローできる
    info.Throw();
}

可能であれば CancellationToken を受け取ります

非同期処理をキャンセル可能とするために CancellationToken を受け取るオーバーロードを作成します。

public async Task<bool> NanikaHandan(CancellationToken cancellationToken)
{
    var result = await _httpClient.GetAsync(_toaruUri, cancellationToken).ConfigureAwait(false);
    var str = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
    return str == _nanika;
}

また、CancellationToken を受け取らないオーバーロードから CancellationToken を受け取るオーバーロードを呼び出す場合には CancellationToken.None を使用できます。

public Task<bool> NanikaHandan()
{
    return NanikaHandan(CancellationToken.None);
}

Task.Run を使わない場合、自身でキャンセル要求に対する中断を実装することは少ないかもしれませんが、呼び出し元からキャンセルが要求されたかを確認して処理を中断したい場合には CancellationToken.ThrowIfCancellationRequested() メソッドを使用します。

末尾の処理が非同期メソッドの呼び出しで、かつ唯一の場合は await を使用せずにそのまま返します

public async Task HogeAsync()
{
    await fuga.DoSomethingAsync();
}

上記は下記のようにします。
不要な Awaitor 呼び出しが挟まらなくなります。

public Task HogeAsync()
{
    return fuga.DoSomethingAsync();
}

ちょうど IEnumerable<T>foreach で列挙して yield return するなら、その IEnumerable<T> をそのまま戻せばいいじゃない、と同じような話かと。

(2016/02/06 追記) とは言え…

実際には以下の様な修正が発生した時に変更量が多くなってしまうので微妙なところです。

public Task HogeAsync()
{
    // ここに piyo.DoSomethingAsync(); を追加したい

    return fuga.DoSomethingAsync();
}

public async Task HogeAsync() // この行が Diff に :sob:
{
    await piyo.DoSomethingAsync();

    await fuga.DoSomethingAsync(); // この行も diff に :sob:
}

ということで、私は普段は「別のオーバーロードを呼び出す場合」くらいでしか「await せずに直接 Task を返却」ということをやっていません。別のオーバーロード呼び出しであれば前後に処理が増えることはほとんど発生しないためです。

(2016/02/12 追記) using を使う場合などは注意が必要

うっかりやりかねないので念のため書いておきます。

下記のようなコードは意図しない動作をするので注意が必要です。

public Task HogeAsync()
{
    using (var foo = new Foo())
    {
        return foo.FugaAsync(); // foo.FugaAsync() の終了を待たずに foo.Dispose() が呼ばれる
    }
}

これは以下と等価ではありません。

public async Task HogeAsync()
{
    using (var foo = new Foo())
    {
        await foo.FugaAsync(); // foo.Dispose() は foo.FugaAsync() の待機後に呼ばれる
    }
}

それぞれ(概ね)以下のように展開されるためです。

// 1 つ目のパターン

public Task HogeAsync()
{
    // using は try-finally で Dispose するコードのシンタックスシュガー
    Foo foo = null;
    try
    {
        foo = new Foo();
        return foo.FugaAsync(); // Task を返す
    }
    finally
    {
        // Task を返した直後に呼ばれる
        if (foo != null)
            foo.Dispose();
    }
}

// 2 つ目のパターン

public Task HogeAsync()
{
    // using 内で await するとコンパイラが実行順序をヨシナニしてくれる
    var foo = new Foo();
    // 実際は Awaiter 経由だし SynchronizationContext.Current が null のケースとかあるけど、イメージとしてはこんな感じ
    return foo.FugaAsync().ContinueWith(t =>
    {
        foo.Dispose();
    }, TaskScheduler.FromCurrentSynchronizationContext());
}

なお、もはや非同期の話ですらないですが、yield return についても using 内で同じ話になるので注意。

public IEnumerable<FooBar> GetFooBars()
{
    using (hoge)
    {
        return fooBars;
    }
}

// ↑と↓は同義ではない

public IEnumerable<FooBar> GetFooBars()
{
    using (hoge)
    {
        foreach (var fooBar in fooBars)
        {
            yield return fooBar;
        }
    }
}