Компьютерные сети: от IPC к C/Winsock
UDP: одно сообщение, одна дейтаграмма
Контракт UDP на примере слова «Привет»: границы сообщений, потери, дубликаты, таймауты и надёжность, которую проектирует приложение.
UDP: одно сообщение, одна дейтаграмма
До этой главы процесс A передавал процессу B поток байтов через TCP. Теперь
изменим требование: каждое Привет должно оставаться отдельной командой. Нам не
нужны соединение и встроенные повторные передачи, зато важно не потерять границу
сообщения. Это другой контракт, а не «облегчённый TCP».
Задача
Пусть клиент посылает серверу одну команду в UTF-8:
Привет
d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82 # 12 bytes
Для UDP вызов sendto() передаёт транспорту одну дейтаграмму. У принимающей
стороны один успешный recvfrom() извлекает одну поставленную в очередь
дейтаграмму, а не произвольный кусок общего потока. Но UDP не обещает, что она
дойдёт, придёт один раз или сохранит порядок относительно соседних дейтаграмм.
Выбор начинается не со скорости, а с нужного свойства:
| Вопрос приложения | TCP | UDP |
|---|---|---|
| Есть ли граница сообщения? | Нет, её добавляет application framing | Да, одна принятая дейтаграмма остаётся одной дейтаграммой |
| Восстанавливается ли порядок? | Да, для байтов живого соединения | Нет |
| Есть ли встроенная повторная передача? | Да | Нет |
| Может ли медленный receiver давить на sender? | Да, через flow control и buffers | Нет эквивалентного end-to-end потока |
| Нужно ли приложение для retry/deduplication? | Для бизнес-операции всё равно часто нужно | Обязательно, если операция требует такой надёжности |
Граница дейтаграммы не отменяет уровни ниже. UDP header попадает в IP packet, а
IP packet — в link-layer frame. Слишком большая дейтаграмма может потребовать IP
fragmentation или вообще не уйти. Поэтому «один sendto — один UDP datagram» не
означает «один Ethernet frame на проводе».
Что именно происходит при обмене
Добавим к payload два application-поля: request_id и kind. Они не нужны
UDP, но нужны нашей программе, чтобы различать повтор одной операции и новую
операцию.
request_id = 41
kind = HELLO
payload = d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82
Happy path состоит из четырёх независимых событий:
- клиент кодирует команду и вызывает
sendto(); - локальный стек принимает одну дейтаграмму и пытается доставить её endpoint;
- сервер получает datagram вместе с адресом отправителя;
- сервер формирует отдельную response datagram с тем же
request_id.
Если response потерялся, клиент видит только отсутствие ответа до deadline. Он не знает, потерялся request, response или сервер выполнил операцию и упал до ответа. Бездумный retry может выполнить неидемпотентную команду второй раз. Именно поэтому request ID и deduplication cache относятся к протоколу приложения, а не к украшению логов.
C/Winsock2 checkpoint
Минимальный UDP client не вызывает listen() или accept(). Он создаёт
SOCK_DGRAM, получает список адресов через getaddrinfo, а затем использует
sendto и recvfrom:
SOCKET s = socket(peer->ai_family, SOCK_DGRAM, IPPROTO_UDP);
if (s == INVALID_SOCKET) {
fail_wsa("socket", WSAGetLastError());
}
int sent = sendto(
s,
(const char *)request,
(int)request_len,
0,
peer->ai_addr,
(int)peer->ai_addrlen
);
if (sent == SOCKET_ERROR) {
fail_wsa("sendto", WSAGetLastError());
}
if ((size_t)sent != request_len) {
fail_protocol("datagram was not accepted atomically");
}
Получатель должен выделить buffer по максимальному размеру вашего протокола, а не надеяться, что любой пакет поместится:
struct sockaddr_storage from;
int from_len = sizeof(from);
int received = recvfrom(
s,
(char *)buffer,
(int)sizeof(buffer),
0,
(struct sockaddr *)&from,
&from_len
);
Для Winsock слишком большая queued datagram может привести к WSAEMSGSIZE;
для ненадёжного протокола хвост, не поместившийся в buffer, теряется. Это не
аналог short recv() TCP: нельзя «дочитать остаток той же UDP-дейтаграммы»
следующим обычным вызовом.
Deadline вместо вечного ожидания
Надёжный учебный клиент задаёт конечный deadline и retry budget. Таймаут одной попытки не должен незаметно начинать новый бесконечный таймаут:
overall deadline = now + 1500 ms
attempt 1 -> wait only until deadline
attempt 2 -> only if time remains
otherwise -> report timeout with request_id
В production параметры выбираются по latency budget и семантике операции. В лабораторной важен сам invariant: число попыток и общее время ограничены.
Предсказание, эксперимент, доказательство
Перед запуском Lab 02 запишите четыре предсказания:
- какой
request_idувидит сервер при happy path; - что напечатает клиент, если instructor fault proxy потеряет только response;
- выполнит ли сервер handler дважды после retry;
- что произойдёт, если receive buffer меньше datagram.
Затем прогоните controlled scenarios pass, drop-response, duplicate и
malformed. В evidence сохраните не скриншот, а наблюдения обеих сторон:
client: timestamp, attempt, request_id, bytes, peer, outcome
server: timestamp, request_id, first/duplicate, handler result, response bytes
Packet capture подтверждает наличие конкретной дейтаграммы в конкретной точке. Он не доказывает, что другой процесс её разобрал. Server log подтверждает работу parser/handler, но не то, что response дошёл обратно. Сильный вывод объединяет обе границы.
Углубление: одна дейтаграмма — много получателей
Этот блок не требуется для Lab 02 и Q2, но отвечает на естественный вопрос: все примеры отправляли дейтаграмму одному endpoint, а может ли UDP доставить одну дейтаграмму сразу нескольким получателям?
Broadcast доставляет дейтаграмму всем узлам локального broadcast domain.
Для сети 192.168.10.0/24 directed broadcast address — 192.168.10.255;
адрес 255.255.255.255 ограничен локальным сегментом. Router broadcast не
пересылает: радиус действия — один link. Winsock требует явного разрешения:
int yes = 1;
if (setsockopt(
s,
SOL_SOCKET,
SO_BROADCAST,
(const char *)&yes,
sizeof(yes)
) == SOCKET_ERROR) {
fail_wsa("setsockopt(SO_BROADCAST)", WSAGetLastError());
}
Типичная задача — discovery: клиент шлёт один HELLO на broadcast address, а каждый работающий учебный server отвечает обычным unicast response со своим адресом. Так работает и первый шаг DHCP: хост без адреса спрашивает настройки broadcast-запросом. Обратная сторона: каждый узел сегмента обязан принять и разобрать такой кадр, поэтому broadcast без нужды — это налог на всех соседей, а не бесплатная рассылка.
Multicast доставляет дейтаграмму группе подписчиков из диапазона
224.0.0.0/4. Получатель явно вступает в group membership, отправитель не
знает список получателей. Для этого курса достаточно модели: подписка на
поток, а не «broadcast посильнее».
Anycast — не режим socket, а свойство адресации: один и тот же адрес настроен на многих узлах, и routing ведёт дейтаграмму к одному из них по правилам маршрутизации. Приложение ничего специально не включает.
Правила надёжности из основной части главы здесь не меняются: потери,
дубликаты и переупорядочение возможны и для broadcast, поэтому request_id,
deadline и dedup остаются обязанностью приложения.
Частая ошибка: retry превращают в reliability
Повтор сам по себе не создаёт корректный протокол. Нужны как минимум:
- стабильный request ID для одной логической операции;
- ограниченный deadline и retry budget;
- правило обработки duplicate на сервере;
- ответ, который можно сопоставить исходному request;
- решение, какие операции идемпотентны.
Если новый retry получает новый ID, сервер видит новую операцию. Если cache вечный, он бесконечно растёт. Если cache слишком короткий, поздний duplicate снова выполнит handler. Эти trade-offs принадлежат application contract.
Итого
- UDP сохраняет границу поставленной в очередь дейтаграммы, но не гарантирует delivery, порядок или уникальность.
- Одна UDP datagram может пройти через несколько link-layer frames; transport и physical units нельзя считать взаимно однозначными.
- Timeout, retry, deduplication и бизнес-идемпотентность проектирует приложение.
Следующая проблема появляется раньше первого network request: человеку удобно
писать имя, а сокету нужен адрес. В следующей главе getaddrinfo превратит одно
имя в список IPv4/IPv6 candidates и заставит нас разделить DNS failure и
connect failure.