TCP не отдаёт Receive теми же порциями, что Send — приём как байтовый поток

· Обновлено: · · TCP, Socket, Сети, .NET, C#, Проектирование протоколов, Эксплуатация, Работа с существующим кодом

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619888)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). TCP не отдаёт Receive теми же порциями, что Send — приём как байтовый поток. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/06/09/001-tcp-send-receive-message-framing/

DOI (зарегистрированный архив)
10.5281/zenodo.21619888
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619889

1. С чего начать

В реализации TCP-обмена есть очень распространённое заблуждение.

Оно звучит так:

отправитель вызвал Send / Write порциями — и получатель теми же порциями прочитает их через Receive / Read.

Допустим, отправитель пишет так:

Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")

Кажется, что получатель прочитает ровно три вызова:

Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"

В TCP это не гарантировано.

На практике может произойти любое из следующего.

Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"

Для TCP всё это нормально.

Грубо говоря, TCP гарантирует, что «отправленная последовательность байтов дойдёт по порядку, без дубликатов и без потерь». Чего он не гарантирует — что «порция, которую приложение передало в Send, сохранится как порция, которую получатель получит через Receive».

Поэтому приложению на TCP на стороне приёма нужен механизм, который отвечает на вопрос: «в только что полученных байтах где начинается и где заканчивается одно сообщение».

Это и есть фрейминг прикладного протокола.

В статье разбираем типичные заблуждения вокруг Send и Receive в TCP и то, как с ними работать в .NET / C#.

Код из статьи опубликован на GitHub как собираемый и запускаемый набор примеров: библиотека, демо loopback-TCP и модульные тесты, которые воспроизводят разбиение, склейку и обрыв посередине кадра.

tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)

Предпосылки этой статьи

Что Предпосылка
Язык и runtime Синтаксис C# 8 и новее (оператор диапазона, nullable reference types) и Stream.ReadAsync, принимающий Memory<byte>. Рассчитывайте на .NET Core 3.1 и новее / .NET 5 и новее
API обмена Приём пишем через async/await к NetworkStream, полученному из TcpClient, то есть к Stream
Если работаете с Socket напрямую Идея приёма та же. Socket.Receive / ReceiveAsync тоже нужно трактовать так: «доверять только числу байт в возвращаемом значении» и «крутить цикл, пока не дочитаете сколько нужно». Разница на стороне отправки: у Socket.Send нужно смотреть возвращаемое значение. Об этом глава 10
.NET Framework Идея проектирования та же, но нет оператора диапазона и перегрузки с Memory<byte>, поэтому цикл нужно переписать на Read(byte[], int, int)
Stream.ReadExactly Stream.ReadExactly / ReadExactlyAsync, о которых речь в главе 8, доступны с .NET 7

Это статья про проектирование прикладного протокола. Вывод не меняется ни от того, ставите ли вы NoDelay, ни от того, есть ли TLS. Почему — в главах 13 и 14.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 19, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. TCP перевозит не «сообщения», а «байты»

Главное — не думать о TCP как об очереди сообщений.

TCP обрабатывает данные, которые отдало приложение, как непрерывную последовательность байтов.

Даже если отправитель вызвал Send три раза,

Send("ABC")
Send("DEF")
Send("GHI")

для TCP это в итоге поток из 9 байт:

ABCDEFGHI

Границы вида

ABC | DEF | GHI

внутри этого потока нигде не сохраняются: они имели смысл только для приложения.

Получатель в какой-то момент читает «то, что сейчас лежит в буфере приёма». Поэтому результат может выглядеть так:

Вызовы отправителя Пример того, что видит получатель
Send("ABC"), Send("DEF") Один Receive() вернул "ABCDEF"
Send("ABCDEF") Два Receive() вернули "AB", "CDEF"
Send("ABC"), Send("DEF"), Send("GHI") Три Receive() вернули "A", "BCDEFG", "HI"
UTF-8-символ вроде Send("\u3042") Многобайтовый символ может быть разрезан посередине

Важно, что во всём этом нет «аномалии».

Баги, которые выглядят как «данные иногда теряются», «несколько сообщений склеиваются» или «текст портится», чаще всего не сбой TCP, а ошибка проектирования: получатель обращается с TCP как с чем-то ориентированным на сообщения.

3. Почему кажется, что приём идёт порциями Send

Заблуждение живёт, потому что в локальной среде и на маленьких данных всё часто случайно выглядит как ожидалось.

В среде разработки легко сходятся такие условия:

  • клиент и сервер на одной машине или в близкой сети;
  • объём данных небольшой;
  • собеседник сразу читает;
  • у CPU и сети есть запас;
  • тесты ручные, дрожание по времени мало;
  • Receive вызывают сразу после Send.

