Old-School Networking / 2026 ・ No.07
いにしえのネットワーク入門
第7回 UDP とソケット ── あいさつしない手紙と、プログラムの窓口
公開
第6回の TCP は、あいさつをし、番号を振り、落ちたら送り直す、とても丁寧な運び方でした。今回の前半は、そのどれもしない UDP です。丁寧さを捨てると何が速くなり、何を自分で引き受けることになるのかを、同じいたずら線で確かめます。
後半は、TCP も UDP も、プログラムからは ソケット という窓口を通して使うという話です。申請を送る Python と、受け取る Node.js の中で何が起きているかを、strace でのぞきます。
丁寧さを選ぶ層
新井TCP の丁寧さ、感動しました。でも前回の最後に、丁寧さが邪魔になるって。
古井電話で話していて、1 秒前の「もしもし」を言い直されたら困るだろう。古い声より、いまの声がほしい。
新井たしかに……。じゃあ、言い直さない運び方もあるんですか?
古井それが UDP だ。第4層には、丁寧な TCP と、ほとんど何もしない UDP の2つがあって、使う側が選ぶ。
全12回の路線図(いまここ:第7回)
- UDP ── 8 バイトの見出し、区切りが残る、誰もいない番号、断片(実測)
- 先頭のつかえ ── 1つ落ちたとき、TCP と UDP で後ろの知らせはどうなるか(実測)
- ソケット ── socket・connect・accept、strace で見る中身(実測)
- サーバーの裏側 ── 受け付け待ちの行列、epoll で多数の客をさばく(実測)
練習用ネットワークは第6回と同じで、PC(192.168.1.10)とサーバー(203.0.113.20)の間に、片道 20 ミリ秒のいたずら線をはさんでいます。いたずら線は、UDP の小包も落とせるように少し書き足しました。
UDP:あいさつしない手紙
試しに、申請を UDP で送ってみます。サーバーには、UDP の 5000 番で申請を受け付ける小さなプログラムを置きました。
# UDP で申請を1回(片道 20 ms の線)
pc$ python3 cli7.py udp
sent 59 bytes, got 46 bytes in 40.9 ms: {"id": "EXP-2026-0100", "status": "submitted"}
# 線の上を流れたのは、行きと帰りの2個だけ(第6回の TCP は 10 個)
0.000 pc → srv UDP 59437 → 5000 length 67 (frame 101 bytes)
0.041 srv → pc UDP 5000 → 59437 length 54 (frame 88 bytes)
線の上を流れたのは、行きと帰りの2つだけです。あいさつも、受け取りの ACK も、別れのあいさつもありません。返事まで 0.041 秒、ちょうど1往復でした。第6回の TCP では、同じ申請に 10 個の小包と、別れまで 0.132 秒を使っていました。
UDP の見出しは 8 バイト、TCP は 20 バイト以上
表紙の写真の明細を見ると、UDP の見出しは番号2つ・長さ・チェックサムだけです。Seq も Ack も窓もありません。つまり UDP は、IP の小包に部屋番号を書き足しただけのもので、届いたかどうか、順番どおりかどうかは、何も保証しません。
| TCP | UDP | |
|---|---|---|
| あいさつ | あり(1往復) | なし。いきなり送る |
| 落ちたら | 送り直す | それっきり |
| 順番 | 並べ直す | 着いた順 |
| 受け手・道の都合 | 窓と混み具合で加減 | 送りたいだけ送る |
| 区切り | なし(バイトの流れ) | 残る(1回送ったら1つ) |
| おもな使い道 | Web、メール、データベース | DNS、音声・映像、ゲーム、QUIC |
区切りが残る
第6回で「TCP は区切りを覚えない」と書きました。UDP と並べて確かめます。100 バイトを3回送り、サーバーは 0.3 秒待ってから読みます。
# PC:100 バイトを3回送る(TCP は小包をためずにすぐ送る設定 TCP_NODELAY)
pc$ send(b'A' * 100); send(b'B' * 100); send(b'C' * 100)
# 線の上では、どちらも 100 バイトの小包が3個
TCP Len=100 Len=100 Len=100 UDP Len=100 Len=100 Len=100
# サーバー:0.3 秒待ってから読むと
TCP recv() → [300] bytes
UDP recv() → [100, 100, 100] bytes
TCP は1本の流れ、UDP は1通ずつ
線の上では、どちらも 100 バイトの小包が3つでした。ところが TCP の受け手は、1回の recv() で 300 バイトをまとめて受け取りました。小包の区切りは、受け手の箱に入った時点で消えてしまうのです。UDP では、1回送ったものが1通の手紙(データグラム)として、そのまま1回ずつ受け取れます。TCP で自前のやりとりを作るときは、長さを先に書く、改行で区切るなど、区切り方を決めておく必要があります。
誰もいない番号
TCP では、誰もいない番号へつなぐと RST が返りました。UDP には RST がありません。代わりに、相手の OS が ICMP の「その番号は使っていない(Port unreachable)」を返します。
# 誰もいない 5009 番へ UDP を送る。サーバーは ICMP で「その番号は使っていない」と返す
0.000 192.168.1.10 → 203.0.113.20 UDP 47 33355 → 5009 Len=5
0.040 203.0.113.20 → 192.168.1.10 ICMP 75 Destination unreachable (Port unreachable)
# connect() したソケットは、この知らせをエラーとして受け取れる。しなかったソケットは知らずに待ち続ける
connected socket: ConnectionRefusedError: [Errno 111] Connection refused after 40.7 ms
unconnected socket: TimeoutError: timed out after 2002 ms
同じ知らせが届いているのに、プログラムの受け取り方は2通りでした。connect() で相手を決めておいたソケットは、0.041 秒で Connection refused のエラーになりました。決めずに sendto() したソケットは、知らせが自分あてだと結びつけられず、2 秒の時間切れまで待ち続けました。UDP でも、相手が1つなら connect() しておくとよい、という小さなコツです。もっとも、途中で落ちた場合は何も返ってこないので、時間切れの備えはいつでも必要です。
大きすぎる手紙
UDP の手紙は、1通で最大 6万5507 バイトまで書けます。でも線の MTU は 1500 バイト(第2回)。入りきらない手紙は、IP が断片に切り分けて送ります。3000 バイトを1通送りました。
# 3000 バイトの UDP を1つ送る(線の MTU は 1500)
0.000 192.168.1.10 → 203.0.113.20 IPv4 1514 Fragmented IP protocol (proto=UDP 17, off=0, ID=56e0)
0.000 192.168.1.10 → 203.0.113.20 IPv4 1514 Fragmented IP protocol (proto=UDP 17, off=1480, ID=56e0)
0.000 192.168.1.10 → 203.0.113.20 UDP 82 58842 → 5000 Len=3000
0.041 203.0.113.20 → 192.168.1.10 UDP 56 5000 → 58842 Len=14
3000 bytes -> reply {"size": 3000} in 41.2 ms
# 2つ目の断片を線で落とすと
3000 bytes -> no reply (2 s timeout)
# 30 秒後、サーバーが「組み立て時間切れ」を返してきた
30.470 203.0.113.20 → 192.168.1.10 ICMP 590 Time-to-live exceeded (Fragment reassembly time exceeded)
断片のどれか1つが落ちると、手紙は丸ごと届かない
3000 バイトに見出し 8 バイトを足した 3008 バイトが、1480・1480・48 バイトの3つに分かれ、サーバーで組み立て直されました。ところが、2つ目の断片を線で落とすと、残り2つは届いているのに、手紙は1通も届きません。サーバーは 30 秒待ってから断片を捨て、「組み立て時間切れ」を返してきました(30.47 秒)。UDP は送り直さないので、これで終わりです。
このため UDP では、1通を線に収まる大きさにするのが定石です。DNS では、2020 年に関係者が足並みをそろえ、UDP の返事を 1232 バイトまでに抑える既定値を推奨しました(DNS Flag Day 2020、第9回)。
先頭がつかえる
新井落ちたらそれっきりなんて、やっぱり TCP のほうが安心じゃないですか。
古井音声を送るつもりで比べてみよう。20 ミリ秒ごとに小さな知らせを 50 個送って、10 個目だけを落とす。
# 20 ms ごとに 100 バイトの知らせを 50 個。10 個目だけを線で落とす。届くまでの時間(ms)
No. 7 8 9 10 11 12 13 14 15
# UDP:49 個届いた。10 個目は届かず、ほかは待たされない
UDP 20.6 20.6 20.3 — 20.3 20.4 20.4 20.4 20.4
# TCP:50 個届いた。10 個目は送り直しで遅れ、11・12 個目もそれを待った
TCP 20.4 20.4 20.3 80.9 60.6 40.5 20.6 20.3 20.6
1つ落ちたとき、後ろの知らせはどうなるか(実測)
UDP では 10 個目が届かなかっただけで、ほかの 49 個はすべて約 20 ミリ秒で着きました。TCP は 50 個すべて届けましたが、10 個目は送り直しを待って 80.9 ミリ秒。さらに、先に着いていた 11 個目と 12 個目も、10 個目がそろうまでアプリに渡してもらえず、60.6 と 40.5 ミリ秒かかりました。順番を守るために、先頭の1つが後ろをつかえさせる、これを先頭のつかえ(head-of-line blocking)と呼びます。
音声なら、20 ミリ秒分が1つ欠けても、ほとんど気づきません。むしろ、遅れて届いた声のほうが邪魔です。だから音声・映像の通話やゲームは UDP を使い、欠けた分は自分で補います。DNS も、1往復で済む短い問い合わせなので UDP です(第9回)。そして、HTTP/3 が使う QUIC は、UDP の上に、送り直しや順番の管理を自前で組み立てています。中の流れごとに順番を守るので、1つの流れで落ちても、ほかの流れはつかえません(第10回)。
ソケット:プログラムの窓口
新井TCP や UDP って、わたしのプログラムからはどうやって使うんですか? fetch しか書いたことないです。
古井fetch も、いちばん下では OS のソケットを使っている。いわば、OS が用意したネットワークの窓口だ。
ソケットの呼び出しの順番
サーバーは、socket() で窓口を作り、bind() で番号を決め、listen() で待ち受け、accept() で客を1人ずつ取り出します。クライアントは connect() でつなぎます。この connect と accept の間で、第6回の3ウェイハンドシェイクが起きます。Python で書くと、これだけです。
# サーバー(いちばん小さい形)
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP の窓口。UDP なら SOCK_DGRAM
s.bind(('0.0.0.0', 5000))
s.listen()
while True:
c, addr = s.accept() # あいさつの済んだ客を1人取り出す
data = c.recv(4096)
c.sendall(b'ok')
c.close()
# クライアント
c = socket.create_connection(('203.0.113.20', 5000))
c.sendall(b'hello'); print(c.recv(4096)); c.close()
本当にこの順番で呼ばれているのかを、strace(プログラムが OS に頼んだことを一覧にする道具)で確かめました。上は PC で申請を1回送る Python、下はそれを受ける側の Node.js です。
# PC:ソケットを直接使って申請を1回送る Python を strace で見る(右は、その呼び出しにかかった秒数)
socket(AF_INET, SOCK_STREAM) = 3 0.0001
connect(3, {203.0.113.20:3000}, 16) = 0 0.0408
sendto(3, "POST /api/ex…", 181) = 181 0.0001
recvfrom(3, "HTTP/1.1 201…", 4096) = 175 0.0411
recvfrom(3, "", 4096) = 0 0.0001
close(3) = 0 0.0001
# サーバー:Node.js の中で起きていること(epoll_pwait で「何か来た?」と待ち、来たら処理する)
epoll_pwait(15, [fd 21 IN]) = 1 1.4631
accept4(21) = 22 0.0001
epoll_pwait(15, [fd 22 IN]) = 1 0.0000
read(22, "POST /api/ex…", 65536) = 199 0.0000
writev(22, ["HTTP/1.1 201…"]) = 203 0.0002
epoll_pwait(15, []) = 0 0.0001
epoll_pwait(15, [fd 22 IN]) = 1 0.0391
read(22, "", 65536) = 0 0.0001
shutdown(22, SHUT_WR) = 0 0.0002
epoll_ctl(15, EPOLL_CTL_DEL, 22) = 0 0.0000
close(22) = 0 0.0000
PC 側の socket() が返した 3 は、ファイルを開いたときと同じ「ファイル記述子」の番号です。ソケットは、OS から見ればファイルの一種なのです。connect() は 0.041 秒、つまりあいさつの1往復の間、プログラムを止めて待ちました。返事を待つ recvfrom() も 0.041 秒。最後の、中身が空の recvfrom() は、相手が別れのあいさつ(FIN)を送ってきたしるしです。
受け付け待ちの行列
あいさつは OS が済ませ、アプリは accept() で取り出すだけ。では、アプリが取り出さなかったら? listen(1) のまま一度も accept しないサーバーへ、4本つないでみました。
# listen(1) のまま accept() しないサーバーへ、4本つなぐ(つなぐ側は 3 秒であきらめる)
client 1: connected in 42 ms
client 2: connected in 41 ms
client 3: TimeoutError after 3003 ms
client 4: TimeoutError after 3003 ms
# サーバー側:2本はあいさつが済んで、取り出されるのを待っている
srv$ ss -tn sport = :5005 ; ss -tln sport = :5005
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 203.0.113.20:5005 192.168.1.10:48164
ESTAB 0 0 203.0.113.20:5005 192.168.1.10:48160
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 2 1 0.0.0.0:5005 0.0.0.0:*
あいさつ済みの接続は、行列で accept を待つ
1本目と2本目は 0.04 秒でつながったと言われました。アプリは何もしていないのに、です。OS があいさつを済ませ、行列(LISTEN の Recv-Q 2)に並べておいたからです。行列が 2(listen の数 + 1)でいっぱいになると、3本目と4本目の SYN は黙って捨てられ、送り直しのすえ 3 秒で時間切れになりました。
サーバーが忙しくて accept が追いつかないと、客からは「つながらない」ではなく「遅い」「時間切れ」に見えます。listen の数の既定は、Node.js が 511、Python が 128 で、Linux の上限(somaxconn)はこのカーネルでは 4096 でした。
1人でたくさんの客をさばく
さきほどの Python のサーバーは、recv() で待っている間、ほかの客の相手ができません。昔は客1人に1つのプロセスやスレッドを用意していましたが、何千人にもなると重すぎます。1999 年には、1台で1万の接続をさばく難しさが「C10K 問題」と名付けられました。
見張り役(epoll)に「どれか来た?」と尋ねる
いまの答えは、待つのは見張り役に任せることです。Linux の epoll に、待ち受けのソケットも客のソケットもまとめて登録し、1つのスレッドが「どれか準備できた?」と尋ねては、準備できたものだけを処理します。さきほどの strace の下半分がまさにそれで、Node.js は epoll_pwait で 1.46 秒待ち、待ち受け(21番)に客が来たので accept4 で 22 番を取り出し、22 番が読めるようになったので読み、返事を書き、また epoll に戻りました。Node.js のイベントループも nginx も、この形です。
Node.js で重い計算を同期的に書くと、その間 epoll に戻れず、すべての客が待たされます。1つのスレッドでたくさんの客をさばく代わりに、1人の客に長居させてはいけない、という約束があるわけです。
自分の PC で確かめる
| 調べたいこと | Mac | Windows | Linux |
|---|---|---|---|
| UDP の口の一覧 | netstat -an -p udp | netstat -an -p udp | ss -uan |
| その番号を使うプログラム | lsof -i UDP:5353 | netstat -ano -p udp | ss -uanp |
| UDP を1通送る | nc -u 宛先 5000 | (Python などで) | nc -u 宛先 5000 |
| プログラムの呼び出しを見る | dtruss(制限あり) | Process Monitor | strace -e trace=%network |
Mac で netstat -an -p udp を見ると、5353 番(同じ LAN の機械を見つける mDNS)など、何もしていないつもりでも UDP の口がいくつも開いているはずです。
結 — 一枚まとめと、次回
- UDP は見出し 8 バイト。あいさつ・送り直し・順番・窓がない。申請は行きと帰りの2つ、1往復で済んだ。
- UDP は区切りが残る。100 バイト×3 は、TCP では 1 回の recv で 300 バイト、UDP では 3 回。
- 誰もいない UDP の番号には ICMP。connect() したソケットだけがエラーとして受け取れる。
- MTU を超える UDP は断片に分かれ、1つ落ちると丸ごと失われる(30 秒後に組み立て時間切れ)。
- 1つ落ちると、TCP は後ろもつかえ(80.9・60.6・40.5 ms)、UDP は欠けるだけで遅れない。QUIC は UDP の上で両立をねらう。
- プログラムはソケットで TCP・UDP を使う。ソケットはファイル記述子の一つ。
- あいさつは OS が済ませ、アプリは accept で取り出す。行列があふれると SYN は黙って捨てられる。多数の客は epoll で1つのスレッドがさばく。
新井表紙の写真、よく見たら申請の JSON がそのまま読めますね……。日付も、金額の 1280 も。
古井そう、ここまでの申請は、途中の誰にでも丸見えだった。書きかえることもできる。次回は第5・6層、TLS。封筒に鍵をかける話だ。
参考
- RFC 768 — User Datagram Protocol
- RFC 8085 — UDP Usage Guidelines
- DNS Flag Day 2020
- RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
- Python — socket
- listen(2) — Linux manual page(backlog と somaxconn)
- Node.js — net(server.listen の backlog 511)
- epoll(7) — Linux manual page
- libuv — Design overview(Node.js のイベントループ)
- Dan Kegel — The C10K problem
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)にネットワーク名前空間で作った練習用ネットワークで、2026年10月25日に取ったものです。いたずら線は第6回のものに、UDP の小包(IP の断片を含む)を落とす機能を足しました。UDP の申請窓口・区切りの実験・受け付けないサーバー・知らせの送受信は Python、申請 API は Node.js です。strace は Ubuntu の strace パッケージで、呼び出しごとの所要時間(-T)を付けて記録しました。表の数字はすべて、この記録とそのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのネットワーク入門 第7回 — UDP とソケット / 2026.10