Learning Platform
Глоссарий Troubleshooting
Урок 08.03 · 18 мин
Начальный
UDPReliabilityCongestion ControlNetworking

Что UDP не делает — ordering, reliability, congestion control

В предыдущих уроках мы посмотрели на UDP как на «минималистичный транспорт» и разобрали кейсы, где это работает в пользу. В этом уроке посмотрим на обратную сторону: чего именно UDP не делает, какие проблемы из-за этого возникают, и как реальные системы их решают.

Эти знания критичны для двух вещей:

  1. Понять, когда использовать UDP вообще нельзя.
  2. Понять, что нужно реализовать самому, если ты строишь поверх UDP.

Многие думают, что UDP — это «быстрый протокол». Это упрощение. UDP — это отсутствие большинства фичей TCP. Иногда это полезно, иногда катастрофично. Разберёмся, какие именно фичи отсутствуют.


Отсутствие 1: Никаких гарантий доставки

Главное, что UDP не делает — не гарантирует, что пакет дойдёт. Отправил sendto() — пакет ушёл в сеть. Дальше его судьба тебя не касается. Может дойти за 30 мс, может потеряться навсегда. Никакого подтверждения, никаких retransmits.

Где пакет может потеряться:

Точки, где UDP-пакет может потеряться
Send bufferБуфер отправки в ядре. Если приложение шлёт быстрее, чем сетевая карта успевает отправить, буфер переполняется и новые пакеты отбрасываются. Обычно 256 KB-1 MB по дефолту
Local networkWi-Fi, особенно на больших расстояниях или с интерференцией, теряет пакеты регулярно. Ethernet надёжнее, но коллизии в hub-based сетях возможны
Router queueМаршрутизаторы имеют очереди ограниченного размера. При перегрузке (более 100% utilization) пакеты дропаются. Это называется 'congestion drop' и происходит на любом интернет-роутере регулярно
Receive bufferБуфер приёма в ядре получателя. Если приложение медленно вызывает recvfrom(), буфер заполняется и новые пакеты отбрасываются. Для UDP это особенно опасно -- никто не замедлит отправителя
MTU fragmentationЕсли датаграмма больше MTU (1500 байт на Ethernet), IP разбивает на фрагменты. Если хотя бы один фрагмент потерялся -- вся датаграмма отбрасывается. Поэтому большие UDP-датаграммы катастрофически ненадёжны
ICMP rate limitЕсли получатель отправил ICMP Port Unreachable обратно, файрволы или ОС могут throttle такие ICMP -- отправитель никогда не узнает о проблеме

На реальной сети типичная потеря пакетов:

  • Качественный 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 -- нет flow control, получатель захлёбывается
SenderОтправляет 10 000 датаграмм в секунду. UDP-стек ядра отдаёт их в сеть без оглядки на получателя
10k pps
NetworkСеть пропускает поток, не имея понятия о том, сколько способен обработать получатель
10k pps
ReceiverМожет обработать только 5000 датаграмм в секунду. Receive buffer ядра быстро заполняется и переполняется. ОС начинает молча дропать пакеты (видно в netstat -s как 'packet receive errors')
ResultПоловина пакетов теряется. Никакого сигнала отправителю об этом -- он продолжает шпарить со своей скоростью. Приложение должно либо мониторить потери и снижать скорость, либо просто принимать что есть
# Посмотри статистику 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-приложение неправильно написано, оно может:

  1. Захватить весь bandwidth канала, мешая TCP-соединениям.
  2. Усугубить congestion, шлющий ещё больше, когда сеть и так перегружена.
  3. Привести к congestion collapse при массовом использовании.

Поэтому ответственные UDP-протоколы (DNS, NTP, QUIC) реализуют свой собственный congestion control. Простейшая стратегия — если за таймаут не пришёл ответ, удвой таймаут перед следующим запросом (exponential backoff). Это «дружелюбно» к сети.

WARNING

