сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Сети

TCP на практике: TIME_WAIT и таймауты

TCP на практике эксплуатации

Поставил простой сервер и отправил ему 300 запросов, открывая под каждый своё соединение. Ушло 0,099 секунды, а в системе осталось 300 сокетов в состоянии TIME_WAIT. Потом отправил те же 300 запросов по ОДНОМУ соединению: 0,010 секунды, и ни одного лишнего сокета. В десять раз быстрее и без мусора - просто потому, что не переустанавливал связь каждый раз.

Стержень: у соединения есть состояния, каждое появляется по конкретной причине, и по ним читается, кто виноват - сеть, сосед или твой код.

// Формулировки: «откуда столько TIME_WAIT?», «что такое CLOSE_WAIT?», «сосед выпал из сети, клиент висит»

TIME_WAIT: плата за новое соединение на каждый запрос

Сокет - это конец соединения на конкретной машине, пара адрес и порт. Установка соединения стоит одного обмена пакетами туда-обратно, и это ещё до того, как ушёл первый байт данных. Закрытие тоже не бесплатно: та сторона, которая закрыла первой, оставляет запись в состоянии TIME_WAIT примерно на минуту.

Зачем это ядру: в сети могут болтаться опоздавшие пакеты старого соединения. Если сразу переиспользовать ту же пару портов, они приедут в новое соединение и испортят данные. Запись TIME_WAIT занимает эту пару и не даёт такому случиться. То есть это не утечка и не поломка, а нормальная работа. В моём замере 300 записей висели и через три секунды после закрытия - им положено висеть около минуты.

// Отсюда и лечение. Оно не в правке настроек ядра, а в том, чтобы перестать открывать соединение на каждый запрос: держать его открытым (это называют keep-alive), брать из пула - заранее открытого запаса соединений, - использовать протоколы, которые умеют вести несколько запросов в одном соединении одновременно. Популярный совет «поправь tcp_fin_timeout» мимо цели: этот параметр управляет другим состоянием, FIN_WAIT_2.

сокет
конец соединения на машине: пара из адреса и порта
TIME_WAIT
запись у того, кто закрыл первым; держит пару портов около минуты
keep-alive
не закрывать соединение после ответа, а слать по нему следующие запросы

CLOSE_WAIT: это уже твой баг

Поставил сервер, который принимает соединения и забывает про них - не читает и не закрывает. Подключил 50 клиентов: на машине стало 100 записей в состоянии ESTABLISHED, то есть установленных соединений, - по 50 с каждой стороны, потому что и клиенты, и сервер тут на одной машине.

Дальше все 50 клиентов отключились. На стороне сервера появилось ровно 50 записей CLOSE_WAIT, на стороне клиента - 50 записей FIN_WAIT_2. Читается это буквально: сосед закрыл свою половину соединения и ждёт, когда закроют вторую, а наше приложение свою половину не закрывает.

// Разница с TIME_WAIT принципиальная. TIME_WAIT рассасывается сам за минуту, CLOSE_WAIT не рассосётся никогда: пока процесс не вызовет закрытие или не умрёт, записи будут копиться и однажды упрутся в предел на число открытых файлов. Поэтому растущий CLOSE_WAIT почти всегда означает потерянный вызов close в коде или необработанную ошибку в ветке, где соединение надо было закрыть.

CLOSE_WAIT
сосед закрылся, а наше приложение свою сторону не закрыло
FIN_WAIT_2
мы закрылись и ждём, когда закроется вторая сторона

Отказ, молчание и очередь у сервера - три разные картины

Отладка сети начинается с того, чтобы различить три исхода. Замерил все три на этой машине. Обращение к порту, где никто не слушает: отказ за 0,2 миллисекунды, текст ошибки «Connection refused» - до машины дошли, ядро ответило отказом. Обращение к адресу, который никому не отвечает: клиент прождал ровно свой предел, 3003 миллисекунды, и сказал «timed out» - ответа не было вовсе, пакеты где-то потерялись или их молча выбросило правило фаервола по дороге. Живой сервис: 0,2 миллисекунды и соединение.

