いまから「同期処理」と「非同期処理」について説明する。きいていけ。

適当にググって出てくる記事どもの説明が本当にわかりづらくって困る。頭にきたから自分で説明してみる。そういう記事です。

図があったほうが分かりやすいんだけど面倒だから文章で話す。

◆処理

「処理」というものについて考える。そして、処理の事を以下「タスク」と呼称する。

例えば下記のタスクを考える。

  • 現在地点から1000メートル先のA地点に、箱が置いてあるので
  • その上に箱を積む

テメーはこのタスクを処理したい。そして、タスクを処理するためにアンドロイドを連れてきた。このタスクを処理するルールとして

  • 箱はロボが運ぶ
  • ロボ一体が持つことのできる箱は一つ

と設定してみる。

タスクを処理するためには、まぁロボに箱を持たして「積んできなっせ」と命令したればいい。ロボは甲斐甲斐しく箱を運搬し、A地点で箱を積み、やり遂げた顔で帰ってくる。

これが1タスクを処理するためのすべてだ。いいだろうか。

◆複数タスク

さて、先ほどロボを使って無事タスクを処理したわけであるが、気を良くしたあなたは同じタスクを五個処理してやろうと考えた。

アンドロイドは十体連れてきている。さて、全タスクを処理するために必要な時間はどれほどであろうか。

この質問に対して「さっきのタスクの五倍の時間がかかる」と答える智将はまずいないだろう。なぜなら、箱は五個でロボが十体なんだから、ロボ五体にそれぞれ箱を持たして命令すればいいからだ。

自明なことのように思えるが、プログラミングでは「五倍の時間がかかる」が正解だったりする。こんな感じだ。

◆おまえ「箱積んできて」
★ロボ「ハコ ツンデクル」
~ 114514秒後 ~
★ロボ「ハコ ツンデキタゾ」
◆おまえ「よし」
◆おまえ「次はこの箱積んできて」
★ロボ「ハコ ツンデクル」
~ 114514秒後 ~
★ロボ「ハコ ツンデキタゾ」
◆おまえ「よし」

以下省略

なんかアホっぽいけど、これが普通のプログラムだ。

ロボはロボ同士で仲がいいので、別に喧嘩はしない。ロボが帰ってくるのを待つ必要はない。五体同時に放てば良いだろう。つまり「同時に五個のタスクをこなす」ように依頼することになる。それが「非同期処理」だ。

理解のキモは「処理の完了をいちいち待たないで次の処理を発火する」という考え方である。

下記のコードを書けばよかろう。

async main(){
  運ばせる(箱);
  運ばせる(箱);
  運ばせる(箱);
  運ばせる(箱);
  運ばせる(箱);
}

async Task 運ばせる(Box 箱){
  ロボに持たせる(箱);
  ロボに命令する(運べ);
}

※例がアレだけど何となくで理解してくれ。

main関数で「運ばせる」タスクが五回呼ばれているな。async(エイシンク)関数であるmainの中で、これまたasyncなタスクが実行されたわけであるが、main関数は「運ばせる」の完了を待たない。

一回目の「運ばせる」を実行し、完了を待たず次の「運ばせる」を実行している。main関数はasyncタスクの完了を何も待たないので、運ばせ終わったかどうかを知る術はない。

運び終わったのかどうかがわからないのはちょっと問題ではあるが、とりあえず先に進もう。

ちなみに、この例での非同期処理は「並列処理」に分類される。「並列処理」についても取り敢えず置いておこう。

◆複雑なタスク

非同期処理は、同期することが可能だ。これはどういうことだろうか。なんとなくできないような気がするけど。

では、下記のタスクを考える。さっきまでのタスクと基本ルールは同じだ。

  • リンゴ箱がふたつ。ミカン箱がみっつある。
  • 最初にメロン箱が置いてあるので
  • まず二つのリンゴ箱を上に載せ
  • そのあと三つのミカン箱を上に載せる

これを処理したい。

非同期処理を知ってテカテカしてる貴様は、愚かにも全ての箱をロボ五体にそれぞれ担がせて「積んできなっせ」と言ってしまった。

async main(){
  運ばせる(リンゴ箱);
  運ばせる(リンゴ箱);
  運ばせる(ミカン箱);
  運ばせる(ミカン箱);
  運ばせる(ミカン箱);
}

async Task 運ばせる(Box 箱){
  ロボに持たせる(箱);
  ロボに命令する(運べ);
}

ロボたちは箱を持っていき、積んだ。ロボたちが帰ってきて「ハコ ツンデキタゾ」と言っているので、エラーは発生しなかったようだ。

タスクを片付けたあなたはロボをねぎらい、そして口笛を吹きながら軽やかなステップで箱タワーを確認しに行った。

ミカン箱とリンゴ箱が交互に積まれていた。

それを見たあなたは失禁しながら膝から崩れ落ち、人目もはばからずに号泣した。

◆await

