TL;DR
- メッセージを小さな
write()2回に分けて送り、そのあと返事を待つクライアントは、Linux で1往復ごとに約40ms止まることがあります。CPUは空いていて、パケットロスもありません。僕のサンドボックスでは、中央値が44msでした。同じバイト列を1回の write で送ると0.02msでした。 - 原因は2つのアルゴリズムの噛み合わせです。送信側の Nagleアルゴリズムは、最初の小さなセグメントにACKが返るまで、2つ目を溜めておきます。受信側の遅延ACKは、返信に相乗りさせたくてACKを遅らせます。ところが返信は、2つ目のセグメントが届かないと作れません。お互いに相手を待ち、遅延ACKのタイマーが切れるまで進みません。Linux ではこのタイマーの下限が40msです。
- 対処は、優先順に次のとおりです。メッセージを1回の write で送る(バッファに組み立てる、または
writev/sendmsgを使う)。それが難しければ、書き込み側にTCP_NODELAYを設定します。最後の手段として、受信側のTCP_QUICKACK(Linux 固有で、再設定が必要)があります。 - 以下はすべて再現できます。短いスクリプトが2本あり、1本は時間を測り、もう1本はパケット単位のタイムラインを出します。
パズル:1往復は何msかかるか
小さなリクエストプロトコルを考えます。リクエストは4バイトのヘッダーと、それに続く8バイトの本文です。サーバーは12バイト揃ってから、2バイトの返事を返します。クライアントのコードは、ごく自然なものです。
sock.sendall(HEADER) # 4 bytes
sock.sendall(BODY) # 8 bytes
reply = sock.recv(2)
両端は同じマシンのループバックにあります。ロスも輻輳もなく、サーバーは何も計算しません。1往復は何msかかるでしょうか。
先を読む前に、予想してみてください。「数十マイクロ秒」と答えた方は多いはずですが、デフォルトの Linux ソケットでは、それは3桁ほど外れます。この記事の後半のスクリプトでは、次の結果になりました(Linux 6.12、Python 3.13、サンドボックス1台、50往復の中央値です。環境が違えば値も変わります)。
two writes, Nagle on (default) median 44.00 ms
one write, Nagle on (default) median 0.02 ms
バイト列は同じで、変わったのは write() の回数だけです。さらに、このバグがコードレビューや一発きりのテストをすり抜ける理由がもう1つあります。接続した直後の1往復目は速いのです(後のトレースでは約0.3msでした)。止まり始めるのは、2回目のリクエストからです。
4つの対処とそれぞれのコスト
| 対処 | 何をするか | コストと注意点 |
|---|---|---|
1回の write にまとめる(バッファに組み立てる、または writev / sendmsg にバッファのリストを渡す) | リクエスト全体が1セグメントで出ていきます。Nagle が溜める対象がなくなります。 | 書き込むコードを自分で触れる必要があります。ソケットオプションは不要で、移植性もあります。最初に試すならこれです。 |
書き込み側の TCP_NODELAY | Nagle を無効にし、小さなセグメントもすぐ送ります。 | 細かい書き込みを続けると、小さなパケットが増えます。1セグメントにつき、オプションなしでも IPv4 と TCP のヘッダーで最低40バイトかかります。リクエスト/レスポンスなら問題になりにくく、バルク転送では無駄になります。 |
読み取り側の TCP_QUICKACK(Linux) | 遅延させず、すぐACKを返します。 | Linux 専用です。しかも永続しません。カーネルが遅延ACKのモードに戻ることがあるので、読み取りの前後で設定し直します。書き込み側を変えられないときに使えます。 |
書き込み側の TCP_CORK(Linux) | uncork するまで部分的なフレームを溜め、まとめて送ります。 | Linux 固有です。man ページによると、コークしておく時間には200msの上限があります。「ヘッダーを書いてから sendfile」には向きますが、普通のリクエスト/レスポンスには扱いにくい方式です。 |
Linux には tcp_autocorking(3.14以降、デフォルトで有効)もあります。前のパケットがqdiscやデバイスの送信キューに残っているときに、小さな書き込みをまとめる仕組みです(tcp(7))。これは Nagle とは別の仕組みです。サンドボックスのカーネルでは /proc/sys/net/ipv4/tcp_autocorking が 1 でしたが、停止は起きました。つまりこのケースは救ってくれません。
ランタイムによっては、すでに答えが選ばれています。Go の net パッケージは、デフォルトで no-delay です。libcurl も、7.50.2 以降は TCP_NODELAY がデフォルトです。Node.js の http サーバーは v18 から noDelay がデフォルトで true ですが、素の net.Socket は Nagle が有効な状態で始まります。僕のスクリプトの Python ソケットは、数字のとおり Nagle が有効なままです。どのデフォルトを引き継ぐかは、足場にしているライブラリ次第です。
2つのアルゴリズムの仕組み
Nagle:小さなセグメントは同時に1つまで
RFC 896 は、1バイトのパケットでネットワークが溢れるのを防ぐために、このルールを導入しました。RFC 9293 §3.7.4 は次のように簡潔に書いています。
If there is unacknowledged data (i.e., SND.NXT > SND.UNA), then the sending TCP endpoint buffers all user data (regardless of the PSH bit) until the outstanding data has been acknowledged or until the TCP endpoint can send a full-sized segment (Eff.snd.MSS bytes).
つまり、未確認のデータがなければ、小さな書き込みもすぐ出ます。小さなセグメントが未確認のまま飛んでいるあいだは、次の小さな書き込みは待たされます。実装は「SHOULD」(推奨)で、アプリケーションが接続ごとに Nagle を無効にできる手段は「MUST」(必須)です(RFC 1122 §4.2.3.4、RFC 9293 §3.7.4)。その手段が TCP_NODELAY です。
遅延ACK:少し待てば、相乗りできるデータがあるかもしれない
受信側は、送信するデータに相乗りさせたり、2セグメントを1つのACKで済ませたりするために、ACKを遅らせられます。RFC 1122 §4.2.3.2 は、その遅延は「0.5秒未満でなければならない(MUST)」としています。RFC 5681 §4.2 も、最初の未確認パケットから500ms以内、かつ最大サイズのセグメント2つにつき少なくとも1回はACKを出すよう求めています。500msはあくまで上限です。Linux はもっと短いタイマーを使います。カーネルの include/net/tcp.h では、TCP_DELACK_MIN が HZ/25(40ms)、TCP_DELACK_MAX が HZ/5(200ms)です。タイトルの「40ms」は、この実装の定数です。RFC の数字ではありません。接続ごとの値は ss -i の ato:40 で見られます。
組み合わさると止まる理由
標準もこの相互作用を把握しています。RFC 9293 は、「Nagle アルゴリズムと遅延ACKのあいだには、問題のある相互作用がありうる」と書いています。付録 A.3 では、一部のOSが実装している修正(IETF ドラフトで提案されたもの)も紹介しています。デフォルトのソケットでは、次の順に進みます。
- クライアントが4バイトのヘッダーを書きます。未確認のデータはないので、すぐ送られます。
- クライアントが8バイトの本文を書きます。ヘッダーはまだ未確認で、本文は最大サイズに満たないので、Nagle が溜めます。
- サーバーがヘッダーを受け取ります。返事は12バイト揃うまで作れないため、ACKを相乗りさせる送信データがなく、ACKを遅らせます。
- 遅延ACKのタイマー(約40ms)が切れてACKが出ます。クライアントの Nagle が本文を放出し、サーバーに12バイト揃って返事が出ます。
どちらも誤動作していません。どちらか一方を外せば、停止は消えます。
中を見る:計測とパケットトレース
計測
次のスクリプトは、4つの変種に加えて、受信側 TCP_QUICKACK の変種も含めて1往復の時間を測ります。サーバーは、12バイトのメッセージが揃ってから返事をします。
#!/usr/bin/env python3
"""Write-write-read over TCP: why does the reply take ~40 ms?
The server answers only after it has received a full 12-byte message
(4-byte header + 8-byte body). The client sends the message either as two
small writes or as one. Run it on any Linux box and compare the medians.
"""
import socket, threading, time, statistics, sys
HEADER, BODY = b"HEAD", b"BODYBODY"
N = 50
def server(listener, quickack):
conn, _ = listener.accept()
while True:
got = b""
while len(got) < len(HEADER) + len(BODY):
if quickack: # not permanent (tcp(7)): re-arm before every wait
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_QUICKACK, 1)
chunk = conn.recv(4096)
if not chunk:
return
got += chunk
conn.sendall(b"ok")
def run(label, *, nodelay, send, quickack=False):
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen()
threading.Thread(target=server, args=(listener, quickack), daemon=True).start()
c = socket.create_connection(listener.getsockname())
if nodelay:
c.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
samples = []
for _ in range(N):
t0 = time.perf_counter()
send(c)
c.recv(2)
samples.append((time.perf_counter() - t0) * 1000)
c.close()
print(f"{label:<34} median {statistics.median(samples):8.2f} ms max {max(samples):8.2f} ms")
two_writes = lambda c: (c.sendall(HEADER), c.sendall(BODY))
one_write = lambda c: c.sendall(HEADER + BODY)
gather = lambda c: c.sendmsg([HEADER, BODY]) # writev-style: one syscall, one segment
print(sys.platform, "-", "n =", N, "round trips per row")
run("two writes, Nagle on (default)", nodelay=False, send=two_writes)
run("two writes, TCP_NODELAY", nodelay=True, send=two_writes)
run("one write, Nagle on (default)", nodelay=False, send=one_write)
run("sendmsg([hdr, body]), Nagle on", nodelay=False, send=gather)
run("two writes, receiver TCP_QUICKACK", nodelay=False, send=two_writes, quickack=True)
サンドボックスでの1回の実行結果です(Linux 6.12、Python 3.13、ループバック)。絶対値はカーネルやタイマーの設定、負荷で変わります。注目してほしいのは、差と、それが消える場所です。
linux - n = 50 round trips per row
two writes, Nagle on (default) median 44.00 ms max 48.04 ms
two writes, TCP_NODELAY median 0.02 ms max 0.08 ms
one write, Nagle on (default) median 0.02 ms max 0.07 ms
sendmsg([hdr, body]), Nagle on median 0.02 ms max 0.05 ms
two writes, receiver TCP_QUICKACK median 0.02 ms max 0.07 ms
この表から3つ読み取れます。書き込み側の TCP_NODELAY で停止が消えます。同じバイト列を sendall や sendmsg 1回で送れば、ソケットオプションに触れなくても消えます。受信側の TCP_QUICKACK でも、反対側から消せます。(スクリプトの初期版では、TCP_NODELAY をサーバー側のソケットに付けていましたが、何も変わりませんでした。溜められているのはクライアントのセグメントだからです。オプションは、小さな断片を書く側に付けます。)
パケットトレース
遅延の表からは、停止していることは分かりますが、理由は分かりません。理由はトレースで見えます。使い捨てのユーザー名前空間とネットワーク名前空間(unshare -Urn。本物のroot権限は要りません)の中で、lo に raw の AF_PACKET ソケットを開けば、送信される TCP セグメントすべてにタイムスタンプを付けられます。
#!/usr/bin/env python3
# Run: unshare -Urn python3 nagle_trace.py (needs CAP_NET_RAW inside a throwaway network namespace)
import socket, struct, subprocess, threading, time, sys
subprocess.run(["ip","link","set","lo","up"],check=True)
sniff = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(3)); sniff.bind(("lo",0))
events=[]; t0=None
def run_sniff():
while True:
raw,addr=sniff.recvfrom(65535)
if addr[2]!=4: continue # lo shows every packet twice; keep PACKET_OUTGOING
p=raw[14:]
if len(p)<40 or p[9]!=6: continue
ihl=(p[0]&15)*4; sp,dp,seq,ack,off,fl=struct.unpack("!HHIIBB",p[ihl:ihl+14]); plen=len(p)-ihl-(off>>4)*4
if fl&2: continue
events.append((time.perf_counter(),sp,dp,fl,plen))
threading.Thread(target=run_sniff,daemon=True).start()
H,B=b"HEAD",b"BODYBODY"
def server(l):
c,_=l.accept()
while True:
got=b""
while len(got)<12:
d=c.recv(4096)
if not d: return
got+=d
c.sendall(b"ok")
l=socket.socket(); l.bind(("127.0.0.1",0)); l.listen(); sport=l.getsockname()[1]
threading.Thread(target=server,args=(l,),daemon=True).start()
c=socket.create_connection(l.getsockname())
for i in range(4): # a few rounds: the first is in quick-ack mode
time.sleep(0.2); events.clear()
t=time.perf_counter(); c.sendall(H); c.sendall(B); c.recv(2); dt=(time.perf_counter()-t)*1000
time.sleep(0.1)
print(f"--- round {i+1}: reply after {dt:.1f} ms")
for (ts,sp,dp,fl,pl) in list(events):
who = "client->server" if dp==sport else "server->client"
kind = f"{pl} B data" if pl else "pure ACK"
print(f" +{(ts-t)*1000:7.2f} ms {who} {kind}")
1つの接続で4往復します。最初の2往復の出力は次のとおりです。
--- round 1: reply after 0.3 ms
+ 0.19 ms client->server 4 B data
+ 0.29 ms server->client pure ACK
+ 0.34 ms client->server 8 B data
+ 0.34 ms server->client pure ACK
+ 0.35 ms server->client 2 B data
+ 0.36 ms client->server pure ACK
--- round 2: reply after 44.1 ms
+ 0.20 ms client->server 4 B data
+ 43.91 ms server->client pure ACK
+ 43.94 ms client->server 8 B data
+ 43.95 ms server->client pure ACK
+ 44.03 ms server->client 2 B data
+ 44.04 ms client->server pure ACK
1往復目は速く、受信側がすぐACKを返し、ヘッダーの0.15ms後に本文が出ます。2往復目では、ヘッダーが+0.20msに出たあと、約44ms何も起きません。その後にサーバーからの純粋なACKが届き、そのあとで初めてクライアントが本文を送ります。クライアントの2回目の write() はすぐ返っていて、カーネルがデータを保留していただけです。同じ実行の3、4往復目も、2往復目と同じ形でした。
1往復目が速い理由はこうです。受信側は、接続で最初にデータを受け取ったとき、quick-ACK モードで始まり、最初のセグメントにはすぐACKを返します。カーネルは最初のデータパケットが届いたときにこれを初期化します(net/ipv4/tcp_input.c の tcp_event_data_recv)。やり取りが対話的に見えてくると、遅延ACKに切り替わります。効果は観測し、コードの経路も読みましたが、カーネルのどの遷移で quick-ACK モードが終わるのかまでは追っていません。ここは「矛盾しない」という説明であり、「証明した」ではありません。
どの対処をいつ使うか
- 小さなメッセージのリクエスト/レスポンスプロトコル: 1メッセージを1つのバッファに組み立て、1回で書きます。遅延が重要な経路で、別の場所に2回目の write がないと言い切れないなら、
TCP_NODELAYも付けておきます。 - 書き込みをフレームワークやライブラリが行い、変更できない: すでに no-delay になっているか確認します(上のデフォルトの一覧を参照)。なっていなければ、そのライブラリが使うソケットに
TCP_NODELAYを設定します。読み取り側しか触れないなら、TCP_QUICKACKはつなぎの手段です。 - 大きな書き込みによる、一方向のバルク転送: Nagle には手を付けません。最大サイズのセグメントは溜められませんし、直すべき小さな書き込みのパターンもありません。
- 原因を診断せずに
TCP_NODELAYで遅さを直そうとしないでください。 遅延が40ms付近(最大200ms)に集まっていなければ、この問題ではありません。
停止が戻ってくるパターン
TCP_NODELAYを付けたうえで、細かい書き込みを続ける。 停止は消えますが、write()1回ごとに別のセグメントになり、数バイトのペイロードに40バイト以上のヘッダーが付くことがあります。症状: データ量の割にパケット数が多い。対処: ソケットの前にバッファ付きライタを置き、メッセージごとに1回 flush します。- 別のレイヤーが2回に分けて書く。 自分は1つのバッファを書いても、下のライブラリがヘッダーと本文を別々に出すことがあります。症状: まとめたのに停止が戻る。対処: システムコールの順序を確認し(例:
strace -e trace=write,sendto,sendmsg。ここでは使っていません)、そのレイヤーの設定を直します。 - オプションを付ける側を間違える。
TCP_NODELAYが効くのは、溜められたデータを書く側です。読み取り側に付けても、この停止には効きません(試しました)。 TCP_QUICKACKを1回設定して終わり。 カーネルは quick-ACK モードを再び抜けることがあるので、accept()の後にsetsockoptを1回呼ぶだけでは直りません。読み取りのたびに設定し直します。Linux 専用でもあります。- 問題を隠してしまうベンチマーク。 リクエスト1回だけの計測や、ほぼ1往復目だけのウォームアップでは速く見えます。1つの接続で、連続した多数の往復を測ってください。
- サーバーが遅いと誤認する。 症状: CPUは空いているのに、遅延が特定の値に張り付く。対処: アプリケーションのプロファイルではなく、タイムラインを見ます。
手元で再現する
- 1つ目のスクリプトを
nagle_demo.pyとして保存し、Linux でpython3 nagle_demo.pyを実行します。1行だけ他より約40ms大きく、それが2回書き込みの行になるはずです。 - 2つ目のスクリプトを
unshare -Urn python3 nagle_trace.pyで実行します(特権なしのユーザー名前空間が必要です)。4バイトのセグメントと8バイトのセグメントのあいだの空白を探してください。 Nを500に上げて、最初の行が約20秒かかるようにし、そのあいだ別の端末でss -tin dst 127.0.0.1を実行します。デモの接続の2つのソケットでato:40を探してください。- サーバーを別のホストに移して比べます。これは試していません。ループバックと実際のリンクは仕組みを共有しますが、数字が同じとは限りません。
確認したことと、していないこと
2本のスクリプトを Linux 6.12 のサンドボックス1台で実行し、上の数字を出しました。計測を3回実行したところ、2回書き込みの行の中央値は44.00、44.00、43.99ms(1ms未満の行は、実行ごとに0.02〜0.04msの間で動きました)でした。RFC の該当箇所、tcp(7)、上でリンクしたカーネルの定数は、原文で確認しています。ほかのカーネル、ほかのOS、実際のネットワーク回線、TLS は試していません。
1メッセージ、1回の write
Nagle は、未確認のデータがあるあいだ小さな書き込みを保留し、遅延ACKはACKを保留します。小さな書き込みを2回行うリクエスト/レスポンスでは、2つがタイマーが切れるまで待ち合います。Linux ではこのタイマーが最低40msです。接続の最初のリクエストは速いことがあるので、一発きりのテストでは気づけません。根本的な対処は構造で解決することで、1メッセージを1回の write で送ることです。実用的な保険は書き込み側の TCP_NODELAY、最後の手段は受信側の TCP_QUICKACK です。この記事から診断を1つだけ持ち帰るなら、これにしてください。空いているマシンで遅延が決まった値に集まっているなら、原因は負荷ではなくタイマーです。