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
Простейший 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)
Заметьте:
- Нет listen, нет accept. Сразу после bind можно принимать пакеты.
recvfrom(N)возвращает (data, addr) — сколько прочиталось и от кого. N — максимальный размер ожидаемого datagram. Если пакет больше — truncated.sendto(data, addr)— отправляем data адресату addr.- Один сокет для всех клиентов. В TCP нужен отдельный socket на каждого, в UDP — один.
- Возвращаются полные 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 может:
- Потерять пакет. Drop где-то на пути — ваш
sendtoотработал, но recipient не получил. Приложение не узнает. - Доставить пакеты в неправильном порядке. Если пакеты идут разными маршрутами, второй может прийти раньше первого.
- Доставить дубликат. Сетевой 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?
- Маленькие запросы. DNS-query ~50 байт, response ~100-500. UDP не имеет handshake — мгновенная отправка.
- Не критична надёжность. Если ответ потерян — клиент повторит через 1-3 секунды. Не катастрофа.
- Низкая latency. Один RTT для query+response. TCP было бы 3 RTT (handshake + query + response).
- Сервер обрабатывает миллионы запросов в секунду. Без state per connection это намного дешевле.
DNS over TCP используется только для больших ответов (>512 байт) — редко.
NTP — ещё пример UDP
NTP (Network Time Protocol) — синхронизация времени. Тоже UDP. Клиент шлёт 48 байт, сервер отвечает 48 байт. Latency критична (она используется в самом algorithm). Точность важна, надёжность не очень — если пакет потерян, повторим.
Когда выбирать UDP
UDP лучше TCP для:
- DNS, NTP, DHCP — маленькие request/response, простой retry.
- Real-time media (audio/video). Потеря пакета лучше, чем повторная передача — лучше иметь пропущенный кадр, чем замороженный.
- Gaming. Position updates: важна свежесть, не история. Потерял старый — следующий перезапишет.
- Telemetry/logging at massive scale. statsd, syslog — если потерял один metric, не критично.
- Multicast/broadcast. Один отправитель -> много получателей. TCP не поддерживает.
TCP лучше для:
- File transfer, email, web pages. Надо доставить всё.
- Database connections. Надо order и reliability.
- 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}')
"
Что вы должны вынести
- UDP: socket(AF_INET, SOCK_DGRAM) — никаких listen/accept.
- Server: bind + recvfrom/sendto. Один socket для всех клиентов.
- recvfrom возвращает (data, sender_addr) — адрес отправителя в каждом datagram.
- UDP сохраняет message boundaries — каждый recvfrom = один datagram.
- Может потеряться, переупорядочиться, продублироваться. Приложение должно справляться.
- Размер меньше 1472 байт (Ethernet MTU) для избежания фрагментации.
- Идеален для маленьких request/response (DNS, NTP), real-time media, gaming, multicast.
- Если нужна reliability — application-level retries + sequence numbers. Или используйте TCP.
Kafka как надёжная альтернатива UDP для IoT и telemetry