Операционные системы Сетевой стек операционной системы: от сокета до драйвера
0%

Сетевой стек операционной системы: от сокета до драйвера

Сетевой стек операционной системы: от сокета до драйвера

Вы пишете conn.Write(data) — и данные оказываются на другом континенте. Между этими двумя событиями лежит, пожалуй, самый сложный подсистемный слой ядра: полтора десятка структур данных, пять очередей, две системы прерываний, конечный автомат протокола, алгоритм управления перегрузкой и драйвер, который общается с железом через кольцо DMA-дескрипторов.

Прикладному программисту незачем помнить смещения полей в struct tcp_sock. Но ему абсолютно необходимо понимать, где именно в этой цепочке стоят буферы, потому что почти все «сетевые» проблемы в проде — это переполнение или, наоборот, раздутость одной конкретной очереди:

  • сервис держит 10 000 rps, а на 10 001-м клиенты получают connection refused — переполнилась accept-очередь, а не «сеть легла»;
  • send() вернул успех, соединение оборвалось, данные потерялись — потому что успех send() означает лишь «скопировано в буфер сокета»;
  • вы увеличили SO_RCVBUF и стало хуже — потому что явная установка отключает автотюнинг окна;
  • p99 latency 800 мс при загрузке канала 30 % — классический bufferbloat в qdisc;
  • контейнер упирается в nf_conntrack: table full и роняет соединения, которых «не должно быть».

Разберём тракт целиком: сначала сверху вниз (что делает send()), потом снизу вверх (как пакет доходит до recv()), потом — очереди, ручки и различия между Linux, BSD, macOS и Windows NT.

Карта территории

Сетевой стек — это конвейер с чёткими границами ответственности. Каждый переход между блоками ниже — это либо смена контекста выполнения (процесс → softirq), либо смена структуры данных (буфер пользователя → sk_buff), либо точка, где пакет может быть отброшен.

Два наблюдения, которые стоит зафиксировать сразу.

Первое: передача и приём асимметричны. Отправка почти целиком происходит в контексте вашего процесса — вы «платите» за неё своим квантом CPU. Приём же случается в контексте прерывания и softirq, то есть до и независимо от того, вызвали ли вы recv(). Пакеты приходят, обрабатываются, складываются в буфер сокета — и только потом кто-то их забирает. Отсюда следует, что медленный читатель не тормозит сеть напрямую, он лишь позволяет буферу заполниться, после чего TCP схлопнет окно и отправитель сам притормозит.

Второе: границы между блоками — это места, где память копируется или, наоборот, нет. Ровно одна обязательная копия на TX (copy_from_iter из буфера пользователя в sk_buff) и одна на RX (skb_copy_datagram_iter обратно); всё многообразие zero-copy API существует ради того, чтобы убрать именно эти две копии.

Сокет: файловый дескриптор с протоколом внутри

Гениальность интерфейса сокетов (Билл Джой и коллеги, 4.2BSD, 1983) в том, что сетевое соединение притворилось файлом. read(), write(), close(), poll() работают на сокете ровно так же, как на файле — а всё специфичное вынесено в отдельные вызовы: socket(), bind(), listen(), accept(), connect(), setsockopt().

#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>

// SOCK_CLOEXEC атомарно: иначе между socket() и fcntl() форк утечёт дескриптор
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
int one = 1;
// SO_REUSEADDR — разрешить bind на порт в состоянии TIME_WAIT (это НЕ то же, что REUSEPORT)
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
// SO_REUSEPORT — несколько независимых listen-сокетов на одном порту, ядро шардирует по хешу
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));  // отключить алгоритм Нейгла

struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(8080) };
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
listen(fd, 1024);   // 1024 — размер accept-очереди, урезается до net.core.somaxconn

Внутри Linux у этого дескриптора три уровня объектов:

Это классическое «наследование через первое поле» в C: struct tcp_sock начинается с struct inet_connection_sock, тот — с struct inet_sock, тот — с struct sock. Поэтому (struct sock *)tcp_sk — корректный upcast, и общий код слоя сокетов работает с любым протоколом. Идею и подробности разбирает «Understanding Linux Network Internals» Кристиана Бенвенути и, для современного ядра, Linux Kernel Networking (Rami Rosen).

В FreeBSD роли те же, но имена другие: struct socket с двумя буферами so_rcv и so_snd (struct sockbuf), а протокол-специфичное состояние висит в so_pcbstruct inpcb / struct tcpcb. В Windows NT сокеты живут не в ядре как файлы, а обслуживаются драйвером afd.sys, к которому Winsock обращается через IOCTL; ядерный API называется WSK (Winsock Kernel).

sk_buff: структура, вокруг которой построено всё

Пакет внутри ядра Linux — это struct sk_buff («skb»). Ключевая идея: у пакета есть метаданные (кому принадлежит, какое устройство, где начинаются заголовки) и линейная область данных с запасом слева и справа. Заголовки не копируются в новый буфер на каждом слое — они дописываются в зарезервированный headroom движением указателя.

Раскладка sk_buff в памяти и добавление заголовков через skb_push

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

  1. Инкапсуляция стоит денег. Если вы заворачиваете трафик в VXLAN/WireGuard/IPsec и headroom не хватило, ядро вызывает pskb_expand_head() — аллокация плюс копирование всего пакета. Поэтому драйверы резервируют NET_SKB_PAD, и поэтому туннели заметно съедают CPU.
  2. Данные могут вообще не лежать в линейной части. Крупный TX-пакет — дескриптор с массивом frags[], указывающим прямо на страницы page cache. Так работает sendfile(): файл не проходит через user-space, страницы уходят в DMA напрямую (про page cache — https://courses.digitable.life/post/operating-systems/04-memory-management/).
  3. skb клонируется дёшево. skb_clone() копирует только метаданные, данные разделяются по refcount: так tcpdump получает копию пакета, не тормозя тракт, и так же один пакет отдаётся нескольким протокольным обработчикам.

Сравнение с другими семействами:

ОС Структура Модель
Linux struct sk_buff один непрерывный буфер + массив фрагментов-страниц
FreeBSD / macOS struct mbuf цепочка мелких буферов (256 B), крупные данные — во внешних кластерах mbuf cluster (2 KB)
OpenBSD / NetBSD struct mbuf та же схема, унаследована от 4.4BSD
Windows NT NET_BUFFER_LISTNET_BUFFERMDL список списков, данные описаны MDL-цепочками

Разница не косметическая. Модель mbuf-цепочки естественно описывает «добавить заголовок» как «прицепить ещё один mbuf спереди» — без резерва headroom, но с большей фрагментированностью и большим числом разыменований. Linux пошёл в сторону одного большого буфера ради кэш-локальности. Классическое описание mbuf — в TCP/IP Illustrated Volume 2 (Wright & Stevens) и в FreeBSD mbuf(9).

Путь наружу: что делает send()

Разберём send(fd, buf, 4096, 0) на TCP-сокете по шагам — с реальными именами функций ядра Linux, которые вы увидите в perf и bpftrace.

send() → sock_sendmsg → tcp_sendmsg
  lock_sock                       — блокировка сокета (BH-safe)
  tcp_send_mss                    — сколько влезет в сегмент: MSS с учётом опций
  sk_stream_alloc_skb             — взять skb или дописать в хвост последнего
  copy_from_iter                  — ЕДИНСТВЕННАЯ обязательная копия из user-space
  sk_stream_wait_memory           — если sndbuf полон: спать либо вернуть EAGAIN
  tcp_push → tcp_write_xmit
    tcp_cwnd_test / tcp_snd_wnd_test — разрешают ли окно перегрузки и окно приёмника?
    tcp_transmit_skb              — skb_push(20), TCP-заголовок, seq, флаги
      ip_queue_xmit
        ip_route_output_key       — поиск маршрута (кэш FIB)
        nf_hook LOCAL_OUT / POST_ROUTING  — netfilter
        ip_finish_output2 → neigh_output  — ARP: узнать MAC следующего hop
          dev_queue_xmit
            qdisc enqueue → dequeue       — traffic control
            ndo_start_xmit                — драйвер: дескриптор, doorbell, DMA

Четыре момента, которые критично понимать прикладнику.

send() вернул N — это не «отправлено». Это «скопировано в sk_write_queue». Данные останутся там до подтверждения ACK’ом, потому что TCP обязан уметь их переслать заново. Успешный send() и даже успешный close() не гарантируют доставки: если гарантия нужна — её обеспечивает протокол уровня приложения (ответ-подтверждение), точка.

Блокировка на send() — признак заполненного буфера отправки: приложение генерирует данные быстрее, чем сеть их вывозит (для неблокирующего сокета — EAGAIN). Коварная деталь: у сокета с большим SO_SNDBUF send() долго не блокируется, и в ядре накапливаются мегабайты, которые уже нельзя «отменить» — отсюда высокая latency при переключении на другой запрос. Лечится опцией TCP_NOTSENT_LOWAT (Linux 3.12+): epoll сообщит о готовности к записи только когда неотправленных данных меньше порога.

// Держать в ядре не более 128 КБ неотправленных данных — резко снижает latency
// в приложениях, которые мультиплексируют потоки поверх одного соединения (HTTP/2, gRPC)
int lowat = 128 * 1024;
setsockopt(fd, IPPROTO_TCP, TCP_NOTSENT_LOWAT, &lowat, sizeof(lowat));

Два независимых «тормоза» на отправку. tcp_cwnd_test — окно перегрузки, оценка ядром пропускной способности сети. tcp_snd_wnd_test — окно приёмника, сколько готов принять другой конец. Реально можно отправить min(cwnd, rwnd) - in_flight байт. Диагностика тривиальна: если в ss -ti окно cwnd маленькое — виновата сеть/потери; если мал rcv_space у пира — виноват медленный читатель на той стороне.

Между IP-слоем и драйвером стоит qdisc. Это не деталь для сетевых инженеров: дисциплина очереди по умолчанию определяет, как ваш трафик конкурирует сам с собой. pfifo_fast (старый дефолт) при заполнении раздувает задержку всем потокам сразу; fq_codel (дефолт в современных дистрибутивах) изолирует потоки и активно дропает из раздутых очередей.

Путь внутрь: от прерывания до recv()

Приём устроен интереснее, потому что происходит в отрыве от приложения.

Ключевые механизмы этого тракта:

NAPI (New API, ядро 2.4.20). Наивная схема «прерывание на каждый пакет» на 10 Гбит/с даёт до 14 млн прерываний в секунду — машина умирает от переключений контекста, не сделав ничего полезного. NAPI переключается в режим опроса: первое прерывание маскирует IRQ и планирует softirq, который выгребает пакеты пачками, пока кольцо не опустеет или не кончится бюджет (net.core.netdev_budget, по умолчанию 300 пакетов / 2 мс). Это тот же приём, что в блочном I/O; подробнее про прерывания и DMA — https://courses.digitable.life/post/operating-systems/06-io-and-drivers/.

GRO (Generic Receive Offload). Соседние TCP-сегменты одного потока склеиваются в один большой skb (до 64 КБ) ещё до входа в IP-слой: один проход стека вместо сорока — разница в разы по CPU. Обратная сторона: GRO ломает точность таймстемпов и мешает отладке — tcpdump покажет «пакеты» по 64 КБ, которых в проводе не было. Отключается через ethtool -K eth0 gro off.

RPS / RFS. Если карта однокольцевая или прерывания сходятся на одно ядро, softirq становится бутылочным горлышком. RPS (Receive Packet Steering) распределяет пакеты по ядрам программно, RFS (Receive Flow Steering) дополнительно старается отправить поток на то ядро, где сидит читающий процесс — ради горячего L2-кэша (/sys/class/net/eth0/queues/rx-*/rps_cpus).

# Обработка по ядрам: колонка 1 — обработано, 2 — dropped (backlog переполнен), 3 — time_squeeze
$ cat /proc/net/softnet_stat
2c4e1f38 00000000 00000a41 00000000 00000000 00000000 00000000 00000000
0f3a2b90 000000c7 000001d3 00000000 00000000 00000000 00000000 00000000

Ненулевая вторая колонка означает: netdev_max_backlog переполнен, пакеты выброшены до всякой обработки. Ненулевая третья — softirq регулярно исчерпывает бюджет, стоит поднять netdev_budget или раскидать очереди по ядрам.

Приём соединения: две очереди, которые все путают

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

То есть соединение может быть полностью установлено (клиент уже шлёт данные!) и при этом висеть в accept-очереди, потому что приложение не успевает вызывать accept(). Клиент видит успешный connect() и таймаут на ответ — а на сервере растут ListenOverflows.

# Recv-Q у LISTEN-сокета — это ТЕКУЩАЯ длина accept-очереди, Send-Q — её максимум
$ ss -ltn
State   Recv-Q  Send-Q  Local Address:Port
LISTEN  0       511     0.0.0.0:80
LISTEN  128     128     0.0.0.0:8080      # очередь ПОЛНА — приложение не успевает accept()

$ nstat -az | grep ListenOverflows        # счётчик отброшенных из-за переполнения
TcpExtListenOverflows           14237     0.0

Обратите внимание: listen(fd, 511) в nginx не значит 511, если net.core.somaxconn = 128 — значение молча урезается. В Linux 5.4+ дефолт somaxconn подняли до 4096, но во многих контейнерных базовых образах и старых ядрах он всё ещё 128, и это один из самых дешёвых тюнингов на свете.

Конечный автомат TCP и практические последствия

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

CLOSE_WAIT копится тысячами — это баг вашего приложения. Пир закрыл соединение, ядро перевело сокет в CLOSE_WAIT и ждёт, когда вы вызовете close(). Ядро не может закрыть за вас. Копящиеся CLOSE_WAIT — всегда утечка дескрипторов в коде: забытый defer conn.Close(), исключение мимо finally, пул соединений, который не убирает мёртвые.

TIME_WAIT копится десятками тысяч — обычно это нормально. Состояние живёт 60 секунд и нужно, чтобы задержавшиеся сегменты старого соединения не попали в новое с той же четвёркой (src ip, src port, dst ip, dst port); занимает ~300 байт. Проблема возникает только на стороне, которая инициирует закрытие и открывает много исходящих соединений: кончаются эфемерные порты (net.ipv4.ip_local_port_range). Лекарства: keep-alive и пул соединений; net.ipv4.tcp_tw_reuse=1 (безопасно для исходящих); шире диапазон портов. Опасное и неправильное: tcp_tw_recycle — ломал NAT-клиентов и удалён из ядра в 4.12. FIN_WAIT_2 же висит долго, когда пир получил ваш FIN, но свой не шлёт — то есть другая сторона застряла в CLOSE_WAIT.

Полезно знать и про RST: «Connection reset by peer» имеет три обычные причины — приложение на той стороне упало или закрыло сокет с непрочитанными данными; сработал SO_LINGER с нулевым таймаутом (жёсткий обрыв вместо FIN); пакет пришёл на порт, где никто не слушает. Firewall, в отличие от этого, чаще даёт таймаут, а не RST.

Пять очередей, ручки и счётчики

Теперь соберём всё вместе. Между проводом и вашим кодом ровно пять мест, где данные ждут, и у каждого — своя ручка и свой счётчик потерь.

Цепочка очередей сетевого стека: буферы, ручки настройки и счётчики дропов

Размер буфера — это не «чем больше, тем лучше»

Пропускная способность TCP ограничена сверху формулой BDP (bandwidth-delay product):

максимальная пропускная способность = размер окна / RTT

Канал 1 Гбит/с с RTT 100 мс требует окна 1e9 бит/с × 0.1 с / 8 = 12.5 МБ. Если буфер приёма 256 КБ, вы получите максимум 256 КБ / 0.1 с ≈ 2.6 МБ/с — 21 Мбит/с вместо гигабита, и никакие «оптимизации приложения» этого не изменят. Это самая частая причина «медленной репликации между регионами».

Linux с 2.4 умеет автотюнинг: net.ipv4.tcp_rmem и tcp_wmem задают тройку min default max, ядро само подбирает размер по наблюдаемому BDP.

$ sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem
net.ipv4.tcp_rmem = 4096	131072	6291456     # min / default / max
net.ipv4.tcp_wmem = 4096	16384	4194304

# Для WAN-передач поднимаем потолок автотюнинга до 64 МБ
$ sudo sysctl -w net.ipv4.tcp_rmem="4096 131072 67108864" net.core.rmem_max=67108864

Ловушка №1: явный вызов setsockopt(SO_RCVBUF) отключает автотюнинг для этого сокета навсегда. Приложение, которое «на всякий случай» ставит 256 КБ, тем самым запрещает ядру вырасти до нужных 12 МБ. Не трогайте SO_RCVBUF/SO_SNDBUF, если вы не измерили, что автотюнинг вам вредит.

Ловушка №2: ядро удваивает переданное значение (SO_RCVBUF учитывает накладные расходы на sk_buff), так что getsockopt вернёт вдвое больше, чем вы установили. Это не баг.

Bufferbloat: слишком большая очередь хуже маленькой

Интуиция «увеличим буфер, чтобы не терять пакеты» на транзитных очередях катастрофически ошибочна. TCP определяет перегрузку по потерям и задержке; если между вами и узким местом стоит очередь на секунду трафика, TCP узнает о проблеме с задержкой в секунду — и всё это время интерактивный трафик (SSH, DNS, игры) стоит в той же очереди. Это и есть bufferbloat, описанный Джимом Геттисом (bufferbloat.net).

Решение — умные дисциплины очереди: CoDel держит целевую задержку (5 мс), дропая пакеты при её превышении, FQ добавляет справедливое разделение между потоками.

$ tc qdisc show dev eth0
qdisc fq_codel 8001: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms

# Статистика: dropped — сколько выкинула дисциплина, overlimits — сколько раз упёрлись в лимит
$ tc -s qdisc show dev eth0 | tail -3
 Sent 8934821334 bytes 6721938 pkt (dropped 1832, overlimits 0 requeues 41)
 backlog 0b 0p requeues 41
  maxpacket 1514 drop_overlimit 0 new_flow_count 90211 ecn_mark 0

Congestion control: cubic против BBR

Алгоритм управления перегрузкой — это подключаемый модуль ядра. Дефолт в Linux — CUBIC: наращивает окно по кубической функции, сокращает при потере пакета. Он предполагает, что потеря = перегрузка, что неверно для беспроводных сетей и для каналов с большими буферами (потери там не случаются, зато растёт задержка).

BBR (Google, 2016) вместо потерь измеряет две величины: минимальный RTT и максимальную наблюдаемую пропускную способность — и держит скорость ровно в точке оптимума, не заполняя буферы. На дальних маршрутах с потерями BBR даёт кратный выигрыш; см. BBR: Congestion-Based Congestion Control (ACM Queue, 2016).

$ sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic bbr
$ sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
$ sudo tc qdisc replace dev eth0 root fq   # BBR требует пейсинга: qdisc fq обязателен

Можно и на отдельный сокет: setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr", 3). Это удобно, когда сервис обслуживает и локальных клиентов (лучше cubic), и трансконтинентальных (лучше BBR).

FreeBSD с 13.0 тоже поддерживает подключаемые модули (cc_cubic, cc_htcp, cc_newreno, есть порт BBR), Windows с Server 2019 использует CUBIC по умолчанию и умеет LEDBAT для фонового трафика.

Фильтрация и обработка: netfilter, nftables, XDP

Сетевой тракт Linux пронизан хуками, к которым цепляются фаервол, NAT, conntrack и eBPF-программы.

Что здесь важно прикладному программисту:

  • XDP (eXpress Data Path) выполняется в драйвере, до создания sk_buff, что позволяет отбрасывать пакеты за десятки наносекунд — на этом строится защита от DDoS у Cloudflare и балансировщик Katran у Meta. Цена: ограниченный eBPF и никакого доступа к состоянию сокетов.
  • conntrack — таблица состояний, необходимая для NAT и правил -m state ESTABLISHED. В контейнерных окружениях с высоким rps она переполняется: в логах nf_conntrack: table full, dropping packet, соединения рвутся без видимой причины. Смотреть net.netfilter.nf_conntrack_count против nf_conntrack_max.
  • nftables заменил iptables: единая таблица правил, компилируемая в байткод виртуальной машины вместо линейного перебора цепочек (iptables-nft — совместимая обёртка).

В BSD-мире аналог — pf (OpenBSD, портирован во FreeBSD/NetBSD/macOS), синтаксически куда чище iptables; во FreeBSD есть ещё ipfw и dummynet для эмуляции задержек и потерь. В Windows NT фильтрация живёт в WFP (Windows Filtering Platform), а драйверный уровень — NDIS с filter-драйверами. Про различия семейств подробнее в https://courses.digitable.life/post/operating-systems/15-bsd-family/ и https://courses.digitable.life/post/operating-systems/16-other-operating-systems/.

Мультиплексирование: где ОС расходятся сильнее всего

Один поток, тысячи соединений — вопрос «как узнать, какой сокет готов» решается в каждой ОС по-своему, и это едва ли не главное практическое различие между ними.

API ОС Сложность Модель
select / poll везде (POSIX) O(n) на каждый вызов передаём весь набор fd каждый раз
epoll Linux 2.5.44+ O(1) набор живёт в ядре, возвращаются только готовые
kqueue FreeBSD 4.1+, macOS, OpenBSD, NetBSD O(1) универсальные события: fd, таймеры, сигналы, vnode, процессы
IOCP Windows NT O(1) завершение, а не готовность: ядро само пишет в ваш буфер
io_uring Linux 5.1+ O(1), батчинг кольца SQ/CQ в разделяемой памяти, syscall не обязателен
/dev/poll, event ports Solaris/illumos O(1) исторические предшественники

Принципиальная разница: epoll и kqueue — это readiness notification («сокет готов, читай сам»), IOCP и io_uringcompletion notification («операция уже выполнена, данные в твоём буфере»). Вторая модель лучше ложится на асинхронный ввод-вывод и убирает лишний системный вызов, но требует, чтобы буфер был отдан ядру заранее.

// Linux: epoll в edge-triggered режиме — читаем до EAGAIN, иначе событие потеряется
int ep = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = conn_fd };
epoll_ctl(ep, EPOLL_CTL_ADD, conn_fd, &ev);

