遅い荷物をいつまでも待たない
一言でいうと
長さと時間には、別々の上限が必要です。毎回少しずつ進んでいるからといって、無限に待ってよいわけではありません。
なぜ必要なのか
荷物の封筒には、4 GiBに近い本文が来ると書かれています。値そのものは32ビット整数に収まりますが、私たちのサービスの受信予算は4096バイトです。この数字を信じて先にメモリを確保すると、小さなヘッダー1つで大きなリソースを消費します。相手の約束は、リソース確保の許可ではありません。このラボは、大きな本文が実際に来るのを待たずに、ヘッダーを完成させた瞬間に拒否する練習です。
メモリを制限した次は、時間が残っています。1バイトが来るたびに1秒を新しく待つなら、相手が0.9秒ごとに1バイトを送るとき、接続は生き続けます。フレーム全体を1秒以内に受け取るという要求と、各recvが1秒以内に戻るという要求は別です。前者は、リクエストの終わりまで残りの予算を伝える必要があります。
どう動くのか
最初にdeadline=clock()+timeoutを1回計算します。毎回recvを呼ぶ直前に、remaining=deadline-clock()を計算します。remainingが0以下なら、それ以上読む前にTimeoutErrorを出します。そうでなければ、sock.settimeout(remaining)で、ブロッキングの読み取りも残りの時間を超えないようにします。ヘッダーと本文が、同じdeadlineを共有する必要があります。ヘッダーが遅く来たからといって、本文用の時間を新しく与えません。
デフォルトの時計はtime.monotonicです。時計の補正で壁時計が後ろに動いても、経過時間の計算が逆行しないようにします。試験では、clock引数で関数を1つ注入します。そうすれば、実際に数秒ずつ眠って境界の状況を待つ代わりに、recvが進むときに模擬の時刻を少しずつ進められます。模擬の試験は時間予算の計算を決定的に検査し、実際のソケットの試験はオペレーティングシステムのインターフェースとの結合を検査します。どちらか一方だけで、もう一方を証明したとは言いません。
read_exactは、nバイトを得るまで、残りのサイズだけrecvを繰り返します。recv(n)がnより少なく返すのは正常です。b""はEOFです。新しいヘッダーをまったく受け取らないままEOFなら、メッセージがもうないのでNoneを返します。ヘッダーを1バイトでも受け取っていたり、本文の一部が足りなかったりすればEOFErrorです。長さ0の本文は、読むバイトがないのでb""をすぐ返します。このときrecvをもう一度呼んでしまうと、次のフレームを食べたり、無駄に待ったりすることがあります。
成功した場合だけでなく、例外が起きた場合も、呼び出し前のソケットのtimeoutを復元する必要があります。同じソケットを次の作業に渡すときに、前の読み取りの短いremainingの値が残っていると、次の作業が原因のわからないまま早く失敗します。try/finallyは、このリソースの契約を表現します。timeoutが0・負の数・無限大・NaNなら、正の有限な予算というAPIの契約に違反するので、ValueErrorです。
長さのエラーの後は、その接続を破棄します。勝手に1バイトずつ捨てながら次のヘッダーを探す復旧を、このプロトコルは定義していません。したがって、次の数字がそれらしく見えるからと再同期すると、本文の途中を新しいメッセージと誤解します。実際のプロトコルが復旧点やチェックサムを定義しているかは、別の問題です。ない規則を、パーサーが想像して追加することはしません。
現場での姿
全体のリクエストの期限と、下位の作業ごとの期限の違いは、DB呼び出しや外部APIにも現れます。それぞれ2秒の作業4つが順番に実行されると、ユーザーのリクエスト全体は2秒をはるかに超えることがあります。残りの予算を下に伝え、キャンセル時に何を閉じるかを決める必要があります。このコースは、同期式のTCPフレーム1つの予算までだけを実装します。大規模な同時接続・イベントループ・グローバルな送信キューのバックプレッシャーは、別のリアルタイムのコースの範囲です。
たとえば、開始時刻が10.0秒で予算が1秒なら、deadlineは11.0です。最初のバイトを10.4に受け取ったら、次のrecvの予算は0.6秒、その次を10.8に受け取ったら0.2秒です。その後のバイトが0.4秒かかって届くなら、残りの0.2秒以内に読めなかったことになります。毎回1秒を与え直す実装は、すべての呼び出しが成功したように見えますが、リクエスト全体の契約はすでに破っています。
逆に、呼び出し側の時計が注入された試験で、実際のtime.sleepを繰り返すと、模擬の時間と現実の時間が混ざります。clock関数は現在時刻を返す役割だけを持ち、模擬のソケットは、バイトの進行に合わせて模擬の時刻を進めます。実際のソケットを使う経路では、デフォルトのmonotonicを維持します。この区別をAPIに書いておけば、他の実装も同じ契約で試験でき、特定のimport文に試験を無理に合わせる必要がありません。
タイムアウトの後は、残りのフレームをもう一度読むか、接続を閉じるかを決める必要があります。このコースは、読み取りの失敗後にその接続を破棄するので、途中の状態を別の呼び出しでつなげません。例外を捕まえて空のbytesに変えて返すと、不完全なデータが、有効な空のメッセージのように見えます。エラーの種類を呼び出し側に伝えることが、復旧ポリシーを分離する出発点です。
次の確認ですること
すぐ後のクイズで、上限と全体の期限を区別し、最後のモジュールで、正常な分割入力、途中のEOF、超過した長さのヘッダー、遅い入力を、それぞれ実行します。毎回timeoutを新しく与える、もっともらしい誤答も試験に入れます。エラーが発生したという事実だけでなく、元のソケット設定が戻ったかも確認します。1秒は教育用の契約であり、すべての業務に適用する推奨値ではありません。