非同期プログラミングの基礎: async、await、Future、Stream
コンピューターに実行させる操作の多くは、完了までに時間がかかることがあります。そうした長時間実行される処理の完了を待つ間に、別のことができると便利です。現代のコンピューターでは、複数の操作を同時に扱うための手法として、並列性と並行性の 2 つがあります。しかし、私たちが書くプログラムのロジックは、たいてい直線的な形になっています。プログラムが実行すべき操作と、関数がいったん停止してその代わりにプログラムの別の部分を実行できる地点を記述でき、しかもコードの各部分をどの順序でどのように実行するかをあらかじめ正確に指定しなくて済むのが望ましいところです。非同期プログラミング は、停止し得る地点と最終的に得られる結果という観点でコードを表現できるようにし、その調整の詳細を私たちに代わって処理してくれる抽象化です。
この章では、第 16 章で扱った、スレッドを使って並列性と並行性を実現する方法を踏まえつつ、コードを書くための別のアプローチを導入します。すなわち、操作がどのように非同期になり得るかを表現する Rust の Future、Stream、および async と await の構文、そして非同期ランタイム、つまり非同期操作の実行を管理し調整するコードを実装するサードパーティ製クレートです。
例を考えてみましょう。家族のお祝いの様子を撮影した動画を書き出しているとします。この操作は、完了までに数分から数時間かかることがあります。動画の書き出しは、利用可能な限り CPU と GPU の処理能力を使います。CPU コアが 1 つしかなく、オペレーティングシステムがその書き出しを完了まで中断しない、つまり 同期的に 実行するとしたら、そのタスクの実行中はコンピューターで他のことは何もできません。それはかなりストレスのたまる体験でしょう。幸い、コンピューターのオペレーティングシステムは、その書き出しを見えない形で十分な頻度で中断して、同時に別の作業を進められるようにしてくれます。
では次に、別の誰かが共有した動画をダウンロードしているとしましょう。これも時間がかかることがありますが、それほど多くの CPU 時間は使いません。この場合、CPU はネットワークからデータが届くのを待たなければなりません。データが届き始めたら読み始めることはできますが、全体がそろうまでには時間がかかるかもしれません。データがすべてそろってからでも、動画がかなり大きければ、それをすべて読み込むのに少なくとも 1、2 秒かかることがあります。それほど長くは聞こえないかもしれませんが、毎秒何十億もの操作を実行できる現代のプロセッサにとっては、非常に長い時間です。ここでも、オペレーティングシステムはプログラムを見えない形で中断し、ネットワーク呼び出しの完了を待つ間に CPU が別の処理を行えるようにします。
動画の書き出しは、CPU バウンド または 計算バウンド な操作の一例です。これは、CPU や GPU 内でのコンピューターの潜在的なデータ処理速度と、その速度のうちどれだけをその操作に割り当てられるかによって制限されます。動画のダウンロードは I/O バウンド な操作の一例です。これはコンピューターの 入出力 の速度によって制限され、ネットワーク越しにデータを送れる速さまでしか進めないからです。
どちらの例でも、オペレーティングシステムによる見えない割り込みが、並行性の一形態を提供しています。ただし、その並行性が起きるのはプログラム全体のレベルに限られます。オペレーティングシステムはあるプログラムを中断して、ほかのプログラムに処理を進めさせるのです。多くの場合、私たちはオペレーティングシステムよりもはるかに細かい粒度で自分たちのプログラムを理解しているため、オペレーティングシステムには見えない並行実行の機会を見つけられます。
たとえば、ファイルのダウンロードを管理するツールを作っているなら、1 つのダウンロードを開始しても UI が固まらないようにプログラムを書けるべきですし、ユーザーは同時に複数のダウンロードを開始できるべきです。ところが、ネットワークを扱うための多くのオペレーティングシステム API は ブロッキング です。つまり、処理対象のデータが完全に準備できるまで、プログラムの進行を止めてしまいます。
注: 考えてみれば、これは ほとんどの 関数呼び出しがそうであるのと同じです。ただし、blocking という用語は通常、ファイル、ネットワーク、またはコンピューター上のほかのリソースとやり取りする関数呼び出しに対して使われます。そうした場合こそ、個々のプログラムがその操作から _非_ブロッキングであることの恩恵を受けられるからです。
各ファイルのダウンロード用に専用スレッドを生成すれば、メインスレッドがブロックされるのは避けられます。しかし、それらのスレッドが使うシステムリソースのオーバーヘッドは、やがて問題になります。そもそも呼び出し自体がブロックしないのが望ましく、その代わりに、プログラムに完了させたい複数のタスクを定義し、それらをどの順序でどのように実行するのが最適かをランタイムに選ばせられるとよいでしょう。
それこそが、Rust の async(asynchronous の略)という抽象化が私たちに与えてくれるものです。この章では、次のトピックを通して async について詳しく学びます。
- Rust の
asyncおよびawait構文の使い方と、ランタイムを使って非同期関数を実行する方法 - async モデルを使って、第 16 章で見たものと同種のいくつかの課題を解決する方法
- マルチスレッディングと async がどのように相補的な解決策を提供し、多くの場合に組み合わせて使えるか
ただし、async が実際にどのように機能するかを見る前に、少し寄り道をして並列性と並行性の違いを説明する必要があります。
並列性と並行性
これまでは、並列性と並行性をほぼ同じ意味のものとして扱ってきました。ここから作業を始めるにあたっては、その違いが表れてくるので、より正確に区別する必要があります。
ソフトウェアプロジェクトでチームが作業を分担するさまざまな方法を考えてみましょう。1 人のメンバーに複数のタスクを割り当てることもできますし、各メンバーに 1 つずつタスクを割り当てることもできますし、その 2 つを組み合わせることもできます。
1 人の人が、どのタスクも完了する前に複数の異なるタスクに取り組む場合、それは 並行性 です。並行性を実現する 1 つの方法は、コンピューター上に 2 つの異なるプロジェクトをチェックアウトしておき、片方のプロジェクトに飽きたり行き詰まったりしたら、もう片方に切り替えるのに似ています。自分は 1 人しかいないので、両方のタスクをまったく同時に進めることはできませんが、切り替えながら片方ずつ進めることで、マルチタスクとして前進できます(図 17-1 を参照)。
チームが、一連のタスクを各メンバーが 1 つずつ引き受けてそれぞれ単独で取り組む形で分担する場合、それは 並列性 です。チームの各人が、まったく同時に前進できます(図 17-2 を参照)。
これら2つのワークフローのどちらでも、異なるタスク間で調整が必要になる かもしれません。ある人に割り当てたタスクはほかの全員の作業から完全に 独立していると思っていたのに、実際にはチーム内の別の人が先に自分の タスクを終える必要がある、ということもあります。作業の一部は並列に 進められても、別の一部は実際には 直列 です。図17-3のように、 一連の流れとして、1つのタスクの後に別のタスクが続く形でしか進みません。
同様に、自分のタスクの1つが別の自分のタスクに依存していることに 気づくかもしれません。すると、自分の並行した作業も直列になります。
並列性と並行性は、互いに交わることもあります。あなたのタスクの1つを 終えるまで同僚の作業が止まっていると分かったら、その同僚の作業の “ブロックを解除”するために、そのタスクに全力を集中するでしょう。あなた と同僚はもはや並列に作業できず、自分自身のタスクも並行して進められません。
同じ基本的な力学は、ソフトウェアとハードウェアでも当てはまります。CPU コアが1つしかないマシンでは、CPUは一度に1つの操作しか実行できませんが、 それでも並行して動作できます。スレッド、プロセス、async などの 手段を使うことで、コンピュータはある処理を一時停止して別の処理に 切り替え、最終的には再び最初の処理へ戻ることができます。CPUコアが 複数あるマシンでは、並列に作業することもできます。あるコアが1つの タスクを実行している一方で、別のコアがまったく無関係な別のタスクを 実行でき、それらの操作は実際に同時に起こります。
Rust で async コードを実行すると、通常は並行に行われます。ハードウェア、 オペレーティングシステム、そして使用している async ランタイム (async ランタイムについてはこのあとすぐ詳しく説明します)によっては、 その並行性が内部的に並列性も利用している場合があります。
では、Rust の async プログラミングが実際にどのように動作するのかを見ていきましょう。