struct epoll_event events[64];
int n = epoll_wait(ep, events, 64, -1);
for (int i = 0; i < n; i++) {
    for (;;) {
        ssize_t got = recv(events[i].data.fd, buf, sizeof(buf), 0);
        if (got > 0)  { handle(buf, got); continue; }
        if (got == 0) { close_conn(events[i].data.fd); break; }   // пир закрыл
        if (errno == EAGAIN) break;                               // всё вычитали
        if (errno == EINTR)  continue;
        close_conn(events[i].data.fd); break;
    }
}
// FreeBSD/macOS: kqueue — одно API для сокетов, таймеров, сигналов и файловых событий
int kq = kqueue();
struct kevent chg, evs[64];
EV_SET(&chg, conn_fd, EVFILT_READ, EV_ADD | EV_CLEAR, 0, 0, NULL);  // EV_CLEAR ≈ edge-triggered
kevent(kq, &chg, 1, NULL, 0, NULL);
int n = kevent(kq, NULL, 0, evs, 64, NULL);
// Бонус kqueue: evs[i].data — число доступных байт, можно сразу взять буфер точного размера

Level-triggered против edge-triggered — источник классических багов. В ET-режиме ядро сообщает о событии один раз при изменении состояния; если вы прочитали половину и вернулись в epoll_wait, повторного уведомления не будет, и соединение «зависнет» с данными в буфере. Правило простое: ET требует читать и писать в цикле до EAGAIN. Практически вы почти никогда не пишете это руками — рантайм Go (netpoller), libuv в Node.js, Netty в JVM, Tokio в Rust, :gen_tcp поверх BEAM прячут epoll/kqueue/IOCP за одинаковым интерфейсом. Но понимать, что там внутри, необходимо: именно оттуда берутся отличия поведения приложения на Linux и macOS. О рантайме Go в этом контексте — https://courses.digitable.life/post/golang/00-overview/.

