Learning Platform
Глоссарий Troubleshooting
Урок 13.03 · 20 мин
Средний
UDPPythonDatagramsSockets

UDP-сервер и клиент на Python — datagrams без connections

UDP — противоположность TCP: ни connections, ни гарантий доставки, ни упорядоченности. Просто шлёшь пакет (datagram) на адрес и надеешься, что дойдёт. Звучит дико — но именно поэтому UDP быстрее TCP в задачах, где надёжность не критична: DNS, NTP, video streaming, gaming, телеметрия, multicast.

В этом уроке напишем UDP-сервер и клиент, разберём, чем API отличается от TCP, и поговорим о типичных проблемах (lost packets, fragmentation, application-level reliability).


Главные отличия от TCP

TCP vs UDP -- API и семантика
TCP: connectionTCP-сервер: socket -> bind -> listen -> accept -> recv/send. Каждый клиент получает свой socket после accept
UDP: connectionlessUDP-сервер: socket -> bind -> recvfrom (получаем datagram + адрес отправителя) -> sendto. Один socket для всех клиентов
TCP: byte streamНет message boundaries. Нужно application-level framing
UDP: datagramsКаждый recvfrom = один полный datagram. Message boundaries сохранены. recv 1000 байт -> приходит 1000-байтное сообщение
TCP: reliableГарантированная доставка, в правильном порядке. Потерянные пакеты переотправляются ядром
UDP: best-effortПакет может: потеряться, прийти после следующих (reordering), прийти дважды (duplication). Приложение само разбирается
TCP: flow/congestion controlАдаптивная скорость. Если получатель медленный или сеть перегружена -- TCP замедляется
UDP: пишешь -- летитНет flow control. Шлёшь 1Gbps -- летит 1Gbps. Получатель не успевает -- его проблема (overflow buffer)

Простейший UDP-сервер

# udp_echo_server.py
import socket

HOST = '0.0.0.0'
PORT = 5353

# 1. Создать UDP-сокет (SOCK_DGRAM вместо SOCK_STREAM)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# 2. Привязать к адресу
sock.bind((HOST, PORT))
print(f"UDP server listening on {HOST}:{PORT}")

# 3. Главный цикл: recvfrom -> sendto
while True:
    # recvfrom возвращает (data, addr_отправителя)
    data, client_addr = sock.recvfrom(4096)
    print(f"Received {len(data)} bytes from {client_addr}: {data!r}")

    # Эхо обратно тому же отправителю
    sock.sendto(data, client_addr)

Заметьте:

  1. Нет listen, нет accept. Сразу после bind можно принимать пакеты.
  2. recvfrom(N) возвращает (data, addr) — сколько прочиталось и от кого. N — максимальный размер ожидаемого datagram. Если пакет больше — truncated.
  3. sendto(data, addr) — отправляем data адресату addr.
  4. Один сокет для всех клиентов. В TCP нужен отдельный socket на каждого, в UDP — один.
  5. Возвращаются полные datagrams. Если клиент послал b"hello" одним sendto, recvfrom вернёт ровно b"hello" (не b"hel" или b"helloworld").

Простейший UDP-клиент

# udp_echo_client.py
import socket

HOST = '127.0.0.1'
PORT = 5353

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

message = b'Hello UDP server'
sock.sendto(message, (HOST, PORT))
print(f"Sent: {message!r}")

# Ждём ответ
sock.settimeout(2.0)  # таймаут на случай потери пакета
try:
    response, server_addr = sock.recvfrom(4096)
    print(f"Got from {server_addr}: {response!r}")
except socket.timeout:
    print("Server didn't respond (lost packet?)")

sock.close()

Заметьте: нет connect(). UDP connectionless — можно просто sendto. Timeout важен потому что в UDP пакет может потеряться — без timeout recvfrom зависнет навсегда.


Что значит «без гарантий» на практике

