Сеть: от пакета до сокета

Что на самом деле происходит между requests.get() и ответом: заголовки, маршруты, NAT, DNS, рукопожатия, окна TCP и очереди в ядре. И почему задержка важнее ширины канала.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Уровни и инкапсуляция

Каждый уровень заворачивает данные верхнего в свой заголовок. HTTP не знает про IP, IP не знает про порты, Ethernet доставляет кадр только до соседнего устройства.

Кадр Ethernet несёт не больше MTU = 1500 байт IP-пакета. Минус 40 байт заголовков IP и TCP — MSS 1460, а с опцией timestamps — 1448 байт данных. Всё, что больше, TCP режет на сегменты. Нажмите на уровень, чтобы увидеть его поля, и двигайте размер ответа.

IP-адрес и подсеть

Адрес 192.168.1.37/24 значит: первые 24 бита — номер сети, остальные — номер хоста. Перед отправкой ядро решает одно: получатель в моей сети или нет.

  • В той же сети — узнать MAC получателя через ARP и отправить кадр прямо ему.
  • В другой — отправить кадр на MAC шлюза, а IP получателя оставить в пакете.
  • Частные диапазоны (RFC 1918): 10/8, 172.16/12, 192.168/16. В интернете они не маршрутизируются.

Выберите получателя и двигайте длину префикса. Таблица маршрутов: ip route.

Маршрут по шагам

Ни один маршрутизатор не знает весь путь. Каждый смотрит адрес назначения, находит в своей таблице самый длинный подходящий префикс и отдаёт пакет следующему. И уменьшает TTL на единицу.

Когда TTL доходит до нуля, маршрутизатор выбрасывает пакет и отвечает ICMP Time Exceeded. На этом построен traceroute: он шлёт пакеты с TTL = 1, 2, 3… и собирает адреса тех, кто ответил. Нажимайте «Следующий TTL».

NAT: один адрес на всех

Дома, в офисе и в облачной VPC у машин частные адреса. Маршрутизатор подменяет адрес и порт отправителя на свой публичный и запоминает соответствие в таблице. Ответ он переводит обратно.

Запись в таблице живёт, пока по соединению ходят пакеты. Простой дольше таймаута — и запись удалена: соединение «тихо умерло», хотя обе стороны считают его открытым. Отсюда TCP keepalive и пинги в WebSocket. Нажимайте кнопки по порядку.

DNS: имя → адрес

getaddrinfo() спрашивает рекурсивный резолвер, а тот при пустом кэше обходит иерархию: корень → зона .com → авторитетный сервер домена. Ответ кэшируется на TTL.

  • Холодный запрос — сотни миллисекунд, из кэша — около миллисекунды.
  • JVM кэширует ответы сама (networkaddress.cache.ttl), Go и Python ходят в резолвер каждый раз.
  • В Kubernetes ndots:5: имя api.example.com сначала пробуют с суффиксами кластера — лишние запросы на каждое соединение.

UDP и TCP

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

  • Заголовок 8 байт: порты, длина, контрольная сумма. Больше ничего.
  • Нет соединения, порядка, повторов и контроля скорости. Потерялось — значит потерялось.
  • Границы сохраняются: один sendto() — одна датаграмма — один recvfrom().
  • DNS, видео- и голосовые звонки, игры, QUIC (HTTP/3) — надёжность там своя, поверх UDP.

TCP: поток байтов

  • Заголовок 20–60 байт: порты, номера последовательности, подтверждения, окно, флаги, опции.
  • Соединение, порядок, повторная отправка потерь, управление потоком и перегрузкой.
  • Границ сообщений нет: TCP доставляет байты, а не ваши send(). Разбивку на сообщения делает протокол сверху: длина в заголовке, разделитель, HTTP Content-Length.
  • Потеря одного сегмента задерживает всё, что идёт за ним.

Проверьте себя

Клиент три раза вызвал sock.send() по 100 байт. Сервер в цикле вызывает sock.recv(4096). Сколько вызовов вернут данные?

TCP — поток. Ядро отправителя может склеить записи в один сегмент (Nagle), сеть может разбить их по MSS, получатель читает всё, что успело накопиться в буфере. Локально чаще всего придёт один или три куска — и код, который считает «один recv = одно сообщение», работает, пока не попадёт в реальную сеть. Читайте, пока не наберётся длина из заголовка: readFully, io.ReadFull, readexactly.

