Old-School Networking / 2026 ・ No.06
いにしえのネットワーク入門
第6回 TCP ── ポート番号、3ウェイハンドシェイク、送り直し、窓と混み具合
公開
第5回で、小包は宛先の住所を頼りにルーターからルーターへ運ばれました。でも途中で落ちることもあれば、順番が入れかわることもあります。今回から第4層、トランスポート層です。
落ちても、入れかわっても、受け手が遅くても、道が混んでいても、申請を確実に届ける TCP のしくみを、わざと小包を落とす「いたずら線」を作って一つずつ確かめます。
部屋番号の層
新井前回の NAT の表にあった 40418 と 3000。部屋番号だって言ってましたよね。
古井そう。住所で建物までは届く。そこから先、どの部屋のどのプログラムに渡すかを決めるのが TCP の仕事の一つだ。
新井一つ、ということは、ほかにも?
古井小包を落とすルーター、順番を入れかえる道、読むのが遅いサーバー、混んだ線。IP はそのどれも面倒を見ない。全部 TCP が引き受けている。今日は全部、わざと起こしてみる。
全12回の路線図(いまここ:第6回)
- ポート番号 ── 住所は建物、番号は部屋。1本の接続は4つの組で決まる
- あいさつと番号 ── 3ウェイハンドシェイク、Seq と Ack、断られる・待たされる(実測)
- 送り直し ── 小包を1つ落とす、順番を入れかえる(実測)
- 窓と混み具合 ── 読まない受け手、混んだ線で小包の数がどう動くか(実測)
- 別れ方 ── FIN、TIME-WAIT、RST
今回の練習用ネットワークは、PC(192.168.1.10)とサーバー(203.0.113.20)の間に、筆者が書いた小さなプログラムを1本はさんだものです。中継しながら、遅らせる・帯域を絞る・決まった小包を落とす・順番を入れかえることができる「いたずら線」です。特に断らない限り、片道 20 ミリ秒(往復 40 ミリ秒)にしてあります。第5回のルーターや NAT は、話を絞るために省きました。
ポート番号
住所は建物、ポート番号は部屋
ポート番号は 16 ビット、0 から 65535 までの数です。サーバーのプログラムは決まった番号で待ち受け(LISTEN)、PC 側は、つなぐたびに空いている番号を一つ借ります。
# サーバーで待ち受けている番号(LISTEN)
srv$ ss -tln
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 128 0.0.0.0:4000 0.0.0.0:*
LISTEN 0 511 0.0.0.0:3000 0.0.0.0:*
# PC が自分の側に使う番号の範囲(Linux の既定)
pc$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768 60999
サーバーでは、申請 API の Node.js が 3000 番、この回のために用意した「読むのが遅いサーバー」が 4000 番で待っています。PC 側の番号は、Linux なら 32768〜60999 から選ばれます(Windows や Mac は 49152〜65535)。今回の申請では 35594 番でした。
| 番号 | よく使うもの |
|---|---|
| 22 | SSH |
| 80 / 443 | HTTP / HTTPS(第8回・第10回) |
| 53 | DNS(第9回。主に UDP) |
| 3306 / 5432 / 6379 | MySQL / PostgreSQL / Redis |
| 3000 / 5173 / 8080 | 開発中のサーバーでよく使われる番号(決まりではない) |
1本の接続は、両側の住所と番号の4つの組で見分けます。同じ PC から同じサーバーの 3000 番へ、ブラウザのタブを2つ開いてつないでも、PC 側の番号が違うので別の接続です。開発中に「Address already in use」と言われたら、その番号で誰かがもう待ち受けています。
あいさつから始める
TCP は、データを送る前に3回のあいさつ(3ウェイハンドシェイク)で接続を作ります。「つなぎたい(SYN)」「どうぞ、こちらもつなぎたい(SYN+ACK)」「了解(ACK)」です。申請1回分の記録を並べます。
# 申請1回のやりとり(片道 20 ms の線。Seq と Ack は Wireshark と同じく最初を 0 とした番号)
0.000 pc → srv SYN Seq=0 Ack=0 Len=0
0.040 srv → pc SYN+ACK Seq=0 Ack=1 Len=0
0.041 pc → srv ACK Seq=1 Ack=1 Len=0
0.041 pc → srv PSH+ACK Seq=1 Ack=1 Len=198 POST /api/expenses
0.081 srv → pc ACK Seq=1 Ack=199 Len=0
0.090 srv → pc PSH+ACK Seq=1 Ack=199 Len=203 201 Created
0.090 pc → srv ACK Seq=199 Ack=204 Len=0
0.090 pc → srv FIN+ACK Seq=199 Ack=204 Len=0
0.132 srv → pc FIN+ACK Seq=204 Ack=200 Len=0
0.132 pc → srv ACK Seq=200 Ack=205 Len=0
# 最初の3つがあいさつ、最後の3つが別れのあいさつ
申請1回:あいさつ・申請と返事・別れ(実測の時刻)
表紙の写真は、最初の SYN を開いたところです。SYN には希望も書かれています。MSS 1460 は「1つの小包の中身は 1460 バイトまでにして」。第2回の MTU 1500 から、IP と TCP の見出し 20 バイトずつを引いた値です。ほかに、届いた範囲を細かく知らせる SACK を使えるか、時刻の印(Timestamps)、窓の倍率(Window scale)もここで決めます。
# curl が測った時間:つながるまで 1 往復、返事の最初の1バイトまで 2 往復
pc$ curl -w '…' -X POST … http://203.0.113.20:3000/api/expenses
connect 0.0407 s first byte 0.0900 s total 0.0901 s local port 35594
# 先に閉じた PC 側に、TIME-WAIT が 60 秒残る
pc$ ss -tano dst 203.0.113.20
TIME-WAIT 0 0 192.168.1.10:35594 203.0.113.20:3000 timer:(timewait,59sec,0)
curl の数字を見ると、つながるまでに 1 往復(0.041 秒)、返事の最初の1バイトが届くまでに 2 往復(0.090 秒)かかっています。あいさつだけで 1 往復使うわけです。往復が 100 ミリ秒の相手なら、それだけで 0.1 秒です。だから、1本の接続を使い回すと速くなります。
# 申請2回:毎回つなぎ直す
0.0827 s + 0.0824 s = 0.1652 s
# 申請2回:1本の接続を使い回す(2回目は connects 0)
0.0825 s + 0.0421 s = 0.1246 s
2回目の申請は、使い回すと 0.042 秒、つなぎ直すと 0.082 秒。差はちょうど1往復分です。HTTP の keep-alive や、データベースの接続プールは、このあいさつを省くためにあります(第10回)。
バイトに番号を振る
新井記録に Seq=199、Ack=204 ってありますけど、199 番目の小包、ってことですか?
古井小包ではなく、バイトに番号を振っている。TCP は、データを小包の列ではなく、一本の長いバイトの列として扱うんだ。
Seq は「このデータは何番のバイトからか」、Ack は「次は何番がほしいか」
PC の POST は 198 バイトで、1 番から 198 番です。サーバーの Ack=199 は「198 番まで全部受け取った。次は 199 番から」という返事です。サーバーの返事 203 バイトには、サーバー側で別の通し番号が振られ、PC は Ack=204 と返しました。両方向それぞれに番号の列があるわけです。
Wireshark は見やすいように最初を 0 にしていますが、本物の番号はでたらめな値から始まります(写真の明細の Sequence Number (raw))。昔の接続の小包が紛れこんだり、他人に番号を当てられて偽の小包を差しこまれたりしないためです。
TCP は区切りを覚えていません。198 バイトを1回で送っても、受け手の 1 回の読み出しで 198 バイトがそろう保証はなく、分かれたり、次のデータとくっついたりします。HTTP が Content-Length で長さを書くのは、このためです。自分でソケットを使うときは、長さや区切り文字を決めておきましょう(第7回)。
断られる、待たされる
あいさつがうまくいかないときは、2通りあります。誰も待ち受けていない番号へつなぐと、相手の OS が RST(そこには誰もいない)を返します。一方、途中で SYN が捨てられると、何も返ってきません。いたずら線で 3002 番あての SYN を捨てて比べました。
# 閉じている番号 3001 へ:サーバーが RST(いません)を返し、1 往復で断られる
pc$ curl http://203.0.113.20:3001/
curl: (7) Failed to connect to 203.0.113.20 port 3001 after 40 ms: Couldn't connect to server
# 返事が来ない番号 3002 へ(線が SYN を捨てる):送り直しを続けて、20 秒で curl があきらめた
pc$ curl --connect-timeout 20 http://203.0.113.20:3002/
curl: (28) Failed to connect to 203.0.113.20 port 3002 after 20002 ms: Timeout was reached
# SYN を送った時刻(秒)
0.0 1.0 2.0 3.0 4.1 5.1 7.1 11.3 19.5
断られるのは一瞬、返事がないと待たされる
断られる方は 0.041 秒、つまり 1 往復で終わります。返事がない方は、PC が 1 秒おきに SYN を送り直し、そのあと間を 2 秒、4 秒、8 秒と倍に延ばしていきました。この Linux(6.18)は最初の数回を 1 秒おきにする設定(tcp_syn_linear_timeouts = 4)で、既定ではおよそ 2 分であきらめます。
Connection refused なら、相手の機械までは届いていて、その番号でプログラムが動いていない。timed out なら、途中の道か、相手のファイアウォールで消えている。エラーの文字が、調べる場所を教えてくれます(第12回)。
落ちたら送り直す
領収書の画像 1 MB を申請に添付する場面を作り、いたずら線を 10 Mbit/s に絞って、PC から出た 40 個目の小包だけを捨てました。
# 領収書の画像 1 MB を送る途中、PC からの 40 個目の小包を線が捨てる(10 Mbit/s、片道 20 ms)
wire: up drop data#40
# PC 側の記録(番号は Wireshark の通し番号)
58 0.1282 pc → srv Seq=55169 Len=1448
123 0.1759 srv → pc Ack=55169 SACK 56617-58065
128 0.1771 srv → pc Ack=55169 (Dup ACK #1)
130 0.1783 srv → pc Ack=55169 (Dup ACK #2)
131 0.1784 pc → srv Seq=55169 Len=1448 (Fast Retransmission)
# かかった時間:落とさない3回と、落とした1回
1.007 s 1.007 s 1.006 s / 1.008 s
同じ Ack が続いたら、落ちたと判断して送り直す
Seq=55169 が届かないので、サーバーは後ろの小包が届くたびに「55169 から先がまだ」と同じ Ack を返し、届いた範囲を SACK で添えます。同じ Ack が3つそろったところで、PC は時間切れを待たずに 55169 だけを送り直しました(Fast Retransmission)。落ちてから 0.050 秒、ほぼ1往復です。全体の時間は 1.008 秒で、落とさなかった3回(1.006〜1.007 秒)とほとんど変わりませんでした。
小包が1つ1448 バイトなのは、MSS 1460 から時刻の印(Timestamps)の 12 バイトを引いた分です。後ろに続く小包がなく、同じ Ack が集まらないときは、時間切れ(RTO)を待って送り直します。Linux の時間切れは最短でも 0.2 秒なので、こちらは目に見えて遅くなります。
順番が入れかわったら
# 40 個目を線で1つ後ろに回す。サーバー側の記録:
76 0.1375 pc → srv Seq=56617 Len=1448 (55169 を飛ばして先に着いた)
77 0.1376 srv → pc Ack=55169 SACK 56617-58065
78 0.1388 pc → srv Seq=55169 Len=1448 (Out-Of-Order)
# 1つ後ろの小包が先に着き、サーバーは「55169 から先がまだ。56617〜58065 は持っている」と返す。直後に 55169 が届いて穴が埋まる
# PC の送り直しは 0
今度は 40 個目を捨てずに、1つ後ろへ回しました。サーバーは先に着いた 56617 を箱に取っておき、「55169 がまだ」と返しますが、すぐに 55169 が届いて穴が埋まります。PC の送り直しは 0 回でした。アプリ(Node.js)は、番号の順に並べ直されたバイトの列だけを受け取るので、入れかわりに気づくことはありません。
受け手の都合:窓
新井送り手ががんがん送って、受け手が追いつかなかったらどうなるんですか?
古井受け手が「あといくつ受け取れるか」を毎回返事に書いている。それが窓だ。読むのをさぼるサーバーを作ってきた。
# 受け取ってから 3 秒間読まないサーバー(受け取りの箱は 32 KB)へ 1 MB を送る
48 0.163 srv → pc Win=0 Ack=40689 (ZeroWindow)
# 箱がいっぱいになり、窓 0。PC は間をあけて「まだ?」とのぞく
49 0.414 pc → srv Len=0 (probe)
51 0.966 pc → srv Len=0 (probe)
53 2.022 pc → srv Len=0 (probe)
# 3 秒後にサーバーが読み始め、窓が開く
55 3.082 srv → pc Win=36200 (Window Update)
curl: total 4.188 s
受け取りの箱がいっぱいになると、窓が 0 になる
4000 番のサーバーは、つながってから 3 秒間、まったく読みません。受け取りの箱(32 KB)は 40,688 バイトでいっぱいになり、0.163 秒に窓が 0 になりました(ZeroWindow)。PC は送るのをやめ、0.41 秒、0.97 秒、2.02 秒と間を延ばしながら「窓は開いた?」とのぞきます。3.08 秒にサーバーが読み始めると窓が開き、送信が再開しました。
窓の欄は 16 ビットで、そのままでは 64 KB までしか書けません。そこで SYN で倍率を決めておきます(表紙の写真の Window scale: 10 は 1024 倍)。この流れの制御(フロー制御)のおかげで、速い送り手が遅い受け手をあふれさせることはありません。Node.js の write() が false を返すのも、元をたどればこの窓です。
道の混み具合
窓は受け手の都合でした。もう一つ、途中の道の都合があります。線が細いのに送りすぎると、ルーターの手前の行列からあふれて落ちます。TCP は、落ちた様子から道の混み具合を推し量り、送る量を自分で加減します(輻輳制御)。
いたずら線を 10 Mbit/s、行列は 30 個までにして、サーバーから CSV 2 MB をもらいました。サーバー側の記録から、送ったがまだ確認されていない小包の数を 10 ミリ秒ごとに数えたのが下の図です。
送る量は、倍々で増え、あふれたら減らし、またゆっくり増やす(実測)
# CSV 2 MB をもらう(10 Mbit/s、片道 20 ms、線の手前に並べるのは 30 個まで)
# サーバーが 1 往復(41 ms)ごとに送った小包の数
10 20 40 51 36 36 35 36 …
# 途中のサーバーの様子(ss -ti。cwnd は小包の数)
cubic mss:1448 cwnd:56 ssthresh:46 rtt:66.713 ms minrtt:40.738 ms
queue drops 4 retransmissions 4 curl: 1.884 s, 8.90 Mbit/s
最初の往復で送ったのは 10 個、次が 20 個、その次が 40 個。往復ごとに倍になる、これをスロースタートと呼びます(最初の 10 個は RFC 6928 の値)。線と行列に入りきらなくなったところで4個があふれ、落ちた分を送り直したあと、送る量を約7割(ssthresh 46)に減らしました。その後は、ゆっくりと増やしていきます。Linux の既定の cubic という方式です。
途中の様子の rtt:66.7 と minrtt:40.7 の差にも注目してください。行列で待たされる分、往復が 26 ミリ秒延びています。行列が深すぎて遅延が大きくなる状態はバッファブロートと呼ばれます。Google の BBR のように、落ちるのを待たず、往復時間の伸びから混み具合を読む方式もあります。
別れ方
申請の記録の最後の3つが別れのあいさつです。先に PC が FIN(もう送るものはない)を送り、サーバーも FIN を返し、PC が ACK で締めくくりました。両方向それぞれを閉じるので、本来は4回ですが、サーバーの ACK と FIN が1つにまとまっています。
先に閉じた側には、TIME-WAIT の状態が 60 秒残ります(さきほどの timer:(timewait,59sec))。最後の ACK が落ちたときに答え直すためと、遅れて届いた古い小包が、同じ番号の新しい接続に紛れこまないためです。短い接続を大量に作るプログラムでは、TIME-WAIT が何万も並んで、PC 側の番号が足りなくなることがあります。これも、接続を使い回す理由の一つです。
あいさつ抜きの強制終了が RST です。待ち受けていない番号への接続(第5章)のほか、相手のプログラムが落ちたときにも飛んできます。Connection reset by peer は、相手から RST が来たという意味です。
自分の PC で確かめる
| 調べたいこと | Mac | Windows | Linux |
|---|---|---|---|
| いまの接続と状態 | netstat -an -p tcp | netstat -an | ss -tan |
| その番号を使っているプログラム | lsof -i :3000 | netstat -ano | findstr :3000 | ss -tlnp |
| その番号につながるか | nc -vz 宛先 443 | Test-NetConnection 宛先 -Port 443 | nc -vz 宛先 443 |
| 接続にかかった時間 | curl -o /dev/null -s -w '%{time_connect} %{time_starttransfer}\n' URL | ||
ブラウザでいくつかのサイトを開いたまま netstat -an を見ると、443 番あての ESTABLISHED がずらりと並び、閉じたばかりのものは TIME_WAIT になっているはずです。
結 — 一枚まとめと、次回
- ポート番号は部屋。1本の接続は両側の住所と番号の4つの組で決まる。PC 側の番号は Linux では 32768〜60999。
- あいさつは SYN・SYN+ACK・ACK。つながるまでに1往復。使い回すと2回目の申請は 0.082 秒から 0.042 秒に。
- Seq と Ack はバイトの通し番号。Ack は「次は何番がほしいか」。TCP は区切りを覚えない。
- 閉じた番号は RST で 0.041 秒で断られ、捨てられる SYN は 1 秒おきに送り直して待たされた。
- 1個落とすと、同じ Ack が続き、約1往復後に送り直し。全体の時間はほぼ変わらず。入れかわりでは送り直し 0 回。
- 読まない受け手には窓 0。送り手は待ち、のぞき、窓が開いたら再開。
- 送る量は 10・20・40 と倍に増え、あふれたら約7割に減らす。行列は往復を 26 ミリ秒延ばした。
新井TCP って、ものすごく丁寧ですね。全部これでいいんじゃないですか?
古井丁寧さが邪魔になる場面もある。ビデオ通話で 1 秒前の映像を送り直されても困るだろう。次回は、あいさつも送り直しもしない UDP と、プログラムからこれらを使う窓口、ソケットだ。
参考
- RFC 9293 — Transmission Control Protocol (TCP)
- RFC 6335 — IANA Procedures for the Management of the Service Name and Transport Protocol Port Number Registry
- IANA — Service Name and Transport Protocol Port Number Registry
- Microsoft Learn — The default dynamic port range for TCP/IP has changed
- RFC 7323 — TCP Extensions for High Performance (Window Scale, Timestamps)
- RFC 2018 — TCP Selective Acknowledgment Options
- RFC 6298 — Computing TCP's Retransmission Timer
- RFC 6928 — Increasing TCP's Initial Window
- RFC 9438 — CUBIC for Fast and Long-Distance Networks
- Linux kernel — IP Sysctl(tcp_syn_retries, tcp_syn_linear_timeouts ほか)
- curl — man page(--write-out)
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)にネットワーク名前空間で作った練習用ネットワークで、2026年10月25日に取ったものです。PC とサーバーの間の「いたずら線」は、2つの口の間でフレームを中継する Python の小さなプログラム(AF_PACKET を使用)で、遅延・帯域・行列の長さ・落とす小包・入れかえる小包を指定できます。サーバーは Node.js、読むのが遅いサーバーは Python、輻輳制御は cubic、送受信の分割の機能(TSO/GRO)は記録を見やすくするため切ってあります。表の数字はすべて、この記録とそのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのネットワーク入門 第6回 — TCP / 2026.10