2016年11月9日水曜日

async、await、CancellationTokenについて

職場でasync、await、CancellationTokenの実装方法についてまとめたので同じ内容をまとめておく。
※サンプルコードはWeb API 2を想定している。またDBアクセス部分はDapperを使用している。

この記事は下記リンクの要約なのでasync, awaitについての詳細は下記のリンクを参照してもらいたい。日本語翻訳されていないので英語。
Asynchronous Programming with async and await (C#)

使うべき場所
下記のような状況ではWebサーバのパフォーマンス向上が見込めるので積極的に使ったほうが良い。
  • DBでの処理(問い合わせ、実行など)
  • DB接続のオープン処理
  • ファイルIOなど
async, awaitの非同期処理で知っておくべきことは「awaitされている処理も呼び出し元のThreadと同一のThreadで処理される」ということにある。なぜならasync, awaitキーワードは呼び出されたメソッドを呼び出し元のThread上で必要に応じて適時処理するだけであり、新規にThreadを作成するわけではないからだ。
Threads(詳細説明)

つまりDBやファイルIOなどの別プロセスの結果待ちが発生する処理などでは非常に有用ではあるけれど、非同期処理になることを期待して単純に処理を二分しても実際には同一のThread上で二つの処理が適時行われるだけなので意味がないということ。ところでWebサーバのCPUリソースを多量に必要とするような処理の場合はasync, awaitとTask.Runを組み合わせると良い。Task.RunはThreadpoolへ処理を投げるのでパフォーマンス向上が見込める。

async, awaitのWeb APIサンプル
// TestController.cs
public async Task<TestDto> GetTest(int id, CancellationToken cancellationToken){ // A
    var dto = new TestDto();
    var data = TestDataFacade.GetDataAsync(id, cancellationToken);               // B
    dto.Salary = CalculateSalary();                                              // C
    dto.Data = await data;                                                       // D
    return dto;
}
  • A、リクエストのキャンセルを考慮してCancellationTokenを最終パラメータに指定する
    時間のかかる処理などはリクエストがキャンセルされた場合に処理を中止できるのが望ましいのでCancellationTokenを実装すると良い
  • B、非同期処理の開始
    AsyncのSuffixは非同期処理のネーミングコンベンションなので関数名につけるようしたほうが良い
  • C、Bが非同期処理されている間にCalculateSalaryが実行される
  • D、Bの非同期処理の結果待ち
// TestDataFacade.cs
public async Task<IEnumerable<TestItem>> GetDataAsync(int id, CancellationToken cancellationToken){
    using (var con = _connectionProvider.GetEditableConnection()){
        await con.OpenAsync(cancellationToken);                  // A
        return await con.QueryAsync<TestItem>(                   // B
            new CommandDefinition(                               // C
                "select * from Tests where id=@id", new { id }, cancellationToken: cancellationToken));
    }
}
  • A、DB接続のオープンを非同期で行う
  • B、Dapperで非同期問い合わせを行う
  • C、cancellationTokenを渡すためにCommandDefinitionが必要
async, awaitの動作解説図
下記リンク先にある解説図を見ると一連の動きが良く分かるので是非参照してもらいたい。
What Happens in an Async Method(詳細説明)

まとめ1
ここまででasync, awaitキーワードの使い方が動作原理を含めて理解できたと思う。使い方を誤らなければ簡易な記述で容易にパフォーマンス向上が見込めるので使える状況では積極的に使っていくべきだとは思うけれど、ともすると処理フローは複雑化しやすく、関連する箇所全体で非同期処理を念頭に置いた実装にしなければならないので使いどころは慎重に見極めないといけない。


非同期リクエストのクライアントからのキャンセル方法
こここらは非同期リクエストをクライアントからキャンセルする方法を解説する。
var currentXHR = $.ajax({
    url: "/api/Test/GetTest/1",
    type: "GET"
});

currentXHR.done(function (data) {
    // 取得したデータで何かする
}).fail(function (jqXHR, textStatus, errorThrown) {
    if (errorThrown === "abort") {
        alert('処理を中断しました。');
    } else {
        alert('処理中にエラーが発生しました。エラー内容:' + errorThrown);
    }
}).always(function () {
    currentXHR = null;
});

// MEMO : 非同期処理中にページ遷移した場合はリクエストをキャンセルする
$(window).unload(function() {
    if (currentXHR) {
        currentXHR.abort();
    }
});

// MEMO : 非同期処理中にキャンセルボタンを押下した場合はリクエストをキャンセルする
$('button.cancel').click(function() {
    if (currentXHR) {
        currentXHR.abort();
    }
});
ページ遷移時に非同期リクエストが処理されている場合は明示的にリクエストをabortしないとキャンセルされない。また重たい処理などを実行する場合はキャンセル処理を実装しておくと不要な処理の実行を防ぐことによってパフォーマンス向上が見込めるので(エンドユーザがレスポンスを待たずにページ遷移してしまったとき用などに)積極的にキャンセル処理は実装したほうが良いだろう。

Web API 2のバグ対応
Web API 2のバグで非同期処理中にAbortするとOperationCanceledExceptionが発生するのでそれを抑制する必要がある。下記コードをGlobal.asax.csに追加してほしい。
class CancelledTaskBugWorkaroundMessageHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        HttpResponseMessage response = await base.SendAsync(request, cancellationToken);
        // キャンセルされた場合はエラー内容を空っぽにして送り返す
        if (cancellationToken.IsCancellationRequested)
        {
            return new HttpResponseMessage(HttpStatusCode.InternalServerError);
        }
        return response;
    }
}

protected void Application_Start(object sender, EventArgs e) {
    GlobalConfiguration.Configure(config => {
        // ...省略...

        // abort処理で発生する例外出力を抑制するための処理
        config.MessageHandlers.Add(new CancelledTaskBugWorkaroundMessageHandler());
    });
}

まとめ2
Single Page Appなどで多量のAjaxリクエストを行っている最中にエンドユーザがページを離れてしまうとサーバサイドで行っている処理はすべて無意味になってしまうので、そのようなときのためにもキャンセル処理を組み込んでおくと良いだろう。