При таких условиях может казаться, что одному Send соответствует один Receive.

В рабочей среде условия другие:

  • данные копятся в буферах отправки и приёма ОС;
  • несколько мелких отправок склеиваются;
  • крупная отправка режется из‑за TCP-сегментов или буфера приёма;
  • планирование потока-получателя запаздывает;
  • появляются TLS, прокси, балансировщик, VPN;
  • возникают задержка и перегрузка сети;
  • сказываются алгоритм Нейгла и отложенные ACK.

В итоге получается неприятный баг: «на разработке работало, в продакшене иногда ломается».

В сетевой обработке состояние «случайно работает» — самое опасное.

4. Типичный хрупкий код приёма

Например, такой код опасен:

byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);

if (read == 0)
{
    // Собеседник корректно закрыл соединение
    return;
}

string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);

Он исходит из того, что «один ReadAsync возвращает одно сообщение». В TCP это не так.

Проблем здесь три.

Первая — одно сообщение может быть разрезано.

отправка: {"command":"login","user":"komura"}\n
приём 1:  {"command":"login",
приём 2:  "user":"komura"}\n

Если разобрать как JSON только «приём 1», разбор упадёт.

Вторая — несколько сообщений могут склеиться.

отправка 1: {"command":"login"}\n
отправка 2: {"command":"get"}\n
приём:      {"command":"login"}\n{"command":"get"}\n

Попытка разобрать это как один JSON тоже упадёт.

Третья — разбиение может пройти по границе символа в кодировке.

В UTF-8 один символ может занимать несколько байт. Нет гарантии, что граница ReadAsync совпадёт с границей символа.

Поэтому, если каждый раз сразу вызывать Encoding.UTF8.GetString, результат может быть повреждён, когда разбиение попало внутрь многобайтового символа.

Базовый принцип — не «получили байты — сразу в строку», а «копить байты, пока не станет известна граница сообщения, и декодировать только целое сообщение».

5. Нельзя определять конец сообщения по DataAvailable

Часто встречается и такой код:

var ms = new MemoryStream();
byte[] buffer = new byte[4096];

while (stream.DataAvailable)
{
    int read = await stream.ReadAsync(buffer, cancellationToken);
    if (read == 0)
    {
        break;
    }

    ms.Write(buffer, 0, read);
}

byte[] message = ms.ToArray();

Это тоже опасно. DataAvailable говорит лишь, «есть ли прямо сейчас в локальном буфере приёма данные, доступные для чтения». Это не значит, что одно сообщение уровня приложения уже собрано.

Например, сообщение занимает 100 байт. DataAvailable может стать true, когда пришли только первые 40 байт, а сразу после их чтения временно стать false. Оставшиеся 60 байт могут прийти чуть позже.

Если в этот момент трактовать DataAvailable == false как «конец сообщения», обрезок обработают как целое сообщение.

DataAvailable можно использовать для оптимизации цикла чтения или неблокирующей проверки. Для границ протокола его лучше не применять.

6. Правильный подход — разделить «приём» и «разбор»

Приём по TCP проще проектировать, если разделить две вещи:

приём:  читать байты из TCP и класть их в буфер
разбор: вырезать из буфера одно сообщение уровня приложения

Receive / Read — это только «прочитать байты». Где заканчивается одно сообщение, должен решать прикладной протокол.

Четыре основных способа:

Способ Суть Куда подходит
Фиксированная длина Одно сообщение всегда занимает заданное число байт Устаревшее оборудование, двоичные телеграммы, системы управления
Разделитель Сообщение читают до заданной последовательности байт, например \n Команды, логи, NDJSON, простые протоколы
Префикс длины В начале стоит длина тела, затем читают ровно столько байт Двоичные данные, JSON, MessagePack, Protocol Buffers и т. п.
Самоописывающийся формат Длина или конец заданы самим форматом, как Content-Length или chunked в HTTP Существующие протоколы, обмен, которому нужна расширяемость

Если проектирую свой протокол, в первую очередь смотрю префикс длины. В тело можно класть перевод строки и произвольные двоичные данные, реализация на приёме однозначна, и максимальный размер ограничить проще.

7. Основы префикса длины

При префиксе длины сообщение выглядит так:

[4 байта длины тела][тело]

Если тело — JSON в UTF-8 длиной 31 байт, отправка выглядит так:

00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
  длина тела              тело

Как три отправки выглядят на приёме и как из этого восстанавливают кадры — на одной схеме:

