C#のTaskと非同期処理についてまとめる

最近非同期処理関係で詰まるところがあったので、頭の整理も兼ねてまとめたいと思います。

Taskとは

C#のTaskとはTaskです。 これ自体は非同期処理でもなんでもない、ただの処理の手順書に過ぎません。

// 返ってきてるのはHTTP GETするという手順のみ
Task<HttpResponseMessage> task = httpClient.GetAsync(url);

このtaskを実行することで結果を得られます。

Task<HttpResponseMessage> task = httpClient.GetAsync(url);
// awaitによって実行されるため、返ってくるのは手順を実施した結果
HttpResponseMessage response = await task;

上記の例では変数taskHttpResponseMessageを返すことを約束するただの関数オブジェクトのようなものです。 中身の処理が何かどうかは呼び出し元には関係なく、 変数urlを渡して実行したらHttpResponseMessageが取得できる手順書であるというのがTaskの本質です。

Taskと非同期処理

Taskは単なる手順書です。 手順通りに実行されていればTaskがどのスレッドで実行されていようが問題がありません。 そのため、Taskを実行すると自然とマルチスレッド処理として実行されます。

これがTaskと非同期処理の関係です。

async await

Taskは同期的に実行することも出来ます *1 が、通常は非同期で実行します。

Taskを非同期で実行するためにはawaitキーワードを使用します。 awaitキーワードの使用されたメソッドにはasyncキーワードを使用します。 asyncキーワードを使用したメソッドはTaskTask<T>voidのいずれかで返します。

そのため、以下のような記述が成り立ちます。

private async Task<HttpResponseMessage> CallGetAsync(string url)
{
    var httpClient = new HttpClient();
    var response = await httpClient.GetAsync(url);
    return response;
}

private async Task GetMarkdownAsync()
{
    var url = "https://neko3cs.hatenablog.com/";

    var response = await CallGetAsync(url);
    var text = await response.Content.ReadAsStringAsync();

    File.WriteAllText(_path, text);
}

上記では、CallGetAsyncメソッド内のhttpClient.GetAsyncはTaskを返すメソッドのため、awaitキーワードを使用して実行しています。 CallGetAsyncメソッド内でawaitキーワードが使用されたため、メソッドにはasyncキーワードを使用します。 返したい値はHttpResponseMessage型のresponseなので戻り値はTask<HttpResponseMessage>になります。

GetMarkdownAsyncメソッドも非同期メソッドであるCallGetAsyncを呼んでいるため、awaitasyncを使用します。 GetMarkdownAsyncメソッドは戻り値が無いため、Task型を変えします。

このように、非同期メソッドを呼ぶメソッドは数珠つなぎのように非同期メソッドになってゆきます。 これは仕様です。仕方ないので数珠つなぎで非同期メソッドにしましょう。

async voidについて

前項でasyncキーワードを使用したメソッドはTaskTask<T>voidのいずれかで返しますと言いました。 非同期処理でvoidを返すということはどういうことなのでしょうか?

TaskクラスにはTask.StatusTask.ExceptionのようなTaskの進行状況を把握するようなプロパティが存在します。 voidで返すということはこれらの情報を取得することが出来ないということになります。

つまり、実行したタスクが終わったのかも、 エラーが起こっても起こったのかすらもわからない、危険な状態になります。

そのため、async voidは特定の条件下以外では使用は推奨されません

その条件が呼び出し元メソッドのシグネチャを変更出来ない場合です。 その代表例がWinFormsやWPFでのUIイベントハンドラになります。

これらのメソッドはvoidを返すことが決められていますので勝手にTaskを返せばビルドエラーとなります。

そのため、必然的にasync voidを使用せざる負えないことになります。

Taskの待ち方について

Taskはawaitキーワードを使って待ちましょう。

他にもTask.Wait()Task.ResultTask.GetAwaiter().GetResult()等の方法でTaskを待つことが出来ますが、 これらはTaskが完了するまで呼び出し元スレッドの処理を停止させます。 *2

コンソールアプリの場合は慣例のConsole.ReadLine()でアプリが終了しないようにする代わりになるため問題ではありません。 ですが、WinFormsやWPFのようなUIのあるアプリの場合はどうでしょうか?

呼び出し元スレッド = UI実行スレッドが停止してアプリが固まります。 だめですね。

そのため、UIのあるアプリではTask.Wait()Task.Resultしてはいけません

非同期処理と排他制御

lockキーワードによる排他制御下でawaitキーワードを使用することは出来ません。

これはlockによる排他制御では排他ロックしたスレッドでロックを解放しないといけない制約があり、 awaitしてしまうとスレッドが切り替わってしまう可能性があるため文法レベルで使えなくなっています。

この代替策としてAsyncLockというものがあるのでこれを使用しましょう。 AsyncLockでは内部的にSemaphoreSlimクラスが使われています。

SemaphoreSlimによる排他制御はスレッドに依存しないため、 非同期処理によってスレッドが切り替わってしまっても排他ロックの解放が出来ます。

AsyncLockはSemaphoreSlimをより使いやすくしたクラスだと思って問題ないです。

参考

*1:Task.RunSynchronously Method

*2:Task.ResultもTask.GetAwaiter().GetResult()も内部的にはTask.Wait()を呼んでいます