バックプレッシャーとフロー制御(リアクティブストリーム)
速い生産者に消費者が押しつぶされる事故を、需要シグナルとバウンドキューで未然に防ぐ設計が身につく。Reactive Streamsのdemand制御とドロップ/バッファ戦略を原理から押さえます。
- 1.バックプレッシャーは「消費者が受け取れる量を生産者へ伝え返す」逆方向の制御。これがないと中間バッファが無限に膨らみOOMで落ちる。
- 2.Reactive Streamsはrequest(n)というdemandシグナルでpull型に変換する。生産者は要求された分しか送れず、速度差は購読側が吸収する。
- 3.demandで吸収しきれない非可制御な発生源には、バウンドキュー+ドロップ/バッファ/最新優先(latest)といった有限の緩衝戦略を明示的に選ぶ。
速度差が引き起こす「圧倒」問題
非同期パイプラインでは、データを生み出す側(生産者・producer)と処理する側(消費者・consumer)の速度が一致しません。秒間10万件を吐くセンサーに対し、DBへ書く消費者は秒間1万件しかさばけない、といった状況は普通に起きます。このとき差分の9万件はどこへ行くのか。素朴な非同期実装では、両者の間にあるキューやバッファへ溜まり続けます。
問題は、この中間バッファが無限に膨らむことです。生産者が止まらない限りキューは単調増加し、やがてメモリを食い尽くしてOutOfMemoryでプロセスが落ちます。GCを採用する処理系では、その手前で巨大なヒープを掃くためにGCが頻発し、レイテンシが崩壊します。つまり「速い生産者が遅い消費者を圧倒する(overwhelm)」状態を、システムは静かなメモリ膨張という形で被るわけです。
「キューを大きく取れば吸収できる」は短時間のバーストにしか効きません。生産レートが消費レートを平均的に上回る限り、どんな大きさのバッファも有限時間で溢れます。本質的な解は、バッファサイズを増やすことではなく、生産者の発生レートそのものを消費能力に合わせて抑えることです。
バックプレッシャー=逆向きの制御シグナル
バックプレッシャー(backpressure)とは、消費者が「自分はいま、あとどれだけ受け取れるか」を生産者へ逆方向に伝え返す仕組みです。データは生産者→消費者へ流れますが、制御信号は消費者→生産者へ逆流します。これにより生産者は、消費者の処理能力を超えた送出を自ら控えられます。
押し方には2つの型があります。push型は生産者が都合よく送りつける方式で、消費者の事情を無視するためバッファ溢れに弱い。pull型は消費者が要求した分だけ生産者が送る方式で、流量の上限が消費者側に握られるため本質的に溢れません。バックプレッシャーの実装とは、push的なデータフローへpull的な制御を後付けし、実効的にpull型へ変換することだと言えます。
Reactive Streamsのdemandシグナル
この変換を標準化したのがReactive Streams仕様です。Publisher(生産者)、Subscriber(消費者)、両者を仲介するSubscriptionの3者で構成され、フロー制御の核はSubscriptionが持つrequest(n)というメソッドです。
Publisher --- subscribe(Subscriber) --->
Subscriber <-- onSubscribe(Subscription) --
Subscriber --- subscription.request(n) ---> // demand を発行(逆向き)
Publisher --- onNext(item) × 最大 n 回 ---> // 要求された分だけ送る
Publisher --- onComplete() / onError(e) ---> // 終端シグナル
肝はrequest(n)です。購読が確立すると、SubscriberはonSubscribeで受け取ったSubscriptionに対し「いまn件まで受け取れる」とdemand(需要)を発行します。Publisherはこの累積demandを超えてonNextを呼んではならない、と仕様が義務付けています。Subscriberは1件処理し終えるごとにrequest(1)を足す、あるいはまとめてrequest(64)し残りが少なくなったら補充する、といった形で自分のペースで蛇口を開け閉めします。
// 消費者が「処理が終わったら次を要求する」pull ループ
onSubscribe(s): this.sub = s; s.request(BATCH) // 初期 demand
onNext(item): process(item) // ここがボトルネック
if (--remaining == 0)
remaining = BATCH; sub.request(BATCH) // 補充
この仕組みにより、Publisherが秒間10万件を生成できても、Subscriberがrequestした総数を超える分は生産者側で待たされる。中間に溜まる未処理アイテムの上限は「未消化のdemand」に等しく、消費者が決めた有限値に固定されます。速度差はキューの膨張ではなく、生産者の送出抑制として吸収されるわけです。これはイベントループ上で多数のストリームを同時にさばく際、各ストリームのメモリ使用量を予測可能に保つうえで決定的に効きます。
request(n)は「次のn件だけ」ではなく、未消化のdemandに加算されます。request(10)の後にさらにrequest(5)すれば、生産者は合計15件までonNextできます。request(Long.MAX_VALUE)は事実上「無制限」を意味し、バックプレッシャーを放棄してpush型に戻すイディオムです。仕様上、requestの引数は正でなければならず、0以下はプロトコル違反になります。
demandで止められない発生源への戦略
request(n)が機能するのは、生産者が送出を遅延できる場合だけです。ファイル読み込みやDBカーソルのように「読む速度を自分で決められる」源(cold source)なら、demandに合わせて読み出しを止めれば済みます。
困るのは、マウス移動・センサー・外部からのプッシュ通知のように**こちらの都合で止められない発生源(hot source)**です。イベントは現実世界の都合で発生し、requestしていなくても到着します。ここでは「溢れた分をどうするか」を設計者が明示的に選ぶ必要があり、有限のバウンドキューと組み合わせた緩衝戦略を取ります。
| 戦略 | 溢れたときの挙動 | 向く場面 | 代償 |
|---|---|---|---|
| buffer(有限) | 上限まで貯め、超過でエラー/打ち切り | 短いバーストを完全保全したい | 上限超過で onError、メモリ消費 |
| drop(新規破棄) | バッファ満杯なら新着を捨てる | 古いデータに価値がある(ログ等) | 新しいイベントを失う |
| latest(最新優先) | 古い未処理を捨て最新だけ残す | 現在値だけ要る(センサー/UI) | 中間値を失う |
| sample/throttle | 時間窓ごとに代表値だけ通す | 高頻度を間引いて十分 | 粒度を落とす |
| error | 溢れた瞬間に例外で打ち切る | 欠落が許されない処理 | ストリーム停止 |
これらはいずれも有限のキャパシティを前提に「何を犠牲にするか」を選ぶ点が共通します。latestはUIの座標更新のように「最後の値だけ正しければよい」場合に最適で、dropは逆に「最初に来たものを優先」したいログ収集に向きます。重要なのは、どの戦略も無限バッファという幻想を捨て、有界性(boundedness)を回復するためにあるということです。戦略を選ばないこと自体が、暗黙に「無限バッファ=いつか必ず溢れる」を選んでいることになります。
バックプレッシャーが効くのは、生産者と消費者が同じ制御チェーンでrequestを伝播できる範囲です。間にスレッド境界やネットワーク境界、observeOnのような非同期切り替えを挟むと、その地点に別のバウンドキューが必要になります。境界ごとにキュー上限と溢れ戦略を設定しないと、demandは上流に伝わっても、境界キューが先に溢れます。並行設計の全体像は並行性モデルも合わせて押さえると見通せます。
demand伝播のコストと連鎖
request(n)は演算子チェーンを上流へ1段ずつ伝わります。mapやfilterはdemandをそのまま透過しますが、buffer(k)のような集約演算子は「下流のdemand 1に対し上流へk件request」のようにdemandを変換します。長いチェーンではrequest呼び出し自体が同期オーバーヘッドになるため、実装は1件ごとではなくバッチでrequestを補充し、残りが閾値(例:バッチの1/4)を切ったら次のバッチを先行要求する、という最適化を行います。これはスレッドプールのタスク粒度調整と同じく、「制御の細かさ」と「制御自体のコスト」のトレードオフです。
まとめ
バックプレッシャーは、(1)消費者の受容能力を生産者へ逆向きに伝える制御で、無限バッファ膨張によるOOMを根本から防ぐ。(2)Reactive Streamsはこれをrequest(n)というdemandシグナルで標準化し、pushなデータフローを実効的にpull型へ変換する。未処理アイテムの上限は消費者が決めた有限値に固定される。(3)止められないhot sourceには、バウンドキューとdrop/latest/bufferなどの有限緩衝戦略を明示的に選び、有界性を回復する。「キューを大きくする」のではなく「生産レートを消費能力へ合わせる」——この向きの逆転こそが、高並行ストリーム処理を予測可能に保つ要点です。
プログラミング Article
バックプレッシャーとフロー制御(リアクティブストリーム)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
バックプレッシャー
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
Reactive Streamsはrequest(n)というdemandシグナルでpull型に変換する。生産者は要求された分しか送れず、速度差は購読側が吸収する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「バックプレッシャー / リアクティブストリーム」に近いか確認する。
- 強みである「バックプレッシャーは「消費者が受け取れる量を生産者へ伝え返す」逆方向の制御。これがないと中間バッファが無限に膨らみOOMで落ちる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。