Заблуждение, что TCP отдаёт данные через Receive такими же порциями, какими их отправили через Send — как проектировать приём, работая с байтовым потоком

· · TCP, Socket, Сети, .NET, C#, Проектирование протоколов, Эксплуатация, Использование существующих активов

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 гарантирует, что «отправленная последовательность байтов дойдёт по порядку, без дублирования и без потерь». Чего TCP не гарантирует - так это того, что «единица, которую приложение передало в Send, сохранится как единица, которую получатель получит через Receive».

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

Это называется фреймингом прикладного протокола.

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

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

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

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("あ") Многобайтовый символ может быть разбит посередине

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

Многие баги, которые выглядят как «данные иногда теряются», «несколько сообщений склеиваются» или «текст искажается», - это не сбой 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

В этом случае попытка распарсить только «Получено1» как JSON завершится ошибкой.

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

Отправлено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. сначала полностью прочитать 4 байта;
  2. извлечь из этих 4 байт длину тела;
  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. И здесь легко возникает ещё одно заблуждение: даже если отправитель записал заголовок и тело двумя отдельными вызовами 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 на «поток, который читается только заданными порциями», логику приёма становится значительно проще проверять.

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

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

20. Чек-лист для проверки существующего кода

При проверке существующего кода 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

21. Итог

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

Ключевые моменты:

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

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

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

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

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

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

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

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

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

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

Об авторе

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

Го Комура

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

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

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

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