Congestion control — slow start, congestion avoidance и почему TCP замедляется при потерях
В прошлом уроке мы обсудили flow control — как получатель замедляет отправителя, когда не справляется. Теперь — об ещё одной форме саморегуляции: congestion control. Это о том, как отправитель сам замедляется, когда сеть перегружена.
Это, пожалуй, самая важная фича TCP с точки зрения функционирования интернета. Без неё интернет бы не работал. В 1986 году произошёл первый известный «congestion collapse»: пропускная способность ARPANET упала с ожидаемых 32 Kbps до 40 bps — в 800 раз. Все слали так быстро, как могли, сети не справлялись, пакеты дропались, TCP-стеки тогда не реагировали умно, и сеть просто захлёбывалась. После этого Ван Якобсон описал серию алгоритмов congestion control, которые с тех пор лежат в основе TCP.
В этом уроке разберём:
- Как TCP детектирует перегрузку (по потерям пакетов).
- Алгоритмы slow start и congestion avoidance.
- Fast retransmit, fast recovery.
- Современные алгоритмы: Reno, Cubic (Linux default), BBR.
- Почему TCP «не умеет» быстро восстанавливаться после потерь на длинных каналах.
Главная идея: потери = перегрузка
Базовая интуиция TCP congestion control: если есть потери пакетов, значит, сеть перегружена. Это не всегда верно (потери могут быть из-за помех, например на Wi-Fi), но в 90-е, когда придумывали TCP, основной причиной потерь действительно была перегрузка роутеров.
Эта интуиция приводит к простому правилу:
- Нет потерь -> можно ускоряться (сеть выдерживает).
- Есть потери -> замедляться (не убивай сеть).
TCP реализует это через congestion window (cwnd) — внутреннюю переменную отправителя. cwnd определяет, сколько байт максимум может быть «в полёте» (отправлено, не подтверждено). Эффективное окно = min(rwnd, cwnd):
- rwnd — flow control, заявленное получателем.
- cwnd — самооценка отправителя, что выдержит сеть.
Когда нет потерь, cwnd растёт. Когда есть — резко падает.
Slow Start — медленный старт
В начале соединения TCP не знает, какая пропускная способность канала. Шпарить со 100% сразу нельзя — можешь перегрузить. Поэтому TCP стартует медленно и экспоненциально ускоряется.
Алгоритм slow start:
- Начальный cwnd =
IW(Initial Window). На современных Linux IW = 10 MSS (~14.6 KB). - На каждый полученный ACK cwnd увеличивается на 1 MSS.
- Получив ACK на N сегментов в RTT, cwnd удваивается каждый RTT.
- Продолжается до ssthresh (slow start threshold) или до первой потери.
Slow start заканчивается, когда:
- cwnd достиг ssthresh (переход в congestion avoidance), или
- произошла потеря (переключение в специальную фазу recovery).
Название «slow start» — историческое. В современных сетях это далеко не медленно: за несколько RTT cwnd может достичь мегабайт. Но «медленно» по сравнению с теоретическим максимумом сразу.
# Замерь slow start вживую:
curl -w 'Total: %{time_total}s, Throughput peak: %{speed_download}\n' \
-o /dev/null https://speed.cloudflare.com/__down?bytes=1000000
# Сравни с большим файлом:
curl -w 'Total: %{time_total}s, Throughput peak: %{speed_download}\n' \
-o /dev/null https://speed.cloudflare.com/__down?bytes=100000000
# На маленьком файле скорость низкая -- slow start не успевает раскачаться
# На большом -- TCP «разгоняется», throughput растёт до канального
Congestion Avoidance — линейный рост
После slow start TCP переходит в congestion avoidance: cwnd растёт линейно, а не экспоненциально. Конкретно: на каждый RTT cwnd увеличивается на 1 MSS (а не удваивается).
Идея: близко к пределу сети нужно расти осторожно. Если рывками удваивать, потери возникнут быстро и сильно. Линейный рост — мягче.
Формально: на каждый ACK cwnd увеличивается на (MSS * MSS) / cwnd. В сумме за RTT (когда приходит cwnd/MSS ACKов) — это 1 MSS.
AIMD — основной паттерн
То, что TCP делает в установившемся режиме, называется AIMD (Additive Increase, Multiplicative Decrease):
- Additive Increase: при отсутствии потерь cwnd растёт на 1 MSS за RTT.
- Multiplicative Decrease: при потере cwnd падает наполовину.
Это даёт характерную «зубчатую» картинку cwnd с течением времени: медленный рост, резкое падение, медленный рост, резкое падение. Каждый «зуб» — это период между потерями.
Почему именно AIMD? Это математически правильное поведение для fairness: если N потоков делят канал, AIMD ведёт всех к равной доле. Multiplicative Decrease при потере уравнивает (всех бьёт в одинаковой пропорции), Additive Increase даёт возможность маленьким догнать. Это доказано в теории — AIMD сходится к fairness.
Сравни с MIMD (Multiplicative Increase, Multiplicative Decrease) или AIAD — они не сходятся.
Fast Retransmit и Fast Recovery
В уроке про reliability мы уже видели fast retransmit: 3 duplicate ACK триггерят немедленный retransmit (не дожидаясь RTO). В контексте congestion control это сопровождается fast recovery (TCP Reno):
- Получили 3 dup ACK -> потеря детектирована.
- ssthresh = cwnd / 2.
- cwnd = ssthresh + 3 MSS (потому что 3 сегмента уже у получателя).
- Каждый последующий dup ACK: cwnd += 1 (можно слать ещё сегменты, ведь они уже не «в полёте»).
- Когда придёт «свежий» ACK (после retransmit) -> cwnd = ssthresh, переход в congestion avoidance.
Это умное поведение: после потери TCP не сбрасывает cwnd до 1 (это сделал бы slow start). Получаем плавную деградацию вместо обвала.
Однако: при RTO (полный таймаут, не fast retransmit) cwnd всё-таки сбрасывается до 1 — это знак, что сеть серьёзно не справляется, начинаем сначала.
Разные алгоритмы: Reno, Cubic, BBR
TCP Reno — описанное выше. Классика, RFC 5681. Все ОС поддерживают.
TCP Cubic — современный default в Linux (с ~2008). Главное отличие: cwnd растёт не линейно, а по кубической функции от времени с момента последней потери. Это даёт более агрессивный рост на больших каналах с большим RTT, при этом более стабильный размер cwnd около «потолка». Cubic лучше использует bandwidth-delay product современных сетей.
TCP BBR (Bottleneck Bandwidth and Round-trip propagation time) — алгоритм Google, выпущенный в 2016. Принципиально другой подход: BBR не использует потери как сигнал. Вместо этого он измеряет реальный bandwidth и minimum RTT, и держит окно равным BDP = bandwidth * min_RTT.
Планировщик ОС и справедливое распределение ресурсов Это значит:
- Не наполняет буферы — отлично решает bufferbloat.
- Не теряет throughput при случайных потерях (например, на Wi-Fi).
- Работает значительно лучше на «long fat networks» (большой RTT, большая bandwidth, лёгкие потери).
# Какой congestion control используется у тебя сейчас:
sysctl net.ipv4.tcp_congestion_control
# Обычно: cubic (default в Linux)
# Что доступно:
sysctl net.ipv4.tcp_available_congestion_control
# Что-то вроде: reno cubic bbr
# Переключиться на BBR (если поддерживается):
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
# Проверить, что текущие соединения переключились:
ss -ti | head
# или для конкретного:
ss -ti dst 8.8.8.8
Сравнение коротко:
- Reno — классика, надёжно работает везде, но плохо использует high-BDP сети.
- Cubic — оптимизирован для современного интернета, лучше Reno на больших каналах. Default в Linux.
- BBR — отлично работает с lossy сетями и bufferbloat, но может быть «жадным» в конкуренции с Cubic-потоками.
Почему TCP замедляется на длинных каналах
«Long fat network» (LFN) — это сеть с большим bandwidth и большим RTT. Пример: оптоволокно из Москвы в Сан-Франциско, gigabit + RTT 200 мс. BDP = 1 Gbps * 0.2 sec = 25 MB.
На таких сетях обычный TCP (Reno, Cubic) страдает по нескольким причинам:
-
Slow start долгий. Чтобы cwnd вырос до 25 MB, нужно много RTT (даже при удвоении). На RTT 200 мс это секунды, а на короткой сессии не успеешь раскачаться.
-
Чувствительность к loss. Если в established состоянии случилась потеря, cwnd падает наполовину. На 25 MB BDP это значит, что после потери ты теряешь 12.5 MB throughput, и восстанавливается это линейно (1 MSS / RTT). Восстановление до пика может занять минуты.
-
Один пакет — много данных. При потере одного сегмента, в transit могут быть мегабайты данных. Все «зависают» в окне retransmission queue.
Решения:
- BBR не использует loss как сигнал, поэтому случайные потери не вызывают коллапс throughput.
- MPTCP (Multipath TCP) — несколько параллельных подключений, агрегирует bandwidth.
- QUIC — наследует уроки этих проблем, имеет более продвинутые алгоритмы recovery (RACK, persistent congestion).
ECN — Explicit Congestion Notification
Идея: вместо того чтобы дропать пакеты при перегрузке, роутер может пометить пакет, сигнализируя «я перегружен». ECN — биты в IP-заголовке. Получатель видит ECN-метку, шлёт обратно ECN-Echo (ECE) флаг в TCP-заголовке. Отправитель замедляется, как при потере, но без потери данных.
ECN — гораздо более эффективный сигнал congestion: нет лишней retransmission, нет задержек на recovery. Но ECN требует поддержки на всём пути (роутеры, оба endpoint). Современные ОС поддерживают, но не все приложения включают. Linux:
# Включить ECN:
sysctl net.ipv4.tcp_ecn
# 0 = выключен, 1 = включён, 2 = только инициируется по запросу
В современном интернете ECN постепенно распространяется. Cloudflare и Google активно используют. С QUIC это становится более стандартным.
Влияние congestion control на ваш код
Что нужно знать практически:
-
Первое подключение всегда медленнее. Slow start. Поэтому connection pooling, HTTP keep-alive, HTTP/2 multiplexing — переиспользование connections даёт огромный буст.
-
Маленькие запросы не получат полной пропускной способности. За 100 мс slow start пройдёт только 4-5 удвоений, throughput не достигнет максимума.
-
При случайных потерях (Wi-Fi, сотовая) cubic может вести себя плохо. На таких сетях BBR обычно лучше.
-
Bandwidth-Delay Product (BDP) определяет требуемый buffer size. Для high-bandwidth high-latency нужны большие буферы (и Window Scaling).
-
TCP «честно» делит канал между потоками. N подключений -> каждое получает ~1/N. Если хочешь больше — нужно больше параллельных подключений или специальный congestion control.
# Полезные инструменты:
# (1) iperf3 -- тестирует throughput между двумя хостами
iperf3 -c <server>
# (2) ss -ti -- текущая статистика congestion для соединения
ss -ti dst <peer>
# Output: cwnd:10 ssthresh:65535 cubic ...
# (3) Слежение за реальным cwnd:
nstat -az TcpExtTCPLossProbes TcpExtTCPLossProbeRecovery
# Сколько раз сработала TCP probe (для подсчёта loss)
Попробуй сам
# 1. Замерь поведение slow start на разных размерах файлов:
for size in 10000 100000 1000000 10000000; do
echo "Downloading ${size} bytes:"
curl -o /dev/null -s -w "Time: %{time_total}s Throughput: %{speed_download} B/s\n" \
"https://speed.cloudflare.com/__down?bytes=$size"
done
# На маленьких -- низкий throughput (slow start не успевает раскачаться)
# На больших -- реальная пропускная способность канала
# 2. Посмотри congestion control в действии:
ss -ti dst 8.8.8.8 || true
# Запусти параллельно скачивание:
curl -o /dev/null https://google.com/largefile &
sleep 1
ss -ti | grep google
# Видишь cwnd:NNN -- это текущее congestion window
# Если повторишь несколько раз -- увидишь, как растёт
# 3. Сравни Cubic и BBR (если можешь свитчить):
# Текущий:
sysctl net.ipv4.tcp_congestion_control
# Запусти download большого файла, замерь throughput:
time curl -o /dev/null -s https://speed.cloudflare.com/__down?bytes=100000000
# Переключи на BBR:
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
# Запусти заново:
time curl -o /dev/null -s https://speed.cloudflare.com/__down?bytes=100000000
# На сетях с потерями BBR обычно быстрее. На идеальном канале -- разницы может не быть
# 4. Симулируй потери и посмотри, как cwnd падает:
sudo tc qdisc add dev lo root netem loss 1%
# Запусти TCP-трансфер, мониторь:
watch -n 0.5 'ss -ti | grep ESTAB'
# cwnd будет периодически расти и падать -- это AIMD
sudo tc qdisc del dev lo root