非同期処理
概要
この記事では並行してタスクを実行するインスタンス (つまりスレッドやプロセス、物理ノードなどの単位) をプロセスと呼ぶ。
Table of Contents
導入
2 つの独立したプロセス
一方で、
非同期が意味する範囲では
Fig 1 はプロセス
同期/非同期は並行性に関する概念だが、必ずしもマルチスレッド/マルチプロセスやマルチプロセッサ環境を前提としていないことに注意。
ノンブロッキング (non-blocking) とは、どのような状況でもプロセスがブロックされることなく制御がすぐに戻ることを保証するという意味である。一般に非同期処理の要求はノンブロッキングで行われることからしばしば双方の混乱も見られるが、ノンブロッキングは何らかの処理が並行して行われることを前提としていない点が異なる。
TCP ソケットからデータを読み出す例で説明すると、ノンブロッキングでは読み込み可能なデータが到着していなければ直ちにエラーで終了する (並行してデータの到着を待たない)。一方で非同期では制御がすぐに戻ることと並行してデータ到着の監視が始まり、データが読み出し可能になったことを通知として受け取ることができる (到着したデータは通知に含まれるか、ブロッキングなしに読み出せるだろう)。
同期処理の非同期化
一般的なプログラミング言語のエントリポイント (main()) は同期処理である。またマルチスレッドのような環境であっても局所的なコードは逐次処理である。
Worker プロセス
システムやアプリケーションに特定の処理を行うための独立したスレッド、プロセス、または物理ノードを導入することは同期処理を非同期化する一般的な手法である。この記事では処理の非同期化のために使用する演算リソースを Worker と呼ぶ。
マルチタスク環境で動作するアプリケーションにおいて時間のかかるタスク (処理) を実行するとき、そのタスクがアプリケーション自体の応答性を阻害しないために一般に Worker と呼ばれる独立したスレッドを導入する。この設計によってタスクの実行は非同期化され、アプリケーションはタスクの実行完了を待つことなくすぐに次のイベント (画面入力や RPC リクエストなど) に取り掛かることができる。
実行するタスクが発生するたびに Worker スレッドを起動することでも目的を達することができるが、そのような無作法な Worker 実装では効率も悪くすぐにスケールしなくなることが予想される。一般には:
- アプリケーションと Worker の間に Fig 2 のようなジョブキュー (job queue) またはタスクキューと呼ばれるキュー構造を設置する。
- Worker 数は固定個数か、負荷に応じて変動するように設計する。ただし動的な場合であっても上限を持つ。一般にスレッドやプロセスのように CPU bound な実行ではアプリケーションが利用可能な最大コア数が上限となる。
アプリケーションはジョブキューにタスク (ジョブ) を投入するだけですぐに次の処理を開始する。Worker はキューからタスクを取り出して実行することを繰り返す (キューが空の場合は新しいタスクが投入されるまで停止する)。
アプリケーションが個々のタスクの実行結果を必要とする場合は少し工夫が必要である。同一プロセス内の処理であれば Future パターンを使用できるだろう。分散システムの場合はジョブキューと反対方向のメッセージング機構や共有 DB をポーリングする必要がある。
Worker は非同期処理のための基本的な設計で、適用はマルチスレッドプログラミング、メッセージパッシング、分散システム、Worker Thread パターン、Producer/Consumer パターンなど、非常に広範囲に及ぶ。またアクターモデルは Worker 設計を概念的に発展させたものである。
スレッドに基づく実装
まず、非同期ソケットや poll を使用しないでイベントループを行っているスレッドとの通信を実装してみよう。どのような言語でも Worker スレッドを用いたこのような非同期処理の設計はしばしば目にする。
イベントループを行っているスレッド内で処理を行い、その結果を Future として返す実装は Rust で以下のように書くことができる。
すでに存在する特定のスレッドに処理を委譲しなければならないため async 関数を使うことができない点に注意。Rust のスレッド間通信は原則として channel を使用するのが望ましい。channel は片側のスレッドで send したデータをもう片方のスレッドから receive することができる (目的は Go の chan や Java の BlockingQueue と似ている)。
非同期で実行する処理 dyn FnMut() はコンパイル時にサイズが決定できないため Box 化する。また、受け渡しを行うために Send が必要である。
TaskState がスレッド間で共有される。
Sender<Task> |
イベントループに Task を送信する。 |
Receiver<Task> |
イベントループが Task を受け取る。 |
TaskState |
スレッド間で共有される状態。 |