Жизнь TCP-соединения

Каждый байт имеет номер. seq — номер первого байта в сегменте, ack — номер следующего байта, который получатель ждёт: «всё до него у меня есть».

  • Рукопожатие SYN → SYN-ACK → ACK: стороны обмениваются начальными номерами. Данные — не раньше чем через RTT.
  • Потерю видно по повторяющимся ACK: получатель снова просит тот же байт. Три дубликата — отправитель повторяет сегмент, не дожидаясь таймаута.
  • Закрывает каждая сторона свою половину (FIN). Кто закрыл первым — тот ждёт в TIME_WAIT.

Нажимайте «Шаг».

Управление потоком: окно получателя

В каждом ACK получатель сообщает, сколько ещё места в его буфере (win). Отправитель не держит в пути больше этого. Медленное приложение-получатель тормозит отправителя через сеть.

Двигайте скорость чтения. При нуле окно закрывается: отправитель ждёт и время от времени шлёт пробу. В ss -tn это видно как растущий Recv-Q у получателя и Send-Q у отправителя.

Контроль перегрузки

Окно получателя защищает получателя, а окно перегрузки cwnd — сеть. Отправитель не знает ширину канала и нащупывает её: в пути не больше min(cwnd, win).

  • Медленный старт: с 10 сегментов, каждый RTT — вдвое больше.
  • После порога — +1 сегмент за RTT (аддитивный рост).
  • Потеря — сигнал перегрузки: окно вдвое меньше (мультипликативное снижение). Таймаут — с нуля.

Здесь Reno. В Linux по умолчанию CUBIC (снижает до 0,7 и растёт по кубической кривой), есть BBR — он ориентируется на измеренные RTT и полосу, а не на потери.

Задержка против ширины канала

Канал — труба: ширина — сколько бит в секунду, длина — RTT. Чтобы заполнить трубу, в полёте должно быть произведение полосы на задержку (BDP). Пока окно меньше BDP, скорость — окно / RTT, а не полоса.

Ответ API в 20 КБ загружается почти одинаково по каналу 10 Мбит/с и 10 Гбит/с: всё время уходит на круги RTT. Двигайте ползунки.

Проверьте себя

Сервер в другом полушарии, RTT 150 мс, канал 1 Гбит/с. Сколько примерно займёт загрузка 100 КБ по новому HTTPS-соединению (TLS 1.3, адрес уже в кэше DNS)?

TCP-рукопожатие — 1 RTT, TLS 1.3 — ещё 1, запрос с первым окном ответа — 1. 100 КБ — это ~70 сегментов, а начальное окно 10: дальше 20 и 40 — ещё два круга. Итого 5 × 150 = 750 мс, из них передача битов — меньше миллисекунды. Лечится переиспользованием соединений, CDN рядом с пользователем и меньшим числом последовательных запросов.

Сколько кругов до ответа

Новое HTTPS-соединение: TCP — 1 RTT, TLS 1.2 — ещё 2, TLS 1.3 — ещё 1 (ключи отправляются сразу в ClientHello). Только потом запрос. QUIC совмещает транспорт и TLS: 1 RTT, при возобновлении — 0.

HTTP/1.1 на одном соединении — один запрос за раз, браузер открывает до 6 соединений. HTTP/2 и HTTP/3 мультиплексируют все запросы в одном. Переиспользованное соединение (keep-alive, пул) стоит только RTT самого запроса.

Блокировка очереди: HTTP/2 и QUIC

HTTP/2 гонит несколько потоков через одно TCP-соединение. Но TCP отдаёт приложению байты строго по порядку: потерян один сегмент — ждут все потоки, даже чьи данные уже пришли.

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

Nagle и отложенный ACK

Алгоритм Нейгла не отправляет маленький сегмент, пока предыдущий не подтверждён. Получатель же не спешит с ACK: ждёт до 40 мс (Linux) в надежде отправить его вместе с ответом. Вместе они дают паузу на ровном месте.

Типичный случай: заголовок и тело — двумя write(). TCP_NODELAY отключает Нейгла: Go ставит его по умолчанию, Node — для HTTP; в Python и Java сокет создаётся с Нейглом. Лучше всего — одна запись буфером.

Сокеты: что делает ядро

Сокет — файловый дескриптор, за которым в ядре стоят очереди и буферы. Рукопожатие ядро делает само, без вашего кода. read и write лишь копируют байты между вашей памятью и буферами ядра.

Выполняйте вызовы по одному. Неблокирующие сокеты и epoll — в презентации про асинхронный ввод-вывод.

Очередь accept

Готовые соединения ждут в очереди, пока приложение не вызовет accept(). Её длина — backlog из listen(), но не больше net.core.somaxconn (4096 с ядра 5.4).

Очередь полна — ядро молча отбрасывает SYN и последний ACK рукопожатия. Клиент повторит SYN через 1 с, потом через 2 с: задержка соединения прыгает на секунды. Счётчик — nstat -az TcpExtListenOverflows, длина очереди — ss -lnt (Recv-Q / Send-Q). Замедлите приложение.

Порты, TIME_WAIT и пулы

Соединение — четвёрка: адрес и порт клиента, адрес и порт сервера. К одному ip:port клиент может открыть столько соединений, сколько у него эфемерных портов: в Linux 32768–60999, это 28 232.

Закрытое клиентом соединение 60 секунд держит порт в TIME_WAIT — чтобы опоздавшие пакеты не попали в новое соединение с той же четвёркой. Новое соединение на каждый запрос упирается в cannot assign requested address. Пул соединений решает и это, и цену рукопожатий.

Проверьте себя

На HTTP-сервере ss -s показывает 40 000 соединений в TIME_WAIT. Что это значит?

В TIME_WAIT попадает тот, кто первым отправил FIN. На сервере это обычно безвредно: все четвёрки разные, свои порты он не тратит, запись в ядре — пара сотен байт. Но это подсказка: соединения не переиспользуются, и каждый запрос платит за рукопожатие. Опасен TIME_WAIT на клиенте, который бьёт в один адрес, — там кончаются порты.

Как это увидеть у себя

  • curl -w раскладывает время запроса: DNS, TCP, TLS, первый байт. Сразу видно, что съело время.
  • ss -tni — соединения со всеми внутренностями: состояние, очереди, cwnd, rtt, ретрансмиты.
  • tcpdump пишет пакеты, Wireshark их разбирает и рисует seq/ack.
  • dig +trace проходит цепочку DNS. mtr — traceroute с потерями на каждом шаге.
  • nstat — счётчики ядра: ретрансмиты, переполнения очередей, сбросы.

Если запрос «иногда висит ровно 1 или 3 секунды» — ищите потерянный SYN. «Ровно 40 мс» — Нейгл с отложенным ACK. «Ровно 5 секунд» — таймаут DNS-запроса.


$ curl -so /dev/null https://example.com -w \
  'dns %{time_namelookup} tcp %{time_connect} \
tls %{time_appconnect} ttfb %{time_starttransfer}\n'
dns 0.012 tcp 0.098 tls 0.187 ttfb 0.281

$ ss -tni state established dst :443
ESTAB 0 0 10.0.0.5:51544 93.184.215.14:443
  cubic rtt:86.2/1.4 cwnd:10 retrans:0/2 ...

$ ss -lnt sport :8080        # очередь accept
State  Recv-Q Send-Q Local Address:Port
LISTEN 129    128    0.0.0.0:8080

$ nstat -az | grep -E 'Retrans|ListenOverflows'
$ sudo tcpdump -ni any 'tcp port 443' -c 20
$ dig +trace example.com
$ mtr -rwc 50 example.com

Главное

  • Пакет — матрёшка заголовков, в кадре ~1,4 КБ данных. NAT держит таблицу с таймаутами, DNS — цепочка кэшей с TTL.
  • TCP — поток байтов с номерами, а не сообщения. Порядок и повторы стоят задержки, и одна потеря тормозит всё за ней.
  • Скорость ограничена окнами: получателя и перегрузки. Для маленьких ответов решают круги RTT, а не полоса.
  • Рукопожатие — ядро, write — копия в буфер, а не доставка. Медленный accept — потерянные SYN и секундные задержки.
  • Переиспользуйте соединения: пул убирает рукопожатия, TIME_WAIT и нехватку портов.

Проблемы с полосой решаются деньгами. С задержкой сложнее: скорость света не купишь.Стюарт Чешир, «It’s the Latency, Stupid»