やたらawaitしてるコードに出会う
他人が作った業務システムの手直ししているとこんなコードに出会った。
string contents1 = await DoSomething1Async();
string contents2 = await DoSomething2Async();
string contents3 = await DoSomething3Async();
DoSomething*Async()を見るとasync
修飾子がついてて、それをawait
で受け止めてる形だ。
なんとなく非同期処理だなぁとは分かるが...恥ずかしながら「重い処理を実行するときにUIスレッドをブロックしないために使うアレね」ぐらいの認識。
これまでは「どうせ読み込み終わるまで次に進めないんだからUIなんてフリーズさせとけ!」の脳筋バカ思考だったので、ちょうどいい機会ということで勉強することに。
とりあえずasync/awaitだけでいい
↑にすべてが書いてある。
非同期にしたい関数の呼び出し元にawait
を付けて、呼び出し先の関数の戻り値をasync Task<T>
にする。基本これだけ。
Task.Run
とかWait
とかResult
要らんし使うな、ということらしい。
あとイベントハンドラのみ戻り値をasync void
に。その時はtry/catch
で例外をキャッチしよう。
まずこれを覚えた。
Microsoft公式の解説
もう少し詳しい解説が欲しくていろんな記事を読み漁ったが中途半端にしか書いていなくて余計に混乱した。
やはり公式のドキュメントが一番信用できる(翻訳精度はクソだが)。
いくつかある中でこれが一番易しかった。
普通に(同期で)書いたら
①Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");
②③Egg eggs = FryEggs(2);
Console.WriteLine("eggs are ready");
④Bacon bacon = FryBacon(3);
Console.WriteLine("bacon is ready");
⑤Toast toast = ToastBread(2);
ApplyButter(toast);
⑥ApplyJam(toast);
Console.WriteLine("toast is ready");
⑦Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
こうなる。上から順に処理する。いつもの脳筋のやつ。
async/awaitを使うと
①Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready");
②Task<Egg> eggsTask = FryEggsAsync(2);
③Task<Bacon> baconTask = FryBaconAsync(3);
④Task<Toast> toastTask = ToastBreadAsync(2);
④'Toast toast = await toastTask;
⑤ApplyButter(toast);
⑥ApplyJam(toast);
Console.WriteLine("Toast is ready");
Juice oj = PourOJ();
Console.WriteLine("Oj is ready");
⑦Egg eggs = await eggsTask;
Console.WriteLine("Eggs are ready");
⑧Bacon bacon = await baconTask;
Console.WriteLine("Bacon is ready");
Console.WriteLine("Breakfast is ready!");
こうなるらしい。急に複雑になった。
と思ったけどコードを見ればそうでもないことが分かる。
②Task<Egg> eggsTask = FryEggsAsync(2);
③Task<Bacon> baconTask = FryBaconAsync(3);
④Task<Toast> toastTask = ToastBreadAsync(2);
で②③④の関数を(非同期に)別スレッドで実行開始。制御(ステップ)はそのまま次に進んでいき、実行結果が欲しい(そこで止まってほしい)所④'⑦⑧にawait
を書く。
そうすれば別スレッドで実行した②③④関数の戻り値を④'⑦⑧の箇所で取得することができる。
な~~~~んとなく雰囲気は分かってきた。
awaitがミソらしい
非同期処理で重要になるのがawait
。上の記事にはこう書いてある。
(同期的)なコードを書くと、それを実行するスレッドは、他の作業を行うことができません。 何らかのタスクの処理中は割り込まれません。
タスクの実行中にスレッドをブロックしないように、このコードを更新することから始めましょう。 await キーワードを使用すると、ブロックしない方法でタスクを開始し、タスクが完了したら実行を継続できます。
例えばUIスレッドに重い処理を直書きすると、その処理が終わるまでUIがフリーズする。これは知ってる。
そこでawait
を入れておけば「ブロックしない方法でタスクを開始し、タスクが完了したら実行を継続できる」
つまりUIスレッドをブロックせずに重い処理を実行開始し、処理が終わるまで制御(ステップ)はそこで待っててくれる。
これって同期で書いたときと動きは変わらないままUIがフリーズしなくなるってこと?君なかなかええね。
awaitするとコントロールが呼び出し元に戻る?
もう少し深堀りしようとawait
のリファレンスを参照すると、もうちょっと詳しい動きが理解できる。
public static async Task Main()
{
Task<int> downloading = DownloadDocsMainPageAsync();
Console.WriteLine($"{nameof(Main)}: Launched downloading.");
int bytesLoaded = await downloading;
Console.WriteLine($"{nameof(Main)}: Downloaded {bytesLoaded} bytes.");
}
private static async Task<int> DownloadDocsMainPageAsync()
{
Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: About to start downloading.");
var client = new HttpClient();
byte[] content = await client.GetByteArrayAsync("https://docs.microsoft.com/en-us/");
Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: Finished downloading.");
return content.Length;
}
- 操作が完了するまで、
await
演算子によってDownloadDocsMainPageAsync
メソッドが保留になります。
DownloadDocsMainPageAsync
が保留になると、DownloadDocsMainPageAsync
の呼び出し元であるMain
メソッドにコントロールが返されます。
DownloadDocsMainPageAsync
メソッドで実行される非同期操作の結果が必要になるまでMain
メソッドが実行されます。
GetByteArrayAsync
ですべてのバイトが得られると、DownloadDocsMainPageAsync
メソッドの残りが評価されます。 その後、Main
メソッドの残りが評価されます。
公式の説明が硬くて読みづらいが、↓こんな感じだろうか?
Task<int> downloading = DownloadDocsMainPageAsync();
で重いasync
メソッドを別スレッドで実行開始した後、
await
が呼ばれるの間がポイントで、この間は呼び出し元の処理↓が動く。
Console.WriteLine($"{nameof(Main)}: Launched downloading.");
呼び出し元の処理はawait
の箇所↓でいったん止まり、
int bytesLoaded = await downloading;
async
メソッドが完了するとその戻り値がbytesLoaded
に格納される。
その後に呼び出し元の続きの処理が実行されるという流れだろう。
確かにUIスレッドがブロックされない
優秀なのはUIスレッドから呼び出した時にawait
で止まってもUIスレッドをブロックせず、メインループに制御が戻ってくること。
Visual Studioで適当なFormアプリケーションを作成し、こんな感じで雑にサンプルを作る。
private async void button1_Click(object sender, EventArgs e)
{
var msg = await MainAsync();
Debug.WriteLine(msg);
}
public async Task<string> MainAsync()
{
Task<string> getStringTask = DoSomethingAsync();
DoIndependentWork();
string contents = await getStringTask;
return contents;
}
void DoIndependentWork()
{
Debug.WriteLine("Working...");
}
private async Task<string> DoSomethingAsync()
{
await Task.Delay(10000);
return "Task Completed.";
}
ブレークポイントを張ってButton1を押せばDoSomethingAsync()が10秒待ち始めるが、これは別スレッドなのでステップはそのままDoIndependentWork()へ進む。
string contents = await getStringTask;
まで来るとようやく待たされると思いきや、await
が制御を呼び出し元に返すためか、すぐデバッグ状態が解除されUIにコントロールが戻ってくる。確かにUIスレッドはブロックされないぞ。
で10秒経ったらまたステップ実行モードになり、次の行でデバッグ状態になった。
ステップ遷移に慣れるまで時間がかかりそう
古い人間なのでステップは上から下に一行ずつ進むものだと思っていたが、非同期処理になると
「別スレッドに行った彼奴がまだ帰っておりませんので👋」で急に呼び出し元に戻ったり、
「彼奴が戻って来ましたので🙌」でさっきのステップに戻ったり、あちこち飛び回る印象。
飛び回るといってもawait
の場所で一旦保留になって後で戻ってくるだけだけど。
このステップの遷移は実際にデバッグ実行して試さないとなかなか分かりづらい。
こちらのドキュメントは図もあってもうちょっと分かり易いかも。
連続してawaitする意味は?
話は冒頭に戻り、↓こんな感じでawait
連続してる処理に意味はあるのか。
string contents1 = await DoSomething1Async();
string contents2 = await DoSomething2Async();
string contents3 = await DoSomething3Async();
毎ステップasync
メソッドを実行開始→終わるのを待つわけで、処理速度的にはメリットはゼロ。
ただ、とりあえずawait
しとけばUIスレッドをブロックしないので、同期で書いて「あ~UI固まってるやんヤバ」ってときに脳死でasync/await
スタイルに書き換えたらこんな感じになるんじゃないだろうか。
何かこのシステム一部ちゃんとawait
中のUI制御ができてないっぽく、データが読み込み終わる前にUIを操作できちゃってぬるぽ起きたりするんだよなぁ...(やっぱりUIフリーズしてる方が楽🤫)
いろいろと雑っぽいのでその辺も直さないと。いいお勉強になって嬉しいなぁ🙂
コメントする