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.
Заголовок: где что лежит
- 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 рукопожатия
Две очереди, которые все путают
tcp_max_syn_backlog"} Q1 -->|"есть место"| SR["SYN_RECV
послан SYN+ACK"] Q1 -->|"переполнена"| SC{"tcp_syncookies?"} SC -->|"1"| CK["ответить SYN-cookie
состояние не хранить"] SC -->|"0"| DROP1["молча выбросить
клиент увидит таймаут"] SR -->|"пришёл финальный ACK"| Q2{"accept queue
min backlog и somaxconn"} CK -->|"ACK с валидной cookie"| Q2 Q2 -->|"есть место"| EST["ESTABLISHED
ждёт accept()"] Q2 -->|"переполнена"| DROP2["ListenOverflows++
ACK обычно дропается"] EST -->|"приложение вызвало accept()"| APP["сокет у приложения"]
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. Ключевая идея: отправитель обязан сам оценивать ёмкость сети и сдерживаться, а сигналом перегрузки считать потерю пакета. Никакой явной обратной связи от маршрутизаторов нет — есть только «дошло/не дошло».
cwnd = IW = 10 сегментов
ssthresh очень большой"] --> SS SS["SLOW START
каждый ACK: cwnd += 1 MSS
рост ×2 за RTT"] SS -->|"cwnd ≥ ssthresh"| CA SS -->|"3 dup ACK"| FR SS -->|"таймаут RTO"| TO CA["CONGESTION AVOIDANCE
за RTT: cwnd += 1 MSS
аддитивное увеличение"] CA -->|"3 dup ACK"| FR CA -->|"таймаут RTO"| TO FR["FAST RECOVERY
ssthresh = cwnd / 2
cwnd = ssthresh, темп по PRR"] FR -->|"ACK на новые данные"| CA FR -->|"таймаут во время восстановления"| TO TO["ТАЙМАУТ — плохой сценарий
ssthresh = cwnd / 2
cwnd = 1 MSS, RTO удваивается"] TO --> SS
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.
Источники
- RFC 9293 — Transmission Control Protocol; RFC 5681 — Congestion Control; RFC 6298 — Retransmission Timer; RFC 7323 — Extensions for High Performance.
- RFC 2018 — SACK, RFC 8985 — RACK-TLP, RFC 6937 — PRR, RFC 9438 — CUBIC, RFC 8289 — CoDel, RFC 9330 — L4S.
- Van Jacobson. Congestion Avoidance and Control, SIGCOMM ‘88 — работа, с которой всё началось.
- W. Richard Stevens, Kevin Fall. TCP/IP Illustrated, Volume 1 (2-е изд.) — по-прежнему лучший разбор с дампами.
- Ilya Grigorik. High Performance Browser Networking — Building Blocks of TCP, бесплатно онлайн.
- Bufferbloat.net и Cardwell et al. «BBR: Congestion-Based Congestion Control», ACM Queue 2016.
- Документация ядра Linux: ip-sysctl — единственный достоверный источник по sysctl.
Что дальше
TCP платит за порядок задержкой восстановления и head-of-line blocking, за надёжность — целым RTT до первого байта, а за то, что живёт в ядре, — невозможностью обновиться. Иногда эта цена не нужна: игре важнее свежий кадр, чем полный, а видеозвонку — низкая задержка, а не отсутствие потерь.
UDP и QUIC: когда без TCP лучше и что изменил QUIC — про транспорт без гарантий, про то, как Google переизобрёл TCP в user space поверх UDP, и почему потоки в QUIC не блокируют друг друга.