UDP может:

  1. Потерять пакет. Drop где-то на пути — ваш sendto отработал, но recipient не получил. Приложение не узнает.
  2. Доставить пакеты в неправильном порядке. Если пакеты идут разными маршрутами, второй может прийти раньше первого.
  3. Доставить дубликат. Сетевой equipment иногда дублирует пакеты.

В большинстве случаев на современных wired-сетях потери и reordering редки (меньше 1%). На WiFi и мобильных — ощутимы (1-5%). Приложение должно решать, что с этим делать.

# Демонстрация: что если клиент шлёт 100 datagrams подряд
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for i in range(100):
    msg = f"Message {i:03d}".encode()
    sock.sendto(msg, ('127.0.0.1', 5353))

# Возможно сервер получит:
# Message 000, Message 001, Message 003, Message 002, Message 005, ...
# Некоторые могут пропасть совсем
# В практике на localhost обычно всё доходит, на реальной сети -- нет

Если вам нужна reliability — добавляйте её на application-level:

  • Sequence numbers. Каждое сообщение нумеровано. Получатель видит gaps -> знает о потерях.
  • ACKs. Получатель шлёт acknowledge для каждого сообщения. Отправитель ретраит unconfirmed.
  • Timeouts. Если ACK не пришёл — повторить.

Но если вы строите вокруг UDP полную reliability — вы просто строите TCP. Лучше используйте TCP. UDP имеет смысл, когда reliability не нужна или приемлема частичная (видео, аудио).


DNS-клиент на UDP — реальный пример

DNS — это UDP. Простой клиент, который резолвит имя:

# udp_dns_client.py
import socket
import struct

def build_dns_query(domain):
    """Сформировать минимальный DNS-запрос (для типа A, IPv4)."""
    # Header (12 байт)
    transaction_id = 0x1234
    flags = 0x0100  # standard query, recursion desired
    qdcount = 1     # 1 question
    header = struct.pack('!HHHHHH', transaction_id, flags, qdcount, 0, 0, 0)

    # Question: имя в формате DNS (labels)
    question = b''
    for label in domain.split('.'):
        question += bytes([len(label)]) + label.encode()
    question += b'\x00'  # null terminator
    question += struct.pack('!HH', 1, 1)  # type=A(1), class=IN(1)

    return header + question

def parse_dns_response(data):
    """Очень упрощённый парсер -- ищем первый A-record."""
    # Headers
    tid, flags, qdcount, ancount, nscount, arcount = struct.unpack('!HHHHHH', data[:12])
    print(f"Got response: {ancount} answers")

    # Просто ищем 4-байтные A-records в конце
    # Это упрощённо -- настоящий парсер сложнее
    if ancount > 0:
        # Найдём конец вопроса
        pos = 12
        while data[pos] != 0:
            pos += data[pos] + 1
        pos += 5  # 1 (null) + 2 (type) + 2 (class)

        # Парсим answers
        for _ in range(ancount):
            # Name (compression pointer, 2 bytes)
            pos += 2
            atype, aclass, ttl, rdlength = struct.unpack('!HHIH', data[pos:pos+10])
            pos += 10
            if atype == 1 and rdlength == 4:  # A record
                ip = '.'.join(str(b) for b in data[pos:pos+4])
                print(f"  A: {ip}")
            pos += rdlength

# Главная программа
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3.0)

query = build_dns_query('example.com')
sock.sendto(query, ('8.8.8.8', 53))  # Google DNS
print(f"Sent DNS query ({len(query)} bytes)")

response, server = sock.recvfrom(4096)
print(f"Got response from {server} ({len(response)} bytes)")
parse_dns_response(response)
sock.close()

Запустите — увидите IP example.com. Это реальный DNS-обмен по UDP в ~50 строк.

В реальной жизни используют библиотеки (dnspython), но этот пример показывает, что DNS — это просто бинарный обмен через UDP. Полная спецификация в RFC 1035.


Размер datagram и fragmentation

UDP-сообщения имеют верхний лимит. Технически — 65535 байт (16-bit length field). Но рекомендуется не превышать 1472 байта на IPv4 Ethernet (MTU 1500 - 20 IP - 8 UDP = 1472).

Если ваш datagram больше MTU, IP его фрагментирует на несколько IP-пакетов. На стороне получателя они собираются обратно. Проблема: если хоть один фрагмент потерян — весь datagram теряется. Поэтому большие UDP-сообщения статистически менее надёжны.

Best practice: держать UDP-сообщения меньше 1472 байт. Если данных больше — application-level fragmentation с ACK на каждый chunk.

# Узнать MTU интерфейса (Linux/macOS)
# ifconfig en0 | grep mtu
# ip link show eth0 | grep mtu

DNS use case: query/response over UDP

Почему DNS на UDP, а не TCP?

  1. Маленькие запросы. DNS-query ~50 байт, response ~100-500. UDP не имеет handshake — мгновенная отправка.
  2. Не критична надёжность. Если ответ потерян — клиент повторит через 1-3 секунды. Не катастрофа.
  3. Низкая latency. Один RTT для query+response. TCP было бы 3 RTT (handshake + query + response).
  4. Сервер обрабатывает миллионы запросов в секунду. Без state per connection это намного дешевле.

DNS over TCP используется только для больших ответов (>512 байт) — редко.


NTP — ещё пример UDP

NTP (Network Time Protocol) — синхронизация времени. Тоже UDP. Клиент шлёт 48 байт, сервер отвечает 48 байт. Latency критична (она используется в самом algorithm). Точность важна, надёжность не очень — если пакет потерян, повторим.


Когда выбирать UDP

UDP лучше TCP для:

  1. DNS, NTP, DHCP — маленькие request/response, простой retry.
  2. Real-time media (audio/video). Потеря пакета лучше, чем повторная передача — лучше иметь пропущенный кадр, чем замороженный.
  3. Gaming. Position updates: важна свежесть, не история. Потерял старый — следующий перезапишет.
  4. Telemetry/logging at massive scale. statsd, syslog — если потерял один metric, не критично.
  5. Multicast/broadcast. Один отправитель -> много получателей. TCP не поддерживает.

TCP лучше для:

  1. File transfer, email, web pages. Надо доставить всё.
  2. Database connections. Надо order и reliability.
  3. APIs где каждый запрос важен.

QUIC, на котором HTTP/3 — это UDP, но поверх него built reliable streams. Лучшее из обоих миров.


Multicast и broadcast

UDP — единственный протокол на этом уровне, поддерживающий multicast (один отправитель -> группа получателей) и broadcast (один -> все в сети). Не разбираем глубоко, но базовый пример:

import socket

# Multicast получатель
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('', 5353))

# Присоединиться к multicast group 224.0.0.251 (mDNS)
mreq = socket.inet_aton('224.0.0.251') + socket.inet_aton('0.0.0.0')
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)

# Теперь получаем все datagrams, посланные на 224.0.0.251:5353
while True:
    data, addr = sock.recvfrom(1024)
    print(f"Multicast from {addr}: {data!r}")

Это mDNS (.local hostnames, Bonjour). На macOS почти всё построено на mDNS.


Попробуй сам

# 1. Запустить UDP echo-сервер и клиент
# Терминал 1
python3 udp_echo_server.py

# Терминал 2
python3 udp_echo_client.py

# 2. Использовать nc как UDP-клиент
echo 'hello' | nc -u localhost 5353
# Ответ от сервера придёт обратно

# 3. dig -- видим DNS over UDP
dig @8.8.8.8 example.com
# По умолчанию dig использует UDP. Видим запрос и ответ

# 4. Принудить DNS через TCP
dig @8.8.8.8 example.com +tcp
# Тоже работает, но через TCP

# 5. Wireshark filter: udp.port == 53 -- увидите DNS-обмен
# Также проверить:
sudo tcpdump -i any -nn 'udp port 53' -X
# X -- показывать hex content. Видим DNS query в полях

# 6. Послать кастомный UDP с Python
python3 -c "
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(b'arbitrary data', ('127.0.0.1', 5353))
"

# 7. Эксперимент с потерей: настроить iperf для UDP traffic
# iperf -s -u  -- UDP server
# iperf -c localhost -u -b 100M  -- shoot 100Mbps UDP
# Видим статистику loss, jitter

# 8. Многоразовый UDP клиент с retry
python3 -c "
import socket
def send_with_retry(sock, data, addr, max_retries=3, timeout=1.0):
    sock.settimeout(timeout)
    for attempt in range(max_retries):
        sock.sendto(data, addr)
        try:
            response, _ = sock.recvfrom(4096)
            return response
        except socket.timeout:
            print(f'Retry {attempt + 1}/{max_retries}')
    return None

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
result = send_with_retry(s, b'test', ('127.0.0.1', 5353))
print(f'Result: {result!r}')
"

Что вы должны вынести

  1. UDP: socket(AF_INET, SOCK_DGRAM) — никаких listen/accept.
  2. Server: bind + recvfrom/sendto. Один socket для всех клиентов.
  3. recvfrom возвращает (data, sender_addr) — адрес отправителя в каждом datagram.
  4. UDP сохраняет message boundaries — каждый recvfrom = один datagram.
  5. Может потеряться, переупорядочиться, продублироваться. Приложение должно справляться.
  6. Размер меньше 1472 байт (Ethernet MTU) для избежания фрагментации.
  7. Идеален для маленьких request/response (DNS, NTP), real-time media, gaming, multicast.
  8. Если нужна reliability — application-level retries + sequence numbers. Или используйте TCP.

Kafka как надёжная альтернатива UDP для IoT и telemetry
Проверка знанийKnowledge check
Команда строит систему телеметрии: тысячи IoT-устройств шлют sensor readings (temperature, voltage) каждую секунду на центральный сервер. Каждое сообщение ~100 байт. Они спорят: TCP или UDP? Аргументы за каждый и какой выбрать?
ОтветAnswer
Это идеальный кейс для UDP, с несколькими aspects: ЗА UDP: (1) Maсштаб. Тысячи устройств = тысячи TCP-соединений на сервере, каждое занимает memory и file descriptor. UDP -- один socket, обрабатывает миллионы датаграмм. (2) Latency. TCP handshake перед каждым reading = 1 RTT overhead. На целлюлярных модемах с RTT 200ms это значимо. UDP -- мгновенная отправка. (3) Малые сообщения. 100 байт идеально для UDP -- умещается в один datagram, никакой fragmentation. (4) Tolerance к потерям. Потерять одно reading из 60 в минуту -- не критично; следующее придёт. Реальные системы (statsd использует UDP по этой причине) отлично работают с 1-5% loss. (5) Power saving на IoT. UDP не требует keep-alive packets, не требует поддержки connection state -- меньше radio time, меньше battery drain. ЗА TCP: (1) Reliability. Каждое reading гарантированно дойдёт. (2) Известная инфраструктура -- любые firewalls/proxies понимают TCP. (3) Если нужны больше 1500 байт payload -- TCP справляется автоматически. (4) Можно использовать TLS для encryption. Решение: UDP -- но с архитектурными mitigations. (a) Sequence numbers в каждом сообщении -- сервер знает gaps; alert если loss > threshold (например, 10%). (b) Critical alerts (например, 'устройство умирает') -- shedding через separate channel, например HTTPS POST на event. (c) Aggregate counts на устройстве -- если потеряли несколько readings, отправить summary 'X readings averaged Y' в next packet. (d) Optional encryption -- DTLS (TLS over UDP) если нужно. (e) Server-side deduplication через (device_id, sequence_no). Производительность: 1000 устройств * 1 пакет/сек = 1000 pps. С UDP server -- легко, single thread. С TCP -- сложнее, нужны pools, более complex memory model. Если scale больше (10000+ устройств), UDP единственный реальный variant. Real-world examples: AWS IoT использует MQTT-over-UDP options для constrained devices, statsd UDP-only.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. В чём принципиальное API-различие между UDP и TCP-сервером в Python?

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

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

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

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