はじめに
「リアクティブプログラミングとは」という前回・前々回の記事では、Observable/Observerやオペレーターといった仕組みを解説しました。
今回は視点を変えて、そもそもなぜこの仕組みが必要とされたのかという背景を整理します。
技術は常に、何らかの問題を解決するために生まれます。
リアクティブプログラミングも例外ではありません。
問題1:コールバック地獄(Callback Hell)
非同期処理(結果がすぐに返ってこない処理)を扱う最も素朴な方法は、コールバック関数を渡すことです。「処理が終わったら、この関数を呼んでね」という約束を、処理を依頼する側とされる側で交わす方式です。
しかし、複数の非同期処理を「Aが終わったらB、Bが終わったらC」という具合に連続させようとすると、コールバックの中にコールバックがネストしていく、いわゆるコールバック地獄(Callback Hell)、または ピラミッド・オブ・ドゥーム(Pyramid of Doom) と呼ばれる状態に陥ります。
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
getShippingInfo(details.shippingId, (shipping) => {
console.log(shipping);
// ネストがどんどん深くなっていく...
});
});
});
});
この書き方には、実務上いくつもの困りごとがありました。
- ネストが深くなるほどコードが横に長くなり、読みづらくなる
- 各階層でエラーハンドリングを個別に書く必要があり、抜け漏れが起きやすい
- 「処理の流れ」を目で追いにくく、修正・デバッグのコストが高い
- 複数の非同期処理を「並行して実行し、両方終わったら次に進む」といった制御が書きにくい
Promiseやasync/awaitは、まさにこの問題への対策として生まれた技術です。しかし、これらは基本的に「1回限りの結果を待つ」ことには強い一方、「継続的に発生し続けるイベントの流れ」を扱うのは本来得意ではありません。マウスのドラッグ、WebSocketで届き続けるメッセージ、センサーの値の変化のような「複数回、いつ来るか分からない値」を素直に表現し、かつ宣言的に合成できる仕組みが求められていました。これが、Observableを中心に据えたリアクティブプログラミングが解決しようとした1つ目の問題です。
問題2:スレッドとブロッキングI/Oの限界
もう1つの背景は、より低レイヤーの話です。サーバーが「1つのリクエストに対して1つのスレッドを割り当てる(スレッド・パー・リクエスト)」という古典的なモデルには、スケーラビリティ上の限界があります。
DBへの問い合わせや外部APIの呼び出しなど、I/O処理の多くは「結果が返ってくるまで待つ」ブロッキングI/Oです。スレッド・パー・リクエストのモデルでは、この「待っている間」もスレッドを占有し続けます。同時接続数が増えれば増えるほど、待機中のスレッドがどんどん積み上がり、スレッドの生成・切り替えにかかるメモリ・CPUコストが無視できなくなります。
この問題は、C10K問題(1台のサーバーで同時に1万接続をさばけるか、という2000年前後に提起された課題)として広く議論されるようになりました。この課題への回答の1つが、スレッドを待機させたまま塩漬けにするのではなく、ノンブロッキングI/O(処理の完了を待たず、準備ができたら通知を受け取る方式)へ切り替えることで、少数のスレッドでも大量の同時接続をさばけるようにする、というアプローチです。
2つの問題は根っこでつながっている
「コールバック地獄」はコードの書きやすさの問題、「スレッドの限界」はシステムの効率・スケーラビリティの問題と、一見別々の話に見えますが、実は根っこは同じです。どちらも、「結果を待つ間、何もせず止まっている」という前提(ブロッキング・同期的な発想)から抜け出せていないことに起因します。
- コールバック地獄は、「非同期処理の結果を待って、次の処理に渡す」という制御の流れを、コールバックのネストという不格好な形でしか表現できなかったことが問題でした
- スレッドの限界は、「I/O処理の結果を待つ」という行為そのものにスレッドという重い資源を占有させ続けていたことが問題でした
リアクティブプログラミングは、この両方に対して 「待つのではなく、変化が起きたら通知を受け取る(Push型)」 という一貫した設計思想で応えます。ノンブロッキングI/Oと相性がよいPush型のストリーム処理モデルを採用することで、少ないスレッドで大量の同時処理をさばきつつ、複数の非同期処理の合成をオペレーターによって宣言的に(ネストさせずに)書けるようにした、というのがリアクティブプログラミングが生まれた技術的な必然性です。
問題3:ユーザー・ビジネス側からの要求の変化
技術的な背景に加えて、Reactive Manifesto(リアクティブ宣言) が指摘するように、システムに対する要求水準そのものが変化してきたという背景もあります。
- ユーザーは、応答に時間がかかるサービスをすぐに見限り、他のサービスに移ってしまう(応答性への要求の高まり)
- クラウド環境の普及により、負荷の増減に応じてリソースを柔軟に伸縮させる必要性が増した(弾力性への要求)
- マイクロサービス化が進み、外部サービス呼び出しの失敗が連鎖的に全体へ波及しないよう、障害を局所化する設計が重要になった(耐障害性への要求)
こうした要求の変化が、「待つ・止まる」という前提を許容しにくくし、非同期・イベント駆動を前提とした設計への移行を後押しした、という側面もあります。ただしこれはシステムアーキテクチャ全体の設計思想である「リアクティブシステム」の話であり、本記事で扱う「コードの書き方」としてのリアクティブプログラミングとは、レイヤーが異なる点に注意してください(この違いは前回記事でも触れています)。
まとめ
| 問題 | 内容 | リアクティブプログラミングの応答 |
|---|---|---|
| コールバック地獄 | 非同期処理を連鎖させるとネストが深くなり、可読性・保守性が悪化する | Observableとオペレーターにより、宣言的にストリームを合成できる |
| スレッド・パー・リクエストの限界(C10K問題) | ブロッキングI/Oで待機するスレッドが積み上がり、同時接続数のスケールに限界がある | ノンブロッキング・Push型の設計により、少ないスレッドで大量の同時処理をさばける |
| ユーザー・ビジネス要求の変化 | 応答性・弾力性・耐障害性への要求が高まった(Reactive Manifesto) | (アーキテクチャレベルの話。リアクティブプログラミングはその実現手段の1つ) |