TCP на практике: сокеты, TcpClient и TcpListener, фрейминг

↑ 1.1 Сети и протоколы для бэкенда · ← Предыдущая · → Следующая

Обязательно~15 мин чтенияУровень: junior

Зачем это на собесе

«Как отправить запрос по TCP?» и «Почему сообщение пришло обрезанным или склеенным?» — вопросы, на которых ломаются даже те, кто годами пишет HTTP-контроллеры. Весь HTTP, gRPC, Redis и PostgreSQL лежат на TCP. Кто понимает, что TCP — это поток байтов без границ сообщений, тот понимает и фрейминг, и таймауты, и причины зависших соединений.

Подтемы

  • Что такое TCP-соединение для программы
  • Жизненный цикл клиента и сервера
  • Поток байтов и проблема границ сообщений
  • Фрейминг: разделитель, длина, фиксированный размер
  • Клиент и сервер на C♯
  • Таймауты, закрытие, ошибки

Объяснение

Что такое TCP-соединение для программы

TCP даёт два конца надёжного упорядоченного потока байтов. Ядро ОС само делает рукопожатие, нумерацию сегментов, повторную отправку потерянных и контроль перегрузки. Программа видит только сокет: пишет в него байты и читает байты.

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

клиент 10.0.0.5:51234  ──────────►  сервер 10.0.0.9:5050
клиент 10.0.0.5:51235  ──────────►  сервер 10.0.0.9:5050   (другое соединение)
клиент 10.0.0.7:40000  ──────────►  сервер 10.0.0.9:5050   (другой клиент)

Жизненный цикл

Клиент: connect → обмен → close. Сервер: bind (занять порт) → listen (принимать подключения) → accept (получить соединение) → обмен → close. accept для каждого клиента создаёт новый сокет, а слушающий остаётся.

sequenceDiagram
    participant C as Клиент
    participant S as Сервер
    Note over S: bind + listen (порт 5050)
    C->>S: SYN
    S->>C: SYN-ACK
    C->>S: ACK
    Note over C,S: connect вернулся у клиента,<br/>accept вернулся у сервера
    C->>S: байты запроса
    S->>C: байты ответа
    C->>S: FIN (клиент закончил писать)
    S->>C: ACK, затем FIN
    C->>S: ACK
    Note over C: TIME_WAIT ~60 с

В C♯ эти системные вызовы спрятаны за двумя классами:

ЗадачаКлассКлючевые методы
ПодключитьсяTcpClientConnectAsync, GetStream
СлушатьTcpListenerStart, AcceptTcpClientAsync
Читать и писатьNetworkStreamReadAsync, WriteAsync
Тонкая настройкаSocket (client.Client)Shutdown, SetSocketOption

Главная ловушка: TCP не знает про «сообщения»

Один Write на одной стороне не равен одному Read на другой. Отправили "Привет" и "Мир" двумя записями, получатель может прочитать "ПриветМир" целиком, "Прив" и "етМир" или три куска. Сеть режет и склеивает поток как ей удобно (MSS, буферы, алгоритм Нэйгла).

flowchart LR
    A["Отправитель:<br/>Write('Привет')<br/>Write('Мир')"] --> B(("Поток байтов TCP<br/>ПриветМир"))
    B --> C["Получатель:<br/>Read → 'Прив'"]
    B --> D["Read → 'етМи'"]
    B --> E["Read → 'р'"]

Поэтому ReadAsync возвращает сколько прочиталось (может быть меньше запрошенного), а 0 значит «собеседник закрыл соединение». Границы сообщений придумывает протокол поверх TCP.

Фрейминг: как отделить сообщения

СпособИдеяГде встречается
Разделительсообщение кончается \r\n или \nHTTP-заголовки, SMTP, Redis (RESP), FTP
Префикс длинысначала 4 байта длины, потом телоKafka, PostgreSQL, gRPC (5-байтовый заголовок), большинство бинарных протоколов
Фиксированная длинавсе сообщения по N байтпростые железные протоколы, игры
Закрытие соединенияконец данных = FINстарый HTTP/1.0, разовая передача

Кадр с префиксом длины (big-endian, то есть «сетевой порядок» байтов):

┌───────────────┬──────────────────────────────┐
│ длина, 4 байта│ тело (ровно «длина» байт)    │
│ 00 00 00 0B   │ "второй кадр" (11 байт UTF-8)│
└───────────────┴──────────────────────────────┘

Плюс префикса длины: тело может содержать любые байты, включая переводы строк. Минус: нужно проверять максимальную длину, иначе чужой клиент пришлёт 0x7FFFFFFF и заставит выделить гигабайты.

Примеры кода

Эхо-сервер: сообщения по строкам

using System.Net;
using System.Net.Sockets;
using System.Text;
 
var listener = new TcpListener(IPAddress.Loopback, 5050);
listener.Start();
Console.WriteLine("Слушаю 127.0.0.1:5050");
 
while (true)
{
    var client = await listener.AcceptTcpClientAsync();
    _ = Task.Run(() => HandleAsync(client));   // каждый клиент в своей задаче
}
 
