リアクター/プロアクターパターンと非同期I/O
高並行サーバーの骨格をどちらで組むか迷わなくなる。準備完了通知型のReactorと完了通知型のProactor、その違いとOSのI/Oモデルとの対応を原理から整理します。
- 1.Reactorは「読める/書ける状態になった」を通知し、I/O自体はアプリが実行する準備完了通知型。epoll/kqueueに対応する。
- 2.Proactorは「操作が完了した(バッファに読み終えた)」を通知する完了通知型。Windows IOCPやLinux io_uringに対応する。
- 3.Reactorはノンブロッキングfdが前提で読み書きはアプリ側、ProactorはOSが実I/Oを代行しデータ転送まで終わった後に呼び戻す。この一点が両者を分ける。
2つのイベント分配パターン
高並行サーバーは「複数の接続を1つの監視ループでさばき、起きたことに応じてハンドラを呼ぶ」という構造を共有します。このイベントを受け取って適切なハンドラへ振り分ける(demultiplex & dispatch) 骨格を定式化したのが、Reactorと Proactorという2つのデザインパターンです。両者の差は一点に集約されます。イベントとして何を通知するかです。
- Reactor(準備完了通知型): 「このfdは今読める/書ける状態になった」を通知する。実際の
read/writeは通知を受けたアプリ側が行う。 - Proactor(完了通知型): 「依頼したI/O操作が完了した(指定バッファへの読み込みが終わった)」を通知する。実I/OはOSが代行し、アプリは結果を受け取るだけ。
つまりReactorは「準備ができた(readiness)」を、Proactorは「終わった(completion)」を返します。この違いが、ノンブロッキングI/Oの要否、OSとの対応、コードの組み立て方すべてを規定します。
Reactor:準備完了を通知する
Reactorの中核は、I/O多重化の上に薄く乗る分配器です。各fdに対し「読み準備ができたら呼ぶハンドラ」を登録しておき、イベントループが多重化APIで待機する。準備のできたfdが返ったら、対応するハンドラを同期的に呼び出します。ハンドラの中で初めてread/writeを発行する、という流れです。
[アプリ] handler を fd に登録
↓
[Reactor] epoll_wait で待機 ──→ 「fd が読める」イベント
↓
[Reactor] その fd のハンドラを dispatch
↓
[ハンドラ] 自分で read() してデータを処理
擬似コードにすると、登録と単一の待機ループに集約されます。
reactor.register(sock, READABLE, on_readable)
def event_loop():
while True:
events = demultiplex() # epoll_wait など、起きたfdだけ返る
for fd, kind in events:
handler = handlers[fd] # 登録済みハンドラを引く
handler(fd, kind) # 同期的に dispatch
def on_readable(fd, kind):
data = read(fd) # ★I/Oはハンドラ側で実行
process(data)
決定的なのは、★のreadをアプリが呼ぶ点です。通知は「呼べば今すぐ読める」ことの保証にすぎず、I/Oそのものではありません。したがってReactorの前提は次の通りです。
通知が「読める」と言っても、ハンドラのreadが要求量を一度に読み切れる保証はありません。fdをノンブロッキングにしておかないと、読み切ろうとした最後のreadがブロックし、単一スレッドのイベントループ全体が止まります。エッジトリガを使う場合はEAGAINが返るまで読み切る運用も併せて必要です。これはイベントループとI/O多重化で扱うLT/ETの議論と直結します。
Proactor:完了を通知する
Proactorでは、アプリは「準備を待つ」のではなく、最初から「この操作を実行せよ」とOSへ依頼します。たとえば「このfdから、用意したバッファへ4096バイトreadせよ」と発行する。OSが裏で実I/Oを進め、データ転送が完了した後に完了ハンドラ(completion handler)を呼び戻します。アプリがハンドラに入った時点で、データはすでに自分のバッファに収まっています。
[アプリ] async_read(fd, buf) を発行 ──→ OSへ依頼して即リターン
↓ (OSが裏で実I/Oを実行:fd→buf へ転送)
[OS] 転送完了 ──→ 完了イベントをキューへ
↓
[Proactor] 完了イベントを取り出し completion handler を dispatch
↓
[ハンドラ] buf には既にデータがある。処理するだけ
proactor.async_read(sock, buf, on_complete) # 操作を依頼して即戻る
def event_loop():
while True:
completions = get_completions() # 完了したものだけ返る
for op, result in completions:
op.handler(result) # 完了ハンドラを dispatch
def on_complete(result):
# ★read は既に終わっている。buf を処理するだけ
process(buf, result.bytes_transferred)
ここでreadを呼ぶコードがアプリ側にないことに注目してください。実I/OはOSが代行済みで、アプリは完了結果(転送バイト数やerrno相当)を受け取るだけです。準備完了fdを走査して自分で読む手間が消える一方、操作の発行時にバッファを渡し、完了まで生かし続ける責任が生じます。
| 観点 | Reactor(準備完了通知) | Proactor(完了通知) |
|---|---|---|
| 通知の意味 | fdが読める/書ける状態になった | I/O操作そのものが完了した |
| 実I/Oの実行主体 | アプリ(ハンドラ内でread/write) | OS(カーネルが代行) |
| ハンドラ呼出し時点 | データはまだバッファに無い | データは既にバッファにある |
| fdのノンブロッキング化 | 必須 | 不要(操作自体が非同期) |
| バッファ確保のタイミング | ハンドラ内で読む直前 | 操作発行時に渡し完了まで保持 |
| 代表的OS API | epoll / kqueue / select | Windows IOCP / Linux io_uring |
OSのI/Oモデルとの対応
両パターンは机上の分類ではなく、OSが提供する非同期I/Oの実体に直結しています。
- Reactor ← readinessモデル:
select/poll/epoll(Linux)/kqueue(BSD・macOS)はいずれも「fdが準備できた」を返す準備完了通知型です。これらの上には自然にReactorが乗ります。epoll/kqueueは登録と通知を分離し、起きたfdだけを返すため、監視数Nではなく発生数に比例した効率でReactorを回せます。 - Proactor ← completionモデル: WindowsのIOCP(I/O Completion Ports)は登場時から完了通知型で、非同期I/Oを発行して完了ポートのキューから結果を回収する設計、すなわちProactorそのものです。Linuxは長くreadiness一辺倒でしたが、io_uringがsubmit/completionの2リングで操作自体を非同期化し、completion側の選択肢を加えました。
クロスプラットフォームな非同期ライブラリ(libuv、Boost.Asioなど)は、OSごとに最適なAPIへ切り替える抽象化層を持ちます。たとえばlibuvは内部的にProactor風の統一APIを外向きに見せつつ、Linux/macOSではepoll/kqueue(Reactor)の上に「読み終えてからコールバック」する完了エミュレーション層をかぶせ、WindowsではIOCP(Proactor)を直接使います。アプリが同じasync_read系APIで書けるのはこの層のおかげで、io_uringが使える環境ではそれを優先する実装も広がっています。
ProactorをReactorで模倣する
readinessモデルしか無いOSでも、Proactor風のAPIは実現できます。やり方は単純で、Reactorの「読める」通知を受けたライブラリ内部でreadまで済ませ、読み終えてからアプリの完了ハンドラを呼ぶ。アプリから見れば「操作を依頼したら、完了時にデータ込みで呼ばれる」Proactorの体裁になります。逆に、Proactor環境でReactor的な「準備完了」を厳密に再現するのは難しい場合があります。完了モデルは実I/Oをカーネルが握るため、「読めるが読んでいない」という中間状態をアプリへ露出しにくいからです。この非対称性が、移植層の多くを「Reactorの上にProactor APIをかぶせる」方向で設計させる理由です。
スレッドとの関係
どちらのパターンも、本質はイベント分配であってスレッド数とは独立です。Reactorは単一スレッドで多数の接続をさばく構成が基本ですが、ハンドラ実行をスレッドプールへ逃がすマルチスレッドReactor(分配は1スレッド、処理は複数)も一般的です。Proactorも、完了ハンドラを複数スレッドで並行に回せます。注意点は、Reactorのハンドラ内で重い同期処理をすると分配ループが詰まること、Proactorでは完了ハンドラが別スレッドで走り得るため共有状態の競合に配慮が要ること、です。
「ReactorとProactorの違いは?」には、通知の対象で答えるのが最短です。Reactorは「I/Oの準備ができた(readiness)」を通知しI/Oはアプリが実行、Proactorは「I/Oが完了した(completion)」を通知しI/OはOSが代行。対応するOS APIは Reactor=epoll/kqueue、Proactor=IOCP/io_uring。Reactorはノンブロッキングfdが前提、Proactorはバッファを操作発行時に渡す、という対比まで言えれば十分です。
まとめ
ReactorとProactorは、イベント駆動サーバーの骨格を分ける2つの定式化です。Reactorは準備完了を通知し、ノンブロッキングfdの上でアプリ自身がread/writeする——epoll/kqueueの世界。Proactorは操作の完了を通知し、実I/OはOSが代行してデータ転送後にハンドラを呼ぶ——IOCP/io_uringの世界です。両者の境目は「イベントとして準備を返すか、完了を返すか」に尽き、ここからノンブロッキング要否・バッファ管理・OS対応がすべて導かれます。この分配層の上に、状態機械で中断と再開を扱うasync/awaitの内部実装や、各種並行性モデルが乗り、現代の高並行サーバーが組み上がります。
プログラミング Article
リアクター/プロアクターパターンと非同期I/Oを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
デザインパターン
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
Proactorは「操作が完了した(バッファに読み終えた)」を通知する完了通知型。Windows IOCPやLinux io_uringに対応する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「デザインパターン / 非同期I/O」に近いか確認する。
- 強みである「Reactorは「読める/書ける状態になった」を通知し、I/O自体はアプリが実行する準備完了通知型。epoll/kqueueに対応する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。