Компьютерные сети TCP: установка соединения, надёжность, окна, управление перегрузкой
0%

TCP: установка соединения, надёжность, окна, управление перегрузкой

TCP: установка соединения, надёжность, окна, управление перегрузкой

IP не обещает ничего. Пакет может пропасть, продублироваться, прийти вторым из трёх или превратиться в мусор — и это не поломка, а спецификация: маршрутизатор с переполненной очередью выбрасывает пакет и идёт дальше, ничего никому не сообщая. Между этой честной, но бесполезной для прикладного кода моделью и вашим write(fd, buf, n) стоит TCP.

Аналогия, которая держится всю статью: пересылка книги открытками. У вас 900 страниц, а на открытку влезает одна. Вы нумеруете открытки, получатель присылает расписки «получил всё до номера N включительно», потерянные вы шлёте заново, а ещё — и это самое неочевидное — вы не имеете права вывалить в ящик все 900 сразу: почта захлебнётся, и тогда потеряется вообще всё. Первая половина TCP — про нумерацию и расписки (надёжность), вторая — про темп (окна и перегрузка). Вторая сложнее и важнее: именно она отделяет работающий интернет от коллапса.

TCP описан в RFC 793 (1981), действующая консолидированная спецификация — RFC 9293 (2022), собравшая сорок лет правок в один документ. Читать её стоит как учебник по инженерному компромиссу: почти каждый механизм здесь — заплатка на конкретную поломку, которая когда-то стоила индустрии денег. Предполагается, что вы прошли канальный уровень (кадры, MTU) и IP с маршрутизацией; карта уровней — в обзоре трека.

Что TCP даёт и чем вы за это платите

Гарантия Как достигается Цена
Доставка (или явная ошибка) номера последовательности, ACK, ретрансмиссии по таймеру и по дубликатам восстановление ≈ RTT (быстрое) или RTO (медленное)
Порядок байтов приёмник не отдаёт данные с дырой head-of-line blocking: одна потеря тормозит весь поток
Отсутствие дубликатов окно приёма отбрасывает уже принятое состояние на соединение в ядре, TIME_WAIT после закрытия
Целостность (слабая) 16-битная сумма по псевдозаголовку ловит не всё; на терабайтах бывают пропущенные ошибки
Управление потоком окно получателя rwnd медленный читатель тормозит быстрого писателя (это фича)
Управление перегрузкой окно перегрузки cwnd, AIMD старт не на полной скорости, чувствительность к потерям
Абстракция «поток байтов» сегментация и склейка границ сообщений НЕТ — фреймить обязано приложение

Три вещи, которых TCP не даёт и за которые их регулярно принимают:

  • Не даёт границ сообщений. Один send() может приехать тремя recv(), три send() — одним. Любой протокол поверх TCP обязан нести длину или разделитель. Половина багов в самописных бинарных протоколах — отсюда, и тесты на localhost её не ловят: там MTU 65536 и всё «случайно» приходит целиком.
  • Не даёт доставки «доехало до приложения». Успешный write() = «скопировано в буфер ядра», ACK = «принято стеком получателя», и ни то ни другое не означает, что процесс на той стороне это прочитал. Подтверждения на прикладном уровне и идемпотентность никуда не деваются — см. распределённые системы.
  • Не даёт безопасности. Ни шифрования, ни аутентификации: подделать RST или влезть в поток, зная 4-кортеж и окно, вполне реально. Этим занимается TLS.

Заголовок: где что лежит

Раскладка заголовка TCP по битам

  • Seq — номер первого байта сегмента, Ack — номер следующего ожидаемого байта. Подтверждение кумулятивное: ack=5001 значит «всё до 5000 включительно у меня есть» и ничего не говорит про то, что пришло после дыры. Именно поэтому понадобился SACK.
  • Начальный номер (ISN) случайный — не из эстетики: предсказуемый ISN позволял вслепую вставлять данные в чужое соединение (RFC 6528).
  • Окно — всего 16 бит, максимум 65535 байт. На канале 1 Гбит/с с RTT 100 мс этого хватает на 5 Мбит/с, то есть на 0.5% канала. Спасает опция Window Scale (RFC 7323) с множителем до 2^14 → окно до 1 ГБ.
  • Опции согласуются только в SYN. Если middlebox вырезал Window Scale или SACK-permitted из SYN (такие ещё встречаются в корпоративных сетях), соединение навсегда останется медленным и никакой sysctl это не починит. Это одна из главных причин, по которой TCP так тяжело эволюционирует — см. UDP и QUIC.
  • Флаг PSH просит стек не придерживать данные; URG и Urgent Pointer — рудимент, реализованный везде по-разному, не используйте. Контрольная сумма почти всегда считается сетевой картой, поэтому tcpdump на отправляющем хосте штатно показывает cksum … (incorrect -> …) — это checksum offload, а не ошибка.

Установка соединения

Почему не два пакета? Обе стороны обязаны синхронизировать свой начальный номер и получить подтверждение, что он принят. Это две независимые синхронизации, склеенные в средний пакет — отсюда «полтора обмена» и ровно один RTT до первого байта данных. Практическое следствие: до первого байта ответа проходит минимум 1 RTT на TCP + 1 RTT на TLS 1.3 + 1 RTT на сам запрос. При RTT 60 мс это 180 мс ещё до того, как сервер начал думать — отсюда keep-alive и пулы соединений (см. HTTP и прокси).

Как это выглядит в tcpdump

# -n не резолвить имена, -S абсолютные номера последовательности, -tttt читаемое время.
# Абсолютные номера критичны: относительные tcpdump и Wireshark считают сами,
# и они не совпадают с тем, что реально в проводе.
sudo tcpdump -ni eth0 -S -tttt 'host 93.184.216.34 and tcp port 443'
12:04:11.101233 IP 10.0.0.12.51234 > 93.184.216.34.443: Flags [S], seq 2387461992, win 64240, options [mss 1460,sackOK,TS val 3921884711 ecr 0,nop,wscale 7], length 0
12:04:11.128940 IP 93.184.216.34.443 > 10.0.0.12.51234: Flags [S.], seq 1180129371, ack 2387461993, win 65160, options [mss 1400,sackOK,TS val 274119320 ecr 3921884711,nop,wscale 9], length 0
12:04:11.128992 IP 10.0.0.12.51234 > 93.184.216.34.443: Flags [.], ack 1180129372, win 502, options [nop,nop,TS val 3921884739 ecr 274119320], length 0

Что читается за пять секунд: [S] = SYN, [S.] = SYN+ACK (точка — это ACK), [.] = чистый ACK. RTT = 27.7 мс — разница между первым и вторым пакетом, первое измерение в соединении, из него родится начальный RTO. mss 1400 от сервера вместо 1460 — почти наверняка на пути туннель, и наш стек больше не пошлёт сегмент крупнее 1400 байт данных. wscale 7 и 9 — множители окна 128 и 512, поэтому win 502 в третьем пакете означает реальные 64256 байт. TS val/ecr — метки времени: сервер вернул наш val в своём ecr, поэтому RTT можно мерить даже по ретрансмитированным сегментам.

Дешёвая проверка «где мы стоим» без дампов:

curl -sS -o /dev/null -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://example.com
# dns=0.014 tcp=0.042 tls=0.089 ttfb=0.151 total=0.152
# (tcp − dns) = 28 мс — это ровно один RTT рукопожатия

Две очереди, которые все путают

SYN queue держит полуоткрытые соединения, accept queue — уже установленные, ждущие accept(). Переполняется почти всегда вторая, и виновато в этом приложение, а не сеть.

# Для LISTEN-сокета Recv-Q = текущая длина accept queue, Send-Q = её лимит
ss -lnt 'sport = :8080'
State    Recv-Q   Send-Q     Local Address:Port     Peer Address:Port
LISTEN   129      128              0.0.0.0:8080          0.0.0.0:*

Recv-Q упёрся в Send-Q — очередь полна, новые соединения отбрасываются. Подтверждение в счётчиках:

nstat -az | grep -E 'ListenOverflows|ListenDrops|TCPSynRetrans|TcpRetransSegs|TCPTimeouts'
TcpExtListenOverflows           1203               0.0
TcpExtListenDrops               1203               0.0
TcpExtTCPSynRetrans               87               0.0
TcpExtTCPTimeouts               1544               0.0
TcpRetransSegs                 18432               0.0

Растущий ListenOverflows = ваш воркер-пул не успевает делать accept(). Лечится не увеличением backlog (это лишь отложит боль и удлинит хвост латентности), а разбором того, почему обработчики заняты. Три величины должны быть согласованы: аргумент listen(fd, backlog) в коде, net.core.somaxconn (с ядра 5.4 по умолчанию 4096, раньше 128) и net.ipv4.tcp_max_syn_backlog. Эффективный backlog = min(backlog, somaxconn), и типичная ошибка — фреймворк жёстко ставит listen(fd, 128), а вы тюните только sysctl.

SYN flood и cookies. Классическая атака: слать SYN с подменённых адресов, забивая SYN queue. Ответ — SYN cookies (RFC 4987): сервер кодирует нужное состояние прямо в ISN ответного SYN+ACK и ничего не хранит. Цена — в cookie влезает не всё, часть опций теряется, поэтому tcp_syncookies=1 (дефолт) включает механизм только при переполнении; значение 2 включает всегда и является диверсией против собственной производительности. TCP Fast Open (RFC 7413) позволяет слать данные прямо в SYN, экономя RTT на повторных соединениях, но широко не взлетел: middlebox-ы ломают, а параллельно появился QUIC с 0-RTT из коробки.

Конечный автомат соединения

Одиннадцать состояний из RFC 9293 — это не академия, это то, что вы читаете в ss -tan во время инцидента.

Закрытие четырёхстороннее, потому что соединение полнодуплексное: FIN закрывает только своё направление. Отсюда half-close (shutdown(fd, SHUT_WR)) — «я всё сказал, но слушаю»; так работает отправка запроса с последующим чтением ответа до EOF.

TIME_WAIT: не баг, не утечка, не надо «лечить»

Сторона, закрывшая соединение первой, оседает в TIME_WAIT на 2×MSL. В Linux это захардкоженные 60 секунд (TCP_TIMEWAIT_LEN, sysctl нет). Зачем: чтобы задержавшиеся в сети сегменты старого соединения не попали в новое с тем же 4-кортежем, и чтобы гарантированно доставить последний ACK — если он потеряется, пир ретрансмитирует FIN, и нужно, чтобы было кому ответить.

ss -tan state time-wait | wc -l                            # сколько их
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn # разбивка по состояниям
  • Закрывайте соединение со стороны клиента, а не сервера — тогда TIME_WAIT копится там, где свободных портов много.
  • Не закрывайте вообще: keep-alive и пул соединений убирают проблему в корне.
  • net.ipv4.tcp_tw_reuse=1 — безопасно и полезно: разрешает исходящим соединениям переиспользовать сокет в TIME_WAIT, если метки времени показывают, что он старый.
  • net.ipv4.tcp_tw_recycleудалён из ядра в 4.12, а до этого ломал всех клиентов за NAT. Если рецепт из интернета его советует, рецепт написан до 2017 года; остальным его пунктам тоже не верьте.

Реальный дефицит — не TIME_WAIT сам по себе, а эфемерные порты: диапазон по умолчанию 32768 60999 даёт 28232 порта на один 4-кортеж. Расширяется через net.ipv4.ip_local_port_range или несколькими адресами назначения.

CLOSE_WAIT — всегда баг вашего кода. Пир прислал FIN, ядро ответило ACK, а приложение не вызвало close(). Дескрипторы текут, дело кончится EMFILE. Ищите путь, где при ошибке чтения выходят из функции, не закрыв соединение — обычно это return внутри if err != nil без defer conn.Close().

Надёжность: как теряется и как чинится

Таймер ретрансмиссии

Первая линия обороны — таймаут. «Сколько ждать» решается адаптивно (RFC 6298, алгоритм Джекобсона–Карелса):

R'      = свежее измерение RTT
SRTT    = (1 − α)·SRTT + α·R'                  α = 1/8   — сглаженный RTT
RTTVAR  = (1 − β)·RTTVAR + β·|SRTT − R'|       β = 1/4   — разброс
RTO     = SRTT + max(G, 4·RTTVAR)              G — гранулярность таймера

Суть в слагаемом 4·RTTVAR: ждать нужно не среднее, а среднее плюс запас, пропорциональный нестабильности. На стабильном канале RTO почти равен RTT, на дёрганом — заметно больше. До этой формулы (1988) TCP использовал фиксированный запас и на нестабильных каналах устраивал лавину лишних ретрансмиссий. Дальше: RTO не измеряется по ретрансмитированным сегментам (правило Карна — непонятно, на какую из копий пришёл ACK; опция Timestamps эту проблему снимает), а при каждом срабатывании таймера удваивается — экспоненциальная выдержка. В Linux минимум 200 мс (TCP_RTO_MIN; спека требует 1 с, ядро сознательно агрессивнее), максимум 120 с.

sysctl net.ipv4.tcp_syn_retries net.ipv4.tcp_retries2 net.ipv4.tcp_fin_timeout
# net.ipv4.tcp_syn_retries = 6      → ~127 с до отказа connect()
# net.ipv4.tcp_retries2 = 15        → 13–30 минут до разрыва установленного соединения
# net.ipv4.tcp_fin_timeout = 60     → сколько висеть в FIN_WAIT_2

Пятнадцать минут — вечность для сервиса. Если ваш SLA измеряется секундами, ставьте на сокете TCP_USER_TIMEOUT (жёсткий потолок времени, которое неподтверждённые данные могут висеть) и таймауты на уровне приложения. Полагаться на tcp_retries2 в микросервисах нельзя.

Быстрая ретрансмиссия, SACK и RACK

Ждать RTO дорого. Если потерялся один сегмент из потока, приёмник продолжает получать следующие и на каждый шлёт ACK с тем же номером — «мне всё ещё нужен байт 5001». Три дубликата подряд отправитель трактует как потерю и ретрансмитирует, не дожидаясь таймера (RFC 5681): восстановление за ~1 RTT вместо RTO. Почему именно три? Компромисс: переупорядочивание в сети — норма, и один-два дубликата чаще означают «пришло не по порядку», а не «потеряно».

Современные ядра вместо фиксированного порога используют RACK-TLP (RFC 8985): решение о потере принимается по времени («сегмент отправлен более чем RTT назад, а ACK-и на более поздние уже пришли»), плюс Tail Loss Probe — зондирующая передача, когда потерялся последний сегмент потока и дубликатам взяться неоткуда. Именно хвостовые потери больнее всего бьют по коротким HTTP-ответам.

Кумулятивный ACK не умеет сказать «у меня есть 5001–6000 и 7001–9000, нет 6001–7000», из-за чего при множественных потерях в окне отправитель ретрансмитировал вслепую. Опция SACK (RFC 2018) добавляет в ACK до 3–4 блоков «получено вне порядка», и отправитель шлёт заново ровно недостающее. DSACK (RFC 2883) сообщает «этот сегмент был дубликатом», по чему стек понимает, что ретрансмиссия была лишней, и откатывает снижение окна.

Скользящее окно отправителя и дыра у получателя

Как потеря выглядит в Wireshark

Включите «Relative sequence numbers» и смотрите на экспертную информацию. Типичная одиночная потеря:

No.   Time      Source        Info
41    0.104     10.0.0.12     [TCP segment of a reassembled PDU] Seq=5001 Len=1000
42    0.105     10.0.0.12     [TCP segment of a reassembled PDU] Seq=6001 Len=1000
43    0.132     93.184.216.34 [TCP Previous segment not captured] ...   ← пропуск виден
44    0.133     93.184.216.34 [TCP Dup ACK 43#1] Ack=5001 SLE=6001 SRE=7001
45    0.134     93.184.216.34 [TCP Dup ACK 43#2] Ack=5001 SLE=6001 SRE=8001
46    0.135     93.184.216.34 [TCP Dup ACK 43#3] Ack=5001 SLE=6001 SRE=9001
47    0.136     10.0.0.12     [TCP Fast Retransmission] Seq=5001 Len=1000
48    0.163     93.184.216.34 ACK Ack=9001                             ← дыра закрыта

SLE/SRE — левая и правая границы SACK-блока: получатель прямо говорит, что у него есть 6001–9001, а нужен 5001. Между кадрами 43 и 48 прошло 59 мс — два RTT, за которые приложение не получило ни одного байта, хотя 3 КБ данных уже лежали в буфере приёмника. Это и есть head-of-line blocking, нарисованный на схеме выше.

Пометка Wireshark Что произошло На что смотреть
TCP Retransmission сработал RTO потери плюс большой RTT, очереди на пути
TCP Fast Retransmission три дубликата ACK нормальная реакция, если редко
TCP Spurious Retransmission переслали уже подтверждённое RTO слишком мал или сильный jitter
TCP Out-Of-Order пришло не по порядку, но пришло ECMP по нескольким путям, не потеря
TCP ZeroWindow приёмник объявил окно 0 приложение не читает сокет
TCP Window Full отправитель упёрся в окно приёмника мал rwnd или медленный читатель
TCP RST сброс закрытый порт, таймаут балансировщика, SO_LINGER=0

Скриптом, без GUI:

tshark -r cap.pcapng -Y 'tcp.analysis.flags && !tcp.analysis.window_update' \
       -T fields -e frame.number -e frame.time_relative -e ip.src -e tcp.seq -e _ws.expert.message

Самый полезный график — Statistics → TCP Stream Graphs → Time Sequence (tcptrace). На нём видно наклон (пропускную способность), ступеньки ретрансмиссий и потолок окна приёмника: если линия данных упирается в линию окна — виноват получатель, если данные идут рывками с провалами — перегрузка.

Окна: два независимых ограничителя

Отправитель в любой момент держит «в полёте» не больше, чем min(cwnd, rwnd). Два числа с совершенно разным смыслом:

  • rwnd (receive window) — сколько готов принять получатель. Это управление потоком: защита медленного читателя от быстрого писателя. Объявляется в каждом ACK.
  • cwnd (congestion window) — сколько, по оценке отправителя, выдержит сеть. В проводе не передаётся вообще: чисто локальная переменная, вычисленная по косвенным признакам.

Путать их — классика. «Скачивание медленное, увеличим буферы» помогает, только если упёрлись в rwnd; если ограничивает cwnd, буферы не изменят ничего, кроме bufferbloat.

BDP = пропускная способность × RTT

1 Гбит/с × 100 мс = 12.5 МБ      ← столько байт должно лететь одновременно,
                                    чтобы канал был загружен полностью
Без window scaling потолок 65535 байт:
   65535 / 0.1 с ≈ 5.2 Мбит/с — 0.5% канала

Это «длинные толстые трубы»: межконтинентальные линки, репликация между регионами, бэкапы.

sysctl net.ipv4.tcp_window_scaling net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.ipv4.tcp_moderate_rcvbuf
# net.ipv4.tcp_window_scaling = 1
# net.ipv4.tcp_rmem = 4096  131072  6291456    ← min / default / max
# net.ipv4.tcp_wmem = 4096  16384   4194304
# net.ipv4.tcp_moderate_rcvbuf = 1             ← автотюнинг приёмного буфера

Максимум 6 МБ приёмного буфера не покроет BDP 12.5 МБ — на таких каналах потолок поднимают до 16–32 МБ. Важно: если приложение само вызывает setsockopt(SO_RCVBUF), автотюнинг отключается и размер фиксируется жёстко. Это самый частый способ случайно ограничить себя.

Zero window, Nagle и delayed ACK

Zero window — получатель объявил win=0: буфер полон, приложение не читает. Отправитель переходит в режим window probe (периодически шлёт байт, чтобы не залипнуть навсегда, если Window Update потеряется). Видите ZeroWindow в дампе — идите профилировать приложение-получателя, сеть ни при чём.

Silly window syndrome — вырожденный случай: приложение читает по байту, получатель честно объявляет окно 1, отправитель шлёт сегменты по 1 байту с 52 байтами заголовков. Лечится с обеих сторон: приёмник не анонсирует окно меньше MSS, отправитель применяет алгоритм Нейгла (RFC 896) — «пока есть неподтверждённые данные, копи мелочь, не шли».

И тут самая знаменитая патология TCP. Нейгл ждёт ACK, а delayed ACK (RFC 1122) ждёт данных, чтобы приклеить ACK к ним (до 40 мс в Linux, до 200 мс в Windows). Итог — паузы по 40 мс на ровном месте в протоколах «запрос — ответ», где запрос отправляется двумя write().

Лечение по убыванию правильности: собрать сообщение целиком и отправить одним write() (или writev/sendmsg) — это исправляет причину; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, 1) — отключить Нейгла, правильный дефолт для интерактивного трафика ценой большего числа мелких пакетов; TCP_QUICKACK на приёмнике — не «залипающий» флаг, сбрасывается сам, годится точечно. Никогда не «лечите» это увеличением буферов или сменой алгоритма перегрузки: это другая подсистема.

Управление перегрузкой

Октябрь 1986: почему это вообще появилось

Пропускная способность сегмента между LBL и Беркли — расстояние 400 метров — упала с 32 кбит/с до 40 бит/с, в тысячу раз. Причина: отправители, увидев потери, ретрансмитировали, добавляя нагрузку в уже перегруженную сеть, что вызывало ещё больше потерь — коллапс с положительной обратной связью. Ван Джекобсон описал лечение в «Congestion Avoidance and Control», SIGCOMM ‘88. Ключевая идея: отправитель обязан сам оценивать ёмкость сети и сдерживаться, а сигналом перегрузки считать потерю пакета. Никакой явной обратной связи от маршрутизаторов нет — есть только «дошло/не дошло».

Slow start назван неудачно: он не медленный, он начинается с малого и растёт экспоненциально — каждый подтверждённый сегмент даёт право отправить два. Начальное окно 10 сегментов ≈ 14.6 КБ (RFC 6928); эта цифра определяет, влезет ли ваш HTML-ответ в первый круг, поэтому «10 килобайт critical CSS» — не суеверие фронтендеров. Congestion avoidance — линейный рост +1 MSS за RTT, осторожное прощупывание границы. AIMD (растём медленно, падаем вдвое) — не вкусовщина: доказано, что именно такая асимметрия сходится к справедливому распределению полосы между конкурирующими потоками. Fast recovery и PRR (RFC 6937, дефолт в Linux с 3.2) не дают после потери ни рухнуть в ноль, ни выстрелить пачкой: PRR аккуратно подводит cwnd к новому ssthresh, распределяя передачи по приходящим ACK. Таймаут — катастрофа: cwnd падает до одного сегмента и всё начинается заново. Разница между потерей, замеченной по дубликатам, и потерей, замеченной по таймеру, — примерно порядок величины в потерянной пропускной способности; поэтому SACK и RACK так важны.

Пропускная способность потока под потерями (Mathis et al., 1997):
throughput ≈ (MSS / RTT) · (C / sqrt(p)),   C ≈ 1.22 для Reno

MSS 1460, RTT 100 мс, потери p = 0.1%:
   (1460 / 0.1) · (1.22 / 0.0316) ≈ 14600 · 38.6 ≈ 563 КБ/с ≈ 4.5 Мбит/с

Два вывода. Первый: потери и RTT в формуле есть, а «ширина канала» — нет. Гигабитный канал через океан с 0.1% потерь даёт единицы мегабит на поток. Второй: лечение — не «купить полосу», а убрать потери, сократить RTT (см. CDN и edge) или распараллелить на несколько потоков.

Эволюция алгоритмов

CUBIC (RFC 9438) — дефолт в Linux с 2006 года. Ключевое отличие от Reno: cwnd растёт как кубическая функция времени с момента последней потери, а не числа RTT. Практический смысл — рост не зависит от RTT, поэтому потоки с разными RTT конкурируют честнее, а после потери окно быстро возвращается почти к прежнему значению и дальше осторожно прощупывает выше. На длинных толстых трубах CUBIC разгоняется на порядки быстрее Reno.

BBR (Bottleneck Bandwidth and RTT, Google, 2016) меняет саму модель: он не считает потерю сигналом перегрузки, а непрерывно оценивает максимальную наблюдаемую пропускную способность узкого места и минимальный наблюдаемый RTT и держит темп ровно на этой границе, не заполняя буферы. Следствия: на каналах с фоновыми потерями, не связанными с перегрузкой (Wi-Fi, мобильные сети, дальние линки), BBR даёт кратно больше, чем CUBIC, который любую потерю трактует как «сеть перегружена»; очереди не раздуваются, поэтому латентность под нагрузкой заметно ниже. Критика BBRv1 — при конкуренции с CUBIC в мелких буферах он отбирал непропорционально много полосы; BBRv2/v3 добавили реакцию на потери и ECN.

sysctl net.ipv4.tcp_available_congestion_control net.ipv4.tcp_congestion_control
# net.ipv4.tcp_available_congestion_control = reno cubic bbr
# net.ipv4.tcp_congestion_control = cubic

sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
sudo sysctl -w net.core.default_qdisc=fq   # BBR управляет ТЕМПОМ, без пейсинга работает неверно

Менять алгоритм перегрузки — предпоследнее средство. Сначала измерьте, во что упираетесь. BBR оправдан на дальних каналах с фоновыми потерями и на раздаче видео; внутри одного ЦОД разница обычно в пределах шума, зато там реальный выигрыш даёт DCTCP.

Bufferbloat и ECN

Дешёвая память привела к маршрутизаторам и модемам с гигантскими очередями, и логика «лучше задержать, чем выбросить» оказалась ядовитой: loss-based алгоритмы наращивают окно, пока не увидят потерю, а потеря не наступает, пока буфер не переполнится. Итог — буфер стабильно полон, RTT растёт с 20 мс до секунд, интерактивный трафик умирает. Явление описал Джим Геттис в 2010–2011; см. bufferbloat.net. Проверяется за минуту:

ping -i 0.2 -c 100 1.1.1.1 > /tmp/loaded.txt &
curl -o /dev/null http://speedtest.example/1G.bin
# rtt min/avg/max = 18.4/412.7/1983.2 ms  ← рост в сто раз под нагрузкой = bufferbloat

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

Лечение — активное управление очередью (AQM) на узком месте: CoDel (RFC 8289) выбрасывает пакеты, когда очередь «застоялась» дольше целевого времени, fq_codel (RFC 8290) добавляет справедливость по потокам, CAKE делает то же для домашних шлюзов с шейпингом. ECN (RFC 3168) идёт дальше: вместо выброса пакета маршрутизатор помечает его битом CE, получатель отражает метку флагом ECE, отправитель снижает окно — перегрузка сигнализируется без потери данных. В интернете классический ECN внедрён неравномерно (net.ipv4.tcp_ecn=2 по умолчанию: отвечаем, если попросят, сами не инициируем), зато DCTCP внутри ЦОД использует ECN на полную и держит очереди почти пустыми, а современный L4S (RFC 9330) переносит этот подход в интернет.

Читаем ss -ti как приборную панель

ss -tin state established '( dport = :443 )'
State  Recv-Q Send-Q      Local Address:Port        Peer Address:Port
ESTAB  0      212992          10.0.0.12:51234    93.184.216.34:443
   cubic wscale:9,7 rto:236 rtt:33.482/2.117 ato:40 mss:1400 pmtu:1500
   rcvmss:1400 advmss:1460 cwnd:24 ssthresh:18 bytes_sent:8461204
   bytes_retrans:41230 bytes_acked:8419974 bytes_received:5361
   segs_out:6042 segs_in:2104 send 8.03Mbps lastsnd:12 lastrcv:8 lastack:8
   pacing_rate 9.63Mbps delivery_rate 6.71Mbps delivered:6018
   busy:104312ms retrans:0/29 rcv_space:14480 minrtt:32.914
Поле Смысл Красный флаг
Send-Q байт в буфере отправки, не подтверждено стабильно большое → упёрлись в сеть или в rwnd пира
Recv-Q принято, но не прочитано приложением растёт → приложение не читает сокет
rtt:33.482/2.117 сглаженный RTT / разброс разброс сравним с RTT → нестабильный путь
minrtt минимальный виденный RTT rtt сильно выше minrtt → очереди на пути, bufferbloat
cwnd / ssthresh окно перегрузки в сегментах cwnd близко к ssthresh и не растёт → потери держат окно
retrans:0/29 сейчас / всего ретрансмиссий отношение к segs_out больше ~1% → разбираться
bytes_retrans переслано заново 0.5% от bytes_sent в примере — заметно много
delivery_rate фактическая скорость доставки сильно ниже pacing_rate → узкое место найдено
wscale:9,7 множители окна (приём, отправка) 0,0 → scaling не согласован, потолок 64 КБ
ss -s                              # сводка по состояниям сокетов
nstat -az | grep -i tcp            # все счётчики TCP с момента загрузки

# Путь и MTU: «чёрные дыры» PMTUD выглядят как «мелкие запросы работают, крупные висят»
traceroute -T -p 443 example.com   # TCP-трассировка: ICMP часто фильтруют, SYN — реже
tracepath example.com              # покажет, где реально режется MTU
ping -M do -s 1472 example.com     # 1472 + 28 = 1500; не проходит — MTU меньше

Тюнинг: что действительно стоит трогать

Порядок действий всегда один: измерить → найти ограничитель → менять по одному параметру.

# 1. Буферы под большой BDP — только если BDP реально велик (WAN, репликация)
sysctl -w net.core.rmem_max=33554432
sysctl -w net.core.wmem_max=33554432
sysctl -w net.ipv4.tcp_rmem='4096 262144 33554432'
sysctl -w net.ipv4.tcp_wmem='4096 65536 33554432'
# 2. Не сбрасывать cwnd после простоя — критично для keep-alive пулов и gRPC
sysctl -w net.ipv4.tcp_slow_start_after_idle=0
# 3. Переиспользование TIME_WAIT для исходящих соединений (безопасно)
sysctl -w net.ipv4.tcp_tw_reuse=1
# 4. Расширить эфемерные порты, если упёрлись
sysctl -w net.ipv4.ip_local_port_range='10240 65535'
# 5. Очередь с пейсингом — обязательна для BBR, полезна и с CUBIC
sysctl -w net.core.default_qdisc=fq
# 6. Не давать сокету копить неотправленное — ниже задержка на отдаче
sysctl -w net.ipv4.tcp_notsent_lowat=131072

tcp_slow_start_after_idle=0 — самый недооценённый пункт. По умолчанию после паузы дольше одного RTO стек сбрасывает cwnd до начального, считая, что за время простоя сеть изменилась. Для долгоживущего HTTP/2 или gRPC-соединения, по которому раз в несколько секунд летит запрос, это означает каждый раз начинать с 10 сегментов.

// Go: типичные настройки интерактивного RPC-сервера
lc := net.ListenConfig{KeepAlive: 30 * time.Second}
ln, _ := lc.Listen(ctx, "tcp", ":8080")
conn, _ := ln.Accept()
tc := conn.(*net.TCPConn)
tc.SetNoDelay(true) // TCP_NODELAY: без склейки Нейгла (в Go это дефолт)
tc.SetKeepAlivePeriod(30 * time.Second)
// Жёсткий потолок «висения» неподтверждённых данных. Без него разрыв
// обнаружится только через tcp_retries2, то есть минут через пятнадцать.
rc, _ := tc.SyscallConn()
rc.Control(func(fd uintptr) {
    unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, 20_000) // мс
})

Про keepalive: дефолты в Linux — tcp_keepalive_time=7200 (два часа!), intvl=75, probes=9, то есть оборванное соединение обнаружится через два с лишним часа. Для сервисов ставьте 15–60 секунд прямо на сокете, иначе NAT-таблицы посередине (см. NAT, фаерволы и VPN) тихо выкинут запись и вы будете писать в чёрную дыру. Про zero-copy: sendfile() и splice() убирают копирование в user space при раздаче файлов, MSG_ZEROCOPY и TCP_ZEROCOPY_RECEIVE — на очень больших потоках; детали работы сокетов внутри ядра — в сетевом стеке ОС.

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

  • Считать, что write() вернул успех = данные доставлены. Успех означает «скопировано в буфер ядра». При обрыве данные пропадут молча; доказывает что-то только подтверждение на прикладном уровне.
  • Читать TCP как поток сообщений. Границ нет. Всегда length-prefix или разделитель.
  • Полагаться на таймауты TCP. Пятнадцать минут tcp_retries2 — это не таймаут, это капитуляция. TCP_USER_TIMEOUT плюс дедлайны в коде.
  • «Оптимизировать» TIME_WAIT через tcp_tw_recycle. Параметра больше нет, а рецепты с ним живут; до удаления он ломал клиентов за NAT.
  • Растить backlog вместо разбора причины. ListenOverflows — про то, что приложение не успевает accept(); больший backlog даёт длинный хвост латентности вместо честного отказа.
  • Ставить SO_RCVBUF/SO_SNDBUF руками. Любое явное значение отключает автотюнинг ядра, а он в 95% случаев справляется лучше.
  • Путать «медленно» из-за cwnd и из-за rwnd. Первое лечится качеством пути и алгоритмом перегрузки, второе — буферами и скоростью чтения; ss -ti отвечает за секунду.
  • Игнорировать несовпадение MSS и MTU в туннелях. Классический симптом: TLS-рукопожатие висит, потому что ServerHello с сертификатом не влезает. Лечится MSS clamping на туннеле.
  • Открывать соединение на каждый запрос. Рукопожатие плюс slow start — минимум два RTT штрафа и старт с 14 КБ окна. Пул соединений почти всегда даёт больше, чем любой sysctl.

Мини-итог

  • TCP превращает ненадёжные датаграммы в надёжный упорядоченный поток байтов, и цена порядка — head-of-line blocking, из-за которого появился QUIC.
  • Рукопожатие стоит 1 RTT, у слушающего сокета две очереди, и переполняется почти всегда accept queue по вине приложения.
  • Надёжность = кумулятивные ACK + адаптивный RTO + быстрая ретрансмиссия + SACK + RACK-TLP. Потеря, замеченная по дубликатам, дешевле потери по таймеру примерно на порядок.
  • В полёте не больше min(cwnd, rwnd): rwnd — про получателя, cwnd — про сеть. Разные проблемы с разным лечением.
  • Управление перегрузкой родилось из коллапса 1986 года. AIMD даёт честность и стабильность, CUBIC — скорость на длинных трубах, BBR — работу на каналах с фоновыми потерями.
  • ss -ti, tcpdump -S, nstat и tcptrace-график в Wireshark отвечают на 90% вопросов быстрее любых догадок.
  • Самый окупаемый тюнинг: пул соединений, TCP_NODELAY для интерактива, TCP_USER_TIMEOUT, короткий keepalive, tcp_slow_start_after_idle=0.

Источники

Что дальше

TCP платит за порядок задержкой восстановления и head-of-line blocking, за надёжность — целым RTT до первого байта, а за то, что живёт в ядре, — невозможностью обновиться. Иногда эта цена не нужна: игре важнее свежий кадр, чем полный, а видеозвонку — низкая задержка, а не отсутствие потерь.

UDP и QUIC: когда без TCP лучше и что изменил QUIC — про транспорт без гарантий, про то, как Google переизобрёл TCP в user space поверх UDP, и почему потоки в QUIC не блокируют друг друга.

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

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

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

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