Почему TCP не начинает на полной скорости: slow start на пальцах
Вы открываете новое TCP-соединение к серверу с гигабитным каналом — и первые данные всё равно идут как будто через узкий канал, а не через гигабит. Это не баг сети и не плохой провайдер: так работает TCP по замыслу. Разберёмся, почему протокол начинает осторожно, как именно он разгоняется, и почему это напрямую влияет на скорость коротких API-запросов и пользу от keep-alive.
Содержание
- Проблема, которую решает slow start
- Congestion window: счётчик, который решает, сколько можно отправить
- Как именно растёт окно — экспоненциально, а не линейно
- Почему короткие соединения никогда не успевают разогнаться
- Почему переиспользование соединений реально ускоряет
- Как посмотреть slow start и cwnd на практике
Проблема, которую решает slow start
Когда компьютер А открывает TCP-соединение к серверу Б, ни один из них не знает: сколько данных выдержит канал между ними без потерь. Полоса пропускания зависит от самого узкого места на всём пути — а путь может проходить через десяток маршрутизаторов, каждый со своей загрузкой, которая к тому же постоянно меняется. TCP не имеет способа заранее спросить у сети "сколько тебе прислать" — единственный способ узнать пропускную способность пути опытным путём: пробовать.
Если бы TCP с первого же RTT (round-trip time, время туда-обратно) начал слать данные на максимальной скорости, какую позволяет размер окна, результат был бы предсказуем: на любом пути с ограниченной пропускной способностью пакеты начали бы копиться в очередях промежуточных маршрутизаторов и буферах, буферы переполнились бы, и часть пакетов была бы отброшена. TCP интерпретирует потерю как сигнал перегрузки и вынужден был бы восстанавливаться — а массовые потери в начале соединения означают долгие таймауты повторной передачи, то есть в сумме медленнее, чем аккуратный разгон.
Именно для этого существует slow start (медленный старт) — часть алгоритма управления перегрузкой TCP (congestion control), описанного изначально в RFC 5681 и обновлённого в RFC 6928 и последующих документах. Идея простая: начать с малого объёма данных на один RTT, и если он проходит без потерь — увеличивать объём, пока либо не столкнёмся с потерей, либо не упрёмся в объявленное окно приёма получателя.
Congestion window: счётчик, который решает, сколько можно отправить
У каждого TCP-соединения есть внутренняя переменная cwnd (congestion window, окно перегрузки) — это не окно, которое видит пользователь, а служебное состояние, которое TCP-стек хранит для конкретного соединения на отправляющей стороне. cwnd ограничивает, сколько байт (точнее, сегментов) можно отправить, не дожидаясь подтверждения (ACK) от получателя.
Важно не путать cwnd с окном приёма (receive window, rwnd), которое объявляет получатель и которое говорит "у меня есть столько-то свободного места в буфере приёма". Реальный лимит на отправку — это минимум из двух: min(cwnd, rwnd). rwnd отражает готовность получателя принимать данные, cwnd — оценку отправителем состояния сети. Slow start управляет именно cwnd.
В начале соединения cwnd устанавливается в небольшое начальное значение (initial window, IW). Исторически это было 1-2 сегмента, современные стеки (согласно RFC 6928) по умолчанию используют побольше — конкретное число зависит от реализации TCP-стека и настроек конкретной ОС, и точное значение лучше смотреть в документации своего ядра, а не считать одинаковым для всех систем. Дальше начинается разгон.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак именно растёт окно — экспоненциально, а не линейно
Механика slow start проста и в этом её сила: за каждый успешно подтверждённый (ACK'нутый) сегмент cwnd увеличивается на один сегмент. На практике это означает, что за один полный RTT, в течение которого отправитель получает подтверждения на все отправленные сегменты, cwnd примерно удваивается.
Разгон выглядит так:
- RTT №1: отправили N сегментов (начальное окно), получили подтверждения → cwnd становится примерно 2N
- RTT №2: отправили 2N, получили подтверждения → cwnd становится примерно 4N
- RTT №3: отправили 4N → cwnd становится примерно 8N
- и так далее, пока не случится одно из двух событий
Это экспоненциальный рост — отсюда и метафора "разгон": соединение не набирает скорость плавно, оно каждый RTT удваивает свою "смелость". На каналах с небольшим RTT (доли миллисекунды до сотни миллисекунд, в зависимости от расстояния до сервера) это может занять всего несколько раундов и пройти незаметно для короткой человеческой паузы. Но на каналах с большим RTT — например, между удалёнными регионами через океан — тот же разгон растягивается на заметно больше реального времени, просто потому что каждый RTT занимает дольше.
Останавливается экспоненциальный рост в двух случаях:
- Достигнут ssthresh (slow start threshold) — порог, после которого TCP переключается в режим congestion avoidance с более осторожным линейным ростом (примерно на один сегмент за RTT, а не в два раза).
- Произошла потеря пакета — сеть сигнализирует о перегрузке. TCP резко снижает ssthresh (обычно вдвое от текущего cwnd) и либо возвращается к малому cwnd и новому slow start, либо, при современных механизмах вроде fast recovery, сокращает окно менее драматично.
Именно потеря пакета — а не явное сообщение от маршрутизатора — исторически была для TCP основным сигналом "хватит разгоняться". Более новые алгоритмы (например, BBR от Google) пытаются оценивать перегрузку по задержке и доступной полосе, а не только по факту потери, но классический slow start в духе Reno/CUBIC (алгоритм по умолчанию в большинстве современных Linux-дистрибутивов) по-прежнему ориентируется в первую очередь на потери.
Почему короткие соединения никогда не успевают разогнаться
Вот здесь начинается практическая часть, из-за которой вообще стоит понимать slow start. Разгон занимает несколько RTT. Если соединение живёт меньше, чем нужно для полного разгона, — оно физически не успевает выйти на ту скорость, которую способен дать канал.
Возьмём типичный сценарий: клиент делает быстрый HTTP-запрос к API — открывает TCP-соединение, отправляет запрос на несколько сотен байт, получает ответ на несколько килобайт, соединение закрывается. Весь обмен укладывается в 2-4 RTT: TCP-хендшейк (можно почитать отдельно про TCP-рукопожатие и что может пойти не так), затем передача самого запроса и ответа. За это время cwnd успевает вырасти всего на пару итераций — соединение всё ещё находится в самом начале экспоненциального разгона, когда данные уже закончились.
Результат: даже если между клиентом и сервером канал в 10 Гбит/с и путь физически способен прокачать данные почти мгновенно, каждый отдельный короткий запрос будет ограничен не пропускной способностью канала, а именно текущим значением cwnd на момент, когда данные закончились передаваться. Здесь легко перепутать причину с задержкой канала как таковой — но задержка (RTT) и полоса пропускания (bandwidth) это разные вещи, и то, как они вместе влияют на ощущаемую скорость сайта, разобрано в статье про разницу между хорошим пингом и медленным сайтом.
Особенно заметно это на путях с большим RTT — например, если сервер API стоит далеко от пользователя. Каждый RTT — это время на один шаг разгона, а если RTT большой, разгон растягивается на реальные (не только сетевые) миллисекунды и секунды, которые пользователь буквально чувствует в виде задержки ответа.
Почему переиспользование соединений реально ускоряет
Отсюда прямой практический вывод: если приложение постоянно открывает новое TCP-соединение на каждый запрос, оно постоянно "сбрасывает" cwnd обратно к начальному значению и заново проходит slow start. Ни один короткий запрос при такой схеме не пользуется полной пропускной способностью канала — она ему просто не успевает открыться.
Решение — переиспользовать уже открытое и разогнанное соединение вместо того, чтобы каждый раз открывать новое:
- HTTP keep-alive — соединение не закрывается после ответа на запрос, а остаётся открытым для следующих запросов к тому же хосту. cwnd, накопленный за предыдущие запросы, никуда не исчезает и продолжает расти (если, конечно, соединение не простаивало долго — подробнее ниже). Про обратную сторону долго живущих соединений — обрывы из-за таймаутов — можно почитать в разборе что происходит, когда keepalive оказался короче, чем нужно.
- HTTP/2 (и HTTP/3 поверх QUIC) — идут дальше: мультиплексируют много логических запросов через одно физическое TCP-соединение (HTTP/2) или один транспортный поток (QUIC/HTTP3), так что даже при большом количестве параллельных запросов используется одна разогнанная "труба", а не десятки заново стартующих соединений.
- Connection pooling на стороне клиента — HTTP-клиенты (в языках программирования, в прокси вроде nginx как upstream-клиенте) обычно держат пул уже открытых соединений к бэкенду и переиспользуют их между запросами вместо того, чтобы открывать новое на каждый.
Есть нюанс: если соединение долго простаивало без передачи данных, некоторые TCP-стеки Linux (при включённой опции tcp_slow_start_after_idle) сбрасывают cwnd обратно к малому значению — логика в том, что за время простоя условия в сети могли измениться, и накопленная "уверенность" уже не актуальна. Это значит, что просто держать соединение открытым не всегда достаточно — если по нему долго ничего не передавалось, следующий всплеск данных всё равно может начать разгон заново.
Как посмотреть slow start и cwnd на практике
Проверить текущее состояние congestion window для активных соединений на Linux можно через ss:
ss -tin
В выводе для каждого соединения будет строка со статистикой, где cwnd показывает текущее значение окна перегрузки в сегментах, а rtt — измеренный round-trip time. Если у вас много коротких соединений к одному и тому же хосту (например, к базе данных или к внешнему API), и cwnd у них стабильно небольшой — это косвенный признак того, что соединения не живут достаточно долго, чтобы разогнаться.
Посмотреть и изменить поведение tcp_slow_start_after_idle:
sysctl net.ipv4.tcp_slow_start_after_idle
# отключить сброс cwnd после простоя (0 = выключено)
sysctl -w net.ipv4.tcp_slow_start_after_idle=0
Начальное окно (initial window) для исходящих соединений по конкретному маршруту можно посмотреть и переопределить через ip route:
ip route show
# пример добавления маршрута с явным initcwnd
ip route add 203.0.113.0/24 via 192.0.2.1 dev eth0 initcwnd 10
Менять initcwnd вручную стоит осторожно и только когда вы понимаете топологию своей сети и характер трафика — искусственно завышенное начальное окно на "плохом" канале приведёт к тем же проблемам, ради которых slow start и был придуман: пакеты будут теряться пачками вместо аккуратного разгона.
Для серверов, которые обслуживают много клиентов с короткими запросами — например, API-бэкенд или reverse-proxy, — практический вывод прежде всего в архитектуре, а не в настройках ядра: держите соединения к бэкендам открытыми (upstream keep-alive в nginx, connection pool в клиентских библиотеках), включайте HTTP/2 там, где клиенты его поддерживают, и не открывайте новое TCP-соединение на каждый мелкий запрос, если можно этого избежать.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Slow start — это про каждое TCP-соединение или про весь трафик хоста?
Про каждое соединение отдельно. У каждого TCP-сокета своя переменная cwnd, и разгоняется она независимо от остальных соединений того же хоста. Поэтому десять параллельных коротких соединений разгоняются десятью параллельными "медленными стартами", а не делят между собой один общий разгон.
Можно ли просто отключить slow start?
Формально можно повлиять на начальное окно (initcwnd) и на поведение после простоя (tcp_slow_start_after_idle), но полностью убрать логику разгона на уровне стандартного TCP-стека нельзя и не нужно — она защищает саму сеть от перегрузки, а не только замедляет ваше соединение. Альтернативные алгоритмы (BBR и другие) меняют то, как оценивается перегрузка, но не отменяют идею постепенного набора скорости в начале.
Влияет ли slow start на UDP-based протоколы?
У классического UDP нет встроенного управления перегрузкой вообще — приложение само решает, с какой скоростью слать данные, и само отвечает за последствия. QUIC, на котором построен HTTP/3, реализует собственный механизм управления перегрузкой поверх UDP, включая свой аналог slow start.
Почему я не вижу этого эффекта на своих тестах через curl?
Одиночный curl часто передаёт слишком мало данных, чтобы разница вообще была заметна, либо тестируется на канале с очень маленьким RTT (например, локально), где разгон занимает доли секунды. Эффект заметнее всего на больших ответах через каналы с ощутимым RTT — тогда видно, что первые пакеты идут медленнее последующих.
Помогает ли CDN против slow start?
Косвенно да — CDN приближает точку раздачи данных к пользователю, снижая RTT, а значит и время на каждый шаг разгона. Плюс CDN и балансировщики обычно держат постоянные разогретые соединения с источником и с популярными клиентами, так что новым запросам не всегда приходится стартовать с нуля.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →