Что на самом деле происходит между requests.get() и ответом: заголовки, маршруты, NAT, DNS, рукопожатия, окна TCP и очереди в ядре. И почему задержка важнее ширины канала.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
Каждый уровень заворачивает данные верхнего в свой заголовок. HTTP не знает про IP, IP не знает про порты, Ethernet доставляет кадр только до соседнего устройства.
Кадр Ethernet несёт не больше MTU = 1500 байт IP-пакета. Минус 40 байт заголовков IP и TCP — MSS 1460, а с опцией timestamps — 1448 байт данных. Всё, что больше, TCP режет на сегменты. Нажмите на уровень, чтобы увидеть его поля, и двигайте размер ответа.
Адрес 192.168.1.37/24 значит: первые 24 бита — номер сети, остальные — номер хоста. Перед отправкой ядро решает одно: получатель в моей сети или нет.
10/8, 172.16/12, 192.168/16. В интернете они не маршрутизируются.Выберите получателя и двигайте длину префикса. Таблица маршрутов: ip route.
Ни один маршрутизатор не знает весь путь. Каждый смотрит адрес назначения, находит в своей таблице самый длинный подходящий префикс и отдаёт пакет следующему. И уменьшает TTL на единицу.
Когда TTL доходит до нуля, маршрутизатор выбрасывает пакет и отвечает ICMP Time Exceeded. На этом построен traceroute: он шлёт пакеты с TTL = 1, 2, 3… и собирает адреса тех, кто ответил. Нажимайте «Следующий TTL».
Дома, в офисе и в облачной VPC у машин частные адреса. Маршрутизатор подменяет адрес и порт отправителя на свой публичный и запоминает соответствие в таблице. Ответ он переводит обратно.
Запись в таблице живёт, пока по соединению ходят пакеты. Простой дольше таймаута — и запись удалена: соединение «тихо умерло», хотя обе стороны считают его открытым. Отсюда TCP keepalive и пинги в WebSocket. Нажимайте кнопки по порядку.
getaddrinfo() спрашивает рекурсивный резолвер, а тот при пустом кэше обходит иерархию: корень → зона .com → авторитетный сервер домена. Ответ кэшируется на TTL.
networkaddress.cache.ttl), Go и Python ходят в резолвер каждый раз.ndots:5: имя api.example.com сначала пробуют с суффиксами кластера — лишние запросы на каждое соединение.sendto() — одна датаграмма — один recvfrom().send(). Разбивку на сообщения делает протокол сверху: длина в заголовке, разделитель, HTTP Content-Length.Клиент три раза вызвал sock.send() по 100 байт. Сервер в цикле вызывает sock.recv(4096). Сколько вызовов вернут данные?
readFully, io.ReadFull, readexactly.Каждый байт имеет номер. seq — номер первого байта в сегменте, ack — номер следующего байта, который получатель ждёт: «всё до него у меня есть».
Нажимайте «Шаг».
В каждом ACK получатель сообщает, сколько ещё места в его буфере (win). Отправитель не держит в пути больше этого. Медленное приложение-получатель тормозит отправителя через сеть.
Двигайте скорость чтения. При нуле окно закрывается: отправитель ждёт и время от времени шлёт пробу. В ss -tn это видно как растущий Recv-Q у получателя и Send-Q у отправителя.
Окно получателя защищает получателя, а окно перегрузки cwnd — сеть. Отправитель не знает ширину канала и нащупывает её: в пути не больше min(cwnd, win).
Здесь 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)?
Новое 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 гонит несколько потоков через одно TCP-соединение. Но TCP отдаёт приложению байты строго по порядку: потерян один сегмент — ждут все потоки, даже чьи данные уже пришли.
QUIC нумерует пакеты сам и знает, где какой поток: потеря тормозит только свой. На мобильной сети с потерями это заметная разница. Запустите передачу в обоих режимах.
Алгоритм Нейгла не отправляет маленький сегмент, пока предыдущий не подтверждён. Получатель же не спешит с ACK: ждёт до 40 мс (Linux) в надежде отправить его вместе с ответом. Вместе они дают паузу на ровном месте.
Типичный случай: заголовок и тело — двумя write(). TCP_NODELAY отключает Нейгла: Go ставит его по умолчанию, Node — для HTTP; в Python и Java сокет создаётся с Нейглом. Лучше всего — одна запись буфером.
Сокет — файловый дескриптор, за которым в ядре стоят очереди и буферы. Рукопожатие ядро делает само, без вашего кода. read и write лишь копируют байты между вашей памятью и буферами ядра.
Выполняйте вызовы по одному. Неблокирующие сокеты и epoll — в презентации про асинхронный ввод-вывод.
Готовые соединения ждут в очереди, пока приложение не вызовет accept(). Её длина — backlog из listen(), но не больше net.core.somaxconn (4096 с ядра 5.4).
Очередь полна — ядро молча отбрасывает SYN и последний ACK рукопожатия. Клиент повторит SYN через 1 с, потом через 2 с: задержка соединения прыгает на секунды. Счётчик — nstat -az TcpExtListenOverflows, длина очереди — ss -lnt (Recv-Q / Send-Q). Замедлите приложение.
Соединение — четвёрка: адрес и порт клиента, адрес и порт сервера. К одному ip:port клиент может открыть столько соединений, сколько у него эфемерных портов: в Linux 32768–60999, это 28 232.
Закрытое клиентом соединение 60 секунд держит порт в TIME_WAIT — чтобы опоздавшие пакеты не попали в новое соединение с той же четвёркой. Новое соединение на каждый запрос упирается в cannot assign requested address. Пул соединений решает и это, и цену рукопожатий.
На HTTP-сервере ss -s показывает 40 000 соединений в 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
write — копия в буфер, а не доставка. Медленный accept — потерянные SYN и секундные задержки.Проблемы с полосой решаются деньгами. С задержкой сложнее: скорость света не купишь.Стюарт Чешир, «It’s the Latency, Stupid»