Сетевое программирование на C# для backend-разработчика и сетевого инженера

Курс системно развивает навыки сетевого программирования в C# с опорой на ваш опыт backend-разработки и сетевых технологий. Вы разберёте TCP/UDP, сокеты и I/O, современные подходы (async/await), прикладные протоколы и практики надёжности, безопасности и диагностики в продакшене.

1. Основы сетевого взаимодействия: модели, адресация, порты и сокеты

Основы сетевого взаимодействия: модели, адресация, порты и сокеты

Эта статья задаёт общий язык для всего курса: что именно происходит, когда ваш backend-сервис принимает запрос, открывает соединение к базе данных или отправляет метрики в UDP. Мы разберём модели сетевого взаимодействия, типы адресации, порты и то, что на практике означает слово сокет в контексте ОС и C#.

Зачем backend-разработчику и сетевому инженеру одинаковые базовые понятия

В сетевом программировании вы постоянно пересекаете границу между:

  • логикой приложения (протокол, сериализация, обработка ошибок);
  • механикой транспорта (TCP/UDP, буферы, таймауты);
  • поведением ОС и сети (маршрутизация, NAT, MTU, firewall, очереди, состояния TCP).
  • Если эти уровни смешивать, возникают типичные проблемы: «почему на локалхосте работает, а в Kubernetes нет», «почему иногда теряются UDP-пакеты», «почему порт занят после рестарта».

    Модели сетевого взаимодействия: OSI и TCP/IP

    Модель OSI

    Модель OSI (7 слоёв) удобна как карта, чтобы понимать, на каком уровне находится проблема.

  • L1 Физический: сигнал по среде.
  • L2 Канальный: MAC-адреса, кадры, коммутаторы.
  • L3 Сетевой: IP-адреса, маршрутизация.
  • L4 Транспортный: TCP/UDP, порты.
  • L5 Сеансовый: управление сеансом (в реальности часто часть приложений/библиотек).
  • L6 Представления: кодировки, шифрование как преобразование данных.
  • L7 Прикладной: HTTP, DNS, SMTP и ваши протоколы.
  • Стек TCP/IP

    В реальных ОС и протоколах чаще говорят о TCP/IP (упрощённо 4–5 уровней). Соотнесём модели:

    | OSI | TCP/IP (практический стек) | Примеры | |---|---|---| | L1–L2 | Link (канальный) | Ethernet, Wi‑Fi, VLAN | | L3 | Internet | IPv4/IPv6, ICMP | | L4 | Transport | TCP, UDP | | L5–L7 | Application | HTTP, DNS, TLS, SSH |

    В этом курсе мы в основном работаем на L4–L7, но постоянно учитываем L2–L3, потому что адресация, маршруты и MTU напрямую влияют на поведение приложений.

    Инкапсуляция: что во что «заворачивается»

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

    !Визуально показывает, что адреса и порты находятся на разных уровнях и идут в разных заголовках

    Адресация: MAC, IP, имена (DNS)

    MAC-адрес (канальный уровень)

    MAC-адрес используется для доставки кадра внутри одного L2-сегмента (обычно одной VLAN/подсети на коммутаторах).

  • MAC актуален только в пределах канального домена.
  • При выходе трафика за пределы L2-сегмента (через маршрутизатор) MAC-адреса меняются на каждом следующем L2-хопе.
  • Связка IP→MAC обычно узнаётся через ARP (для IPv4) или NDP (для IPv6).

    IP-адрес (сетевой уровень)

    IP-адрес нужен для маршрутизации между сетями.

  • IPv4: 32 бита, записи вида 192.0.2.10.
  • IPv6: 128 бит, записи вида 2001:db8::10.
  • Ключевые идеи:

  • IP-адрес описывает узел в сети, а не конкретный процесс.
  • Маршрутизация выбирает путь до сети назначения, а доставка внутри сети назначения уже опирается на L2.
  • Полезные источники:

  • RFC 791 (Internet Protocol)
  • RFC 8200 (IPv6)
  • Имена и DNS (прикладной уровень)

    Люди и приложения часто используют имя хоста (например, api.company.local) вместо IP. DNS сопоставляет имя с адресами.

    Практически важно:

  • Одно имя может возвращать несколько A/AAAA записей (балансировка, отказоустойчивость).
  • Ваш клиент может выбрать IPv6 вместо IPv4 (или наоборот), и это влияет на маршрут и доступность.
  • Источник:

  • RFC 1034 (Domain Names — Concepts and Facilities)
  • Сводная таблица: что чем адресуется

    | Что вы хотите найти | Чем обычно адресуют | Где живёт | |---|---|---| | Соседний узел в одной сети L2 | MAC | Ethernet/Wi‑Fi кадр | | Узел в другой сети | IP | IP пакет | | Конкретный сервис/процесс на узле | Порт + протокол (TCP/UDP) | TCP сегмент / UDP датаграмма | | Удобное человеко-читаемое имя | DNS-имя | DNS/конфигурация |

    Порты: как на одном IP живут тысячи сервисов

    Порт — это число, которое идентифицирует конечную точку на транспортном уровне (TCP или UDP) внутри хоста.

    Важно сразу зафиксировать два факта:

  • Порты существуют отдельно для TCP и для UDP. TCP/53 и UDP/53 — разные вещи.
  • В реальности «адрес сервиса» — это не просто порт, а endpoint.
  • Endpoint и 5‑tuple

    Соединение (или поток обмена) в сети обычно идентифицируется набором:

  • src IP
  • src port
  • dst IP
  • dst port
  • протокол (TCP или UDP)
  • Этот набор часто называют 5‑tuple. Он объясняет, почему множество клиентов могут одновременно подключаться к одному dst IP:dst port: у каждого клиента будет уникальная пара src IP:src port.

    Диапазоны портов

    На практике полезно помнить классику:

  • 0–1023: well-known ports (часто требуют привилегий в Unix-подобных ОС).
  • 1024–49151: registered.
  • 49152–65535: dynamic/private (часто используются как ephemeral порты клиента).
  • Эфемерный (ephemeral) порт — это временный исходящий порт, который ОС выбирает автоматически для клиентского соединения.

    TCP и UDP: два базовых транспорта

    TCP

    TCP даёт:

  • установление соединения (состояние на обеих сторонах);
  • надёжную доставку (повторы, подтверждения);
  • упорядоченность байтового потока;
  • управление перегрузкой.
  • Цена:

  • больше накладных расходов;
  • возможны задержки из-за ретрансмитов;
  • важны состояния соединения (например, TIME_WAIT).
  • Источник:

  • RFC 793 (Transmission Control Protocol)
  • UDP

    UDP даёт:

  • минимальные накладные расходы;
  • модель датаграмм (сообщений), а не непрерывного потока;
  • отсутствие соединения как состояния протокола.
  • Цена:

  • нет гарантий доставки, порядка, уникальности;
  • вам нужно самим продумывать повтор, порядок, дедупликацию, если это требуется.
  • Источник:

  • RFC 768 (User Datagram Protocol)
  • Что такое сокет в ОС и в C#

    Сокет — это объект (дескриптор) в операционной системе, через который приложение отправляет и получает данные по сети.

    Два взгляда на сокет

  • Для backend-разработчика сокет — это API для I/O: открыть, подключиться, читать, писать.
  • Для сетевого инженера сокет — это конечная точка L4 на хосте, связанная с локальным адресом/портом и состояниями TCP.
  • Основные операции с сокетами

    Для TCP сервера типичный жизненный цикл:

  • bind к локальному IP:port.
  • listen — перейти в режим ожидания подключений.
  • accept — принять конкретного клиента и получить новый сокет для обмена с ним.
  • Для TCP клиента:

  • создать сокет;
  • connect к dst IP:dst port;
  • обмениваться данными.
  • Для UDP:

  • можно bind (чтобы принимать на фиксированный порт);
  • можно отправлять без явного соединения, указывая адрес назначения.
  • Какие классы C# используются на практике

    В .NET есть несколько уровней абстракции:

  • System.Net.Sockets.Socket — низкоуровневый доступ.
  • TcpListener/TcpClient — удобные обёртки для TCP.
  • UdpClient — удобная обёртка для UDP.
  • Документация:

  • System.Net.Sockets namespace
  • TcpListener
  • TcpClient
  • UdpClient
  • Минимальные примеры на C#: TCP и UDP

    Примеры ниже намеренно простые: их цель — связать в голове IP, порт, транспорт и сокет.

    TCP: мини-echo сервер

    Что важно заметить:

  • Сервер привязан к 127.0.0.1:5000, то есть доступен только локально.
  • AcceptTcpClientAsync создаёт отдельное TCP-соединение и отдельный объект для клиента.
  • TCP — это поток байт: границ сообщений нет, их нужно проектировать протоколом (к этому вернёмся в следующих темах).
  • TCP: клиент, который отправляет строку и читает эхо

    UDP: приёмник на порту

    UDP: отправитель

    Что важно заметить:

  • UDP работает сообщениями: один SendAsync соответствует одной датаграмме.
  • Потери, дубликаты и изменение порядка теоретически возможны всегда.
  • Типовые ошибки, которые важно понять с самого начала

    Неправильный bind-адрес

    Частые варианты:

  • 127.0.0.1 или ::1: доступно только с локальной машины.
  • 0.0.0.0 или ::: слушать на всех интерфейсах (вы становитесь доступнее, но растут требования к firewall и безопасности).
  • Порт занят и TIME_WAIT

    Вы можете увидеть «Address already in use» даже после остановки сервиса.

    Причины:

  • другой процесс уже слушает этот порт;
  • предыдущие TCP-соединения ещё в состоянии TIME_WAIT (нормально для TCP).
  • UDP «не доходит»

    Частые причины:

  • пакет блокирует firewall;
  • вы отправляете не на тот IP/порт;
  • принимающая сторона не bind-ится на нужный endpoint;
  • NAT и правила на границе сети не настроены.
  • DNS даёт «не тот» адрес

    Если имя возвращает AAAA-запись, клиент может уйти в IPv6. Если IPv6 маршрутизация или firewall настроены иначе, вы получите неожиданные таймауты.

    Как это ляжет на дальнейшие темы курса

    Дальше мы будем последовательно углубляться:

  • асинхронный I/O в .NET, отмена, таймауты и backpressure;
  • проектирование прикладных протоколов поверх TCP: фрейминг, границы сообщений, heartbeat;
  • диагностика: логи, метрики, трассировка, Wireshark;
  • безопасность: TLS, сертификаты, mTLS, прокси и балансировщики;
  • производительность: соединения, пуллинг, настройки сокетов, лимиты ОС.
  • 2. TCP в C#: клиент/сервер, потоки данных, фрейминг и управление соединением

    TCP в C#: клиент/сервер, потоки данных, фрейминг и управление соединением

    В предыдущей статье мы договорились о базовых терминах: IP-адреса, порты, 5-tuple и сокет как объект ОС, через который приложение общается с сетью. Теперь углубимся в TCP как основной транспорт для большинства backend-сервисов: как писать TCP-сервер и TCP-клиент на C#, почему TCP — это поток байт без границ сообщений, как правильно делать фрейминг и как управлять жизненным циклом соединения.

    Модель TCP, полезная для разработчика и сетевого инженера

    TCP даёт поток байт, а не сообщения

    Ключевой факт: TCP предоставляет приложению упорядоченный поток байт.

    Это означает:

  • Если вы отправили Send("ABC"), а затем Send("DEF"), получатель увидит байты в порядке ABCDEF.
  • Но получатель не обязан прочитать их теми же порциями. Он может получить AB, потом CDE, потом F.
  • Причина проста: TCP оперирует сегментами, буферами, окнами, ретрансмитами, и границы ваших WriteAsync не считаются границами данных на стороне ReadAsync.

    !Поясняет, почему TCP не сохраняет границы сообщений и зачем нужен фрейминг

    Идентификация соединения и роль портов

    TCP-соединение в ОС однозначно определяется набором:

  • src IP
  • src port
  • dst IP
  • dst port
  • Плюс протокол (TCP), как мы обсуждали в контексте 5-tuple. Это объясняет, почему тысячи клиентов могут подключиться к одному dst IP:dst port: у всех будут разные src port (обычно эфемерные).

    Установление и завершение соединения: что важно знать

    На практике для разработки важны три вещи:

  • Установление соединения (3-way handshake) происходит до того, как ConnectAsync вернётся успешно.
  • Корректное закрытие обычно идёт через обмен FIN (graceful close).
  • Состояние TIME_WAIT на стороне, которая закрывает соединение последней, является нормальным и влияет на поведение при быстрых рестартах.
  • Официальные спецификации TCP:

  • RFC 9293 (Transmission Control Protocol (TCP))
  • TCP в .NET: какие уровни API использовать

    В .NET есть несколько уровней работы с TCP:

  • TcpListener и TcpClient — удобный уровень для типовых задач.
  • NetworkStream — поток чтения/записи поверх сокета.
  • Socket — низкоуровневый контроль (опции, частичные отправки, тонкая настройка).
  • Документация:

  • TcpListener
  • TcpClient
  • NetworkStream
  • Socket
  • Практическая рекомендация:

  • Для обучения и большинства прикладных протоколов начинайте с TcpListener + TcpClient.
  • Переходите на Socket, когда нужна особая производительность, специфические опции сокета, или вы пишете свой высоконагруженный сервер.
  • Сервер TCP: accept-loop, конкуренция, отмена

    Базовый шаблон сервера

    Типовая архитектура:

  • Создать TcpListener и вызвать Start().
  • В цикле принимать клиентов через AcceptTcpClientAsync.
  • На каждого клиента запускать обработчик.
  • Поддерживать отмену (CancellationToken) и аккуратно закрывать соединения.
  • Ниже пример сервера, который принимает сообщения в формате length-prefix (длина + payload).

    Почему ReadAsync нужно вызывать в цикле

    ReadAsync возвращает:

  • число байт > 0, если какие-то байты прочитаны;
  • 0, если удалённая сторона закрыла соединение (graceful close).
  • Важный момент: ReadAsync может вернуть меньше, чем вы ожидаете, даже если вы точно знаете, что сообщение целиком отправлено. Поэтому для чтения фиксированного объёма (например, 4 байта заголовка длины) нужен цикл ReadExactly.

    Ограничение параллелизма и защита от истощения ресурсов

    Наивный подход одна задача на клиента работает в обучающих примерах, но в продакшене важно ограничивать ресурсы:

  • Ограничьте максимальное число одновременных соединений.
  • Ограничьте размер фрейма и входных буферов.
  • Введите таймауты на неактивные соединения.
  • Технически это можно сделать через SemaphoreSlim вокруг Accept или внутри обработчика.

    Клиент TCP: подключение, таймауты, повтор

    Пример клиента для протокола выше:

    Практические замечания:

  • ConnectAsync может зависнуть на сетевых проблемах, поэтому почти всегда нужен CancellationToken или внешняя стратегия таймаута.
  • Повтор подключения обычно делается с backoff (например, экспоненциальным) и лимитом попыток.
  • Если вы подключаетесь по имени хоста, то DNS может вернуть несколько адресов, и попытка может уйти в IPv6 или IPv4 в зависимости от настроек ОС и сети.
  • Фрейминг: как сделать из TCP потока сообщения

    Фрейминг отвечает на вопрос: где начинается и где заканчивается одно прикладное сообщение в потоке байт.

    Популярные подходы к фреймингу

    | Подход | Пример | Плюсы | Минусы | |---|---|---|---| | Разделитель (delimiter) | строки с \n | просто для текстовых протоколов | нужно экранирование, сложнее для бинарных данных | | Префикс длины (length-prefix) | 4 байта длины + payload | быстрый и универсальный, хорош для бинарных протоколов | важно валидировать длину и ограничивать максимум | | Фиксированный размер | всегда 128 байт | простая реализация | неэффективно для переменной длины |

    Что может пойти не так без фрейминга

    Типичные ошибки:

  • Читать до первого ReadAsync и считать это сообщением. Это неверно, потому что чтение возвращает произвольную порцию потока.
  • Доверять длине из сети без лимитов. Это путь к DoS по памяти: атакующий может прислать длину в гигабайты.
  • Не учитывать, что ReadAsync может вернуть 0. Это означает закрытие соединения, а не пустое сообщение.
  • Пример: length-prefix и сетевой порядок байт

    В примерах выше длина кодируется как Int32 в big-endian.

    Почему так:

  • Big-endian часто называют сетевым порядком байт.
  • Явная фиксация порядка байт убирает неоднозначность между разными архитектурами.
  • Для этого используется BinaryPrimitives.ReadInt32BigEndian и BinaryPrimitives.WriteInt32BigEndian.

    Документация:

  • BinaryPrimitives
  • Управление соединением: закрытие, half-close, TIME_WAIT, RST

    Graceful close и ReadAsync == 0

    Корректное завершение со стороны удалённого узла обычно выглядит так:

  • Удалённая сторона закрывает сокет.
  • Ваше чтение возвращает 0 байт.
  • Вы завершаете обработчик и освобождаете ресурсы.
  • Это нормальный сценарий и его надо обрабатывать без ошибок и исключений.

    Half-close: когда полезно закрыть только отправку

    TCP позволяет закрыть одну сторону обмена: например, вы закончили отправлять запрос, но хотите дочитать ответ.

    На уровне Socket это делается через Shutdown(SocketShutdown.Send).

    Документация:

  • Socket.Shutdown
  • Практический смысл:

  • Вы сигнализируете удалённой стороне: от меня данных больше не будет.
  • При этом вы ещё можете читать входящий поток.
  • TIME_WAIT и рестарты сервисов

    TIME_WAIT часто проявляется как:

  • невозможность быстро переиспользовать локальный порт (обычно для клиента),
  • или непонимание, почему после остановки сервиса остаются записи соединений.
  • Это нормальная часть TCP: состояние нужно, чтобы сеть не перепутала старые сегменты от предыдущего соединения с сегментами нового.

    Для сервера типичная проблема порт занят после рестарта чаще связана не с TIME_WAIT входящих соединений, а с тем, как приложение закрывает слушающий сокет и как настроен SO_REUSEADDR на конкретной ОС. В .NET эти нюансы лучше разбирать на уровне Socket и под конкретную платформу.

    RST: когда соединение обрывается жёстко

    Если удалённая сторона отправляет RST (reset), то:

  • чтение или запись часто завершаются исключением (SocketException),
  • это обычно означает аварийное завершение, закрытие без корректного FIN, или попытку писать в уже несуществующее соединение.
  • В прикладном протоколе это нужно интерпретировать как неуспешный обмен и, возможно, делать повтор операции, если она идемпотентна.

    Таймауты, keepalive и опции сокета

    Таймауты: что реально контролировать

    В TCP нет встроенного понятия таймаут запроса. Это ответственность приложения.

    Обычно комбинируют:

  • Таймаут на операцию чтения или запись через CancellationToken.
  • Таймаут на простои соединения: если в течение N секунд нет данных, разрывать соединение.
  • Таймаут на установление соединения через отмену ConnectAsync.
  • Подход через CancellationToken обычно наиболее переносимый и управляемый.

    Keepalive

    TCP keepalive нужен, чтобы обнаруживать “мертвые” соединения (например, оборвался линк, NAT забыл состояние), когда трафика нет.

    В .NET keepalive настраивается на уровне Socket и платформы. На практике многие backend-протоколы делают heartbeat на прикладном уровне, потому что:

  • тайминги keepalive по умолчанию могут быть слишком большими,
  • прикладной heartbeat позволяет точнее контролировать SLA.
  • Документация по опциям:

  • SocketOptionName
  • Nagle и NoDelay

    Опция TcpClient.NoDelay = true отключает алгоритм Нейгла (Nagle).

    Практическая эвристика:

  • Если у вас интерактивный протокол с маленькими сообщениями и важна задержка, NoDelay = true часто оправдан.
  • Если у вас потоковая передача больших объёмов данных, можно оставить по умолчанию и измерять.
  • Влияние зависит от паттерна записи и наличия подтверждений, поэтому лучший ответ всегда в измерениях и трассировке.

    Буферизация, backpressure и почему FlushAsync не гарантирует доставку

    NetworkStream пишет в сокет, а сокет — в буферы ОС. Если принимающая сторона медленная или сеть перегружена, буферы заполняются и запись начинает:

  • замедляться,
  • блокироваться (в синхронном API),
  • или ждать возможности отправки (в асинхронном API).
  • Это и есть backpressure: естественное “давление” системы, сообщающее, что быстрее отправлять нельзя.

    Важно понимать:

  • FlushAsync на NetworkStream означает “протолкнуть в нижележащий поток”, но не означает “данные точно дошли и обработаны”. Гарантия доставки в TCP относится к передаче между стеками TCP, но не к тому, что приложение на той стороне успело обработать сообщение.
  • Если вам нужна гарантия обработки, её делает прикладной протокол: подтверждения, номера сообщений, идемпотентность.

    Наблюдаемость и диагностика TCP-проблем

    Минимальный набор практик:

  • Логировать RemoteEndPoint, длительность соединения, причины закрытия.
  • Разделять ошибки OperationCanceledException (ожидаемая отмена) и сетевые ошибки (SocketException).
  • При необходимости использовать пакетный анализ (Wireshark) и системные утилиты (ss, netstat) на стороне ОС.
  • Wireshark:

  • Wireshark (официальный сайт)
  • Как это связано со следующими темами

    После понимания TCP на уровне потока, фрейминга и управления соединением проще переходить к:

  • проектированию прикладных протоколов поверх TCP: версии протокола, handshake, heartbeat, компрессия;
  • безопасности: TLS и mTLS;
  • производительности: пул соединений, SocketAsyncEventArgs, System.IO.Pipelines.
  • Документация по высокопроизводительным пайплайнам в .NET:

  • System.IO.Pipelines
  • Краткое резюме

  • TCP — это поток байт, границы сообщений отсутствуют.
  • Любой прикладной протокол поверх TCP обязан решить задачу фрейминга.
  • ReadAsync может вернуть меньше данных, чем вы ожидаете, поэтому для заголовков и фиксированных объёмов нужен ReadExactly.
  • Управление соединением включает корректное закрытие, обработку ReadAsync == 0, понимание TIME_WAIT и сценариев RST.
  • Таймауты и обнаружение “мертвых” соединений чаще всего реализуются на уровне приложения с помощью отмены, таймеров и heartbeat.
  • 3. UDP и realtime: датаграммы, надёжность, NAT и мультикаст

    UDP и realtime: датаграммы, надёжность, NAT и мультикаст

    В прошлых статьях мы закрепили базу (адресация, порты, сокеты) и подробно разобрали TCP как поток байт без границ сообщений, где фрейминг и управление соединением критичны. Теперь переключимся на UDP: транспорт без соединения и без гарантий доставки, который часто выбирают для realtime-нагрузок (голос, видео, игры, телеметрия, discovery), а также для мультикаста в локальных сетях.

    Ключевая мысль темы: UDP даёт вам контроль над задержкой и форматом сообщений (датаграммы), но перекладывает на приложение всё, что TCP делал автоматически: восстановление потерь, порядок, дедупликацию, контроль перегрузки, а иногда и установление псевдосессии.

    Справочные спецификации:

  • RFC 768 (User Datagram Protocol)
  • RFC 4787 (NAT Behavioral Requirements for Unicast UDP)
  • RFC 1112 (Host Extensions for IP Multicasting)
  • Ментальная модель UDP

    UDP как датаграммы

    UDP передаёт сообщения (датаграммы), а не поток байт:

  • один вызов Send или SendAsync соответствует одной UDP-датаграмме;
  • один Receive возвращает одну датаграмму целиком (или она будет отброшена, если буфер слишком маленький).
  • Это фундаментальное отличие от TCP, где границы сообщений отсутствуют и нужен фрейминг.

    !Сравнение «поток байт» (TCP) и «датаграммы» (UDP)

    Что UDP не гарантирует

    UDP сам по себе не гарантирует:

  • доставку (потери возможны);
  • порядок (датаграммы могут прийти переставленными);
  • уникальность (возможны дубликаты);
  • отсутствие задержек (джиттер и очереди в сети реальны);
  • контроль перегрузки на уровне протокола (его нужно учитывать в приложении).
  • На практике многие системы добавляют поверх UDP свой минимум протокола: номера сообщений, подтверждения, окна, heartbeat, либо используют готовые протоколы на UDP.

    Где UDP подходит, а где нет

    UDP часто выбирают, когда:

  • важнее задержка, чем идеальная надёжность;
  • сообщения небольшие и естественно дискретные;
  • вы готовы проектировать политику потерь (например, лучше пропустить кадр, чем ждать его ретрансмит);
  • нужен мультикаст (TCP мультикаст не поддерживает).
  • UDP часто не подходит, когда:

  • нужно строго гарантировать доставку каждого сообщения и порядок;
  • ожидаются большие объёмы данных, которые сложно сегментировать на безопасные размеры;
  • вы не готовы поддерживать логику повторов, таймаутов, контроля перегрузки.
  • Размеры сообщений, MTU и фрагментация

    Почему размер UDP-сообщения критичнее, чем кажется

    UDP-сообщение живёт внутри IP-пакета. Если пакет не помещается в MTU канала, он может быть фрагментирован на IP-уровне.

    Практический вывод:

  • большие UDP-датаграммы резко повышают риск потерь;
  • фрагментация часто ухудшает надёжность: потеря одного фрагмента делает бесполезной всю датаграмму.
  • Умеренное инженерное правило (не абсолютный закон): стараться держать полезную нагрузку UDP такой, чтобы влезать в типичный MTU Ethernet 1500 с запасом под заголовки.

    Если вам нужны большие объёмы данных, чаще выбирают:

  • разбиение на чанки на уровне приложения с номерами и сборкой;
  • или уход на TCP/QUIC в зависимости от требований.
  • Надёжность поверх UDP: что добавлять в приложении

    Если вам нужна надёжность частично, вы проектируете её под сценарий, а не копируете TCP один в один.

    Базовые кирпичики протокола поверх UDP

    Минимальный набор идей:

  • sequence number (номер сообщения), чтобы обнаруживать потери, порядок и дубликаты;
  • подтверждения (ACK) для выбранных сообщений или пакетов;
  • таймаут и повтор (retransmission), но аккуратно, чтобы не создать перегрузку;
  • ограничение размера и скорости отправки (пейсинг), чтобы не забить сеть и буферы;
  • идентификатор сессии (потому что UDP «без соединения», но вам часто нужна логическая сессия).
  • Типичные стратегии (выбирают по задаче):

  • realtime без повторов: лучше пропустить устаревшее (голос/видео);
  • realtime с выборочными повторами: повторяем только ключевые сообщения (например, state sync в игре);
  • надёжная доставка поверх UDP: подтверждения и окна, но это уже почти «свой TCP», и часто выгоднее взять готовый протокол.
  • Идемпотентность как инструмент надёжности

    С точки зрения backend-разработчика важный принцип: повторы неизбежны. Поэтому полезно, чтобы сообщения были идемпотентны.

    Практики:

  • добавлять MessageId и хранить краткоживущий кэш обработанных MessageId для дедупликации;
  • разделять команды и события;
  • проектировать ответы так, чтобы повтор не ломал состояние.
  • Контроль перегрузки и backpressure в мире UDP

    UDP позволяет отправлять очень быстро, но сеть и получатель имеют пределы:

  • если вы шлёте быстрее, чем сеть может передать, пакеты будут сбрасываться в очередях;
  • если получатель не успевает обрабатывать, переполнится буфер приёма, и ОС начнёт дропать датаграммы.
  • В UDP важно:

  • ограничивать частоту отправки;
  • делать батчинг маленьких сообщений (если это не ломает задержку);
  • мониторить потери и адаптировать битрейт/частоту.
  • Realtime: задержка, джиттер и «полезные» потери

    Realtime-системы чаще оптимизируются под время, а не под 100% доставку.

    Ключевые понятия:

  • latency: сколько времени идёт сообщение от отправителя к получателю;
  • jitter: разброс задержек (сегодня 10 мс, через секунду 60 мс);
  • loss: доля потерь;
  • out-of-order: перестановка.
  • Типичный механизм на приёмнике для потоковых данных:

  • небольшой jitter buffer (буфер выравнивания), который держит N миллисекунд данных и сглаживает скачки задержек;
  • пропуск слишком поздних пакетов (если дедлайн прошёл, пакет бесполезен).
  • Инженерная мысль: в realtime иногда выгоднее принять небольшие потери, чем увеличивать задержку ожиданием ретрансмитов.

    UDP и NAT: почему «не работает из интернета»

    UDP почти всегда встречает на пути NAT и firewall. Для сетевого инженера это привычно, а для разработчика это источник «магических» таймаутов.

    Что делает NAT с UDP

    NAT хранит таблицу соответствий вида:

  • внутренний endpoint 10.0.0.10:53000 преобразуется во внешний 203.0.113.5:40001;
  • соответствие живёт ограниченное время (UDP-mapping timeout) и исчезает без трафика.
  • RFC, который полезно читать именно разработчику, потому что он описывает поведение NAT для UDP:

  • RFC 4787 (NAT Behavioral Requirements for Unicast UDP)
  • Главная практическая проблема:

  • входящий UDP снаружи часто отбрасывается, если NAT не видит «свежего» соответствия или правила.
  • !Как NAT пропускает UDP только при наличии соответствия (mapping)

    Keepalive для NAT

    Если вы хотите удерживать mapping живым:

  • периодически отправляйте маленький keepalive-пакет;
  • выбирайте интервал меньше типичного таймаута NAT (точные значения зависят от оборудования).
  • Важно: keepalive увеличивает трафик и нагрузку, поэтому его нужно осмысленно дозировать.

    Hole punching и готовые протоколы

    Когда два клиента за NAT хотят общаться напрямую, применяют UDP hole punching. Обычно это делается не «ручным кодом», а через семейство стандартов:

  • STUN для определения внешнего адреса/порта: RFC 8489 (Session Traversal Utilities for NAT (STUN))
  • TURN для ретрансляции через сервер, когда прямой путь невозможен: RFC 5766 (Traversal Using Relays around NAT (TURN))
  • ICE как методика выбора лучшего пути: RFC 8445 (Interactive Connectivity Establishment (ICE))
  • Для backend-разработчика вывод такой:

  • «пробить NAT» в общем случае сложно и лучше опираться на готовые стек/протоколы;
  • если ваша система работает внутри одной сети или под контролируемыми правилами firewall, задача проще: можно заранее открыть порты и настроить ACL.
  • Мультикаст: когда один отправитель и много получателей

    Зачем мультикаст

    Мультикаст позволяет отправить один поток пакетов в группу, и сеть доставит его множеству подписчиков. Это полезно для:

  • discovery в локальной сети;
  • трансляции котировок/телеметрии внутри датацентра;
  • некоторых industrial/OT сценариев.
  • Важные ограничения

    Мультикаст сложнее, чем unicast:

  • не все сети и Wi‑Fi корректно поддерживают мультикаст;
  • маршрутизация мультикаста между подсетями обычно требует явной настройки;
  • NAT и интернет-сегменты почти всегда делают мультикаст непригодным.
  • Базовая спецификация IPv4 multicast:

  • RFC 1112 (Host Extensions for IP Multicasting)
  • !Как один UDP-поток доставляется группе получателей (multicast)

    TTL и область видимости

    Для мультикаста важны:

  • TTL (в IPv4) или Hop Limit (в IPv6): ограничивает, насколько далеко уйдёт пакет;
  • выбор адресного диапазона (например, административно-локальный диапазон 239.0.0.0/8 часто используют внутри организации).
  • UDP в C#: API, примеры и практические опции

    UdpClient или Socket

    В .NET есть два основных пути:

  • UdpClient проще для большинства задач;
  • Socket даёт больше контроля (опции, производительность, тонкая работа с мультикастом и несколькими интерфейсами).
  • Документация:

  • UdpClient
  • Socket
  • Unicast UDP: приём и отправка

    Приёмник, который слушает на конкретном IP:port и печатает отправителя:

    Пример отправителя в мультикаст-группу:

    Практические замечания по мультикасту:

  • часто нужно явно выбрать интерфейс для мультикаста (особенно на хостах с несколькими NIC);
  • Wi‑Fi и некоторые виртуальные сети могут сильно ограничивать мультикаст;
  • мультикаст-пакеты могут не маршрутизироваться между подсетями без настройки.
  • Диагностика UDP

    UDP сложно отлаживать «по логам приложения» без сетевого контекста. Хорошие практики:

  • логировать RemoteEndPoint и размеры датаграмм;
  • считать метрики: PPS (packets per second), долю потерь (по sequence), джиттер (по timestamps);
  • при проблемах включать захват трафика в Wireshark.
  • Инструменты:

  • Wireshark
  • Как эта тема связывается с дальнейшим курсом

    Из UDP логично вырастают следующие уровни:

  • проектирование прикладных протоколов: версия, handshake, heartbeat, дедупликация;
  • безопасность: DTLS или туннелирование, а также контроль доступа на уровне сети;
  • производительность: работа с Socket, настройка буферов, масштабирование приёма, минимизация аллокаций.
  • Краткое резюме

  • UDP передаёт датаграммы: границы сообщений сохраняются, фрейминг как в TCP обычно не нужен.
  • UDP не гарантирует доставку, порядок и уникальность, поэтому надёжность часто строят на уровне приложения.
  • Для realtime чаще важнее задержка и джиттер, чем идеальная надёжность: запоздавшие пакеты часто хуже, чем потерянные.
  • NAT и firewall сильно влияют на UDP: нужны keepalive, понимание mapping и иногда STUN/TURN/ICE.
  • Мультикаст полезен в локальных сетях, но требует сетевой поддержки и аккуратной настройки.
  • 4. Высокопроизводительный I/O: async/await, SocketAsyncEventArgs и масштабирование

    Высокопроизводительный I/O: async/await, SocketAsyncEventArgs и масштабирование

    В предыдущих статьях мы разобрали основу: адреса/порты/сокеты, затем TCP как поток байт (где нужен фрейминг), и UDP как датаграммы (где важны потери, NAT и мультикаст). Теперь соберём это в инженерную картину: как писать сетевой код на C# так, чтобы он выдерживал много соединений, высокую частоту сообщений и предсказуемые задержки.

    Эта статья про производительность I/O на практике:

  • почему async/await масштабируется лучше, чем поток на соединение
  • где в высоконагруженных системах упираются в аллокации и планировщик
  • что даёт SocketAsyncEventArgs (SAEA) и зачем его пулить
  • как думать про backpressure, лимиты и архитектуру серверного цикла
  • Справочные источники:

  • Документация по SocketAsyncEventArgs
  • Документация по Socket
  • System.Buffers.ArrayPool
  • System.IO.Pipelines
  • Асинхронное программирование с async и await
  • Как выглядит масштабирование сетевого сервера

    Упрощённо сервер можно представить как цикл:

  • принять соединение
  • читать байты
  • выделить сообщения (в TCP это фрейминг из предыдущей статьи)
  • обработать
  • записать ответ
  • Масштабирование ломается обычно не на протоколе, а на ресурсах исполнения:

  • потоках и планировании
  • аллокациях и сборке мусора
  • копированиях буферов
  • очередях (в сокетах, в ThreadPool, в пользовательских каналах)
  • !Почему async I/O позволяет обслуживать много соединений меньшим числом потоков

    async/await в сетевом коде: что ускоряет, а что нет

    Что реально даёт async/await

    async/await не делает сеть быстрее. Он делает ваш сервис способным не удерживать поток, пока ОС ждёт данные из сети.

    Ключевая идея:

  • синхронный Read держит поток, который ничего не делает
  • асинхронный ReadAsync возвращает управление, а продолжение выполнится, когда ОС сообщит о готовности данных
  • Практический эффект:

  • меньше потоков нужно для обслуживания большого числа соединений
  • меньше переключений контекста
  • ThreadPool занят полезной работой чаще, а не ожиданием
  • Где находится “настоящая” асинхронность

    В современных ОС сетевой стек умеет уведомлять приложение о завершении I/O:

  • Windows: completion ports (IOCP)
  • Linux: epoll
  • .NET скрывает детали, но смысл один: вы регистрируете операцию I/O, а затем получаете уведомление о её завершении.

    Цена async/await

    У асинхронного кода есть накладные расходы:

  • создание и планирование задач
  • состояния async-машины (state machine)
  • захват контекста (в UI-приложениях; на сервере обычно неактуально)
  • На обычном backend это редко проблема. На очень высоких нагрузках (сотни тысяч сообщений в секунду) вы начинаете видеть стоимость:

  • лишних аллокаций
  • лишних continuation
  • лишних копирований
  • И вот тут появляется SocketAsyncEventArgs и буферные пулы.

    Главные враги производительности I/O

    Ниже — список типичных причин деградации. Они часто проявляются одновременно.

  • Аллокации на каждый пакет/сообщение (GC паузы, рост latency)
  • Копирование буферов (лишняя работа CPU)
  • Слишком мелкие записи (много системных вызовов и overhead)
  • Неограниченная конкуренция (все подключились и все что-то делают одновременно)
  • Отсутствие backpressure (вы принимаете быстрее, чем обрабатываете)
  • Чтобы системно с этим работать, полезно разделить проблему на два слоя:

  • I/O слой: принять байты/отправить байты как можно дешевле
  • протокольный слой: фрейминг, парсинг, бизнес-логика
  • Backpressure: почему сервер “вдруг начинает тормозить”

    Backpressure — это “давление” системы, которое появляется, когда один участок пайплайна быстрее другого.

    В сетевом сервере источники backpressure обычно такие:

  • входящие данные приходят быстрее, чем вы успеваете их обработать
  • исходящие ответы накапливаются быстрее, чем сеть может их передать
  • Как это выглядит технически:

  • у сокета есть буферы ОС; когда они заполняются, Send начинает завершаться медленнее
  • если вы продолжаете бесконтрольно читать и ставить задачи на обработку, растут очереди в памяти
  • Инженерные выводы:

  • нужны лимиты (соединения, размер сообщений, количество параллельных обработчиков)
  • нужна стратегия на перегрузку (отказывать, замедлять чтение, закрывать “шумных” клиентов)
  • Уровни API в .NET для сетевого I/O

    В .NET можно условно выделить три подхода.

    | Подход | API | Когда выбирать | |---|---|---| | Высокоуровневый | TcpListener/TcpClient + NetworkStream | обучение, прототипы, большинство внутренних сервисов | | Средний | Socket + ReceiveAsync/SendAsync с Memory<byte> | нужен контроль, меньше лишних абстракций | | Низкоуровневый высокопроизводительный | SocketAsyncEventArgs | очень высокая нагрузка, важны аллокации и предсказуемость |

    Важно понимать связку с прошлой статьёй про TCP:

  • на любом уровне API TCP остаётся потоком байт
  • значит, фрейминг и ReadExactly-логика всё ещё нужны, просто реализация может стать более “низкоуровневой”
  • SocketAsyncEventArgs: зачем он нужен

    SocketAsyncEventArgs — это объект, который:

  • хранит буфер (или ссылку на буфер)
  • хранит параметры операции (например, SocketFlags)
  • даёт событие/колбэк завершения операции
  • Главное преимущество в высоконагруженном сервере: вы можете переиспользовать один и тот же объект для множества операций, избегая выделений памяти под каждое чтение/запись.

    Почему SAEA обычно пулится

    Если на каждое Receive создавать новый SocketAsyncEventArgs, вы снова приходите к:

  • частым аллокациям
  • давлению на GC
  • Типовой паттерн:

  • на старте выделить пул SocketAsyncEventArgs для приёма
  • на каждое соединение взять один объект из пула
  • после закрытия соединения вернуть объект в пул
  • Буфер тоже обычно берут из пула (ArrayPool<byte>), чтобы не создавать новый byte[].

    !Переиспользование SAEA и буферов уменьшает аллокации и GC

    Архитектура высокопроизводительного TCP-сервера на SAEA

    Ниже — минимальный, но показательный скелет echo-сервера:

  • принимает соединения
  • на каждом соединении держит один SocketAsyncEventArgs для приёма
  • переиспользует буферы из ArrayPool<byte>
  • не создаёт Task на каждое чтение
  • Ограничения примера (осознанно):

  • это демонстрация I/O-механики, а не полноценного протокола
  • для реального TCP-протокола вам нужно добавить фрейминг (например, length-prefix из прошлой статьи)
  • Что важно заметить в примере:

  • SocketAsyncEventArgs переиспользуется, а буфер берётся из ArrayPool<byte>
  • колбэк Completed может вызываться на потоке ThreadPool, поэтому обработчик должен быть быстрым
  • BytesTransferred == 0 для TCP означает корректное закрытие со стороны клиента (graceful close), как мы обсуждали в TCP-теме
  • Почему в примере Send синхронный

    В демонстрации Send сделан синхронно, чтобы акцентировать внимание на механике чтения и переиспользовании SocketAsyncEventArgs.

    В реальном высоконагруженном сервере вам часто нужно:

  • сделать отправку тоже асинхронной
  • ввести очередь отправки на соединение (чтобы не сломать порядок и не делать конкурентных Send на один сокет)
  • Это напрямую связано с backpressure: если клиент медленный, очередь отправки начнёт расти, и вы должны решить, что делать.

    Частые ошибки при переходе к высокопроизводительному I/O

  • Считать, что async/await сам по себе “ускоряет” сеть
  • Делать одну задачу на каждую операцию без измерений и удивляться overhead
  • Не ограничивать входящий трафик по размеру сообщений (риск DoS памятью), как в теме про фрейминг
  • Не пулить буферы и создавать byte[] на каждый Receive
  • Смешивать I/O и тяжёлую обработку в одном колбэке Completed
  • Если обработка тяжёлая (CPU-bound), хорошая практика:

  • быстро скопировать нужный минимум в структуру сообщения
  • передать в очередь обработчиков (например, Channel), которые ограничены по параллелизму
  • Буферы: ArrayPool, MemoryPool и копирования

    Когда достаточно ArrayPool

    ArrayPool<byte>.Shared полезен, когда вы:

  • делаете много чтений/записей
  • хотите переиспользовать большие массивы
  • Принцип:

  • Rent берёт массив
  • вы используете только нужный диапазон
  • Return возвращает в пул
  • Документация: System.Buffers.ArrayPool

    Когда смотреть в сторону System.IO.Pipelines

    System.IO.Pipelines помогает, когда вы хотите:

  • эффективно парсить поток байт без лишних копирований
  • разделить чтение I/O и разбор протокола
  • работать с ReadOnlySequence<byte> (в том числе с сегментированными буферами)
  • Документация: System.IO.Pipelines

    Связка с темой TCP-фрейминга получается естественной:

  • PipeReader читает поток байт
  • ваш код ищет границы фреймов (delimiter/length-prefix)
  • вы продвигаете курсор, оставляя неполные данные до следующего чтения
  • Лимиты и настройки сокетов, которые реально влияют

    Ниже — практические настройки, которые часто обсуждают, но важно понимать их смысл.

  • NoDelay отключает Nagle и может уменьшить задержку на мелких сообщениях, но не является “универсальным ускорителем”
  • ReceiveBufferSize и SendBufferSize влияют на буферизацию ОС и могут менять поведение под нагрузкой
  • backlog в Listen(backlog) влияет на очередь входящих соединений (это не “максимум клиентов”, но влияет на пики)
  • Документация по сокетам: Документация по Socket

    Как выбирать подход в реальном проекте

    Ниже — практическая шкала выбора.

  • Если вы пишете внутренний сервис и у вас тысячи соединений, но умеренный трафик, обычно достаточно TcpListener/NetworkStream с корректным async/await, таймаутами и фреймингом.
  • Если вы делаете шлюз, брокер, высокочастотный протокол, или у вас десятки/сотни тысяч соединений, начинайте измерять аллокации и latency, и рассматривайте Socket и SocketAsyncEventArgs.
  • Если основная боль — парсинг потока и копирования, часто следующий шаг — System.IO.Pipelines.
  • Полезный принцип: оптимизируйте только после того, как у вас есть:

  • метрики (RPS/PPS, latency p50/p99, ошибки)
  • профилирование CPU и аллокаций
  • воспроизводимый нагрузочный тест
  • Связь с дальнейшими темами курса

    После того, как вы освоили высокопроизводительный I/O, логичный следующий шаг в сетевом программировании:

  • прикладные протоколы: версии, handshake, heartbeat, подтверждения
  • безопасность: TLS/mTLS для TCP, и варианты для UDP
  • эксплуатация: наблюдаемость, диагностика, Wireshark, системные лимиты ОС
  • Краткое резюме

  • async/await улучшает масштабирование, потому что не удерживает поток во время ожидания I/O.
  • Высокая нагрузка часто упирается в аллокации, копирования и очереди, а не в “скорость сети”.
  • SocketAsyncEventArgs позволяет переиспользовать объекты операций I/O и буферы, снижая давление на GC.
  • Backpressure и лимиты — обязательная часть дизайна: без них сервер под перегрузкой будет деградировать непредсказуемо.
  • Для сложного парсинга потока и минимизации копирований полезен System.IO.Pipelines.
  • 5. Протоколы, безопасность и эксплуатация: TLS, HTTP/2-3, диагностика и тестирование

    Протоколы, безопасность и эксплуатация: TLS, HTTP/2-3, диагностика и тестирование

    В прошлых темах мы разобрали транспорт и I/O:

  • TCP как поток байт и необходимость фрейминга
  • UDP как датаграммы, где важны потери, NAT и мультикаст
  • масштабирование через async/await, SocketAsyncEventArgs, буферы и backpressure
  • Теперь поднимемся на уровень, с которым чаще всего живёт backend в продакшене:

  • прикладные протоколы, которые реально используются (HTTP/2 и HTTP/3)
  • безопасность канала (TLS) и типовые ошибки интеграции
  • эксплуатация: диагностика проблем по слоям, тестирование, наблюдаемость
  • Важная рамка: протокол почти всегда опирается на конкретный транспорт и модель I/O. Вы не сможете качественно отлаживать TLS или HTTP/2, если не понимаете, где заканчивается проблема TCP, а где начинается проблема шифрования или прикладного уровня.

    TLS: что защищает и как устроен

    TLS (Transport Layer Security) решает три задачи:

  • конфиденциальность: данные в канале не читаются посторонними
  • целостность: данные нельзя незаметно подменить
  • аутентификация: клиент понимает, что подключился именно к нужному серверу (по сертификату)
  • Спецификация:

  • RFC 8446 (The Transport Layer Security (TLS) Protocol Version 1.3)
  • Где TLS находится в стеке

    Обычно TLS работает поверх TCP, а прикладной протокол (например, HTTP/2) работает поверх TLS.

    !Где расположен TLS относительно TCP и HTTP

    Практический вывод для разработчика:

  • ошибки вида connection reset, timeout могут быть на L3/L4 (маршрут, firewall, TCP)
  • ошибки вида Authentication failed часто уже на уровне TLS (сертификат, имя хоста, цепочка)
  • ошибки уровня HTTP/2 могут возникать после успешного TLS (ALPN, настройки протокола)
  • Сертификаты и проверка подлинности сервера

    Чтобы клиент доверял серверу, должны выполняться условия:

  • сертификат сервера содержит имя (SAN), совпадающее с именем хоста, к которому подключается клиент
  • цепочка сертификатов строится до доверенного корневого центра (CA), который есть в хранилище доверенных
  • сертификат не просрочен и подходит по назначению (обычно Server Authentication)
  • Ключевой момент: проверяется имя хоста, а не IP. Подключение к https://10.0.0.10 и к https://api.company.local с точки зрения TLS проверки имени обычно разные сценарии.

    Документация по сертификатам в .NET:

  • X509Certificate2
  • SNI и ALPN: почему один порт обслуживает много протоколов

    В современном продакшене на одном IP:443 может жить несколько сайтов и протоколов. Для этого в TLS существуют расширения:

  • SNI (Server Name Indication): клиент сообщает имя хоста в TLS рукопожатии, и сервер выбирает правильный сертификат
  • ALPN (Application-Layer Protocol Negotiation): клиент и сервер договариваются, какой прикладной протокол будет поверх TLS (например, h2 для HTTP/2 или http/1.1)
  • Спецификация ALPN:

  • RFC 7301 (Transport Layer Security (TLS) Application-Layer Protocol Negotiation Extension)
  • Практические симптомы:

  • сертификат не тот при нескольких доменах на одном IP часто означает проблему с SNI
  • HTTP/2 не включается, хотя клиент умеет, часто означает проблему с ALPN или настройками сервера
  • TLS 1.2 и TLS 1.3: практические отличия

    TLS 1.3 обычно даёт:

  • более быстрое рукопожатие (меньше round-trip)
  • меньше рискованных устаревших криптоконструкций
  • часть параметров согласования упрощена
  • Но в эксплуатации важно:

  • старые прокси, DPI и некоторые middlebox могут ломать современные режимы
  • при интеграциях иногда приходится контролировать минимальную версию TLS на сервере
  • mTLS: взаимная аутентификация

    mTLS (mutual TLS) добавляет проверку клиента по сертификату.

    Используется, когда:

  • сервис-сервис внутри периметра (service mesh без sidecar или вместе с ним)
  • доступ к инфраструктурным API (админские панели, control plane)
  • В mTLS появляются дополнительные задачи:

  • управление доверием к клиентским CA
  • ротация клиентских сертификатов
  • корректное сопоставление кто это (subject/SAN) с политиками доступа
  • TLS в C#: SslStream для собственных протоколов

    Если вы делаете не-HTTP протокол поверх TCP, то TLS в .NET чаще всего реализуют через SslStream.

    Документация:

  • SslStream
  • Клиент: TLS поверх TCP

    Ниже минимальный пример: подключаемся TCP, затем поднимаем TLS и отправляем данные.

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

  • TargetHost критичен для правильной проверки сертификата и SNI
  • не отключайте проверку сертификата в продакшене (частая ошибка при отладке)
  • Сервер: TLS поверх TcpListener

    На сервере вы принимаете TCP-соединение, затем оборачиваете NetworkStream в SslStream и делаете AuthenticateAsServerAsync.

    Замечания:

  • RequestVersionOrHigher позволит перейти на HTTP/3, если вы запрашивали 2.0, а сервер/платформа умеют 3.0
  • всегда логируйте фактически использованную версию resp.Version, иначе легко обмануться
  • Безопасность прикладного протокола: что TLS не решает

    TLS защищает канал, но не делает ваше приложение автоматически безопасным.

    С учётом предыдущих статей (фрейминг TCP, лимиты, backpressure) в сетевом коде почти всегда нужны:

  • Валидация входных данных и строгий формат сообщений
  • Лимиты
  • - максимальный размер сообщения - максимальное число соединений - максимальная скорость сообщений (rate limit)

  • Таймауты
  • - таймаут рукопожатия (TCP connect и TLS handshake) - таймаут простоя соединения - таймаут обработки запроса

  • Аутентификация и авторизация на уровне приложения
  • - токены, подписи, mTLS, API keys, зависимости от модели угроз

  • Защита от повторов (replay)
  • - уникальные MessageId, nonce, временные окна, идемпотентность

  • Осторожность с логированием
  • - не логировать секреты, токены, приватные данные - маскирование и классификация данных

    Диагностика: как искать проблемы по слоям

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

    Таблица: симптом и вероятный слой проблемы

    | Симптом | Где чаще всего проблема | Что проверить | |---|---|---| | timeout при подключении | L3/L4 | маршрут, ACL, firewall, порт, ss/netstat | | connection refused | L4/приложение | слушает ли порт, правильный bind-адрес, сервис жив | | TLS: Authentication failed | TLS | имя хоста, цепочка, доверие CA, время на хосте | | HTTP/2 не включается | TLS/ALPN/HTTP | ALPN h2, настройки прокси/балансировщика | | HTTP/3 не работает, но HTTP/2 работает | UDP/QUIC | открыт ли UDP/443, поддержка на LB, политика сети |

    Инструменты, которые полезны одновременно разработчику и сетевому инженеру

  • Wireshark
  • tcpdump
  • OpenSSL
  • curl
  • Примеры команд для диагностики протокольного уровня:

    Практический чек-лист (в порядке, который часто экономит часы):

  • Проверить DNS и конкретный IP, куда реально идёт трафик
  • Проверить доступность порта и маршрут
  • Проверить TCP handshake и ретраи
  • Проверить TLS
  • - SNI - имя хоста (SAN) - цепочку

  • Проверить ALPN и версию HTTP
  • Проверить прикладные таймауты и лимиты
  • Наблюдаемость в .NET: системные события и трассировка

    Для эксплуатации полезно уметь включать диагностику сети в .NET:

  • dotnet-trace
  • EventCounter
  • System.Net.Http logging
  • Что стоит логировать в сетевых сервисах:

  • endpoint: локальный и удалённый адрес/порт
  • протокол: HTTP версия, TLS версия (если доступно), cipher suite (по возможности)
  • длительность этапов
  • - connect - handshake - time-to-first-byte - полная длительность запроса

  • причины закрытия соединения
  • - graceful (Read == 0 для TCP) - reset - таймаут

    Тестирование сетевого кода: от юнитов до сетевых аварий

    Сетевые баги часто проявляются только под нагрузкой и в реальной сети. Поэтому тестирование лучше строить слоями.

    Юнит-тесты: протокол и фрейминг без реальной сети

    То, что вы делали в темах TCP/UDP, отлично тестируется без сокетов:

  • парсер фреймов (length-prefix, delimiter)
  • валидация лимитов
  • обработка частичных входных данных
  • Идея: ваши функции должны уметь принимать ReadOnlySpan<byte> или ReadOnlySequence<byte> и возвращать результат без I/O.

    Интеграционные тесты: реальный сокет на localhost

    Подход:

  • стартовать сервер в тесте на 127.0.0.1 и порту 0 (пусть ОС выберет свободный)
  • подключиться клиентом
  • прогнать сценарии: корректные сообщения, ошибки, таймауты
  • Это особенно важно для:

  • корректного закрытия соединений
  • поведения при отмене CancellationToken
  • проверки, что в TCP нет границ сообщений (частичные чтения)
  • Нагрузочное тестирование и профилирование

    Инструменты зависят от протокола:

  • wrk для HTTP/1.1
  • h2load для HTTP/2
  • iperf3 для оценки сети как транспорта
  • Смысл нагрузки в контексте прошлой статьи про производительность:

  • увидеть рост очередей и backpressure
  • найти аллокации и GC паузы
  • проверить поведение при медленных клиентах
  • Инъекция сетевых проблем

    Если вы хотите убедиться, что ваш протокол и таймауты корректны, моделируйте:

  • задержку
  • джиттер
  • потери
  • ограничение полосы
  • На Linux для этого часто используют tc netem:

  • Linux traffic control (tc)
  • Пример сценариев, которые стоит прогнать:

  • 1% потерь на UDP и проверка дедупликации/sequence
  • 200 мс задержки и проверка таймаутов клиента
  • ограничение bandwidth и проверка backpressure на сервере
  • Практические рекомендации для продакшена

    Как выбирать HTTP версию

  • если у вас классический REST и простая инфраструктура, HTTP/2 часто даёт улучшение за счёт меньшего числа соединений и лучшей утилизации
  • HTTP/3 имеет смысл, когда важны задержки и мобильные/нестабильные сети, но он требует готовности инфраструктуры к UDP/QUIC
  • Где завершать TLS

    Есть две распространённые модели:

  • TLS termination на edge (LB/ingress), дальше внутренняя сеть
  • end-to-end TLS до приложения (или mTLS внутри)
  • Выбор зависит от модели угроз и требований комплаенса. Если TLS завершается на балансировщике, приложению труднее получить детали клиентского TLS, и нужно доверять заголовкам типа X-Forwarded-For.

    Что обязательно внедрить на уровне эксплуатации

  • Ротация сертификатов и мониторинг сроков действия
  • Метрики и алерты
  • - ошибки подключений - ошибки TLS handshake - распределения latency p50/p95/p99

  • Ограничение ресурсов
  • - лимиты соединений - лимиты размера запросов - политика на перегрузку

  • Повторяемые сценарии диагностики
  • - набор команд curl/openssl - сбор pcap при необходимости

    Резюме

  • TLS защищает канал: конфиденциальность, целостность и аутентификацию сервера; правильные SNI, TargetHost и проверка имени критичны.
  • HTTP/2 мультиплексирует множество запросов в одном соединении, но остаётся зависимым от TCP.
  • HTTP/3 работает поверх QUIC/UDP, снижает head-of-line blocking на уровне транспорта и требует готовности инфраструктуры к UDP.
  • Диагностика должна идти по слоям: DNS → TCP/UDP → TLS → ALPN/HTTP.
  • Тестирование сетевого кода требует не только юнитов, но и интеграции, нагрузки и моделирования сетевых проблем.