送ったぶんが全部出るとは限らない
一言でいうと
recvとsendは、要求したサイズを約束しません。残りを持っておくのは、プログラムの役目です。
なぜ必要なのか
多重化に変えると、新しい種類のバグが現れます。ブロッキングモードでは、sendallは、全部送るまで勝手に戻ってきませんでした。非ブロッキングに変えた瞬間、その便利さはなくなります。カーネルの送信バッファーに空きが20バイトしかないのに、200バイトを送ろうとすると、sendは20を返し、残りをプログラムに任せます。
send(2)の戻り値の説明は短いです。成功したら、送ったバイト数を返すというものです。「送ったバイト数」と書かれていて、「送ろうとしたバイト数」とは書かれていません。この1行を読み流すと、普段はうまく動いていて、応答が大きくなったり相手が遅くなったりしたときにだけ、後ろの部分が切れてしまうプログラムになります。そして、その症状は、ほとんどいつも、相手側のパーサーのエラーとして先に報告されます。
どう動くのか
受け取る側も送る側も、接続ごとに残りの断片を入れる場所が必要です。受け取る側のバッファーは、前のコースで扱ったフレーミングと同じ話です。今回のコースのプロトコルは行単位なので、改行が出るまで集めればよいのですが、改行がないからといって不正な入力なのではありません。まだ全部来ていないだけです。
送る側のバッファーは、新しく学ぶ部分です。接続ごとに、まだ出ていけないバイトをoutboxのような名前で持ち、書き込みの準備完了が来たら1回sendして、返ってきた数だけ前から切り取ります。
sent = sock.send(self.outbox)
self.outbox = self.outbox[sent:] # 나머지는 남긴다
ここでよくあるミスは、self.outbox = b""です。短い文字列でテストすると、たいてい1回で全部出ていくので通過し、本番で応答が大きくなったときにだけ壊れます。
これで、関心イベントをいつ変えるかが重要になります。読み取りの準備完了は、相手が送ったものがあるときにだけ真ですが、書き込みの準備完了は、送信バッファーに空きがあるときに真です。ほとんどの時間はバッファーが空なので、書き込みの準備完了はほとんど常に真です。送るものがないのに書き込みの準備完了を見張ると、多重化の呼び出しが、何も起きないままただちに戻ってくる繰り返しになります。
| 状態 | 見張るイベント | 理由 |
|---|---|---|
| 送るものがない | 読み取りだけ | 書き込みの準備完了はほとんど常に真なので、ループが空回りします |
| 送るものが残っている | 読み取りと書き込み | 空きができた瞬間を知らないと、続きを送れません |
そこで、ルールは1行にまとめられます。送るものが残っているあいだだけ、書き込みの準備完了を見張ります。キューに何かを入れた直後と、送り終えた直後の2つの時点で、関心イベントを計算し直せば、このルールが守られます。
受け取る側にも、対になるルールがあります。1回の読み取りの準備完了で、recvを1回だけ呼ぶのか、EAGAINが出るまで呼ぶのかです。selectorsの既定の動作であるレベルトリガーでは、残りのデータがあれば次にまた起こしてくれるので、1回だけ呼んでも安全です。1回だけ呼ぶほうが、接続間の公平性にも有利です。おしゃべりなお客さん1人が、1回の起床でループを独占できないからです。
ここで、epoll(7)が説明するエッジトリガーを思い浮かべるかもしれません。エッジトリガーは、状態が変わる瞬間にだけ通知するので、1回起きたときに、これ以上読むものがなくなるまで読んでおかないと、残ったデータが次の通知を待ったまま眠ってしまいます。逆にレベルトリガーは、条件が維持されているあいだ通知し続けるので、読み残しても忘れられません。2つの方式は、性能の優劣ではなく、責任の置き場所が違うのです。どちらを使っているのか知らないままコードを移して貼りつけると、ときどき応答が消えるのに、再現はしない種類のバグになります。
バッファーには、上限も必要です。相手が改行のないバイトを送り続けると、受け取る側のバッファーは際限なく育ちます。送信側も同じで、相手が読まないのに、応答をキューに積み続けると、メモリがその接続1つに縛られます。前のコースで学んだ長さの上限とバックプレッシャーが、ここでもそのまま必要だという意味です。多重化は待ちを移してくれるだけで、なくしてはくれず、移された待ちは、たいていメモリの姿で現れます。
現場での姿
部分送信は、普段はうまく隠れています。応答がカーネルの送信バッファーより小さければ、いつも1回で出ていくので、テストも通り、ステージングも通ります。現れるのは、決まった場面です。応答に一覧が付いて大きくなるとき、相手が読み取りを遅らせるとき、そして1つの接続に応答を立て続けに押し込むときです。このとき相手は、途切れたJSONや半分だけの行を受け取り、報告はパーサーのエラーとして入ってきます。送った側のログには、成功だけが残っています。
逆に、関心イベントを直さないために起きる事故は、CPUのグラフで先に来ます。接続数と関係なく、コア1つが100%に張り付いていて、プロファイラーは、多重化の呼び出し自体を最もホットな地点として見せます。実際には、その呼び出しが遅いのではなく、何も起きないのに、あまりにも頻繁に呼ばれているのです。
次のクイズですること
sendが20バイトのうち3を返した状況、書き込みの準備完了をいつも見張っているサーバーのCPUグラフ、そして改行のない断片を受け取った状況を、それぞれどう扱うべきか、整理してみてください。3つとも、残りをどこに置くのかという、同じ問いの別の顔です。