Что UDP не делает — ordering, reliability, congestion control
В предыдущих уроках мы посмотрели на UDP как на «минималистичный транспорт» и разобрали кейсы, где это работает в пользу. В этом уроке посмотрим на обратную сторону: чего именно UDP не делает, какие проблемы из-за этого возникают, и как реальные системы их решают.
Эти знания критичны для двух вещей:
- Понять, когда использовать UDP вообще нельзя.
- Понять, что нужно реализовать самому, если ты строишь поверх UDP.
Многие думают, что UDP — это «быстрый протокол». Это упрощение. UDP — это отсутствие большинства фичей TCP. Иногда это полезно, иногда катастрофично. Разберёмся, какие именно фичи отсутствуют.
Отсутствие 1: Никаких гарантий доставки
Главное, что UDP не делает — не гарантирует, что пакет дойдёт. Отправил sendto() — пакет ушёл в сеть. Дальше его судьба тебя не касается. Может дойти за 30 мс, может потеряться навсегда. Никакого подтверждения, никаких retransmits.
Где пакет может потеряться:
На реальной сети типичная потеря пакетов:
- Качественный Ethernet: 0.001% (один из 100 000)
- Хороший Wi-Fi: 0.1-1% (один из 100-1000)
- Плохой Wi-Fi или 4G: 1-5%
- Спутник, сотовая связь с плохим покрытием: 5-30%
- VoIP-звонок на плохой сети: часто 10%+
Эти проценты выглядят маленькими, но представь, что у тебя 1000 пакетов в секунду (типичный игровой клиент). При 1% потерь это 10 потерянных пакетов в секунду — заметно. При 5% — катастрофа.
# Проверь потери пакетов до какого-нибудь сервера через ping (использует ICMP, но потери похожие):
ping -c 100 8.8.8.8 | tail -5
# Что-то вроде:
# 100 packets transmitted, 99 received, 1% packet loss
# А если ты на нестабильном Wi-Fi:
# 100 packets transmitted, 87 received, 13% packet loss
Отсутствие 2: Нет ordering — пакеты приходят как попало
Даже если все UDP-пакеты доходят, они могут прийти не в том порядке, в котором были отправлены. Это происходит, потому что разные пакеты могут идти разными маршрутами через интернет, или один пакет задержался в очереди роутера, а следующий пошёл быстрее.
Пример:
# Отправитель шлёт три датаграммы подряд:
sock.sendto(b"msg 1", target) # t=0
sock.sendto(b"msg 2", target) # t=0.001
sock.sendto(b"msg 3", target) # t=0.002
# На стороне получателя recvfrom() может вернуть в порядке:
# msg 1, msg 3, msg 2 (msg 2 задержался на роутере)
# или
# msg 3, msg 1, msg 2 (разные маршруты)
# или
# msg 1, msg 3 (msg 2 вообще потерялся)
Что приложению делать с reordering? Зависит от задачи:
- DNS, NTP: не важно. Каждый запрос имеет свой ID, ответы матчатся независимо.
- VoIP/видео: важно. Аудио-пакеты должны воспроизводиться в правильном порядке, иначе будет каша. RTP добавляет sequence number в свой собственный заголовок поверх UDP.
- Игры: иногда не важно (старый state перезатёрт новым), иногда важно (events типа «выстрел» должны обрабатываться по порядку).
- Файловая передача: критично. Если ты передаёшь файл и куски пришли не по порядку, нужно их собрать в правильной последовательности.
Решение — sequence numbers на уровне приложения. Кладёшь в payload счётчик, на стороне получателя сортируешь по нему. Это и есть «первый шаг к реализации TCP поверх UDP».
# Пример простой реализации ordering поверх UDP
import struct
# Отправитель
seq = 0
def send(data):
global seq
packet = struct.pack("!I", seq) + data # 4 байта sequence + данные
sock.sendto(packet, target)
seq += 1
# Получатель
buffer = {} # sequence -> data
expected = 0
def on_recv():
raw, addr = sock.recvfrom(4096)
pkt_seq = struct.unpack("!I", raw[:4])[0]
payload = raw[4:]
buffer[pkt_seq] = payload
# Отдаём приложению последовательно
while expected in buffer:
process(buffer.pop(expected))
expected += 1
Отсутствие 3: Нет flow control
Flow control — это механизм, позволяющий получателю замедлить отправителя, если получатель не успевает обрабатывать данные. В TCP это делается через receive window: каждый ACK содержит «у меня свободно ещё столько-то байт в буфере». Отправитель не шлёт больше, чем «помещается» в этом окне.
В UDP — никакого flow control нет. Отправитель шлёт со своей максимальной скоростью. Если получатель не успевает — пакеты копятся в receive buffer ядра, и когда буфер переполнен, новые пакеты тупо отбрасываются. Отправитель об этом не узнает.
Это создаёт классическую проблему: быстрый отправитель «забивает» медленного получателя.
# Посмотри статистику UDP на своей машине:
# Linux:
cat /proc/net/snmp | grep Udp
# или:
netstat -su | head -20
# Обрати внимание на 'InErrors' и 'RcvbufErrors' -- это потери из-за переполнения receive buffer
# macOS:
netstat -s -p udp
# Look for 'dropped due to full socket buffers'
Если приложение нужно построить надёжный канал поверх UDP — flow control приходится реализовать самому. Один из подходов: получатель шлёт периодически «у меня всё ок» или «замедлись», отправитель адаптирует rate.
Отсутствие 4: Нет congestion control
Congestion control — это «не я не справляюсь, а сеть не справляется». В отличие от flow control (про конкретного получателя), congestion control — про общую перегрузку сети между источником и назначением.
TCP реализует congestion control через ряд алгоритмов: slow start, congestion avoidance, fast retransmit, разные варианты (Reno, Cubic, BBR). Когда TCP замечает потери, он замедляется. Это самокорректирующее поведение всего интернета: если бы все TCP-соединения были «жадными» (слали со 100% скоростью независимо от потерь), интернет бы рухнул в congestion collapse.
UDP — никакого congestion control нет. И это серьёзная проблема, потому что если UDP-приложение неправильно написано, оно может:
- Захватить весь bandwidth канала, мешая TCP-соединениям.
- Усугубить congestion, шлющий ещё больше, когда сеть и так перегружена.
- Привести к congestion collapse при массовом использовании.
Поэтому ответственные UDP-протоколы (DNS, NTP, QUIC) реализуют свой собственный congestion control. Простейшая стратегия — если за таймаут не пришёл ответ, удвой таймаут перед следующим запросом (exponential backoff). Это «дружелюбно» к сети.
Сетевые операторы и провайдеры обычно throttle или приоритизируют TCP-трафик над UDP именно из-за этой проблемы — UDP-приложения не саморегулируются и могут вести себя ‘agresive’. Поэтому иногда UDP в публичной сети едет хуже TCP, даже если ‘теоретически’ UDP должен быть быстрее.
QUIC интересен тем, что он реализует congestion control в user-space — каждое приложение носит свою копию алгоритма. Это и плюс (можно деплоить новые алгоритмы), и минус (приложение должно быть написано правильно).
Отсутствие 5: Нет отграничения границ соединения
В TCP есть очевидное начало и конец соединения: SYN -> ACK -> … -> FIN. Сервер всегда знает, кто к нему подключился и кто отключился.
В UDP нет понятия соединения вообще. Сервер получает датаграмму от какого-то IP — это новый клиент или старый? Сервер не знает. Это создаёт сложности:
- Привязка сессии. Игровой сервер должен помнить, что (192.168.1.10:54321) — это игрок Bob, чтобы корректно обрабатывать его input. Это «искусственная» сессия, реализованная поверх stateless UDP.
- Таймауты. Если клиент перестал слать пакеты, как сервер поймёт, что он «отключился»? Только через timeout: «не было пакетов 30 секунд -> считаем offline».
- Дубликаты от старых клиентов. Если клиент перезагрузился и получил новый ephemeral порт, сервер увидит «нового» клиента. Старая сессия может висеть до timeout.
- Spoofing. Любой может прислать UDP-пакет с поддельным source IP. В TCP это сложнее из-за handshake (нужно вернуть правильный SYN-ACK), а в UDP — буквально любой пакет принимается. Поэтому DNS amplification attacks работают именно через UDP.
# Игровой сервер -- pseudo-code
sessions = {} # (ip, port) -> player
while True:
data, addr = sock.recvfrom(4096)
if addr not in sessions:
# Новый клиент? Может быть. А может быть переподключение.
# А может быть атака. Нужно application-level handshake.
handle_new_client(addr, data)
else:
process_input(sessions[addr], data)
# Периодически чистим неактивных
now = time.time()
sessions = {k: v for k, v in sessions.items() if now - v.last_seen < 30}
Отсутствие 6: Нет фрагментации на уровне UDP
В TCP есть Maximum Segment Size (MSS) — TCP сам разбивает большие данные на куски, влезающие в MTU. Приложение может вызвать send(data) с любым размером, TCP сам разрулит.
UDP не делает этого. Если приложение шлёт датаграмму больше MTU, IP-уровень фрагментирует её. И это намного хуже:
- All-or-nothing на уровне датаграммы. Если хоть один IP-фрагмент потерялся — выкидывается вся датаграмма. У UDP нет механизма повторной передачи только потерянного фрагмента.
- Path MTU не виден приложению. TCP узнаёт фактический MTU через Path MTU Discovery. UDP-приложение должно либо угадывать (часто берут 1200 для безопасности), либо делать своё PMTUD.
- Файрволы блокируют фрагменты. Многие сетевые устройства дропают IP-фрагменты (для безопасности), и большие UDP-датаграммы просто не доходят.
Это значит: на UDP никогда не отправляй больше ~1200 байт в одной датаграмме без серьёзной причины. Если нужно передать больше — разбивай сам и реализуй reassembly на стороне получателя.
Отсутствие 7: Нет шифрования
В TCP с TLS — стандартный паттерн (HTTPS, SMTPS, etc). В UDP шифрования нет «из коробки». Если хочешь шифровать UDP-трафик:
- DTLS (Datagram TLS) — версия TLS для UDP. Используется в WebRTC.
- QUIC — встроенное шифрование TLS 1.3.
- WireGuard — современный VPN, использует ChaCha20 поверх UDP.
- IPsec — на сетевом уровне, может быть поверх UDP.
Без всего этого UDP-трафик полностью открытый. Любой провайдер может его читать. Поэтому в продакшене никогда не шли plaintext UDP с чувствительными данными — обязательно DTLS или какой-то аналог.
Что приходится реализовать самому при использовании UDP
Сложим картинку: если ты хочешь сделать «надёжный» канал поверх UDP, тебе придётся реализовать (или взять готовую библиотеку):
Если ты добавил всё это — поздравляю, ты получил TCP. Только хуже, потому что TCP сделан экспертами и отшлифован за 40+ лет. Поэтому совет: если действительно нужен «надёжный UDP», возьми готовое:
- QUIC — для большинства general-purpose кейсов.
- WebRTC Data Channels — для p2p-приложений.
- ENet, RakNet, GameNetworkingSockets — игровые библиотеки с reliable/unreliable channels.
- KCP — простой reliable UDP, открытый, популярен в Азии.
Не пиши свой «надёжный UDP», если ты не знаешь, что делаешь.
Буферы ядра: как SO_RCVBUF работает на уровне памятиКогда отсутствие фич — это благо
Может показаться, что UDP — это «недо-TCP». Это не так. Отсутствие фич в правильных кейсах — это благо:
- DNS: retransmission реализован на уровне resolver, congestion control не нужен (запросы маленькие, редкие).
- Видеостриминг: ordering и reliability не нужны (устаревший кадр бесполезен).
- Игры: flow control не нужен (state-updates быстро устаревают).
- Метрики: потерять одну метрику не страшно, не блокировать критический путь — важно.
- QUIC: строит свои фичи поверх UDP, но в user-space (не привязан к ядру). Гибче, обновляемо.
Главный принцип: «заплати только за то, что используешь». TCP заставляет тебя платить за reliability+ordering+flow+congestion даже когда они не нужны. UDP не заставляет — но и не даёт. Сам решаешь, что важно.
Попробуй сам
Демонстрация типичных проблем UDP в реальной жизни:
# 1. Симулируй loss и reordering через ipfw (macOS) или tc (Linux)
# Linux -- добавь 10% loss и 50ms задержку:
sudo tc qdisc add dev lo root netem loss 10% delay 50ms
# Теперь запусти UDP-listener и UDP-сендер:
nc -u -l 9999 &
for i in $(seq 1 100); do echo "msg $i" | nc -u -w0 127.0.0.1 9999; done
# Многие сообщения потеряются. Порядок может быть нарушен
# Убрать симуляцию:
sudo tc qdisc del dev lo root
# 2. Посмотри текущую UDP-статистику ОС
# Сколько UDP-пакетов было дропнуто из-за переполнения буферов:
# Linux:
ss -u -a -n | head -20
cat /proc/net/snmp | grep Udp
# macOS:
netstat -s -p udp | head -30
# 3. Тест 'жадного' UDP -- пошли максимум что можем
python3 -c "
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for i in range(1_000_000):
sock.sendto(b'X' * 1000, ('127.0.0.1', 9999))
" &
# Запусти recvr и посмотри, сколько он реально получит -- будут потери
# 4. Проверь, что 'большие' UDP-пакеты могут не дойти
# Пошли 5000-байтовый payload -- это IP-фрагментация:
python3 -c "
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(b'X' * 5000, ('8.8.8.8', 12345))
"
# Этот пакет на пути может быть отброшен файрволом или роутером,
# который не любит фрагментированные UDP