к следующему кадру1-я отправкатело HELLO00 00 00 05 48 45 4C 4C 4F2-я отправкатело ABC00 00 00 03 41 42 433-я отправкатело QUIT00 00 00 04 51 55 49 54TCP — упорядоченный байтовый потокграницы отправки не перевозятсядоходят только 24 байта по порядку1-й Read = 6 байт00 00 00 05 48 452-й Read = 11 байт4C 4C 4F 00 00 00 03 41 42 43 003-й Read = 7 байт00 00 04 51 55 49 54буфер приёмабайты от Read накапливают по порядкудочитать первые 4 байтадлина тела = 5дочитать тело 5 байтсобрано 1 сообщение = HELLOостаток не отбрасыватьоставить как начало следующего кадра

Рис. 1: Три отправки не соответствуют трём Read на приёме; кадры восстанавливают через буфер приёма.

На схеме первый Read донёс только часть длины тела. Во втором Read смешались остаток первого тела, весь второй кадр и первый байт заголовка третьего. Видно, что границы отправки и границы приёма не совпадают.

Получатель обрабатывает данные в таком порядке:

  1. сначала дочитать 4 байта;
  2. извлечь из них длину тела;
  3. проверить, что длина корректна;
  4. дочитать тело ровно указанной длины;
  5. обработать прочитанное тело как одно сообщение;
  6. читать следующий кадр.

Важно: «даже 4-байтовый заголовок может быть разрезан».

приём 1: 00 00
приём 2: 00 1F 7B 22 63 ...

То, что это «всего лишь заголовок», не значит, что один Read вернёт все 4 байта.

С телом то же самое. То, что Read вернул меньше, чем просили, — обычное дело. Если нужное число байт известно, пишут цикл, который дочитывает до конца.

8. Пример приёма в .NET: префикс длины

Ниже — чтение кадров с префиксом длины в .NET / C#.

Первые 4 байта здесь — int в порядке big-endian, это длина тела.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 1 МиБ. Задайте по задаче

    public static async ValueTask<byte[]?> ReadFrameAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        byte[] header = new byte[HeaderSize];

        int headerBytes = await ReadUntilFullOrEndAsync(
            stream,
            header,
            cancellationToken);

        if (headerBytes == 0)
        {
            // Собеседник корректно завершил работу до начала следующего кадра, а не посередине текущего
            return null;
        }

        if (headerBytes != HeaderSize)
        {
            throw new EndOfStreamException("Frame header was truncated.");
        }

        int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);

        if (payloadLength < 0 || payloadLength > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Invalid payload length: {payloadLength} bytes.");
        }

        byte[] payload = new byte[payloadLength];

        int payloadBytes = await ReadUntilFullOrEndAsync(
            stream,
            payload,
            cancellationToken);

        if (payloadBytes != payloadLength)
        {
            throw new EndOfStreamException("Frame payload was truncated.");
        }

        return payload;
    }

    private static async ValueTask<int> ReadUntilFullOrEndAsync(
        Stream stream,
        Memory<byte> buffer,
        CancellationToken cancellationToken)
    {
        int totalRead = 0;

        while (totalRead < buffer.Length)
        {
            int read = await stream.ReadAsync(
                buffer[totalRead..],
                cancellationToken);

            if (read == 0)
            {
                break;
            }

            totalRead += read;
        }

        return totalRead;
    }
}

Использование выглядит так:

while (true)
{
    byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
        stream,
        cancellationToken);

    if (payload is null)
    {
        // Собеседник аккуратно закрыл соединение на границе кадра
        break;
    }

    await HandleMessageAsync(payload, cancellationToken);
}

В этой реализации не важно, сколько байт вернул каждый ReadAsync. Даже если данные приходят по одному байту, цикл крутится, пока заголовок и тело не будут дочитаны.

И наоборот: если в буфере приёма ОС уже лежат данные нескольких сообщений, вырезают только первый кадр по длине тела, а следующий читают на следующей итерации.

В актуальном .NET в части окружений доступны Stream.ReadExactly / ReadExactlyAsync. Тогда дочитывание нужного числа байт можно отдать стандартному API. Как приложение отличает нормальное завершение до начала кадра от аварийного завершения посередине — всё равно нужно продумать самим.

9. Пример отправки

Отправитель пишет в том же формате кадра.

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocolWriter
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024;

    public static async ValueTask WriteFrameAsync(
        Stream stream,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        if (payload.Length > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Payload is too large: {payload.Length} bytes.");
        }

        byte[] header = new byte[HeaderSize];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        await stream.WriteAsync(header, cancellationToken);
        await stream.WriteAsync(payload, cancellationToken);
    }
}

Здесь заголовок и тело пишут отдельными WriteAsync. И здесь легко возникнуть ещё одно заблуждение: даже если отправитель записал заголовок и тело двумя вызовами, получатель вовсе не обязан прочитать их двумя порциями.

Получатель может увидеть, например, так:

Read() => [4 байта заголовка + часть тела]
Read() => [остаток тела]

Или так:

Read() => [первые 2 байта заголовка]
Read() => [последние 2 байта заголовка + всё тело + заголовок следующего кадра]

Поэтому получатель ориентируется не на «сколько раз вызвали Read», а на «сколько байт удалось прочитать по формату кадра».

10. Если вызываете Socket.Send напрямую, смотрите возвращаемое значение и на отправке

NetworkStream.Write / WriteAsync в целом можно считать API, который записывает указанный диапазон целиком.

А вот у Socket.Send нужно смотреть возвращаемое значение.

Socket.Send возвращает «число байт, которые удалось отправить». Особенно на неблокирующих сокетах вызов может успешно завершиться, отправив меньше, чем просили.

Поэтому при прямом Socket.Send на стороне отправки тоже нужен цикл «повторять, пока всё не уйдёт».

using System.Net.Sockets;

public static async ValueTask SendAllAsync(
    Socket socket,
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken)
{
    while (!buffer.IsEmpty)
    {
        int sent = await socket.SendAsync(
            buffer,
            SocketFlags.None,
            cancellationToken);

        if (sent == 0)
        {
            throw new IOException("Socket was closed while sending data.");
        }

        buffer = buffer[sent..];
    }
}

«Удалось отправить» здесь не значит, что «приложение-собеседник обработало сообщение». Успех API отправки — не то же самое, что успешный ответ прикладного протокола.

Если по бизнесу нужно подтвердить, что «заказ принят», «файл сохранён» или «команда выполнена», в протоколе нужно определить ACK или ответное сообщение от приложения-собеседника. Успешной отправки по TCP для этого мало.

11. На что смотреть при разделителе

В текстовых протоколах иногда режут по переводу строки.

LOGIN komura secret\n
GET item-001\n
QUIT\n

Способ понятный и хорошо ложится на логи и командный формат.

Но нужно учесть следующее:

  • задать экранирование, если разделитель встречается внутри тела;
  • решить, как обрабатывать \r\n и \n;
  • задать максимальную длину строки;
  • не копить в памяти неограниченный объём, пока не придёт разделитель;
  • не ломать данные, если многобайтовый символ UTF-8 разрезан.

В частности, такого кода лучше избегать:

int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);

foreach (string line in text.Split('\n'))
{
    await HandleLineAsync(line, cancellationToken);
}

Он не учитывает ни того, что конец полученного диапазона может оказаться посередине строки, ни того, что разбиение может пройти посередине символа UTF-8.

Если режете по переводу строки, как минимум либо «копить байты, искать байт перевода строки и декодировать только целую строку», либо читать строки из потока API вроде StreamReader.ReadLineAsync.

Даже с StreamReader.ReadLineAsync стоит заранее продумать максимальную длину строки, тайм-ауты, отмену и закрытие соединения.

12. На что смотреть при фиксированной длине

В телеграммах фиксированной длины задают правило вроде «одно сообщение всегда ровно 128 байт». Такой способ встречается в старых учётных системах, системах управления и при обмене с оборудованием.

Идея та же.

Если

1 сообщение = 128 байт

получатель крутит цикл, пока не наберёт все 128 байт.

byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);

if (read != message.Length)
{
    throw new EndOfStreamException("Fixed-length message was truncated.");
}

await HandleMessageAsync(message, cancellationToken);

И здесь один ReadAsync не обязан вернуть все 128 байт.

Фиксированная длина проста, потому что границы очевидны. Минусы: неудобно работать с данными переменной длины, трудно расширять формат, приходится возиться с заполнением, а смена кодировки меняет число байт.

13. Отключение алгоритма Нейгла не решает проблему границ сообщений

Когда небольшой объём нужно отправить сразу, иногда рассматривают Socket.NoDelay = true. Это отключение алгоритма Нейгла.

Но NoDelay — про задержку отправки и эффективность, то есть про то, «как склеивать мелкие отправки». Это не настройка, которая «сохраняет порцию Send как порцию Receive».

Иначе говоря, даже при NoDelay = true никуда не деваются такие проблемы:

  • один Send режется на несколько Receive;
  • несколько Send склеиваются в один Receive;
  • разбиение проходит посередине символа;
  • получатель не может определить границы сообщений.

NoDelay имеет смысл как настройка задержки, но не заменяет фрейминг.

14. С TLS и SslStream идея та же

