最近非同期処理関係で詰まるところがあったので、頭の整理も兼ねてまとめたいと思います。
Taskとは
C#のTaskとはTaskです。 これ自体は非同期処理でもなんでもない、ただの処理の手順書に過ぎません。
// 返ってきてるのはHTTP GETするという手順のみ Task<HttpResponseMessage> task = httpClient.GetAsync(url);
このtaskを実行することで結果を得られます。
Task<HttpResponseMessage> task = httpClient.GetAsync(url); // awaitによって実行されるため、返ってくるのは手順を実施した結果 HttpResponseMessage response = await task;
上記の例では変数task
はHttpResponseMessage
を返すことを約束するただの関数オブジェクトのようなものです。
中身の処理が何かどうかは呼び出し元には関係なく、
変数url
を渡して実行したらHttpResponseMessage
が取得できる手順書であるというのがTaskの本質です。
Taskと非同期処理
Taskは単なる手順書です。 手順通りに実行されていればTaskがどのスレッドで実行されていようが問題がありません。 そのため、Taskを実行すると自然とマルチスレッド処理として実行されます。
これがTaskと非同期処理の関係です。
async await
Taskは同期的に実行することも出来ます *1 が、通常は非同期で実行します。
Taskを非同期で実行するためにはawait
キーワードを使用します。
await
キーワードの使用されたメソッドにはasync
キーワードを使用します。
async
キーワードを使用したメソッドはTask
、Task<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
を呼んでいるため、await
とasync
を使用します。
GetMarkdownAsync
メソッドは戻り値が無いため、Task
型を返します。
このように、非同期メソッドを呼ぶメソッドは数珠つなぎのように非同期メソッドになってゆきます。 これは仕様です。仕方ないので数珠つなぎで非同期メソッドにしましょう。
async voidについて
前項でasync
キーワードを使用したメソッドはTask
、Task<T>
、void
のいずれかで返しますと言いました。
非同期処理でvoidを返すということはどういうことなのでしょうか?
Task
クラスにはTask.Status
やTask.Exception
のようなTaskの進行状況を把握するようなプロパティが存在します。
voidで返すということはこれらの情報を取得することが出来ないということになります。
つまり、実行したタスクが終わったのかも、 エラーが起こっても起こったのかすらもわからない、危険な状態になります。
そのため、async voidは特定の条件下以外では使用は推奨されません。
その条件が呼び出し元メソッドのシグネチャを変更出来ない場合です。 その代表例がWinFormsやWPFでのUIイベントハンドラになります。
これらのメソッドはvoid
を返すことが決められていますので勝手にTask
を返せばビルドエラーとなります。
そのため、必然的にasync voidを使用せざる負えないことになります。
Taskの待ち方について
Taskはawait
キーワードを使って待ちましょう。
他にもTask.Wait()
、Task.Result
、Task.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をより使いやすくしたクラスだと思って問題ないです。
参考
- Task クラス (System.Threading.Tasks) | Microsoft Docs
- Taskを極めろ!async/await完全攻略 - Qiita
- できる!C#で非同期処理(Taskとasync-await) – kekyoの丼
- Kouji Matsuiさんとの会話
- 非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)[C#、VB]:.NET TIPS - @IT
*1:Task.RunSynchronously Method
*2:Task.ResultもTask.GetAwaiter().GetResult()も内部的にはTask.Wait()を呼んでいます