Отсюда правило: отказ означает «дошли, но никого нет», молчание означает «не дошли или ответ не вернулся». Первое - неверный порт или упавший процесс, второе - фильтр, маршрут или мёртвая машина. И отсюда же требование ставить предел ожидания на каждую сетевую операцию: без него в случае молчания поток будет ждать очень долго, а не 3 секунды.

// Есть и третья, самая коварная картина. Слушающий сокет держит очередь принятых соединений, которые приложение ещё не разобрало. Я поставил сервер с очередью на 2 и не стал разбирать: соединиться удалось трём клиентам из двадцати, а остальные не смогли. При этом с точки зрения тех трёх клиентов всё в порядке - соединение установлено, они спокойно ждут ответа, которого никто не пишет. Приложение висит, а по сети всё выглядит здоровым.

// Отдельная история - протокол без соединений, UDP. Там нет ни рукопожатия (обмена парой пакетов перед передачей данных), ни подтверждений о доставке: я отправил пакет на порт, где никого нет, и отправка прошла без ошибки. Ошибка «Connection refused» пришла только на попытке ЧИТАТЬ ответ. То есть для UDP «отправлено» не значит «доставлено».

отказ соединения
машина ответила «тут никто не слушает»; до неё дошли
предел ожидания
сколько клиент готов ждать; без него ждать можно очень долго
очередь у слушающего сокета
принятые ядром соединения, которые приложение ещё не разобрало

Как отвечать: «Десятки тысяч сокетов в TIME_WAIT. Что делать?»

Сначала понять, кто закрывает соединения: TIME_WAIT висит около минуты у того, кто закрыл первым, и держит пару портов, чтобы опоздавшие пакеты старого соединения не приехали в новое. Если это наш сервис, значит, он открывает и закрывает связь на каждый запрос. Я это мерил: 300 запросов со своим соединением на каждый дают 300 таких записей и десятикратное замедление против тех же 300 запросов по одному соединению. Поэтому лечение - keep-alive и пул соединений к соседям, а не правка настроек ядра. И совет поправить tcp_fin_timeout тут вообще мимо, этот параметр про другое состояние. Отдельно проверю, не растёт ли рядом CLOSE_WAIT: вот он сам не рассосётся и означает потерянный вызов закрытия в коде.

Ответ отделяет норму от поломки, подкрепляет числом и снимает популярный вредный совет. Плюс сразу называет соседнее состояние, которое действительно опасно.