Если соединение закрыто TLS через SslStream, с точки зрения приложения обработка в целом та же.

В TLS есть внутренняя единица — TLS-запись (TLS record), но это не граница сообщения приложения.

Даже SslStream.ReadAsync не гарантирует, что за один раз вернётся ровно одно ожидаемое приложением сообщение.

Поэтому независимо от TLS на уровне приложения проектируют один из способов:

  • префикс длины;
  • разделитель, например перевод строки;
  • фиксированную длину;
  • формат существующего протокола.

TLS — слой шифрования и аутентификации, а не слой, который сам рисует границы сообщений.

15. Ошибки, которые стоит различать в цикле приёма

В приёме по TCP важно явно обрабатывать не только штатный сценарий, но и закрытие и обрыв посередине.

Если Read / Receive вернул 0, это, как правило, значит, что собеседник нормально закончил отправку.

На уровне прикладного протокола нужно различать два случая.

Состояние Как обрабатывать
0 байт получено до чтения следующего кадра В части случаев можно считать нормальным завершением
Завершение посередине заголовка или посередине тела кадра Незавершённая телеграмма, это ошибка

При префиксе длины рассуждать можно так:

разрыв на границе кадра:
  можно считать нормальным завершением

разрыв после 2 из 4 байт заголовка:
  ошибка протокола

разрыв после 60 из заявленных 100 байт тела:
  ошибка протокола

Если заложить такое различение заранее, разбор по логам становится заметно проще.

Вместо просто «собеседник отключился» можно вывести

Frame payload was truncated. expected=100 actual=60

и тогда легче подозревать аварийное завершение на стороне собеседника, тайм-аут или несовпадение протокола.

16. Обязательно задайте максимальный размер

При префиксе длины в начале стоит длина тела.

Опасность в том, что собеседник может указать огромную длину.

FF FF FF FF

Если взять это значение напрямую при выделении массива, приложение попытается выделить огромный объём памяти и станет нестабильным.

Поэтому получатель обязан задать максимум.

private const int MaxPayloadSize = 1024 * 1024;

if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
    throw new InvalidDataException(
        $"Invalid payload length: {payloadLength} bytes.");
}

Максимум задают по бизнес-требованиям. Для команд может хватить 64 КиБ. Если передаёте изображения или файлы, стоит подумать о другом способе передачи или о потоковой передаче. Важно не проектировать систему так, будто она «теоретически принимает данные любого объёма».

17. В строковых протоколах смотрите на число байт, а не на число символов

TCP перевозит не строки, а байты.

Поэтому в префиксе длины обычно кладут не «число символов», а «число байт».

Возьмём такую строку в UTF-8:

こんにちは

Это 5 символов, но в UTF-8 — 15 байт.

Если в протоколе указать длину 5, получатель прочитает только 5 байт тела, и результат обрежется посередине символа.

Отправитель всегда считает длину по закодированному массиву байтов.

string json = "{\"message\":\"こんにちは\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);

await LengthPrefixedProtocolWriter.WriteFrameAsync(
    stream,
    payload,
    cancellationToken);

Получатель дочитывает тело кадра как байты и только потом превращает его обратно в строку.

byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
    stream,
    cancellationToken);

if (payload is not null)
{
    string json = Encoding.UTF8.GetString(payload);
    await HandleJsonAsync(json, cancellationToken);
}

При таком порядке не важно, что Read может разрезать данные посередине символа UTF-8.

18. Следите и за смешиванием на уровне приложения из‑за параллельной записи

Ещё один момент, который часто упускают, — параллельная запись.

Несколько задач одновременно пишут кадры в одно и то же TCP-соединение.

_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);

Без контроля на уровне приложения может получиться такое смешивание:

заголовок A
заголовок B
тело A
тело B

Получатель, прочитав заголовок A, ждёт тело A. Когда в этот момент вклинивается заголовок B, протокол ломается.

Поэтому безопаснее сериализовать запись в одно соединение. Например, SemaphoreSlim или очередь на отправку, чтобы записи на уровне кадра не перемешивались.

private readonly SemaphoreSlim _sendLock = new(1, 1);

public async ValueTask SendFrameSafelyAsync(
    Stream stream,
    byte[] payload,
    CancellationToken cancellationToken)
{
    await _sendLock.WaitAsync(cancellationToken);

    try
    {
        await LengthPrefixedProtocolWriter.WriteFrameAsync(
            stream,
            payload,
            cancellationToken);
    }
    finally
    {
        _sendLock.Release();
    }
}

TCP сохраняет порядок байтов. Но если приложение само смешало байты из нескольких задач, TCP добросовестно доставит именно этот смешанный порядок.

