UDP — почему минималистичный транспорт всё ещё нужен
В предыдущих модулях мы дошли до уровня IP — это слой, который умеет одно: доставить пакет от одного IP-адреса до другого через цепочку маршрутизаторов. Без гарантий, без порядка, без понятия «соединение». Чистый best-effort.
Теперь поднимемся на ступеньку выше — на транспортный уровень. И первое, что мы изучим, — это UDP. На первый взгляд кажется парадоксом: зачем нужен транспортный протокол, который почти ничего не делает? Сложите два слова — datagram и port — и получится UDP. Серьёзно. Это весь его смысл.
Но именно эта минималистичность делает UDP незаменимым в современных системах. DNS, видеостриминг, голосовые звонки в Discord, протокол QUIC, на котором работает HTTP/3, мультиплеерные игры — всё это UDP. В этом уроке разберём, что именно UDP даёт (и чего не даёт), как устроен его 8-байтовый заголовок и почему «отсутствие фичей» — это иногда фича.
Connectionless vs Connection-oriented
Главное архитектурное различие между UDP и TCP — в том, как они смотрят на общение между двумя узлами.
TCP — connection-oriented. Перед тем как отправить хоть один байт данных, TCP делает 3-way handshake, договаривается о sequence numbers, размере окон, опциях. Только после этого можно слать данные. Сетевое соединение — это объект, у которого есть состояние на обеих сторонах: начало, середина, конец. Сервер хранит таблицу активных соединений и для каждого помнит, какой байт уже подтверждён.
UDP — connectionless. Никакого handshake нет. Никакого состояния. Узел А просто пакует данные в datagram и говорит ядру: «отправь это туда». Ядро добавляет UDP-заголовок и IP-заголовок и отдаёт сетевой карте. На другой стороне узел Б получает datagram и обрабатывает. Если он не пришёл — узел Б об этом даже не узнает.
Метафора, которая помогает: UDP — это почтовые открытки. Бросил в ящик — пошла. Куда-то дошла, наверное. Никто не подтвердит. TCP — это телефонный звонок. Сначала набираешь, дожидаешься ответа, говоришь «алло», и только потом разговор начинается, а каждое слово другая сторона подтверждает кивком («угу», «понял»).
Что такое datagram
Datagram (датаграмма) — это самодостаточная единица передачи. В отличие от TCP-«потока», у каждой UDP-датаграммы есть чёткие границы: где начинается и где заканчивается. Если приложение вызвало sendto(socket, data, 100, ...) — то на другой стороне recvfrom() вернёт ровно 100 байт за один вызов. Не больше, не меньше.
Это очень важное свойство. Многие сетевые протоколы естественно устроены как «запрос — ответ» с фиксированными сообщениями. DNS-запрос вмещается в один UDP-пакет, DNS-ответ — обычно тоже в один. Не нужно никакого парсинга «где сообщение заканчивается» — границы UDP-датаграммы и есть границы сообщения.
# Простая демонстрация: запустим UDP-сервер на порту 9999
# и пошлём в него датаграмму
# Терминал 1 -- слушаем:
nc -u -l 9999
# Терминал 2 -- шлём:
echo "Hello UDP" | nc -u -w1 127.0.0.1 9999
# Сервер увидит ровно одно сообщение целиком: "Hello UDP"
# Каждый echo + nc -- это одна датаграмма
В отличие от TCP, где если мы пошлём два сообщения подряд, они могут «склеиться» на стороне получателя, в UDP каждое отправленное сообщение приходит отдельным recvfrom().
UDP-заголовок: 8 байт, и всё
Это, пожалуй, мой любимый момент в курсе. UDP-заголовок настолько прост, что его можно нарисовать на салфетке за минуту. Восемь байт, четыре поля по два байта каждое.
Для сравнения: TCP-заголовок минимум 20 байт, обычно 32-40 байт из-за опций. UDP в четыре раза легче. На больших объёмах коротких сообщений (DNS, IoT-телеметрия) это экономит много байт.
Что не входит в UDP-заголовок и чего там никогда не будет:
- Sequence number. UDP не нумерует датаграммы. Если пришли в другом порядке — приложение увидит их в этом другом порядке.
- Acknowledgment. UDP ничего не подтверждает. Получил — обработал. Потерялось — пожали плечами.
- Window size. Нет flow control. UDP-отправитель шлёт со своей максимальной скоростью, не оглядываясь на получателя.
- Flags вроде SYN/FIN/RST. Нечем устанавливать и закрывать соединение, потому что его нет.
Порт — это адрес приложения
Порт — это, пожалуй, главная (и единственная) полезная фича UDP над IP. IP знает, как доставить пакет до хоста (1.2.3.4 -> 5.6.7.8). Но на одном хосте крутится десятки приложений. К какому из них доставить пакет? Через порт.
Порт — это 16-битное число (0-65535). Стандартные диапазоны:
Сочетание (IP, port, protocol) называется socket address и однозначно идентифицирует «точку коммуникации» в сети. Например, (8.8.8.8:53, UDP) — это DNS-сервер Google. Когда вы пишете dig @8.8.8.8 google.com, ваша программа создаёт UDP-сокет, ОС выдаёт ей случайный ephemeral порт (скажем, 54321), и потом ваш пакет идёт от (ваш_IP:54321, UDP) к (8.8.8.8:53, UDP).
# Посмотрим, какие UDP-порты сейчас слушают на твоей машине
# Linux:
ss -ulnp
# macOS:
lsof -iUDP -P -n | grep LISTEN
# или
netstat -anu | grep LISTEN
# Типичная картинка: mDNSResponder на 5353 (Bonjour),
# что-то на 123 (NTP), DHCP-клиент на 68 (если получаешь IP по DHCP)
Почему UDP не пытается быть TCP
Часто junior-разработчики спрашивают: «Окей, TCP делает всё то же самое, что UDP, и ещё много чего сверху. Зачем тогда вообще UDP? Почему бы не использовать TCP всегда?»
Ответ — в том, что «фичи» TCP всегда стоят денег: латентность, память, CPU. И иногда эти траты неоправданы.
Латентность. TCP перед первым байтом данных делает 3-way handshake — это один RTT (round-trip time). Если вы общаетесь с сервером в другой стране, это 100-200 мс просто на «здравствуйте, давай подключимся». Для DNS-запроса, который сам по себе укладывается в 10 мс, добавлять 100 мс на handshake — катастрофа. UDP отправляет первый байт сразу.
Голова-к-голове. TCP — это поток байт в строгом порядке. Если один сегмент потерялся, TCP не отдаст приложению следующие, пока не получит потерянный. Это называется head-of-line blocking. Для видеостриминга это плохо: лучше пропустить один кадр, чем заморозить картинку на 200 мс. UDP отдаст всё, что пришло, в том порядке, в котором пришло.
Состояние сервера. Каждое TCP-соединение требует памяти на сервере: TCB (Transmission Control Block) занимает несколько килобайт. Сервер на миллион одновременных соединений — это гигабайты RAM просто на учёт состояния. UDP-сервер не хранит ничего; он обрабатывает каждую датаграмму отдельно. Поэтому DNS-сервер легко справляется с миллионами запросов в секунду на одном хосте.
UDP не «хуже» и не «лучше» TCP. Это другой инструмент. TCP — для случаев, когда важна надёжность и порядок (HTTP, SMTP, FTP). UDP — для случаев, когда важна низкая латентность или короткие сессии без оверхеда (DNS, видеостриминг, метрики). Современный QUIC — это попытка взять лучшее из обоих миров: надёжность TCP, но поверх UDP, чтобы избежать его ограничений.
Простой UDP-сервер на Python
Давайте увидим минималистичность UDP в коде. Вот полноценный UDP-эхо-сервер на пять строчек:
# udp_server.py
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 9999))
print("UDP server listening on :9999")
while True:
data, addr = sock.recvfrom(4096)
print(f"From {addr}: {data!r}")
sock.sendto(b"echo: " + data, addr)
И клиент:
# udp_client.py
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(b"hello from client", ("127.0.0.1", 9999))
data, addr = sock.recvfrom(4096)
print(f"Got: {data!r}")
Сравните с TCP-эквивалентом, где нужно listen(), accept(), обработка disconnect-ов. UDP — это буквально «отправить байты» и «получить байты». Никакого жизненного цикла соединения. Сервер не знает, что какой-то клиент к нему «подключился» — он просто получает датаграммы от случайных IP-адресов.
MTU и фрагментация — главная ловушка UDP
В заголовке UDP поле Length — 16 бит, что даёт теоретический максимум 65535 байт на датаграмму. Но на практике передавать такие большие датаграммы — плохая идея. Причина — Maximum Transmission Unit (MTU) нижележащих сетей.
Ethernet имеет MTU 1500 байт. Это значит, что один IP-пакет не может быть больше 1500 байт (включая IP-заголовок 20 байт + UDP 8 байт = 28 байт оверхеда, остаётся ~1472 байт для payload). Если UDP-датаграмма больше этого, IP-уровень разбивает её на фрагменты при отправке и собирает на стороне получателя.
Проблема: если хотя бы один фрагмент потеряется, вся датаграмма отбрасывается. UDP не умеет пересылать только потерянный фрагмент — он просто видит, что не все куски собрались, и выкидывает всё. На сетях с потерями это значит, что вероятность доставки большой датаграммы катастрофически падает.
Поэтому современная практика — держать UDP-датаграммы в районе 1200-1400 байт, чтобы они гарантированно влезали в один IP-пакет с запасом на VPN/туннели. QUIC специально проектировался так, чтобы все его пакеты влезали в один MTU.
# Посмотрим MTU нашего интерфейса:
# Linux:
ip link show eth0 | grep mtu
# или
cat /sys/class/net/eth0/mtu
# macOS:
networksetup -getMTU en0
# или
ifconfig en0 | grep mtu
Попробуй сам
Поиграйся с UDP вживую — это даёт лучшее интуитивное понимание, чем чтение спецификации.
# 1. Запусти UDP-listener на порту 9999 (терминал A)
nc -u -l 9999
# 2. В другом терминале (B) пошли несколько сообщений
echo "msg 1" | nc -u -w1 127.0.0.1 9999
echo "msg 2" | nc -u -w1 127.0.0.1 9999
echo "msg 3" | nc -u -w1 127.0.0.1 9999
# Заметь: каждое сообщение приходит как отдельная датаграмма
# 3. Запусти tcpdump и посмотри пакеты:
sudo tcpdump -i lo0 -n udp port 9999 -X
# (или -i lo на Linux)
# Ты увидишь, что каждое сообщение -- это один UDP-пакет.
# Обрати внимание на размер: всего 8 байт заголовка над данными
# 4. Останови UDP-listener и пошли датаграмму на 'мёртвый' порт
echo "no one listens" | nc -u -w1 127.0.0.1 9999
# Никакой ошибки! UDP fire-and-forget -- отправителю всё равно,
# что на той стороне ничего нет (хотя иногда приходит ICMP Port Unreachable)
# 5. Попробуй DNS -- он работает через UDP по дефолту:
dig +short google.com
# Захвати в Wireshark и посмотри UDP-датаграмму к 8.8.8.8:53
Если хочешь увидеть реальный live UDP-трафик на работающей машине — запусти Wireshark и поставь фильтр udp.port == 53. Каждый раз, когда твой браузер резолвит домен, ты увидишь маленькие UDP-пакеты летающие туда-сюда.
UNIX-сокеты и файловые дескрипторы в ОС HTTP поверх TCP — почему не UDP