Сети — как данные доходят до сервера
В коде на C++ сетевой запрос выглядит обманчиво просто: один connect(), один send(). Но за этим вызовом скрыт целый конвейер — разрешение DNS-имени в адрес, трёхстороннее рукопожатие TCP, рукопожатие TLS, маршрутизация пакетов, буферы ядра. Когда соединение «зависает», «иногда теряется» или «работает, но медленно», причина почти всегда в этом невидимом слое.
Сеть устроена послойно: каждый уровень добавляет свой заголовок и свою гарантию, опираясь на уровень ниже. Прикладной протокол (HTTP) не знает про маршрутизацию, транспорт (TCP) не знает про содержимое запроса. Понимать сеть — значит понимать, какой уровень за что отвечает и где именно ломается то, что вы наблюдаете.
Зачем это C++-разработчику? Backend-сервис, игровой сервер, встраиваемое устройство — везде вы работаете с сокетами напрямую, и абстракции тут тоньше, чем в высокоуровневых языках. Заметить TIME_WAIT, исчерпание портов, поломанный PMTUD или гонку при shutdown общего сокета можно, только зная механику.
Сетевые модели и адресация
Эталонная модель OSI описывает 7 уровней; практическая модель TCP/IP — 4. Прикладной уровень TCP/IP объединяет уровни OSI L5+L6+L7. TLS не вписывается в эту схему ровно — он логически сидит между транспортом (L4) и приложением (L7), поэтому его часто называют «L4.5».
IP-адрес идентифицирует узел в сети. IPv4 — 32 бита (4 байта), IPv6 — 128 бит. Маска подсети делит адрес на сетевую и хостовую части. IPv6 решает главную проблему IPv4 — исчерпание адресного пространства, — а также упрощает заголовок и автоконфигурацию.
// IP-адрес — это число, и у сети порядок байт big-endian (network order).
// Хост может быть little-endian — конвертируйте явно:
uint32_t addr_host = ntohl(packet_addr); // network → host
uint16_t port_net = htons(8080); // host → network
Несколько важных деталей адресации:
- NAT транслирует много приватных адресов в один публичный — это отсрочило исчерпание IPv4. Приватные диапазоны (RFC 1918):
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16. Домашние роутеры делают, строго говоря, PAT — трансляцию ещё и портов. NAT ломает P2P: узел за NAT по умолчанию не принимает входящие соединения без проброса портов. - Порт — 16-битный номер. Соединение определяется не портом, а пятёркой (протокол, IP и порт источника, IP и порт назначения) — поэтому один порт
80обслуживает множество одновременных соединений от разных клиентов. Порты< 1024требуют привилегий (или capabilityCAP_NET_BIND_SERVICE). Пространства портов TCP и UDP независимы. - Multicast в IPv4 — это диапазон
224.0.0.0/4(весь, вплоть до239.255.255.255); «multicast» закодирован в самом адресе назначения, а не во флаге. - MTU — максимальный размер кадра канального уровня, обычно 1500 байт, но VPN и туннели его уменьшают.
MSS = MTU − заголовки IP и TCP. Если пакет больше MTU, происходит фрагментация; блокировка всего ICMP на firewall ломает Path MTU Discovery.
TCP и UDP
Это два транспортных протокола с противоположными подходами.
TCP — с установлением соединения: надёжная упорядоченная доставка байтового потока, с подтверждениями, повторами, управлением потоком и перегрузкой. UDP — без соединения: датаграммы доставляются «как получится», без гарантий порядка и доставки, без накладных расходов на соединение.
UDP не «бесполезен из-за ненадёжности»: именно поверх UDP построен QUIC (а значит HTTP/3), который добавляет надёжность сам и при потерях обгоняет TCP. UDP быстрее TCP заметно лишь на высокой частоте мелких пакетов; для крупных передач TCP не медленнее. TCP строго «точка-точка» — для широковещания и multicast нужен UDP.
Соединением TCP управляют флаги в заголовке сегмента:
SYN— запрос на открытие соединения (синхронизация номеров последовательности).ACK— подтверждение принятых данных.FIN— корректное закрытие своей половины соединения.RST— немедленный односторонний разрыв.FIN— это вежливое «до свидания»,RST— «связь оборвана».PSH— подсказка «доставить данные приложению сразу», а не флаг сброса буферов ядра.URG— пометка срочных данных (на практике почти не используется).
TCP-соединение
TCP открывает соединение трёхсторонним рукопожатием: клиент шлёт SYN, сервер отвечает SYN+ACK, клиент подтверждает ACK. Данные можно слать только после завершения всех трёх шагов — не после первого SYN.
Внутри соединения каждый байт нумерован. Номер подтверждения (ACK) — это номер следующего ожидаемого байта, а не последнего принятого. Закрывается соединение обменом FIN/ACK с каждой стороны; сторона, закрывшая активно, переходит в состояние TIME_WAIT на ~2×MSL. TIME_WAIT — не баг, а защита: он гарантирует, что запоздалые сегменты старого соединения не попадут в новое. Настоящая проблема — исчерпание портов из-за множества короткоживущих соединений.
Управление перегрузкой и управление потоком
Это два разных механизма, и их постоянно путают:
- Управление потоком (flow control) защищает получателя. Получатель объявляет окно приёма
rwnd— сколько байт он готов принять. Отправитель не должен переполнить буфер получателя. - Управление перегрузкой (congestion control) защищает сеть. Отправитель сам оценивает окно перегрузки
cwndпо потерям и задержкам. «Медленный старт» вопреки названию наращиваетcwndэкспоненциально и довольно агрессивен.
Отправитель в каждый момент шлёт не больше, чем min(cwnd, rwnd) — доминировать может любое из двух окон.
Сокеты
Сокет — это конечная точка соединения, представленная файловым дескриптором. Серверный сценарий: socket() → bind() (привязать к адресу и порту) → listen() → accept(). Клиентский: socket() → connect().
int fd = socket(AF_INET, SOCK_STREAM, 0); // TCP-сокет
int yes = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes); // см. ниже
bind(fd, /* sockaddr */ ...);
listen(fd, SOMAXCONN);
Две ловушки на собеседованиях. Первая — SO_REUSEADDR: без него после перезапуска сервера порт ещё ~2 минуты держится в TIME_WAIT, и bind() падает. Вторая — recv() возвращает количество байт, а не C-строку: байты надо самому завершать нулём или работать по счётчику.
Отдельно — shutdown() против close() на сокете, разделённом через fork(). close() лишь уменьшает счётчик ссылок на дескриптор в текущем процессе — пока другой процесс держит копию, соединение живо. shutdown() действует на сам разделяемый сокет: он закрывает указанную половину соединения, и пир сразу видит FIN.
Масштабирование до 100 000 соединений
select() и poll() на каждом вызове обходят весь набор дескрипторов — это O(n) и упирается в потолок (select исторически ограничен ~1024). На больших числах соединений нужен epoll (Linux) или kqueue (BSD) — они работают за O(1) по активным событиям. Поток на соединение не масштабируется: тысячи потоков топят планировщик в контекстных переключениях. И не забудьте про лимиты ядра — ulimit -n, диапазон эфемерных портов, размеры буферов сокетов.
HTTP
HTTP — прикладной протокол запрос-ответ поверх TCP. Запрос несёт метод, и методы различаются по двум свойствам:
- Безопасный (safe) — не меняет состояние на сервере:
GET. - Идемпотентный — повтор даёт тот же результат:
GET,PUT,DELETEидемпотентны;POST— нет.
GET для операций, меняющих состояние, использовать нельзя — кэши и предзагрузчики выполнят его не вовремя. PUT — это полная замена ресурса, PATCH — частичное изменение.
HTTPS — это HTTP поверх TLS. Важно: HTTPS шифрует канал, но не делает сервер за ним доверенным — на том конце может быть и скомпрометированный узел. Без заголовка Strict-Transport-Security (HSTS) браузер при первом заходе можно понизить до HTTP.
Аутентификация в вебе:
- Cookie с флагом
HttpOnlyнедоступна JavaScript — это защита от кражи через XSS. - JWT в
localStorageхранить нельзя — XSS их украдёт; и у JWT надо заранее продумать стратегию отзыва.
CORS — это политика браузера: страница с одного origin по умолчанию не может обращаться к другому. На «непростой» запрос браузер шлёт предварительный OPTIONS (preflight); сервер обязан его обработать. Access-Control-Allow-Origin: * вместе с Allow-Credentials: true запрещён — нужен явный origin. И CORS не защищает от CSRF — для этого нужны SameSite-cookie или CSRF-токены.
HTTP/2 и HTTP/3
HTTP/1.1 обрабатывает по одному запросу на соединение за раз. HTTP/2 мультиплексирует много потоков в одном TCP-соединении — но head-of-line blocking остаётся на уровне TCP: потеря одного сегмента тормозит все потоки. HTTP/3 решает это, заменив транспорт: он построен на QUIC поверх UDP, где потоки независимы. Платой идёт то, что некоторые сети режут или блокируют UDP — HTTP/3 не всегда быстрее.
TLS
TLS шифрует соединение и аутентифицирует сервер (термин «SSL» устарел — SSL 2.0/3.0 сломаны; «SSL-сертификат» — это по сути сертификат X.509).
Рукопожатие TLS согласует набор шифров, обменивается ключами и проверяет сертификат сервера по цепочке доверия до корневого УЦ. TLS 1.2 тратил на это 2 round-trip; TLS 1.3 сократил рукопожатие до 1-RTT и убрал устаревшие шифронаборы.
TLS 1.3 добавил 0-RTT — отправку данных в первом же пакете при возобновлении сессии. 0-RTT-данные зашифрованы, поэтому называть их «небезопасными» неточно — конкретная проблема в том, что их можно воспроизвести (replay). Поэтому 0-RTT нельзя использовать для неидемпотентных запросов (POST, меняющий состояние). Кроме того, для early-data 0-RTT не даёт прямой секретности (forward secrecy).
Отдельно — отзыв сертификатов: скомпрометированный сертификат остаётся доверенным до истечения срока, если не проверять статус через OCSP или CRL.
Прикладные протоколы
DNS переводит имя в IP-адрес. Резолвинг рекурсивный: резолвер опрашивает корневой сервер, затем сервер зоны верхнего уровня, затем авторитативный сервер домена; результат кэшируется на время TTL. Длинные TTL при деплое дают устаревшие записи; хардкод IP «чтобы пропустить DNS» ломается при смене инфраструктуры.
WebSocket даёт постоянное двунаправленное соединение. Он стартует как обычный HTTP-запрос с заголовком Upgrade, после чего соединение переходит в полнодуплексный режим. Прокси и балансировщики закрывают простаивающие соединения — поэтому нужны heartbeat-кадры ping/pong.
REST против gRPC. REST поверх HTTP с JSON — человекочитаемый, простой для отладки и браузера. gRPC использует бинарный Protobuf со схемой и работает поверх HTTP/2 — компактнее и быстрее, но в браузере ограничен (нужен gRPC-Web), а payload не прочитать глазами.
Балансировка нагрузки бывает двух уровней. L4-балансировщик распределяет по транспорту (IP и порт) — быстро, но не видит содержимого. L7-балансировщик разбирает прикладной уровень (HTTP), поэтому может маршрутизировать по пути или заголовку и делать канареечные выкатки — но видит плейнтекст, так что терминация TLS происходит именно на нём.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| IP-адрес как число без учёта порядка байт | Мусор вместо адреса — нужны htonl/ntohl |
Нет SO_REUSEADDR на серверном сокете | bind() падает ~2 минуты после перезапуска (TIME_WAIT) |
recv() трактуют как C-строку | Чтение за пределами данных — recv возвращает счётчик байт |
TIME_WAIT считают багом | Это защита; реальная проблема — исчерпание портов |
Данные шлют сразу после SYN | Данные идут только после полного трёхстороннего рукопожатия |
cwnd путают с rwnd | Перегрузка сети ≠ переполнение получателя |
select/poll при десятках тысяч соединений | O(n) на вызов, потолок ~1024 — нужен epoll/kqueue |
GET для операции, меняющей состояние | Кэши и предзагрузчики выполнят её не вовремя |
JWT в localStorage | Кража токена через XSS |
Allow-Origin: * вместе с Allow-Credentials: true | Запрещено спецификацией — нужен явный origin |
| CORS считают защитой от CSRF | Не защищает — нужны SameSite-cookie или CSRF-токены |
| 0-RTT для неидемпотентного запроса | Риск replay-атаки — повтор изменит состояние |
HTTP/2 считают концом head-of-line blocking | На уровне TCP блокировка остаётся; решает только HTTP/3 |
Значение для собеседований
Сети — обязательная тема на интервью backend- и системного C++-разработчика. Проверяют не зубрёжку портов и аббревиатур, а понимание: какой уровень за что отвечает, какие гарантии даёт транспорт и где скрыта задержка.
Что проверяет интервьюер:
- Разницу TCP и UDP и осознанный выбор между ними
- Трёхстороннее рукопожатие,
TIME_WAIT, нумерацию байт - Управление потоком против управления перегрузкой (
rwndvscwnd) - Сокетный API и
shutdownпротивcloseна разделённом сокете - Масштабирование на десятки тысяч соединений (
epoll, лимиты ядра) - HTTP: идемпотентность методов, HTTPS vs HTTP, эволюцию HTTP/1.1 → 2 → 3
- TLS-рукопожатие, что изменил TLS 1.3 и в чём риск 0-RTT
Типичные вопросы:
- Чем TCP отличается от UDP и когда выбрать UDP?
- Как работает трёхстороннее рукопожатие и зачем нужен
TIME_WAIT? - Чем управление потоком отличается от управления перегрузкой?
- Чем
shutdown()отличается отclose()на сокете послеfork()? - Как масштабировать сервер до 100 000 соединений?
- Как устроено рукопожатие TLS 1.3 и в чём опасность 0-RTT?
Типичная ошибка: перечислять протоколы и команды, не объясняя механику и слой. Интервьюер ищет понимание границ между уровнями, гарантий транспорта и того, где именно возникает задержка или сбой.