19. В тестах намеренно провоцируйте разбиение и склейку

Если тестировать приём по TCP «как обычно», легко пропустить состояние «случайно работает».

Поэтому в тестах намеренно делают такие сценарии:

Что проверяют Пример
Данные приходят по одному байту И заголовок, и тело читаются Read по одному байту
Обрыв посередине заголовка Из 4 байт заголовка приходят только 2, затем соединение закрывается
Обрыв посередине тела Из заявленных 100 байт тела приходят только 60, затем соединение закрывается
Склейка нескольких кадров Два кадра оказываются в одном внутреннем буфере
Указан огромный размер Отправляют длину тела больше допустимого максимума
Тело нулевой длины Проверяют, допускается ли длина тела 0
Разбиение UTF-8 Последовательность байт японского текста или эмодзи режется посередине символа

В модульных тестах реальные TCP-сокеты не нужны. Если подменить Stream на «поток, который читается только заданными порциями», логику приёма проверять заметно проще.

Как воспроизвести разбиение: обернуть Stream

Отдельная библиотека не нужна. Достаточно унаследовать Stream и ограничить число байт, которое возвращает Read.

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

// Поток, который за один Read возвращает не больше maxChunkSize байт.
// Только оборачивает внутренний поток, тестовый код приёма не трогает.
public sealed class ChunkedReadStream : Stream
{
    private readonly Stream _inner;
    private readonly int _maxChunkSize;

    public ChunkedReadStream(Stream inner, int maxChunkSize)
    {
        if (inner is null) throw new ArgumentNullException(nameof(inner));
        if (maxChunkSize < 1) throw new ArgumentOutOfRangeException(nameof(maxChunkSize));

        _inner = inner;
        _maxChunkSize = maxChunkSize;
    }

    public override int Read(byte[] buffer, int offset, int count)
        => _inner.Read(buffer, offset, Math.Min(count, _maxChunkSize));

    public override ValueTask<int> ReadAsync(
        Memory<byte> buffer,
        CancellationToken cancellationToken = default)
        => _inner.ReadAsync(
            buffer[..Math.Min(buffer.Length, _maxChunkSize)],
            cancellationToken);

    public override bool CanRead => true;
    public override bool CanSeek => false;
    public override bool CanWrite => false;
    public override long Length => throw new NotSupportedException();

    public override long Position
    {
        get => throw new NotSupportedException();
        set => throw new NotSupportedException();
    }

    public override void Flush() { }
    public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
    public override void SetLength(long value) => throw new NotSupportedException();
    public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}

С этим обёрткой и «по одному байту», и «два кадра за один Read» пишутся одним и тем же тестом с разными аргументами.

using System.Buffers.Binary;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Xunit;

public class LengthPrefixedProtocolTests
{
    // Тестирует LengthPrefixedProtocol.ReadFrameAsync из главы 8
    [Theory]
    [InlineData(1)]      // и заголовок, и тело приходят по одному байту
    [InlineData(3)]      // разрез посередине заголовка
    [InlineData(1024)]   // два кадра приходят сразу
    public async Task ДваКадраВосстанавливаютсяПриЛюбомРазмереПорции(int chunkSize)
    {
        using var source = new MemoryStream();
        WriteFrame(source, "HELLO");
        WriteFrame(source, "ABC");
        source.Position = 0;

        using var stream = new ChunkedReadStream(source, chunkSize);

        byte[]? first = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);
        byte[]? second = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);
        byte[]? afterLast = await LengthPrefixedProtocol.ReadFrameAsync(
            stream, CancellationToken.None);

        Assert.NotNull(first);
        Assert.NotNull(second);
        Assert.Equal("HELLO", Encoding.UTF8.GetString(first!));
        Assert.Equal("ABC", Encoding.UTF8.GetString(second!));
        Assert.Null(afterLast); // нормальное завершение на границе кадра
    }

    private static void WriteFrame(Stream destination, string text)
    {
        byte[] payload = Encoding.UTF8.GetBytes(text);
        byte[] header = new byte[4];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        destination.Write(header, 0, header.Length);
        destination.Write(payload, 0, payload.Length);
    }
}

Обрыв посередине тоже воспроизводят через MemoryStream: пишут неполный кадр. Например, вместо WriteFrame записать только 2 из 4 байт заголовка — это «обрыв посередине заголовка», и можно проверить, что ReadFrameAsync бросает EndOfStreamException.

Как вызвать разбиение через loopback

Если интеграционный тест должен идти через настоящий TCP, на отправке кадр намеренно пишут двумя порциями и между ними ждут.

using System;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