На чём валятся

  • Считают TIME_WAIT поломкой и лечат настройками ядра вместо переиспользования соединений.
  • Правят tcp_fin_timeout, думая, что это про TIME_WAIT. Параметр про FIN_WAIT_2.
  • Советуют tcp_tw_recycle: опция ломала клиентов за общим адресом и убрана из ядра.
  • Видят рост CLOSE_WAIT и перезапускают сервис вместо починки кода.
  • Не различают отказ и молчание и ищут причину не там.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 7, остальные разбираются в тренажёре.

  1. #dvo_net_tcpip1 / 5
    На сервисе, который сам закрывает исходящие соединения, десятки тысяч сокетов в TIME_WAIT. Что делать?
    A)Держать соединения открытыми: keep-alive и пул к апстриму
    B)Сократить net.ipv4.tcp_fin_timeout — он и задаёт длину TIME_WAIT
    C)Включить tcp_tw_recycle, штатный способ ускорить переиспользование
    D)Расширить диапазон эфемерных портов и тем убрать TIME_WAIT
    показать ответ и разбор
    +A)Держать соединения открытыми: keep-alive и пул к апстриму

    // разбор: TIME_WAIT висит примерно 60 секунд у той стороны, которая закрыла соединение первой: так ядро отсекает опоздавшие сегменты старого соединения. Это не утечка, а плата за частое открытие-закрытие. Правильное лечение — перестать плодить короткоживущие соединения: keep-alive, пул к апстриму, HTTP/2. Тюнинг ядра лишь отодвигает потолок.

  2. #dvo_net_tcpip2 / 5
    Узел выпал из сети без FIN и RST. Клиентский поток висит на соединении минутами. Почему и что настраивать?
    A)Поднять backlog слушающего сокета
    B)Включить SO_REUSEADDR на клиентском сокете
    C)Прикладные таймауты: keepalive молчит два часа
    D)Уменьшить MTU, чтобы разрыв обнаруживался быстрее
    показать ответ и разбор
    +C)Прикладные таймауты: keepalive молчит два часа

    // разбор: Молча пропавший узел ничего не присылает, а TCP по умолчанию начинает проверять живость соединения только через два часа простоя. Пока приложение ждёт ответа, оно занимает поток и место в пуле. Потому в проде выставляют таймауты на соединение и чтение в клиенте, а keepalive настраивают агрессивнее — тогда обрыв обнаруживается за секунды, а не за минуты.

  3. #dvo_net_tcpip3 / 5
    Один порт 443 сервера обслуживает тысячи клиентов сразу. Как это возможно, если порт один?
    A)Соединение опознаётся по четвёрке src-ip:port + dst-ip:port
    B)Сервер выдаёт каждому клиенту новый порт из диапазона на подключение
    C)Клиенты встают в очередь и обслуживаются строго по одному
    D)Балансировщик заранее раскидывает клиентов по разным портам
    показать ответ и разбор
    +A)Соединение опознаётся по четвёрке src-ip:port + dst-ip:port

    // разбор: TCP-соединение однозначно определяется четвёркой: адрес и порт источника + адрес и порт назначения. Сервер продолжает слушать 443, а каждое принятое соединение — отдельный сокет, отличающийся портом (и адресом) клиента. Поэтому один слушающий порт обслуживает тысячи клиентов одновременно: они различаются источником, а не серверным портом.

  4. #dvo_net_tcpip4 / 5
    На сервере, принимающем входящие, копятся сокеты в CLOSE_WAIT и не уходят. О чём это говорит?
    A)Это нормальный этап, такие сокеты сами уйдут минуты через две
    B)Слишком много новых подключений, ядро не успевает закрывать
    C)Пир закрыл, а приложение не вызвало close() — утечка у нас
    D)Апстрим не отвечает, соединения зависли в ожидании ответа
    показать ответ и разбор
    +C)Пир закрыл, а приложение не вызвало close() — утечка у нас

    // разбор: CLOSE_WAIT значит: пир прислал FIN (закрыл свою половину), ядро его подтвердило, но наше приложение не вызвало close() на своём сокете. Такие сокеты копятся, когда приложение течёт дескрипторами (забыли закрыть, завис обработчик). В отличие от TIME_WAIT (нормальное затухание на стороне, закрывшей первой, уходит за ~2×MSL), CLOSE_WAIT сам не исчезнет — это баг приложения. Лечится гарантированным close() на всех путях.

  5. #dvo_net_tcpip5 / 5
    Один поток копирует файл между ДЦ на разных континентах и выжимает малую долю канала, хотя ширины полно. Что упирается?
    A)Пропускную режет MTU, надо включить jumbo-фреймы
    B)Тормозит шифрование канала, оно не параллелится
    C)Диск на приёмнике не успевает записывать входящий поток
    D)Окно TCP ограничено произведением полосы на задержку (BDP)
    показать ответ и разбор
    +D)Окно TCP ограничено произведением полосы на задержку (BDP)

    // разбор: Пропускная одного TCP-потока ≈ размер окна / RTT. На длинном плече in-flight данные ограничены окном приёма/перегрузки; если окно меньше произведения полосы на задержку (BDP), канал не заполнить. Лечится увеличением окна (window scaling, тюнинг буферов) или параллельными потоками (rsync/aria2/multipart), которые вместе набирают BDP.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.