static async Task HandleAsync(TcpClient client)
{
    using var _ = client;                      // Dispose закроет сокет
    await using var stream = client.GetStream();
    using var reader = new StreamReader(stream, Encoding.UTF8);
    await using var writer = new StreamWriter(stream, new UTF8Encoding(false)) { NewLine = "\n", AutoFlush = true };
    try
    {
        // ReadLineAsync сам собирает кусочки потока в строку до \n
        while (await reader.ReadLineAsync() is { } line)
            await writer.WriteLineAsync($"echo: {line}");
    }
    catch (IOException) { /* клиент оборвал соединение */ }
}

Клиент

using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", 5050);
client.NoDelay = true;                          // отключить алгоритм Нэйгла для мелких сообщений
 
var stream = client.GetStream();
await stream.WriteAsync(Encoding.UTF8.GetBytes("привет\nмир\n"));
 
using var reader = new StreamReader(stream, Encoding.UTF8);
Console.WriteLine(await reader.ReadLineAsync());   // echo: привет
Console.WriteLine(await reader.ReadLineAsync());   // echo: мир

Кадры с префиксом длины

using System.Buffers.Binary;
 
static async Task WriteFrameAsync(Stream s, byte[] payload, CancellationToken ct)
{
    var header = new byte[4];
    BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);
    await s.WriteAsync(header, ct);
    await s.WriteAsync(payload, ct);
}
 
static async Task<byte[]?> ReadFrameAsync(Stream s, CancellationToken ct)
{
    var header = new byte[4];
    try { await s.ReadExactlyAsync(header, ct); }          // читает ровно 4 байта, сколько бы кусков ни пришло
    catch (EndOfStreamException) { return null; }          // чисто закрыли между сообщениями
 
    var length = BinaryPrimitives.ReadInt32BigEndian(header);
    if (length is < 0 or > 1_000_000)                      // защита от чужих длин
        throw new InvalidDataException($"Недопустимая длина кадра: {length}");
 
    var body = new byte[length];
    await s.ReadExactlyAsync(body, ct);                    // оборвали посреди кадра → EndOfStreamException
    return body;
}

ReadExactlyAsync (с .NET 7) как раз решает проблему «Read вернул меньше»: он читает в цикле, пока не наберёт нужное количество байт.

Как делать неправильно

var buf = new byte[1024];
var n = await stream.ReadAsync(buf);           // думаем: «это одно сообщение»
var msg = Encoding.UTF8.GetString(buf, 0, n);  // на деле: кусок, или два сообщения подряд, или половина символа UTF-8

Работает на localhost и в тестах, ломается под нагрузкой и в реальной сети.

Таймаут на чтение

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { var n = await stream.ReadAsync(buf, cts.Token); }
catch (OperationCanceledException) { /* собеседник молчит больше 5 секунд */ }

Корректное закрытие

client.Client.Shutdown(SocketShutdown.Send);   // «я всё отправил» (отправит FIN), но читать ещё можно
// дочитать ответ до конца (ReadAsync вернёт 0), затем Dispose

Нюансы и подводные камни

  • Частичное чтение. Никогда не считайте, что ReadAsync вернул целое сообщение. Используйте ReadExactlyAsync, StreamReader.ReadLineAsync или буфер, в который докладываются байты, пока кадр не наберётся.
  • ReadAsync вернул 0 — это конец соединения, а не «пока нет данных». Если цикл не выходит по нулю, получите бесконечный цикл и 100 % CPU.
  • Порядок байтов. В сети принят big-endian. BitConverter использует порядок процессора (обычно little-endian), поэтому для протоколов берите BinaryPrimitives.
  • Кодировка. Длину считайте в байтах, не в символах: «привет» это 6 символов, но 12 байт в UTF-8.
  • Таймауты обязательны. Без них зависший собеседник держит соединение вечно. Ставьте таймаут на подключение, на чтение, а на сервере ещё и на простой между сообщениями.
  • Алгоритм Нэйгла и NoDelay. Ядро копит мелкие записи и отправляет пачкой. Для интерактивных протоколов с маленькими сообщениями включают NoDelay = true.
  • Keep-alive. Молча пропавшего собеседника (кабель выдернули) без SocketOptionName.KeepAlive не заметить: сокет считает соединение живым.
  • Лимиты. Чужие длины кадров, число соединений и размер очередей всегда ограничивайте.
  • TIME_WAIT и порты. Активно закрывающая сторона несколько десятков секунд держит порт в TIME_WAIT. Тысячи коротких соединений подряд могут исчерпать эфемерные порты клиента. Решение: переиспользовать соединения (пул, keep-alive).
  • Привязка адреса. IPAddress.Loopback принимает только локальных, IPAddress.Any (0.0.0.0) всех. В контейнере слушать надо Any, иначе извне не достучаться.
  • Ошибки. SocketException (connection refused, reset, timeout) и IOException при обрыве. ConnectionReset (RST) означает, что собеседник сбросил соединение, а не закрыл по-хорошему.
  • В продакшене редко нужны голые сокеты. Для HTTP берите HttpClient, для RPC gRPC. Но сокеты нужны для своего протокола, работы с устройствами и отладки.

Вопросы с ответами

Связанные темы