非同期処理に手を出したせいで泣きながらパンツを洗うことになった。どうすればこの悲劇を免れることができただろう。お分かりのこととは思うが、まず先にリンゴ箱を運ばせて、ロボが帰ってくるのを待ってからミカン箱を運ばせればよかったんじゃなかろうか。

じゃあコードを直そう。直すためには「処理の待ち受け」という概念が必要になる。その概念をawaitと表現する。リンゴ箱ひとつとミカン箱ひとつを順に積むコードは以下のようになる。

async main(){
  await 運ばせる(リンゴ箱);
  await 運ばせる(ミカン箱);
}

async Task 運ばせる(Box 箱){
  ロボに持たせる(箱);
  ロボに命令する(運べ);
}

awaitでリンゴ箱の積み終わりを待ち受けてから、ミカン箱をもっていかせている。

そして、 複数のタスクをまとめて一つのタスクにできればよかろう。Runというメソッドを定義する。Runは

  • 引数に渡されたTaskをまとめて新しいTaskをひとつ生成し
  • それを実行する
async main(){
  await Run(運ばせる(リンゴ箱), 運ばせる(リンゴ箱));
  await Run(運ばせる(ミカン箱), 運ばせる(ミカン箱), 運ばせる(ミカン箱));
}

async Task 運ばせる(Box 箱){
  ロボに持たせる(箱);
  ロボに命令する(運べ);
}

さっきも言ったけどawaitは「待ち受ける」という意味で、これがつまり「同期」の役割を果たす。

一回目のRunメソッドでリンゴ箱を運んで行ったロボたちについて、帰ってくるのをawaitしてからミカン箱タスクを開始している。これは何かうまくいきそうな気配がするだろう。実際うまくいく。

◆asyncの実行タイミング

タスクを実行していることを誰かに知らせたいとき、どういうコードを書けばいいだろうか。

「報告」メソッドを定義する。

  • 報告書を作成し
  • 対象へ報告書を持って行って渡し
  • 帰ってくる

このタスクにもまぁまぁ時間がかかる。ロボたちを見送ってから報告タスクを実行してみよう。

async main(){
  リンゴ箱タスク = Run(運ばせる(リンゴ箱), 運ばせる(リンゴ箱));
  報告("いまリンゴ箱積んでます");
  await リンゴ箱タスク;
  ミカン箱タスク = Run(運ばせる(ミカン箱), 運ばせる(ミカン箱), 運ばせる(ミカン箱));
  報告("いまミカン箱積んでます");
  await ミカン箱タスク;
}

async Task 運ばせる(Box 箱){
  ロボに持たせる(箱);
  ロボに命令する(運べ);
}

「実行開始」と「実行完了の待ち受け」を別の場所に書くことができる。こういうコードが書けるのは非同期の利点の一つだ。

同期処理では報告処理を完了してから運搬タスクを実行するしかない。なぜなら、運搬タスクを処理している間は何もできないからだ。しかし、非同期処理では運搬タスクの実行を裏で開始してから報告処理を開始することができる。とりあえず実行してから後でawaitすることができる。

時間の無駄が減っているよね。報告書を作って渡しに行くのはそれなりに時間がかかるんだから、ロボを先に動かしてから報告書を作った方が素敵だ。

◆タスクの戻り値

以下を考える。

  • 現在地点から1000メートル先のA地点に、箱が置いてあるので
  • そこから箱をとってくる
  • 取ってきた箱を「ハコ置き場」に置く

ロボがいるんで、ロボにとってきてもらえばいい。

async main(){
  ハコ置き場 = await 取ってこさす();
}

async Task<箱?> 取ってこさす(){
  ロボに命令する(もってこい);
  if (ロボが箱を持ってたら) return ロボから受け取る();
  return null;
}

ハコ置き場に箱を置くことができた。こんな書きかたになるんです。箱を持ってなかったらnullだったりもするよね。

報告をしてみる。

async main(){
  取ってくるタスク = 取ってこさす();
  報告("いま箱取ってきてます");
  ハコ置き場 = await 取ってくるタスク;
}

async Task<箱?> 取ってこさす(){
  ロボに命令する(もってこい);
  if (ロボが箱を持ってたら) return ロボから受け取る();
  return null;
}

ざっとこんなもんよ。

沢山の箱を非同期で持ってきてリストに積みたいときのコードの書き方は自分で何とかしろ。

◆非同期処理と並列処理と並行処理

非同期処理は並列処理だったり並行処理だったりする。どっちもだったりする。

つまり、複数体のロボが同時に、並列に動いていたら並列処理だ。一体のロボが色んなタスクを細かいタスクに分解して、実行できるものから手あたり次第、並行して実行してたらそれは並行処理だ。

もう少し詳しく説明する。

並行処理でも並列処理でも、async関数を「走らせた」とき、処理されるべきタスクのキューにその関数がエンキューされる。複数走らせたら、その数ぶんキューにタスクが積まれていく。ロボはキューからタスクをデキューして片付け、次のタスクをデキューして片付け…をひたすら繰り返す。(※ただ、処理系によって動きは変わるだろう。)

JavaScriptなんかは基本的にロボが一体で、複数体のロボを使うにはWeb Workerという仕組みを使わなければならない。つまりPromiseと、内部的にPromiseを使ってるasyncは(Web Workerを使わん限り)並行処理だ。

例えばsetTimeoutは「指定時間待った後に関数を実行する」ように思えるが、つまり「指定時間待った後に関数をエンキューしている」に過ぎない。

「ロボ」と言っていたものはCPUで言う所のコアだ。一つのコアは並列に一つの計算しかできない。ただし、コア内に複数のスレッドを持つことで、タスクを並行してこなすことはできる。並列処理はコア。並行処理はスレッド。「物理的に存在しているコア」は「論理的に存在しているスレッド」を複数持っていて、一つのコア内で並行にスレッドが動く。こんな説明でいい?

◆asyncの使いどころさん

asyncは伝播する。asyncをawaitしたい関数はasyncじゃなければいけない。この連鎖は無限に続いていく。終着はイベントハンドラメソッド(≒イベントトリガー)とかかな。つまり、(始まりと終わりのある)メイン処理ではasyncの出番を作ってはいけない。C# 7.1以降であれば素敵なことにコンソールアプリでasync mainを使うこともできるけどね。

あらゆるメソッドの終着点は「タスクの終わりを別に監視しなくてもいい」か「監視する必要がある」処理だけだ。終わりを監視しなければいけない場面で使おうとすれば、C#なら最終的にはWait()で親玉スレッド(メインスレッド)を止めてAsyncメソッドの終わりを見張らなくてはならない。それはそれで正しい。
別パターンとして「動き続けるもの」、つまりGUIなんかだと、それが使われている間は動き続けるわけだからメインスレッドを止める必要はない。むしろ描画に使っているスレッドを止めてしまうと問題がある。なぜなら、なんかの処理をしている間、ユーザーの操作を全く受け付けられなくなってしまうからだ。だから「裏で動ける」非同期処理が生きてくる。

そして、話を戻すとWaitはスレッドをブロックする。awaitはスレッドをブロックしない。つまり、ロボから「タスク ゼンブ オワッタゾ」と肩を叩かれるまで気絶してるのがWaitだ。

await 式は、自身が実行されているスレッドをブロックするのではなく、 非同期メソッドの残りの部分が待機中のタスクの継続として登録されるようにします。 これによって、コントロールは非同期のメソッドの呼び出し元に戻されます。 タスクが完了すると、継続が呼び出され、中断したところから非同期メソッドの実行が再開されます。

await C# リファレンス

スレッドを止めるってことは、次のタスクに進めないってことだ。勘のいい人なら違和を感じるんでしたよね?感じたかもしれない。Waitは危険だ。何が危険かっていうと、WaitしたAsyncメソッド内でawaitしていた時に死ぬ。

つまり、awaitでメインスレッドに「ツギノ タスク ヤッテテ イイゾ」って通知したんだが、メインスレッドはAsyncのWaitでブロックされている。ロボはタスクが完了したときにメインスレッドのキューに「もう処理していいタスク」を積んでいるんだが、メインスレッドは「次のタスクなにかな?」って調べることができない。次のタスクを処理しようとしない。そうなったらもう終わりよ。

C#だとTask実行のケツに「.ConfigureAwait(false)」を付けてやればメインスレッドに戻らずに済む。Wait対象でawaitしてたらそれでどうにかしろ。

話を戻すと、非同期処理の使い時は「ほかの処理の状態に影響されない処理」だ。値の変更を監視できる仕組みがあればそれをトリガーにして新しい非同期処理を発火することもできるね。

他の使いどころとしてはAzure FunctionsのRunメソッドだ。async Taskにすることができる。まぁAzure Functions自体イベントハンドラみたいなもんだけども。

何らかのメソッドを作る時は、通常版と非同期版の両方を作るのが一番やさしい。「非同期処理からしか呼ばれねぇわ」と思ったらAsync版だけ作れ。読み違えたんならその時に新しく生やせ。

並列並行して処理できるものは無駄に同期するな。そして同期しなきゃいけない処理は同期しろ。それはしゃあない。

◆注意:時間で待っちゃだめだよ

asyncタスクの終わりを待つのは基本awaitだ。たとえば「sleep(10000)」とかでメインスレッドを止めて非同期処理の終わりを待ってはいけない。さっきのRunメソッドみたいなのがあるはずだから、タスクをまとめて一つのタスクにしてそれをawaitしよう。

バッチ処理ってものを考えた時、「まぁ…このバッチは10分で終わるやろ。だから次のバッチは15分に動くようにしときゃええやろ。」みたいなのは正しくない。awaitしろ。

Azure FunctionsでawaitしたいんならDurable Functionsを使え。

◆結論

なんか…わかった?

俺ならわかる。わからないのはお前のせいだ。きっとそうだ。じゃあな。