同じサーバーに 200 バイト送るだけなのに、なぜ 2 回に分けて write すると 40ms 遅れるのか?
自作のクライアントで、リクエストのヘッダとボディを別々に write したら、レスポンスが毎回きっちり 40ms ほど遅れた。ローカルホスト同士で、サーバーの処理は 1ms もかからないのに。CPU もネットワークも空いているのに時間だけが消えるこの現象は、TCP に組み込まれた 2 つの「親切」が互いを待ち合うことで起きる。
- 送信側の Nagle アルゴリズムは「未 ACK のデータがある間は小さな断片を送らずに溜める」、受信側の delayed ACK は「ACK を返事に相乗りさせるため少し待つ」。
- write → write → read の順で小さく書くと、送信側は ACK 待ち、受信側は残りのデータ待ちで膠着し、delayed ACK のタイマー(Linux では最短 40ms)が切れるまで止まる。
- 対策は 1 回の write にまとめるか
TCP_NODELAYを立てること。Go・Node.js の http サーバー・curl は既定で Nagle を切っているが、生のソケットでは有効なことが多い。
小さなパケットを減らすための 2 つの仕組み
TCP で 1 バイトを送ると、IP と TCP のヘッダ計 40 バイトが付いて 41 バイトのパケットになる。1984 年の RFC 896 で John Nagle はこれを「4000% のオーバーヘッド」と呼び、キー入力 1 文字ごとにパケットが飛ぶ端末通信が回線を詰まらせる問題を指摘した。この小さすぎるパケットは tinygram とも呼ばれる。
Nagle の解決策は単純だ。Nagle アルゴリズムとは、送信済みで ACK(受信確認)がまだ返っていないデータがある間、新しく書かれた小さなデータを送らずに送信バッファへ溜めておく規則である。ACK が戻るか、溜まった量が 1 セグメントの最大長(MSS)に達した時点でまとめて送る。往復が遅い回線ほど溜まる量が増えるので、タイマーを使わずに回線の状態へ自動で合わせられる。
受信側にも似た発想の仕組みがある。delayed ACK(遅延 ACK)とは、データを受け取ってもすぐには ACK を返さず、少し待つ規則である。待つ理由は、アプリがすぐに返事を書けば ACK をその返事のパケットに相乗りさせられ、ACK だけのパケットを 1 つ減らせるからだ。RFC 1122 は遅延を 0.5 秒未満に抑え、最大長のセグメントが続く場合は少なくとも 2 つに 1 回は ACK を返すよう定めている。
2 つが出会うと何が起きるか
どちらも単独では無駄を省く良い仕組みに見える。しかし「送信側は ACK を待って送らない」「受信側は返事を待って ACK を返さない」が同時に成り立つと、お互いが相手の動きを待つ状態になる。その典型が、クライアントが 100 バイトのヘッダと 100 バイトのボディを 2 回の write で送り、サーバーが 200 バイト揃ってから応答する場面だ。
Client(Nagle ON) Server(delayed ACK) │ │ │ ① header 100B │ │────────────────────────▶│ │ │ need 200B │ ② body 100B : HOLD │ ACK : DELAY │ (unACKed data exists) │ │ │ │ ...... ~40 ms ...... │ │ │ │◀────────────────────────│ │ ACK │ (timer) │ ② body 100B │ │────────────────────────▶│ │◀────────────────────────│ │ "ok" │ HOLD : Nagle が未ACK中の小片を保留 DELAY: 返事に相乗りさせるため ACK を待機
1 回目の write は未 ACK のデータが無いので即座に送られる。2 回目は 1 回目の ACK がまだなので Nagle に止められる。サーバーはヘッダだけ受け取っても応答を作れないので返事を書かず、相乗り先が無いまま ACK を遅らせる。この膠着は delayed ACK のタイマーが切れて ACK 単体が送られた瞬間に解け、ボディが送られ、ようやく応答が返る。
Linux カーネルではこのタイマーの最小値 TCP_DELACK_MIN が HZ/25(40ms)、最大値 TCP_DELACK_MAX が HZ/5(200ms)と定義されている。冒頭の「きっちり 40ms」はこの最小値が見えている。帯域や CPU が原因ではなく、タイマーが原因なので、サーバーを速くしても遅延は減らない。
注文票を 2 枚に分けて厨房へ渡す店員と、受け取ったら「了解」の合図を料理と一緒に返したい料理長だ。店員は「前の票に了解をもらうまで次の票は出さない」と決めており、料理長は「票が揃わないと料理を作れないし、料理が無いと了解を言う機会が無い」。料理長が 40ms 待って我慢できずに「了解」だけ言った瞬間、2 枚目の票がようやく出てくる。
TCP_NODELAY を立てると何が変わるか
TCP_NODELAY はソケットごとに Nagle アルゴリズムを無効にするオプションである。Linux の tcp(7) は「設定すると、データが少量でもセグメントを可能な限りすぐ送る」と説明している。RFC 1122 も、アプリが接続ごとに Nagle を無効にできる手段を必ず用意するよう求めている。
# Python(生のソケットは既定で Nagle が有効)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
// Go(net.TCPConn は既定で true = 遅延なし)
conn.(*net.TCPConn).SetNoDelay(false) // あえて Nagle を有効化する場合
// Node.js(net.createServer / net.connect の既定は noDelay: false)
net.createServer({ noDelay: true }, handler)
既定値は言語やライブラリで揃っていない。Go の TCPConn.SetNoDelay は既定が true(遅延なし)とドキュメントに明記されている。Node.js は net モジュールの noDelay 既定が false だが、http.createServer の noDelay は v18.0.0 から既定 true に変わった。curl は 7.50.2 以降、既定で TCP_NODELAY を立てる。一方で Python の素の socket は何もしなければ Nagle が有効なままで、後述の実験もこれを利用する。
ただし TCP_NODELAY は無料ではない。Nagle を切ると、アプリが細かく 10 回 write すれば 10 個の小さなパケットがそのまま出ていき、ヘッダの比率と送信処理の回数が増える。Redis はこのトレードオフを設定として露出しており、repl-disable-tcp-nodelay を yes にするとレプリカへの送信パケットと帯域が減る代わりに、既定設定の Linux では最大 40ms ほどレプリカへの反映が遅れうると redis.conf に書かれている。既定は no(低遅延優先)だ。
本当の対策は「write をまとめる」こと
TCP_NODELAY を立てれば膠着は消えるように思える。しかし根本原因は、1 つの論理メッセージを複数の write に分けているアプリ側の書き方にある。ヘッダとボディをアプリのバッファで連結して 1 回で書けば、Nagle が有効でも 2 回目の小片が生まれないので待ちは起きない。bufio.Writer のようなバッファ付き writer を挟んで最後に Flush するのも同じ効果になる。
まとめて書くと、パケット数も write システムコールの回数も減る。TCP_NODELAY だけで済ませると遅延は消えても細切れのパケットは残るので、両方を組み合わせるのが定石だ。「メッセージは 1 回で書く、そのうえで遅延に敏感な接続は NODELAY」という順番で考えるとよい。
Linux には逆向きの道具もある。TCP_CORK は「満杯でないセグメントを送らずに溜める」オプションで、解除した時点でまとめて送る。sendfile の前にヘッダを書くときなどに使う。tcp(7) によれば現在の実装では栓をしておける時間に 200ms の上限があり、それを超えると自動で送信される。受信側の delayed ACK を止める TCP_QUICKACK もあるが、一度立てても TCP の内部処理によって通常モードに戻るため恒久的な設定ではなく、どちらも移植性は無い。
典型は「p50 レイテンシが 40ms か 200ms 付近に不自然に張り付く」グラフだ。自作の RPC クライアント、古いドライバ、プロキシを挟んだ構成、テストで書いた素のソケットサーバーなどで、ヘッダとボディを別 write にしているコードが原因になりやすい。tcpdump で見ると、小さなセグメントのあとに約 40ms の空白があり、ACK 単体のパケット直後に残りのデータが出ていく並びになっている。この形が見えたら、サーバー処理ではなく送信側の書き方とソケットオプションを疑う。逆に Go や Node.js の http サーバーで同じ症状が出た場合は既定で Nagle が切れているので、別の原因(コネクション確立、DNS、GC など)を探すほうが早い。
ローカルホスト上で「ヘッダ 100B とボディ 100B を 2 回に分けて write し、200B 揃うと応答するサーバー」を立て、Nagle 有効時と TCP_NODELAY 時の往復時間を比べる。Python 3 だけで動く。
mkdir -p ~/nagle-test && cat > ~/nagle-test/nagle.py <<'EOF'
import socket, sys, threading, time
NODELAY = len(sys.argv) > 1 and sys.argv[1] == "nodelay"
srv = socket.socket(); srv.bind(("127.0.0.1", 0)); srv.listen()
port = srv.getsockname()[1]
def server():
c, _ = srv.accept()
while True:
buf = b""
while len(buf) < 200:
d = c.recv(4096)
if not d: return
buf += d
c.sendall(b"ok")
threading.Thread(target=server, daemon=True).start()
cli = socket.create_connection(("127.0.0.1", port))
if NODELAY:
cli.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
for i in range(5):
t = time.perf_counter()
cli.send(b"h" * 100)
cli.send(b"b" * 100)
cli.recv(2)
print(f"{(time.perf_counter()-t)*1000:7.2f} ms")
EOF
python3 ~/nagle-test/nagle.py
python3 ~/nagle-test/nagle.py nodelay
Linux で実行すると、Nagle 有効時は 1 往復目だけ 0.1ms 台で、2 往復目以降が約 41ms に揃い、nodelay 付きでは全往復が 0.1ms 未満になった。1 往復目が速いのは、接続直後の ACK が遅延されずに返るためだ。macOS では delayed ACK の実装と値が Linux と異なるので遅れの大きさは変わりうるが、2 つの実行の差は観察できるはずだ。続けて 2 回の send を cli.send(b"h"*100 + b"b"*100) の 1 回にまとめると、Nagle 有効のままでも遅延が消えることも確かめてほしい。
- Nagle アルゴリズムは一定時間データを溜めるタイマーだ — Nagle 自体はタイマーを持たず、「未 ACK のデータがあるか」だけで判断する。40ms という数字は受信側の delayed ACK のタイマーから来ている。
- 遅いなら
TCP_NODELAYを全ソケットに立てれば解決する — 遅延は消えても、細かい write がそのまま細かいパケットになり、パケット数と送信処理が増える。まず 1 つのメッセージを 1 回で書くようにアプリを直すのが先である。 - ローカルホストなら TCP の待ちは関係ない — Nagle と delayed ACK はループバックでも同じ規則で動く。実際、上の実験はローカルホストだけで 40ms の遅れを再現している。
- Nagle アルゴリズム
- 未 ACK の送信データがある間、新しい小さなデータを溜めて送らない送信側の規則(RFC 896)。
- delayed ACK
- 返事のパケットに相乗りさせるため、受信後すぐには ACK を返さず短時間待つ受信側の規則。
- TCP_NODELAY
- ソケット単位で Nagle アルゴリズムを無効にし、小さなデータもすぐ送らせるオプション。
- TCP_CORK
- 満杯でないセグメントの送信を止めて溜め、解除時にまとめて送る Linux 固有のオプション。
- MSS
- 1 つの TCP セグメントに載せられるデータの最大長。Nagle はこの長さに達すると ACK を待たずに送る。
- RFC 896: Congestion Control in IP/TCP Internetworks — Nagle 本人による原典。4000% のオーバーヘッドと「タイマー不要の解決策」の説明が短く読める。
- RFC 1122: Requirements for Internet Hosts — 4.2.3.2 が delayed ACK の 0.5 秒未満・2 セグメントに 1 回、4.2.3.4 が Nagle の無効化手段の義務を定める。
- tcp(7) — Linux manual page —
TCP_NODELAY・TCP_CORK(200ms 上限)・TCP_QUICKACKの正確な挙動。 - Go net package: TCPConn.SetNoDelay — Go が既定で Nagle を切っていることの一次ソース。