Learning Platform
Глоссарий Troubleshooting
Урок 08.01 · 18 мин
Начальный
UDPTransport LayerDatagramsNetworking

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 vs TCP -- две парадигмы транспорта
UDPConnectionless. Никакого handshake. Каждый datagram самостоятелен. Сервер не знает, когда клиент 'подключился' или 'отключился' -- он просто получает datagrams. Минимум кода в ядре, минимум памяти на соединение
контраст
TCPConnection-oriented. SYN/SYN-ACK/ACK перед данными. Сервер хранит state для каждого активного соединения (sequence numbers, retransmission queue, congestion window). Закрытие через FIN или RST
DatagramПолностью независимая единица. В UDP отправка одного datagram -- это одна системная команда sendto(), которая прямо превращается в один IP-пакет (если влезает в MTU)
vs
Stream of bytesTCP -- это поток байт без границ сообщений. send('hello') и send('world') могут прийти получателю как одно recv('helloworld'), или как 'hel' + 'loworld'. TCP не сохраняет границы

Метафора, которая помогает: 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-заголовок настолько прост, что его можно нарисовать на салфетке за минуту. Восемь байт, четыре поля по два байта каждое.

Структура UDP-заголовка -- 8 байт целиком
Src Port (2 байта)Source port. Откуда был отправлен пакет. У клиента обычно случайный (ephemeral) порт из диапазона 49152-65535, у сервера -- известный (53 для DNS, 123 для NTP)
Dst Port (2 байта)Destination port. Куда направляется пакет. Это то, что отличает UDP от чистого IP -- он добавляет понятие 'порт', т.е. адрес приложения внутри хоста
Length (2 байта)Длина датаграммы включая заголовок (8 байт) и данные. Максимум теоретически 65535, реально ограничен MTU нижележащих сетей (обычно ~1472 байт данных)
Checksum (2 байта)Контрольная сумма всей датаграммы плюс псевдо-заголовок IP. В IPv4 опциональна (можно занулить), в IPv6 -- обязательна. Защищает от случайного искажения битов, но не от злоумышленника
Payload (до 65527 байт)Данные приложения. То, что вы хотите передать. UDP не разбивает payload на части -- если он больше MTU, IP-уровень фрагментирует на куски, но это плохо (если один кусок потеряется, теряется весь datagram)

Для сравнения: 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). Стандартные диапазоны:

Диапазоны портов и их назначение
0-1023Well-known ports. Зарезервированы для системных служб. На Unix-системах для bind() нужны привилегии root. 53 (DNS), 67/68 (DHCP), 123 (NTP), 161 (SNMP), 514 (syslog)
1024-49151Registered ports. IANA выдаёт их разработчикам приложений. 1812 (RADIUS), 5060 (SIP), 8080 (HTTP alt). Можно использовать без root
49152-65535Ephemeral ports. Используются клиентами как source port. ОС выдаёт случайный свободный порт при connect(). На Linux диапазон настраивается в /proc/sys/net/ipv4/ip_local_port_range

Сочетание (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-сервер легко справляется с миллионами запросов в секунду на одном хосте.

NOTE

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
Проверка знанийKnowledge check
Junior спрашивает: 'Я написал клиент-серверное приложение на UDP. Иногда клиент шлёт серверу команду, сервер её получает и логирует, но клиент не уверен, что команда дошла. Почему UDP так устроен? И как мне это исправить, не переходя на TCP?'
ОтветAnswer
UDP так устроен потому, что его задача -- быть минимальной обёрткой над IP с добавлением только понятия 'порт'. Доставка не гарантирована, как и в IP. Это не баг, это фича: позволяет приложению самому решать, нужны ли подтверждения. Для DNS, например, не нужны -- если ответа нет за таймаут, клиент сам пошлёт повторный запрос. Для видеостриминга подтверждения не нужны принципиально -- устаревший кадр уже бессмысленен. Как исправить, не переходя на TCP: реализовать application-level acknowledgments поверх UDP. Сервер на каждую команду шлёт обратно ACK (короткая датаграмма с command_id и статусом). Клиент, отправив команду, запускает таймер. Если ACK не пришёл за, скажем, 500 мс, клиент пересылает команду с тем же command_id. Сервер на повторные команды с тем же ID отвечает тем же ACK (idempotency). Это и есть приличный паттерн 'UDP с подтверждениями' -- именно так устроены DNS retries, QUIC, и многие игровые протоколы. Главное преимущество такого подхода над переходом на TCP: ты сам контролируешь timeout, retry policy, congestion control. На нестабильных сетях (мобильная связь, спутник) TCP часто ведёт себя слишком пассивно -- ждёт долго перед retry. Своя реализация поверх UDP может быть агрессивнее и реагировать быстрее. Цена -- нужно правильно реализовать (а это нетривиально).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что главное отличает UDP от TCP с точки зрения архитектуры?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3