[C#] 非同期処理がマジで分からなくて泣きそう

  • 投稿日:
  • by
  • Category:

やたら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!");

synchronous-breakfast.png

こうなる。上から順に処理する。いつもの脳筋のやつ。

 

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!");

asynchronous-breakfast.png

こうなるらしい。急に複雑になった。

と思ったけどコードを見ればそうでもないことが分かる。

②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の場所で一旦保留になって後で戻ってくるだけだけど。

このステップの遷移は実際にデバッグ実行して試さないとなかなか分かりづらい。

こちらのドキュメントは図もあってもうちょっと分かり易いかも。

navigation-trace-async-program.png

 

連続してawaitする意味は?

話は冒頭に戻り、↓こんな感じでawait連続してる処理に意味はあるのか。

string contents1 = await DoSomething1Async();
string contents2 = await DoSomething2Async();
string contents3 = await DoSomething3Async();

毎ステップasyncメソッドを実行開始→終わるのを待つわけで、処理速度的にはメリットはゼロ。

ただ、とりあえずawaitしとけばUIスレッドをブロックしないので、同期で書いて「あ~UI固まってるやんヤバ」ってときに脳死でasync/awaitスタイルに書き換えたらこんな感じになるんじゃないだろうか。

何かこのシステム一部ちゃんとawait中のUI制御ができてないっぽく、データが読み込み終わる前にUIを操作できちゃってぬるぽ起きたりするんだよなぁ...(やっぱりUIフリーズしてる方が楽🤫)

いろいろと雑っぽいのでその辺も直さないと。いいお勉強になって嬉しいなぁ🙂

コメントする