TCP на практике: TIME_WAIT и таймауты
Поставил простой сервер и отправил ему 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, остальные разбираются в тренажёре.
- На сервисе, который сам закрывает исходящие соединения, десятки тысяч сокетов в TIME_WAIT. Что делать?A)Держать соединения открытыми: keep-alive и пул к апстримуB)Сократить net.ipv4.tcp_fin_timeout — он и задаёт длину TIME_WAITC)Включить tcp_tw_recycle, штатный способ ускорить переиспользованиеD)Расширить диапазон эфемерных портов и тем убрать TIME_WAIT
показать ответ и разбор
+A)Держать соединения открытыми: keep-alive и пул к апстриму// разбор: TIME_WAIT висит примерно 60 секунд у той стороны, которая закрыла соединение первой: так ядро отсекает опоздавшие сегменты старого соединения. Это не утечка, а плата за частое открытие-закрытие. Правильное лечение — перестать плодить короткоживущие соединения: keep-alive, пул к апстриму, HTTP/2. Тюнинг ядра лишь отодвигает потолок.
- Узел выпал из сети без FIN и RST. Клиентский поток висит на соединении минутами. Почему и что настраивать?A)Поднять backlog слушающего сокетаB)Включить SO_REUSEADDR на клиентском сокетеC)Прикладные таймауты: keepalive молчит два часаD)Уменьшить MTU, чтобы разрыв обнаруживался быстрее
показать ответ и разбор
+C)Прикладные таймауты: keepalive молчит два часа// разбор: Молча пропавший узел ничего не присылает, а TCP по умолчанию начинает проверять живость соединения только через два часа простоя. Пока приложение ждёт ответа, оно занимает поток и место в пуле. Потому в проде выставляют таймауты на соединение и чтение в клиенте, а keepalive настраивают агрессивнее — тогда обрыв обнаруживается за секунды, а не за минуты.
- Один порт 443 сервера обслуживает тысячи клиентов сразу. Как это возможно, если порт один?A)Соединение опознаётся по четвёрке src-ip:port + dst-ip:portB)Сервер выдаёт каждому клиенту новый порт из диапазона на подключениеC)Клиенты встают в очередь и обслуживаются строго по одномуD)Балансировщик заранее раскидывает клиентов по разным портам
показать ответ и разбор
+A)Соединение опознаётся по четвёрке src-ip:port + dst-ip:port// разбор: TCP-соединение однозначно определяется четвёркой: адрес и порт источника + адрес и порт назначения. Сервер продолжает слушать 443, а каждое принятое соединение — отдельный сокет, отличающийся портом (и адресом) клиента. Поэтому один слушающий порт обслуживает тысячи клиентов одновременно: они различаются источником, а не серверным портом.
- На сервере, принимающем входящие, копятся сокеты в CLOSE_WAIT и не уходят. О чём это говорит?A)Это нормальный этап, такие сокеты сами уйдут минуты через двеB)Слишком много новых подключений, ядро не успевает закрыватьC)Пир закрыл, а приложение не вызвало close() — утечка у насD)Апстрим не отвечает, соединения зависли в ожидании ответа
показать ответ и разбор
+C)Пир закрыл, а приложение не вызвало close() — утечка у нас// разбор: CLOSE_WAIT значит: пир прислал FIN (закрыл свою половину), ядро его подтвердило, но наше приложение не вызвало close() на своём сокете. Такие сокеты копятся, когда приложение течёт дескрипторами (забыли закрыть, завис обработчик). В отличие от TIME_WAIT (нормальное затухание на стороне, закрывшей первой, уходит за ~2×MSL), CLOSE_WAIT сам не исчезнет — это баг приложения. Лечится гарантированным close() на всех путях.
- Один поток копирует файл между ДЦ на разных континентах и выжимает малую долю канала, хотя ширины полно. Что упирается?A)Пропускную режет MTU, надо включить jumbo-фреймыB)Тормозит шифрование канала, оно не параллелитсяC)Диск на приёмнике не успевает записывать входящий потокD)Окно TCP ограничено произведением полосы на задержку (BDP)
показать ответ и разбор
+D)Окно TCP ограничено произведением полосы на задержку (BDP)// разбор: Пропускная одного TCP-потока ≈ размер окна / RTT. На длинном плече in-flight данные ограничены окном приёма/перегрузки; если окно меньше произведения полосы на задержку (BDP), канал не заполнить. Лечится увеличением окна (window scaling, тюнинг буферов) или параллельными потоками (rsync/aria2/multipart), которые вместе набирают BDP.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.