Zero-copy и обход ядра

Обязательная копия user↔kernel стоит дорого: на 10 Гбит/с это ~1.25 ГБ/с трафика через память, заметная доля ядра CPU. Способы её избежать, по возрастанию радикальности. sendfile(out_fd, in_fd, ...) — отправить файл в сокет без прохода через user-space: страницы page cache подцепляются во фрагменты skb, именно так nginx отдаёт статику (ограничение: данные нельзя трансформировать, TLS ломает схему, если нет kTLS).

splice() / vmsplice() — перенос данных между дескрипторами через pipe-буфер в ядре; позволяет строить прокси без копирования (socket → pipe → socket). MSG_ZEROCOPY (Linux 4.14+) — send() не копирует, а закрепляет страницы пользователя; уведомление о том, что буфер можно переиспользовать, приходит через error queue сокета (выигрышно от ~10 КБ на сообщение, ниже накладные расходы съедают эффект). kTLS (Linux 4.13+, есть и во FreeBSD) — шифрование переносится в ядро или в NIC, что возвращает возможность использовать sendfile() для HTTPS.

AF_XDP / DPDK / netmap — полный обход стека: приложение получает кольца дескрипторов напрямую в user-space. Пропускная способность десятки Mpps, но вы теряете всё, что даёт ядро: TCP, маршрутизацию, фаервол, сокеты, tcpdump. Оправдано для NFV, программных роутеров и балансировщиков, почти никогда — для обычного сервиса. netmap придуман Луиджи Риццо во FreeBSD (USENIX ATC'12).

# Какие offload включены: checksum, TSO и GRO делают за вас работу, которую иначе делает CPU
$ ethtool -k eth0 | grep -E 'tcp-segmentation|generic-receive|checksum' | head -4
rx-checksumming: on
tx-checksumming: on
tx-tcp-segmentation-offload: on
generic-receive-offload: on

Диагностика: что запускать, когда «сеть тормозит»

Порядок действий, который экономит часы.

# 1) Состояние конкретных соединений с внутренними метриками TCP
$ ss -tin dst 10.0.5.7
ESTAB 0 214748 10.0.1.4:44312 10.0.5.7:5432
	 cubic wscale:7,7 rto:236 rtt:34.8/1.2 mss:1448 cwnd:10 ssthresh:7
	 bytes_sent:88213 bytes_acked:88213 segs_out:812 segs_in:790
	 retrans:0/17 rcv_space:14480 notsent:0 lastsnd:4 lastrcv:4

Читаем: rtt:34.8/1.2 — среднее и разброс; cwnd:10 при ssthresh:7 — окно микроскопическое, значит недавно были потери; retrans:0/17 — 17 ретрансмиссий за жизнь соединения; notsent — сколько байт лежит в буфере отправки неотправленными.

# 2) Агрегированные счётчики протокола: показывает только НЕНУЛЕВЫЕ — идеально для поиска аномалий
$ nstat -az | grep -E 'Retrans|Drop|Overflow|Timeout' | head -4
TcpRetransSegs                  84213     0.0
TcpExtListenOverflows              12     0.0
TcpExtTCPTimeouts                1041     0.0
TcpExtTCPSynRetrans               338     0.0

# 3) Дропы на уровне устройства (счётчики железа и драйвера)
$ ethtool -S eth0 | grep -Ei 'drop|miss|no_buf'
     rx_missed_errors: 0
     rx_no_buffer_count: 921
     tx_dropped: 0

# 4) Дропы внутри ядра — с точным местом, где пакет умер (tracepoint с reason, Linux 5.17+)
$ sudo bpftrace -e 'tracepoint:skb:kfree_skb { @[kstack(3)] = count(); }'

# 5) Что реально в проводе (обязательно -n, чтобы не ждать DNS)
$ sudo tcpdump -ni eth0 -c 20 'tcp port 5432 and (tcp[tcpflags] & (tcp-rst|tcp-syn) != 0)'

Отдельно про /proc/net — это окно во внутренности стека без всяких утилит: tcp/tcp6 (все сокеты в hex: адреса, состояние, inode, uid, длины очередей — именно отсюда читает ss), snmp и netstat (сырые счётчики, которые агрегирует nstat), softnet_stat (обработка и дропы по ядрам), nf_conntrack (таблица соединений). Плюс /proc/sys/net/ipv4/* — все sysctl как обычные файлы и /sys/class/net/eth0/statistics/* — счётчики интерфейса поштучно.

Во FreeBSD эквиваленты: netstat -s, netstat -m (mbuf), sockstat, sysctl net.inet.tcp, трассировка через DTrace. В macOS — netstat, nettop, dtruss. В Windows — netstat -ano, Get-NetTCPConnection, netsh int tcp show global, счётчики Performance Monitor.

Типичные заблуждения

«TCP передаёт сообщения». Нет: TCP — поток байтов. Один send(1000 байт) может прийти как три recv(), а три send() — как один. Любой протокол поверх TCP обязан сам делать framing (длина-префикс, разделитель, HTTP-заголовки). Это причина №1 багов в самописных протоколах. Если нужны сообщения — SOCK_SEQPACKET, SCTP или явный фрейминг.

«Успешный send() = данные доставлены». Разобрано выше: это лишь копия в буфер ядра, и даже close() без SO_LINGER возвращается немедленно, не дожидаясь подтверждений.

«localhost — это бесплатно». Через loopback пакет всё равно проходит слой сокетов, протокольную обработку и netfilter (правила INPUT/OUTPUT применяются!). Копирование остаётся, MTU там 65536, но задержка порядка 20–40 мкс и буферы вполне себе переполняются. Для действительно быстрого локального IPC используйте Unix domain sockets (в 2–3 раза быстрее TCP на loopback) или разделяемую память — см. https://courses.digitable.life/post/operating-systems/07-syscalls-and-ipc/.

«Nagle надо отключать всегда». Алгоритм Нейгла (не слать новый мелкий сегмент, пока не подтверждён предыдущий) вредит в паре с delayed ACK: искусственная задержка до 40 мс на request-response. Но TCP_NODELAY увеличивает число пакетов, и если вы пишете крупными буферами, отключение ничего не даст. Правильная стратегия — собирать сообщение в один write()/writev(), а не полагаться на ядро.

«Потери пакетов — всегда вина сети». Дроп на входе часто происходит на самом хосте: переполненный backlog, исчерпанный netdev_budget, полный conntrack, переполненная accept-очередь. Поэтому диагностику надо начинать с локальных счётчиков, а не с ping до шлюза.

«MTU 1500 — можно слать 1500 байт». Payload TCP = MTU − 20 (IP) − 20 (TCP) = 1460, а с опциями (timestamps) — 1448. В туннелях (VXLAN, WireGuard, PPPoE) меньше. Классическая беда — PMTU black hole: промежуточный узел режет ICMP «Fragmentation needed», и соединение устанавливается, но зависает на первом крупном ответе. Симптом узнаваемый: handshake проходит, мелкие запросы работают, большие — таймаут. Лечится net.ipv4.tcp_mtu_probing=1.

«SO_REUSEPORT — это про TIME_WAIT». Нет, это SO_REUSEADDR. SO_REUSEPORT (Linux 3.9+, давно есть в BSD) позволяет N процессам держать свой listen() на одном порту, ядро распределяет входящие соединения по хешу четвёрки. Это способ горизонтально масштабировать accept без единой точки контention — и способ делать перезапуск без потери соединений.

Практический чек-лист для сервиса под нагрузкой

  1. net.core.somaxconn ≥ 4096 и backlog в listen() не меньше — иначе всплески соединений теряются молча.
  2. Не устанавливайте SO_RCVBUF/SO_SNDBUF вручную; поднимайте потолки tcp_rmem/tcp_wmem, если BDP это требует.
  3. TCP_NODELAY для request-response протоколов, TCP_NOTSENT_LOWAT для мультиплексированных (HTTP/2, gRPC).
  4. Включите TCP keep-alive (SO_KEEPALIVE + TCP_KEEPIDLE) или прикладные пинги: без них полумёртвые соединения через NAT висят часами.
  5. qdisc = fq_codel или fq (для BBR — обязательно fq); прерывания NIC раскиданы по ядрам, а не сидят на CPU0.
  6. ulimit -n и fs.file-max под число соединений; диапазон ip_local_port_range и пул — если сервис ходит наружу.
  7. Мониторьте четыре числа как SLI инфраструктуры: TcpRetransSegs, ListenOverflows, softnet_stat колонка 2, nf_conntrack_count/max.
  8. Прежде чем крутить sysctl — найдите растущий счётчик. Тюнинг без измерения делает хуже примерно всегда.

Что стоит прочитать

Мини-итог

Сетевой стек — конвейер из очередей, а не «чёрный ящик, который иногда тормозит». Сверху вниз: буфер отправки сокета (ограничен tcp_wmem и окнами cwnd/rwnd) → qdisc → TX-кольцо драйвера → провод. Снизу вверх: RX-кольцо → NAPI-опрос в softirq с GRO-склейкой → протокольная обработка с хуками netfilter → приёмный буфер сокета → пробуждение процесса. Отправка идёт в вашем контексте, приём — в контексте прерывания и независимо от вас. sk_buff (mbuf в BSD, NET_BUFFER_LIST в NT) — единица работы всего тракта: заголовки наращиваются влево в зарезервированный headroom, что делает инкапсуляцию дешёвой, а её нехватку — дорогой.

Всё практическое знание сводится к умению найти конкретную переполненную очередь: ss -tin для состояния соединений, nstat -az для счётчиков протокола, softnet_stat для softirq, tc -s qdisc для дисциплины, ethtool -S для железа. Дальше понятно, какую ручку крутить — и, что важнее, какую крутить не надо.

Что дальше

Безопасность и изоляция: права, capabilities, SELinux, namespaces, cgroups, jails — там мы разберём, как ядро отвечает на вопрос «кому что можно», включая сетевые namespaces, которые превращают описанный здесь единый стек в десятки независимых копий внутри контейнеров.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов