一人のせいで列が止まる
一言でいうと
逐次サーバーでは、接続が1つ話し始めるのが遅いと、その後ろに並んだ接続がすべて、その時間を一緒に待つことになります。
なぜ必要なのか
接続が数千あるときの話をする前に、2つの接続がどのようにお互いを塞ぐのかから見る必要があります。最も一般的なサーバーの形は、acceptで接続を1つ受け取って最後まで処理し、またacceptに戻る繰り返しのループです。この構造は読みやすく、お客さんが1人ずつ順番に来るときは、何の問題もありません。問題は、お客さんが重なったときではなく、前の人が遅いときに現れます。
ここで「遅い」という言葉は、2つに分かれます。1つは、サーバーがそのリクエストを処理するのに時間がかかること、もう1つは、お客さんが話し始めるのが遅いことです。2つ目のほうが危険です。サーバーは何もしていないのに、recvの中で、相手が最初のバイトを送ってくれるのを待って、プロセス全体が止まっているからです。このとき、CPU使用率は0に近く、ログにも1行も残りません。忙しいことと塞がっていることは、見かけの指標がほとんど同じです。
前のコースでバイトの境界と再接続を扱ったなら、今回は、それより前の問いを扱います。1つのプロセスが複数の接続を同時に持つには、何が変わる必要があるのか、という問いです。
どう動くのか
ソケットの呼び出しのうち、止まりうるものは決まっています。ブロッキングモードでは、下の3つがすべてプロセスを止めます。
| 呼び出し | いつ止まるか | そのあいだ、ほかの接続は |
|---|---|---|
| accept | リッスンキューが空のとき | 新しい接続を受け取れません |
| recv | 受け取ったバイトがないとき | 受け取っておいた接続も読めません |
| send | 送る場所がないとき | 応答を送れません |
新しい接続が完全に消えるわけではありません。カーネルは、3-way handshakeを終えた接続をリッスンキューに入れておいて、acceptが呼ばれたときに1つずつ渡します。そのため、お客さんの側ではconnectがすぐに成功したように見え、応答だけが来ません。画面には、接続はできるのに遅いという形で現れます。
そのキューの大きさがlistenのbacklogですが、listen(2)は、この値が/proc/sys/net/core/somaxconnより大きいと、黙ってその値に切り詰められると書いています。同じドキュメントによると、Linux 5.4からそのファイルの既定値は4096で、それ以前のカーネルでは128です。つまり、コードに大きな数字を書いておいても、実際のキューはカーネルの設定が決めます。そして、キューを増やすのは待つ人の数を増やすだけで、待つ時間を減らしません。
測っておくことが先です。遅いお客さんを1人先に付けて、速いお客さんを6人後ろに並べると、逐次サーバーでは、6人全員が遅いお客さんの時間だけ待ちます。ラボで使う測定ツールが、その数字を次のように出します。
client 1: 1699 ms OK
...
blocked=6
fast=0
blockedは1秒を超えたお客さんの数、fastは0.2秒以内に終わったお客さんの数です。多重化に変えると、同じ負荷で、この2つの数字が逆転します。変える前に測っておかなければ、何がよくなったのかを言えず、よくならなかったときもわかりません。
ブロッキング自体が悪い設計なのではありません。PythonのソケットHOWTOも、ブロッキングソケットから始めて説明しています。ただし、ブロッキングモードでは一度に1つの接続しか扱えず、複数の接続を同時に扱うには、接続ごとに実行の流れを別に与える(スレッド・プロセス)か、止まらない呼び出しに変える必要があります。このコースは、2つ目の道を進みます。
接続ごとにスレッドを与える道も、実際によく使われます。コードがブロッキングの形のままなので読みやすいという、大きな利点があります。ただし、スレッドはそれぞれスタックを持ち、コンテキストスイッチにコストがかかるので、目標が接続数千なら、スループットよりリソースの面で先に限界に達します。もっと重要なのは、2つの道が同じ問題を違うやり方で解くという点です。スレッドは待つ場所を接続の数だけ増やし、多重化は待つ場所を1つにまとめます。どちらでも核心は同じです。1つの接続の待ちが、ほかの接続の進行を塞がないようにすることです。
現場での姿
障害の報告が、こんな形で入ってきます。サーバーのCPUは空いているのに、応答時間のテールだけが長く、再起動すると少しよくなってから、また悪くなります。ヘルスチェックは通ります。ヘルスチェックを送る側は、すぐにリクエストを送ってくれる誠実なお客さんなので、自分の順番が来れば、すばやく答えを受け取れるからです。
原因は、たいてい遅いお客さん数人です。モバイル回線でリクエストヘッダーが遅れて届いたり、クライアントが接続だけ先に開いておいてあとで書いたり、悪意を持って1バイトずつ送ったりする場合です。逐次サーバーでは、こうしたお客さん1人が、サービス全体のスループットを、自分の速度に縛りつけます。平均レイテンシだけを見ていると、この状況は見えません。塞がれた人たちの時間は、平均の陰に隠れます。
次のクイズですること
自分で図に描いてみてください。遅いお客さんが2秒後に話し始め、速いお客さん6人が0.3秒に到着したなら、逐次サーバーで、6人の応答はいつ出ていくでしょうか。彼らのconnectは、いつ成功したでしょうか。この2つが違う時刻だという点が、このモジュールの核心です。クイズでは、どの呼び出しがプロセスを止めるのか、リッスンキューが何を遅らせ、何を遅らせられないのか、そしてbacklogを大きくすることがなぜ解決にならないのかを確認します。