キャンセルが効かない:制御チャネルと所有権
一言でいうと
停止の意思を記録することと、カーネルで眠っているループを起こすことは、別のことです。状態を先に記録し、制御ソケットで知らせてから、ソケットの所有者であるループが後始末します。
なぜ必要なのか
診断画面にキャンセルボタンを付けました。別のスレッドはstop=Trueを書きましたが、ループはselectの中で待っています。ユーザーはボタンが効かないと感じて、何度も押します。メモリの値はすでに変わっていても、ループが次の行を実行できなければ、その値を見る機会がありません。selectの待機をとても短く変えれば、反応は速くなりますが、アイドル状態でも繰り返し起きる必要があります。
このとき必要なのは、「状態と通知」の2つの部分です。状態は、キャンセルが要求されたという事実を保存し、通知は、その事実を読む順番を作ります。通知を受け取ったというだけで、キャンセルが処理された、あるいはすべてのリソースが回収されたと判断することはしません。要求・観察・後始末の完了を分けて考えると、UIとバックエンドの境界も明確になります。
どう動くのか
threading.Eventは、フラグを保管し、Event.waitで待っているスレッドに知らせます。ソケットの準備完了を待つselectorに、自動的にイベントを入れる仕組みではありません。ラボは、socketpairの読む側の端をselectorに登録し、書く側の端を要求者に残します。データ接続の完了と制御の通知を、1回の待機で一緒に見られます。
python3 /opt/fixtures/reactor/wakeup_probe.py
実験は、select(2)に入るスレッドを用意して、Eventを設定します。短く観察するあいだ、スレッドは終わりません。続いて、Qの1バイトを制御ソケットに送ると、読み取りの準備完了が生じ、ループが起きて、すでに設定されているEventを確認します。この観察時間は、本番環境の保証されたレイテンシではなく、状態の変更だけではソケットの待機が起きないという反例を作る条件です。
必ず状態が先
通知を先に送ってから、あとでEventを設定すると、ループがその間に起きることがあります。まだ停止ではないと見て、また眠ったあとには、新しい通知がないかもしれません。したがって、request_stopは、Event.setの次にwriter.sendの順序です。制御ソケットは、両端とも非ブロッキングにします。UI側のキャンセル要求が、送信バッファーが空くまで止まると、制御チャネルが新しいボトルネックになります。
バッファーがいっぱいになってBlockingIOErrorが出たら、すでに読むデータがあるという意味です。停止状態はEventに残っているので、同じ通知を延々とリトライしません。複数の要求が1回のウェイクアップにまとめられてもよい設計です。バイト1つを、ユーザーのリクエスト1つに対応させるコマンドキューとは違います。注文・キャンセルIDのように、すべてのメッセージを処理しなければならないチャネルなら、別のキューとフレーミングが必要です。
drainは、最大4096バイトを1回だけ読みます。通知をすべて空にしようと無限に読むと、通知を大量に送るスレッドが、データ処理を飢えさせることがあります。残った通知は、次のターンで処理します。空の状態のEAGAINと、チャネル終了のEOFも違います。EAGAINは、いま読むものがないという意味で、EOFは、制御接続の相手が閉じたという意味です。ラボは、予期しないEOFをConnectionErrorとして処理し、全体のリソースを回収します。
通知の回数と処理の完了を区別すること
ユーザーがキャンセルを3回押しても、終了処理は1回で十分です。応答画面にキャンセル完了を表示するときは、送ったバイトの個数ではなく、runが終わったかどうかを確認する必要があります。逆に、完了の通知を待っているスレッドが、制御writerを先に閉じると、EOFが発生して、正常なキャンセルとは別の例外の経路に入ることがあります。終了ボタン、要求スレッド、ループの間で、どの順序で所有権を渡すのか、短いタイムラインを描いてみると、こうした競合を見つけやすくなります。
誰がcloseするのか
要求スレッドは、データソケットを勝手に閉じません。ループは、そのソケットの登録状態と準備キューを一緒に管理している必要があります。キャンセルの要求者はEventと制御writerだけを使い、ループは、未完了のDialをcancelledに変えたあとで、登録解除とcloseを行います。selectorsのドキュメントの、登録解除してから閉じる順序を守ります。
今回のAPIは、runを呼び出すと、有効な入力のソケットとControlを預け、戻りまたは例外のあとは、再び使いません。不正な引数を実行の前に拒否した場合、所有権は引き続き呼び出し側にあります。run終了のあとで、request_stopをもう一度呼ぶことは許可しません。実際の画面と接続するときは、完了状態を共有して、要求者が終わる前にチャネルを解放しないという、ライフサイクルの取り決めが必要です。
現場での姿
運用ツールが終了要求を受け取ったのに止まらなければ、ユーザーはプロセスを強制終了することになります。データの保存が必要なサーバーでは、新しいリクエストの拒否、進行中のリクエストのドレイン、制限時間後の強制終了が、さらに必要です。今回のものは、接続診断器の即時キャンセルです。サーバーのgraceful shutdown全体を実装したと表現してはいけません。
次の確認ですること
次のクイズでは、状態と通知の順序を判断します。そのあとの総合ラボで、Controlを作ってから、実際にカーネルの待機に入ったループを、別のスレッドから起こします。停止フラグだけを変える誤答と、制御ソケットをブロッキングのままにする誤答を区別してください。最終的な判定は、ボタンを押したというログではなく、未完了の結果のキャンセルとしての分類と、すべてのFDの回収です。