public static class SplitSender
{
    // Отправляет frame, разрезав его на firstChunkSize байт.
    // Без NoDelay алгоритм Нейгла может склеить первую и вторую
    // половины в один сегмент, и разбиения не будет.
    public static async Task SendSplitAsync(
        TcpClient client,
        byte[] frame,
        int firstChunkSize,
        CancellationToken cancellationToken)
    {
        if (client is null) throw new ArgumentNullException(nameof(client));
        if (frame is null) throw new ArgumentNullException(nameof(frame));
        if (firstChunkSize < 1 || firstChunkSize >= frame.Length)
        {
            throw new ArgumentOutOfRangeException(nameof(firstChunkSize));
        }

        client.NoDelay = true;
        NetworkStream stream = client.GetStream();

        await stream.WriteAsync(frame.AsMemory(0, firstChunkSize), cancellationToken);
        await Task.Delay(50, cancellationToken);
        await stream.WriteAsync(frame.AsMemory(firstChunkSize), cancellationToken);
    }
}

Если передать в firstChunkSize значение 2, получите «разрез посередине 4-байтового заголовка». Если передать длину тела + 2 — «разрез посередине тела».

Это не гарантия разбиения по спецификации TCP. Это только условие, при котором на практике границы почти наверняка разъедутся. Тесты, которым нужна предсказуемость, держите на подмене Stream, а не на сокетах.

Интеграционные тесты через настоящий TCP тоже нужны. Но если сначала вынести парсер приёма в чистую работу со Stream, тестировать становится намного удобнее.

Качество сетевого кода меряют не тем, «работает ли обычная отправка», а тем, «ведёт ли он себя как задумано при разбиении, при склейке и при обрыве посередине».

20. Как наблюдать баг, который «ломается только в продакшене»

Баг фрейминга на разработке часто не проявляется, а в продакшене всплывает нерегулярно. Когда растёт объём данных, когда канал становится медленнее, когда меняется реализация собеседника, картина разбиения меняется впервые.

Первое, что стоит решить: ломается отправленная последовательность байтов или восстановление на приёме. Уже одно это деление режет область поиска пополам.

Три средства наблюдения используют по-разному.

Средство Что даёт На что смотреть
Журнал приложения Ожидаемое и фактически прочитанное число байт, длина вырезанного кадра, момент закрытия Это первое, что стоит завести. Всегда пишите и expected, и actual. Одного «не хватило» мало
Wireshark Байты, которые реально прошли по сети, границы TCP-сегментов, повторные передачи, наличие RST Чтобы на Windows смотреть loopback (127.0.0.1), нужен Npcap. В Wireshark 3.0.0 и новее в списке интерфейсов выбирайте «Adapter for loopback traffic capture»
pktmon Захват штатными средствами Windows. Полученный ETL можно превратить в pcapng и открыть в Wireshark pktmon.exe входит в состав начиная с Windows 10 build 19041

Открыв захват, смотрите в таком порядке:

  1. Follow > TCP Stream — восстановить последовательность байтов. Проверьте, что 4 байта префикса длины имеют ожидаемое значение.
  2. Если ожидаемое значение на месте, отправитель собрал кадр правильно. Проблема в восстановлении на приёме.
  3. Если значение не то, подозревайте сборку кадра на отправке или смешивание из‑за параллельной записи из главы 18.
  4. Если есть RST или обрыв посередине, проверьте, как получатель обрабатывает закрытие посередине кадра.

Есть ещё одна путаница.

Граница TCP-сегмента тоже не граница сообщения приложения. Если в Wireshark одно сообщение случайно уместилось в один сегмент — это совпадение. Единица, которую приложение получает через Read, не обязана совпадать и с границей сегмента. Захват нужен, чтобы увидеть «байты, которые реально прошли», а не чтобы читать «вот здесь одно сообщение».

Кроме того, захват на хосте отправителя из‑за сегментации на стороне NIC (LSO / TSO) может записать пакет больше MTU. Это не тот вид, в котором сегменты шли по проводу. Если важны сами размеры сегментов, снимайте на приёме или на промежуточном хосте либо временно отключайте offload.

21. Чек-лист, когда правите существующий код

Когда смотрите существующий код TCP-обмена, проблемы легче найти по таким пунктам.

Аспект Что проверить
Единица приёма Не трактуется ли один Read / Receive как одно сообщение?
Возвращаемое значение Всегда ли используется число байт, которое вернул Read / Receive?
Накопление Копят ли байты, пока не соберётся целое сообщение?
Границы Есть ли правило: фиксированная длина, разделитель, префикс длины?
Кодировка Не превращают ли данные в строку до завершения сообщения?
Максимальная длина Ограничены ли длина сообщения и длина строки?
Закрытие Различают ли закрытие на границе кадра и обрыв посередине?
Отправка Не игнорируется ли возвращаемое значение Socket.Send?
Параллелизм Не могут ли перемешаться записи нескольких задач в одно соединение?
Журнал Можно ли вывести ожидаемое и фактическое число байт?
Тесты Есть ли тесты на разбиение, склейку и обрыв посередине?

Особенно опасен такой вид:

int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);

Проблем сразу несколько:

  • значение read не используется;
  • в строку превращается весь буфер;
  • один Receive трактуется как одно сообщение;
  • границ сообщения нет;
  • разбиение посередине символа не учитывается.

Как минимум нужно перейти к такой схеме:

В буфер приёма добавить только те read байт, что вернул Receive
  ↓
Проверить, можно ли вырезать из буфера один кадр по протоколу
  ↓
Если можно — обработать
  ↓
Остаток сохранить как начало следующего кадра
  ↓
Если данных не хватает — ждать следующего Receive

22. Итог

В TCP нельзя полагаться на то, что Receive вернёт данные теми же порциями, которыми их отправили через Send. Это не исключение, а базовая особенность работы с TCP.

Что стоит закрепить:

  • TCP даёт не сообщения, а упорядоченный байтовый поток;
  • порция вызова Send / Write не сохраняется как порция Receive / Read;
  • одна отправка может разбиться на несколько приёмов, несколько отправок — склеиться в один приём;
  • получатель сам задаёт границы сообщений в прикладном протоколе;
  • для своего протокола чаще всего удобнее префикс длины;
  • в проектирование стоит заложить цикл дочитывания нужного числа байт, максимальный размер, обрыв посередине, кодировку и параллельную запись;
  • NoDelay и DataAvailable не заменяют границы сообщений;
  • когда ломается только в продакшене, сначала по захвату отделите «байты отправителя» от «восстановления на приёме».

Сетевая обработка кажется простой, если смотреть только на штатный сценарий. Стабильный обмен получается только когда решено, «где проводить границу», «как ждать, если данных не хватает», «как сохранять остаток, если данных слишком много» и «как поступать при обрыве посередине».

Если вы используете TCP, Receive возвращает не сообщение, а лишь часть последовательности байт. Собрать из этого сообщение — ответственность проектирования прикладного протокола.

Справочные материалы

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Как создать и эксплуатировать службу Windows — от выбора между Планировщиком заданий и службой до превращения BackgroundService в службу

Стоит ли держать постоянно работающую обработку как службу Windows или хватит Планировщика заданий. Практический разбор: таблица выбора, ...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Почему в TCP нельзя рассчитывать, что Receive вернёт данные теми же порциями, которыми их отправили через Send?
TCP гарантирует, что отправленная последовательность байтов дойдёт по порядку, без дубликатов и без потерь. Он не гарантирует, что порция, переданная в Send, сохранится как порция, которую вернёт Receive. TCP перевозит не сообщения, а непрерывный поток байтов, поэтому один Send может разбиться на несколько Receive, а несколько Send — склеиться в один Receive. Это нормальное поведение. На стороне приёма нужен свой механизм границ сообщений — фрейминг.
Какие есть способы фрейминга (как задать границы сообщений) в TCP?
Четыре основных. Фиксированная длина: одно сообщение всегда занимает заданное число байт. Разделитель: сообщение читают до заданной последовательности байт, например перевода строки. Префикс длины: в начале стоит длина тела, затем читают ровно столько байт. Самоописывающийся формат: длина или конец заданы самим форматом, как Content-Length в HTTP. Если проектируете свой протокол, в первую очередь стоит рассмотреть префикс длины: в тело можно класть произвольные двоичные данные, и максимальный размер ограничить проще.
Решает ли Socket.NoDelay = true проблему разбиения и склейки в TCP?
Нет. NoDelay отключает алгоритм Нейгла и относится к задержке и эффективности мелких отправок, а не к сохранению порций Send как порций Receive. Даже при NoDelay = true один Send может разбиться на несколько Receive, несколько Send — склеиться в один Receive, а разбиение может пройти посередине символа. NoDelay не заменяет фрейминг.
Почему при приёме по TCP портится текст?
В UTF-8 один символ может занимать несколько байт, и нет гарантии, что граница Read совпадёт с границей символа. Если каждый раз сразу вызывать Encoding.UTF8.GetString, результат повреждается, когда разбиение попало внутрь многобайтового символа. Нужно копить байты, пока не станет известна граница сообщения, и декодировать только целое сообщение. Длину тела в префиксе длины считают не в символах, а в байтах после кодирования.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог