Компьютерные сети: от IPC к C/Winsock
TCP-поток: как вернуть границы сообщений
Отправляем «Привет» по Winsock правильно: принимаем partial send/recv, добавляем length prefix, ограничиваем frame до allocation и наблюдаем flow-control backpressure.
TCP-поток: как вернуть границы сообщений
Задача: получить два сообщения, а не случайные куски
Процесс A установил TCP connection и дважды отправил Привет. Каждый payload
содержит 12 UTF-8-байтов. Вот два допустимых лога сервера:
run 1: recv -> 24 bytes
run 2: recv -> 5, 7, 3, 9 bytes
В первом запуске байты двух сообщений склеились. Во втором граница между
первым и вторым send() попала внутрь одного из чтений. Оба результата
совместимы с исправным TCP.
TCP даёт приложению надёжный упорядоченный byte stream, а не передачу
массива вызовов send(). Успешно доставленный stream сохраняет bytes и их
порядок; если connection больше нельзя продолжать, приложение увидит close
или error. Но метки «здесь закончился send номер 1» в stream не появятся.
send #1: [12 bytes "Привет"]
send #2: [12 bytes "Привет"]
TCP stream:
[D0 9F ... D1 82 D0 9F ... D1 82]
границы сообщений здесь не записаны
Задача прикладного протокола — добавить границы, проверить их и выдавать сообщение бизнес-логике только после получения целого frame.
Шаг 1. Один send() не равен одному segment или recv()
На пути между двумя вызовами API находятся send buffer, TCP segmentation, повторные передачи, receive buffer и планировщик удалённого процесса. Любой из этих этапов может изменить наблюдаемое разбиение без изменения потока.
Корректный recv(buffer, 4096, 0) имеет право вернуть:
- один доступный байт;
- часть UTF-8-последовательности;
- ровно одно сообщение — случайно;
- конец первого и начало второго сообщения;
- несколько сообщений, если они уже помещаются в buffer;
0, если peer выполнил orderly shutdown и предыдущие данные прочитаны;SOCKET_ERROR, если операция завершилась ошибкой.
Положительный результат — количество доступных сейчас байтов. Он не указывает, сколько ещё байтов относится к логическому сообщению.
С отправкой действует похожая дисциплина. send() возвращает количество
байтов, принятых этим локальным вызовом. Для stream socket код должен быть
готов к short positive result и продолжить с первого неотправленного байта.
На nonblocking socket WSAEWOULDBLOCK означает «попробуйте после сигнала
writable», а не потерю уже принятых байтов.
Шаг 2. Length prefix создаёт проверяемую границу
Определим ByteBox frame первого уровня:
[ length: uint32, network byte order ][ payload: exactly length bytes ]
Для Привет header содержит:
00 00 00 0C | D0 9F D1 80 D0 B8 D0 B2 D0 B5 D1 82
0C — число 12. Network byte order фиксирует big-endian представление
независимо от порядка байтов CPU. Получатель сначала читает ровно четыре
байта header, преобразует длину через ntohl(), проверяет лимит и только
затем читает payload.
Почему нужен лимит до allocation? Header FF FF FF FF означает
4294967295. Слепой malloc(length) позволяет одному peer вызвать огромное
выделение памяти или integer overflow. В учебном протоколе установим
MAX_FRAME_SIZE = 1 MiB; значение выше — protocol error и закрытие
connection.
C/Winsock checkpoint: blocking send_all и recv_exact
Эти функции предназначены для одного thread и blocking socket. Для nonblocking/overlapped I/O состояние продолжения хранится в event loop, но инвариант тот же: cursor двигается только на фактически обработанное число байтов.
#include <winsock2.h>
#include <stdint.h>
#include <stdlib.h>
#define MAX_FRAME_SIZE (1024u * 1024u)
static int send_all(SOCKET s, const unsigned char *data, int length) {
int total = 0;
while (total < length) {
int n = send(
s,
(const char *)data + total,
length - total,
0
);
if (n == SOCKET_ERROR || n == 0) return 0;
total += n;
}
return 1;
}
enum read_result {
READ_ERROR = -1,
READ_TRUNCATED = -2,
READ_EOF = 0,
READ_OK = 1
};
static int recv_exact(SOCKET s, unsigned char *data, int length) {
int total = 0;
while (total < length) {
int n = recv(s, (char *)data + total, length - total, 0);
if (n == SOCKET_ERROR) return READ_ERROR;
if (n == 0) return total == 0 ? READ_EOF : READ_TRUNCATED;
total += n;
}
return READ_OK;
}
Отправитель строит frame двумя вызовами send_all(). Два вызова не означают
две наблюдаемые части потока; протокол полагается на четыре байта длины:
static int send_frame(
SOCKET s,
const unsigned char *payload,
uint32_t length
) {
if (length > MAX_FRAME_SIZE) return 0;
uint32_t wire_length = htonl(length);
return send_all(s, (const unsigned char *)&wire_length, 4) &&
send_all(s, payload, (int)length);
}
Получатель сначала валидирует header. Память выделяется только после bounds check:
static int recv_frame(
SOCKET s,
unsigned char **payload_out,
uint32_t *length_out
) {
uint32_t wire_length;
int rc = recv_exact(s, (unsigned char *)&wire_length, 4);
if (rc != READ_OK) return rc;
uint32_t length = ntohl(wire_length);
if (length > MAX_FRAME_SIZE) return READ_ERROR;
unsigned char *payload = malloc((size_t)length + 1);
if (payload == NULL) return READ_ERROR;
rc = recv_exact(s, payload, (int)length);
if (rc != READ_OK) {
free(payload);
return rc == READ_EOF ? READ_TRUNCATED : rc;
}
payload[length] = 0; /* sentinel; caller ещё обязан валидировать UTF-8 */
*payload_out = payload;
*length_out = length;
return READ_OK;
}
Нулевой length может быть валидным frame, если протокол это разрешает.
Нулевой результат recv() — другой факт: peer закрыл направление отправки.
EOF между frames — нормальное завершение; EOF внутри обещанного payload —
truncated frame.
Шаг 3. Parser живёт дольше одного recv()
У production parser обычно есть состояние:
READING_HEADER: накоплено 0..4 bytes
READING_PAYLOAD: ожидаем length, накоплено 0..length
FRAME_READY: передать immutable payload обработчику
Blocking recv_exact() скрывает это состояние в цикле. В select, IOCP или
другом asynchronous design оно хранится явно между completions. Нельзя
записывать указатель на временный stack buffer в pending operation или
забывать, сколько bytes уже накоплено.
UTF-8 декодируется после получения целого frame. Если разрезать
D0 9F между двумя recv(), первый chunk заканчивается половиной символа
П. Это не повреждение сети: parser обязан сначала собрать все 12 bytes.
Шаг 4. Медленный читатель создаёт backpressure
TCP receiver сообщает, сколько места доступно в receive window. Если процесс
B перестал вызывать recv(), его receive buffer заполняется, advertised
window уменьшается. Sender ограничивает новые bytes этим окном; затем его
локальный send buffer тоже может заполниться.
Для приложения A эффект наблюдаем:
- blocking
send()может ждать освобождения места; - nonblocking
send()может принять лишь часть или вернутьWSAEWOULDBLOCK; - очередь pending writes растёт, если application игнорирует backpressure.
Flow control защищает receiver. Congestion control отдельно защищает network path от перегрузки. Оба механизма влияют на скорость TCP, но ни один не создаёт границы сообщений и не гарантирует, что процесс B обработал payload.
Предсказание → эксперимент → доказательство
Сделайте клиент, который без паузы вызывает send_frame() дважды для
Привет. На сервере временно замените recv_exact() диагностическим циклом
с raw buffer всего 5 bytes, но передавайте chunks в тот же stateful parser.
Предсказание. Нельзя честно предсказать точные размеры каждого recv().
Можно предсказать инварианты: общий stream содержит 32 bytes — два frame по
4 + 12; parser должен выдать два payload длиной 12 в исходном порядке.
Эксперимент. Логируйте transport chunks и отдельно parser events:
recv chunk: offset=?, size=?, hex=?
header complete: declared_length=?
frame complete: payload_length=?, utf8=?
EOF at frame boundary / truncated frame / socket error
Повторите минимум три раза. Затем измените raw buffer на 1 и 64 bytes. Для
негативного теста отправьте header 00 20 00 00 — 2 MiB, больше лимита — без
payload.
Доказательство. Успешный отчёт содержит разные допустимые chunk layouts, но одинаковые события:
frame 1: declared=12, received=12, UTF-8="Привет"
frame 2: declared=12, received=12, UTF-8="Привет"
oversized header: rejected before malloc(payload)
Не пытайтесь заставить localhost воспроизводить конкретное разбиение с
Sleep(): timing может изменить вероятность, но не контракт. Сильный test
harness сам подаёт parser'у все разрезы header/payload и несколько frames в
одном chunk.
Частые заблуждения
«TCP segment и есть сообщение». Segments принадлежат transport. Их размер может меняться из-за MSS, retransmission и offload; приложение видит stream.
«Флаг PSH вернёт границы send()». TCP не обязан передавать PSH приложению как message delimiter. Формат сообщения должен быть частью application protocol.
«TCP_NODELAY исправляет склейку». Эта option влияет на отправочную задержку небольших writes, но не превращает stream в message transport.
«recv() == 0 — пустое сообщение». Для TCP это orderly shutdown после доставленных bytes. Пустой frame кодируется length prefix со значением zero.
Проверьте модель
Короткий итог
- TCP сохраняет byte order, но не message boundaries.
- Любой positive
send()/recv()может обработать только часть buffer. - Length prefix работает лишь вместе с network byte order, bounds и циклом чтения ровно заявленной длины.
- EOF на границе и EOF внутри frame имеют разные значения.
- Flow control переносит давление медленного reader назад к sender.
- Packet capture помогает наблюдать transport, но не определяет framing API.
Мост к следующей главе
Мы добавили сообщения поверх TCP собственным протоколом. Следующий вопрос: всегда ли нужен connection, stream, retransmission и flow control? В следующей главе сравним TCP с UDP. UDP сохраняет границу одной datagram, но не обещает доставку, порядок или подавление дубликатов — эти свойства придётся либо принять, либо спроектировать на уровне приложения.