TCPを(少しは)理解しておくべきその理由

この記事はTCPの全てを理解する、あるいは『TCP/IP Illustrated』(訳注:日本語版:『詳解TCP/IP〈Vol.1〉プロトコル』)を読破しようとか、そういうことではありません。ほんの少しのTCPの知識がどれほど欠かせないものなのかについてお話します。まずはその理由をお話しましょう。

私がRecurse Centerで働いているとき、PythonでTCPスタックを書きました(またPythonでTCPスタックを書いたらどうなるかについても書きました)。それはとても楽しく、ためになる経験でした。またそれでいいと思っていたんです。

そこから1年ぐらい経って、仕事で、誰かが「NSQへメッセージを送ったんだが、毎回40ミリ秒かかる」とSlackに投稿しているのを見つけました。私はこの問題についてすでに1週間ほど考え込んでいましたが、さっぱり答えがでませんでした。

ここでちょっとした補足を。NSQはメッセージを送るときのキューです。メッセージを送る方法は、HTTPリクエストをローカルホストに送るというものです。本来ならローカルホストへHTTPリクエストを送るのに40ミリ秒もかかりません。何かが完全に間違っていました。NSQデーモンはCPUの読み込みに高い負荷を与えてはいませんし、多くのメモリを使ってもいません。またガベージコレクションによって休止しているようにも見えません。どういうことでしょう。

そこで1週間前に、「パフォーマンスを求めて-各POSTリクエストごとに、どうやって200ミリ秒を削ったか」という記事を読んだのを思い出しました。この記事では、なぜPOSTリクエストを送るごとに200ミリ秒余分にかかっているのかについて触れています。確かに妙ですね。以下が、その記事のポイントとなる段落です。

遅延ACKとTCP_NODELAY

RubyのNet::HTTPはPOSTリクエストを2つのTCPパケットに分け、1つはヘッダーに、もう1つはボディになります。対照的にcurlでは、2つのTCPパケットが単一パケットにフィットするようであれば、これらを統合します。さらに悪いことに、Net::HTTPは通信を行うTCPソケットにTCP_NODELAYを設定することができません。ですから、1つ目のパケットのACKを受信しないと2つ目のパケットが送信されません。これはNagleアルゴリズムによって生じる結果です。

接続のもう一方のほうに移ると、HAProxyはこれら2つのパケットをどうACKするかを選択しなくてはなりません。バージョン1.4.18(私たちが使用した物)では、TCP遅延ACKを使うことになっていました。遅延ACKはNagleアルゴリズムとの相性が悪く、サーバが遅延ACKのタイムアウトになるまでリクエストがストップしてしまいます。

要約すると次のようになります。

  • TCPとはパケットでデータを送信するアルゴリズムである。
  • HTTPライブラリはPOSTリクエストを2つの小さいパケットにして送信していた。

その後のTCPのやりとりは、以下のように続きます。

アプリケーション: やあ、パケット1だよ。
HAProxy:<無反応で、2つ目のパケットを待っている>
HAProxy:<そのうちACKするけど、まあいいか>
アプリケーション:<無反応>
アプリケーション:<ACKを待っているんだけど、ネットワークが混雑しているのかな>
HAProxy:もう待ち疲れた。はい、ACKだよ。
アプリケーション:やった! じゃあ2つ目のパケットを送るよ。
HAProxy:よかった。これで終了だ。

アプリケーションとHAProxyが両方、意図的に他方が情報を送ってくるのを待っている期間がありますよね。それが余分の200ミリ秒です。アプリケーションはNagleのアルゴリズムにのっとっているためにそうなっていて、HAProxyは遅延ACKsのせいでそうなっているのです。

私の知る限り、全てのLinuxシステムでは初期値で遅延ACKが設定されています。だからこれはエッジケースとか例外ではありません。2つ以上のTCPパケットでデータを送れば起こり得ることです。

魔法のように問題が解決した

前述の記事を読んだ後、そのことについては忘れてしまっていました。でも、余分な40ミリ秒について思い悩んでいた時、ふと思い出したのです。

私はこう思いました。これは私の問題とは別物なはずだ。いや、同じなのか? そして私はチームにメールを送りました。「頭が変になったのかもしれないが、これはTCP問題かもしれない」と。

そこで私はアプリケーションに対しTCP_NODELAYをオンにして変更をコミットしたのです。すると、ジャーン。

なんと全ての40ミリ秒遅延の問題は瞬時に消えました。全てが解決したのです。まるで魔法使いになったような気分でした。

遅延ACKの使用を完全にやめるべきか?

補足:@alicemazzyが投稿したすばらしい内容のツイートに、Hacker Newsに掲載されたJohn Nagle(Nagleアルゴリズム)のコメントがありました。

本来の問題はACKの遅延にある。200ミリ秒の”ACK遅延”タイマーは、1985年頃にバークレー校の誰かがBSDに埋め込んだ、ひどいアイデアである。彼らは問題をちゃんと理解していなかったのだ。遅延ACKは、200ミリ秒以内にアプリケーションレベルからの返信があるだろう、という賭けなのだ。たとえ毎回この賭けに負けると分かっていても、TCPは遅延ACKを使い続ける。

続けて彼は、「ACKは小さくて安価だが、実際に遅延ACKによって起こる問題は恐らくACKが解決している問題よりもひどいと言える」とコメントしています。

TCPを理解せずして、TCPの問題は解決できない

私はこれまで、TCPはさほど難しいものではないと思っていましたし、理解する必要もないと思っていました。確かにそうかもしれません。しかし現実的には、TCPアルゴリズム内の何かが原因で起こるバグが発生したりします。ですから、結局はTCPを理解することは重要なのです(私のブログの中で度々話題にしていますが、これはシステムコールやOSといった多くのことに当てはまります:):))。

遅延ACKとTCP_NODELAYの相性は特に悪く、あらゆるプログラミング言語でHTTPリクエストを送信するコードを書く人に影響を与えかねません。このようなことを思いつくために、システムプログラミングの天才である必要はありません。TCPがどう機能するのかを少しでも理解していたおかげで、この問題に対処する手助けになりました。そしてこのブログに書かれていたことが、私の問題と原因が同じであるということに気付けたのです。私はstraceも使っていましたけどね。ずっと使い続けますよ。