Сетевые операторы и провайдеры обычно 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-уровень фрагментирует её. И это намного хуже:

  1. All-or-nothing на уровне датаграммы. Если хоть один IP-фрагмент потерялся — выкидывается вся датаграмма. У UDP нет механизма повторной передачи только потерянного фрагмента.
  2. Path MTU не виден приложению. TCP узнаёт фактический MTU через Path MTU Discovery. UDP-приложение должно либо угадывать (часто берут 1200 для безопасности), либо делать своё PMTUD.
  3. Файрволы блокируют фрагменты. Многие сетевые устройства дропают 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, тебе придётся реализовать (или взять готовую библиотеку):

Стек 'надёжного UDP' -- что добавляется поверх голого UDP
ReliabilitySequence numbers + acknowledgments + retransmission. Каждый пакет получает номер, получатель шлёт ACK, отправитель ждёт ACK с таймаутом и шлёт повторно при отсутствии
OrderingSequence numbers и буфер на стороне получателя. Пакеты могут прийти не по порядку -- сортируем по seq number перед отдачей приложению
Flow controlПолучатель сообщает отправителю, сколько он может ещё принять. Без этого быстрый отправитель забьёт медленного получателя
Congestion controlАдаптация скорости к состоянию сети. Slow start, congestion avoidance, fast retransmit. Без этого ваше приложение будет 'жадным' и мешать другим
EncryptionDTLS, QUIC, или своя крипто. Без этого данные открытые
SessionApplication-level handshake, session ID, keepalives, timeouts. Без этого нет понятия 'клиент онлайн'
FragmentationБольшие сообщения нужно разбивать самому, чтобы не зависеть от IP fragmentation. Иначе один потерянный фрагмент убьёт всё сообщение

Если ты добавил всё это — поздравляю, ты получил 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

Проверка знанийKnowledge check
Junior спрашивает: 'Я написал realtime-приложение поверх UDP. Под нагрузкой клиенты жалуются, что данные приходят 'кусками' -- то нормально, то надолго ничего, потом всё сразу. Хотя на стороне отправителя я шлю с постоянной скоростью. В чём может быть проблема?'
ОтветAnswer
Это классическая проблема UDP без congestion control и без flow control. Несколько возможных причин: 1. Переполнение receive buffer на стороне клиента. UDP пакеты копятся в буфере ядра, ядро дропает новые. Когда твой код наконец вызывает recvfrom(), он читает то, что осталось, но между чтениями есть провалы. Лечится: увеличить SO_RCVBUF (через setsockopt), читать чаще (отдельный поток), агрессивнее обрабатывать пакеты. 2. Переполнение буфера на каком-то роутере по пути. Если ты шлёшь 10 МБ/с, а где-то на пути есть узкое место (Wi-Fi на 5 МБ/с), пакеты дропаются на роутере. UDP это никак не замедляет -- ты продолжаешь шпарить. Эффект -- burst-доставка: какие-то микро-периоды роутер успевает пересылать, другие -- нет. 3. Bufferbloat. Большие буферы на промежуточном оборудовании могут не дропать пакеты, а накапливать их с задержкой в секунды. Клиент получает 'батчем' старые пакеты после долгой паузы. 4. Wi-Fi. Беспроводная среда сама по себе burst-овая. Микро-периоды потерь и помех приводят к группам потерянных и группам успешных пакетов. Что делать: реализовать application-level rate limiting на отправителе -- пускай он сам адаптируется к ситуации. Можно по схеме: получатель периодически шлёт назад 'ок, получил X пакетов из Y за последнюю секунду', отправитель смотрит loss rate и снижает rate. Это и есть congestion control 'для бедных'. В чистом UDP без этого ты обречён на такие проблемы, особенно на любой реальной сети сложнее localhost. Альтернатива -- использовать QUIC или другой готовый 'reliable UDP' протокол, где congestion control реализован профессионально. Велосипед с congestion control написать правильно -- это месяцы работы.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что произойдёт, если UDP-датаграмма потерялась по пути?

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

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

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

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