50. TIME_WAIT, CLOSE_WAIT и проблемы сокетов
Как это устроено в Linux и Go
«Открыть канал связи несложно; подлинная проверка инженерной зрелости — закрыть его корректно, не оставляя бродить по сети призраков».
— Свободный афоризм в духе классиков сетевых протоколов
Введение: TCP State Machine и влияние на бэкенд
В инженерной практике бэкенд-разработчика мало что вызывает такое недоумение, как сервер, который медленно и неотвратимо угасает при почти нулевой загрузке центрального процессора и гигабайтах свободной оперативной памяти. Графики бизнес-метрик устремляются вниз, клиенты получают обрывы соединений, а в логах Go-сервиса начинают сыпаться фатальные системные ошибки: cannot assign requested address или too many open files.
Вы подключаетесь к серверу по SSH, запускаете утилиту ss -tanp и замираете: тысячи сетевых сокетов застряли в таинственных состояниях TIME_WAIT или CLOSE_WAIT.
Каждое сетевое TCP-соединение на протяжении своей жизни подчиняется строгому конечному автомату состояний (TCP State Machine), стандартизированному в RFC 793 (с 2022 года его заменил RFC 9293). В Go мы привыкли укрываться от этих низкоуровневых деталей за абстракциями net.Conn, http.Client и http.Transport. Однако в момент закрытия соединения сетевой стек ядра Linux вступает в фазу четырехэтапного разрыва (4-Way Teardown), где любая небрежность прикладного кода или непонимание физики протокола оборачивается исчерпанием портов и отказом в обслуживании.
sequenceDiagram
participant Active as Активная сторона (Go Client / Server)
participant Passive as Пассивная сторона (Remote Peer)
Note over Active,Passive: Соединение в статусе ESTABLISHED
Active->>Passive: 1. FIN (инициация закрытия close())
Note over Active: Переход в FIN_WAIT_1
Note over Passive: Прием FIN, отправка ACK
Passive-->>Active: 2. ACK
Note over Passive: Переход в CLOSE_WAIT<br/>(Ожидание закрытия приложением!)
Note over Active: Переход в FIN_WAIT_2
Passive->>Active: 3. FIN (приложение вызвало close())
Note over Passive: Переход в LAST_ACK
Active-->>Passive: 4. ACK
Note over Active: Переход в TIME_WAIT (в Linux фиксированные 60 сек)<br/>(Удержание порта!)
Note over Passive: Прием финального ACK -> сокет закрыт (CLOSED)
Note over Active: Истечение таймера 2MSL -> сокет закрыт (CLOSED)
TIME_WAIT: Активное закрытие и 2MSL
Состояние TIME_WAIT возникает исключительно на активной стороне закрытия — то есть на том конце соединения, который первым инициировал разрыв связи, вызвав системный вызов close() (или отправив пакет с флагом FIN).
После отправки финального подтверждения ACK в ответ на встречный FIN сокет не уничтожается сразу: он переходит в статус TIME_WAIT и замирает на интервал 2MSL (Two Maximum Segment Lifetime). Величина MSL — это максимальное расчетное время жизни IP-дейтаграммы в глобальной сети. Спецификация TCP предполагает, что 2MSL составляет порядка двух минут, но в ядре Linux длительность TIME_WAIT жестко зафиксирована константой TCP_TIMEWAIT_LEN и составляет 60 секунд (это вся длительность состояния, а не MSL).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph WhyTimeWait["Зачем ядру Linux нужно состояние TIME_WAIT?"]
R1["1. Надежное завершение связи
Если финальный ACK потеряется в сети,
удаленная сторона повторит FIN.
Сокет в TIME_WAIT повторно отправит ACK,
не давая сорвать соединение ошибкой RST."]
R2["2. Защита от блуждающих пакетов (Ghosts)
Пакеты старого соединения могут задержаться
в буферах промежуточных маршрутизаторов.
Интервал 2MSL гарантирует, что все старые пакеты
исчезнут до того, как этот IP:Port будет выдан заново."]:::process
end
Зачем ядру нужно это состояние?
- Гарантия доставки финального ACK: Если последний
ACKот активной стороны потеряется из-за сбоя в сети, пассивная сторона сочтет свойFINнедошедшим и отправит повторныйFIN. Если бы сокет активной стороны был немедленно уничтожен, ядро ответило бы на повторныйFINжестким пакетомRST(сброс), посчитав его ошибочным. СостояниеTIME_WAITпозволяет серверу корректно переотправитьACKи дать партнеру спокойно закрыться. - Очистка «призрачных» пакетов: В глобальной маршрутизации пакеты могут задерживаться и приходить с опозданием в десятки секунд. Если бы операционная система сразу выдала этот же локальный порт для нового соединения к тому же удаленному сервису, задержавшийся пакет из прошлой сессии попал бы в новое соединение, вызвав искажение данных. Тайм-аут 2MSL гарантирует полную очистку сети от старых сегментов.
CLOSE_WAIT: Пассивное закрытие и утечки приложения
Состояние CLOSE_WAIT возникает на пассивной стороне закрытия — то есть на узле, который получил пакет FIN от удаленного партнера, и сетевой стек ядра автоматически подтвердил его получение пакетом ACK.
Состояние CLOSE_WAIT буквально кричит разработчику:
«Удаленный партнер закрыл свою сторону соединения. Сетевой стек Linux обработал FIN и сообщил приложению (вернув io.EOF при чтении), но само прикладное приложение на Go ДО СИХ ПОР не вызвало метод conn.Close()!»
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Remote["Удаленный узел"]:::system -->|"Отправил FIN"| Kernel["Ядро Linux"]
Kernel -->|"Отправил ACK
(перешел в CLOSE_WAIT)"| Remote
Kernel -.->|"Сигнал: io.EOF"| GoApp["Go Приложение"]
GoApp -.->|"Забыли вызвать conn.Close()!"| Leak["Утечка fd и сокета в CLOSE_WAIT"]
Почему это происходит в Go?
В отличие от TIME_WAIT, которое является естественным физическим состоянием протокола TCP, зависание сокетов в CLOSE_WAIT — это в 99.9% случаев грубый баг в коде вашего приложения:
- Забытый
conn.Close(): Горутина прочитала данные изnet.Conn, наткнулась на ошибкуio.EOF, завершила цикл обработки, но разработчик забыл вызватьconn.Close()(например, не поставилdefer conn.Close()в начале обработчика). - Непрочитанное тело ответа (
resp.Body): В стандартном клиентеhttp.Client, если приложение прочитало только часть заголовков или данных, но не вычитало тело до конца черезio.Copy(io.Discard, resp.Body)илиdrainи не вызвалоresp.Body.Close(), базовое TCP-соединение не может вернуться в пул и зависает вCLOSE_WAIT. - Утечка в пуле соединений
http.Transport: Если удаленный микросервис штатно разорвал Keep-Alive соединение по таймауту, но пулTransportиз-за некорректных блокировок или утечек горутин не освободил дескриптор, сокет будет висеть вCLOSE_WAITбесконечно — ядро Linux не имеет таймаута дляCLOSE_WAIT(параметрtcp_fin_timeoutуправляет исключительно состояниемFIN-WAIT-2на активной стороне закрытия), поэтому сокет вCLOSE_WAITостанется до явного вызоваClose()или завершения процесса.
Go-специфика: Управление соединениями и SO_REUSEPORT
Стандартная библиотека Go предоставляет богатый арсенал средств для низкоуровневого контроля за поведением сокетов при закрытии и перезапуске.
SO_REUSEADDR vs SO_REUSEPORT
Для сетевого инженера критически важно понимать принципиальную разницу между этими системными флагами:
| Системный флаг | Механика работы в ядре Linux | Влияние на состояние TIME_WAIT | Применение в Go |
|---|---|---|---|
SO_REUSEADDR |
Разрешает привязать сокет к локальному порту (bind), даже если на нем уже висят старые сокеты в состоянии TIME_WAIT. |
✅ Позволяет мгновенно перезапустить Go-сервис без ожидания 60 секунд. Не удаляет TIME_WAIT из ядра. |
Включается в Go автоматически при вызове net.Listen("tcp", ":8080"). |
SO_REUSEPORT |
Позволяет нескольким независимым процессам (или потокам) биндиться на один и тот же IP:Port. Ядро распределяет входящие SYN по очередям сокетов. | ❌ Никак не влияет на TIME_WAIT. Решает проблему масштабирования на многоядерных CPU. |
Доступен через хук net.ListenConfig.Control и системный пакет unix.SetsockoptInt. |
Управление таймаутами и linger
В стандартной библиотеке Go существует метод conn.(*net.TCPConn).SetLinger(sec), который транслируется в системную опцию сокета SO_LINGER:
package main
import (
"fmt"
"net"
"time"
)
func configureSocketLinger(conn net.Conn) error {
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
return fmt.Errorf("не является TCP-соединением")
}
// 1. По умолчанию: graceful close с фоновым сбросом буферов
// 2. Установка положительного linger: блокировка Close() на указанное время
linger := 30 * time.Second
if err := tcpConn.SetLinger(int(linger.Seconds())); err != nil {
return err
}
// 3. Установка linger = 0: Абортивное жесткое закрытие!
// При вызове Close() ядро не отправляет FIN и не входит в TIME_WAIT,
// а немедленно сбрасывает соединение пакетом RST.
// tcpConn.SetLinger(0) // Использовать с крайней осторожностью!
return nil
}
Если установить linger = 0, операционная система при вызове conn.Close() откажется от классического 4-этапного закрытия: сокет будет немедленно уничтожен, в сеть уйдет жесткий пакет RST, а состояние TIME_WAIT вообще не будет создано. Это полностью ликвидирует проблему TIME_WAIT, но ломает гарантии надежной доставки данных TCP и может оборвать транзакции клиентов на полуслове!
Диагностика и оптимизация
Как мониторить состояния
Для комплексного анализа состояния сокетов на продакшен-сервере используются утилиты ss (Socket Statistics) и классический netstat:
# 1. Сводная статистика по всем состояниям TCP сокетов
ss -s
# 2. Подсчет распределения сокетов по состояниям прямо сейчас
ss -tanp | awk '{print $1}' | sort | uniq -c | sort -nr
# 3. Детальный просмотр соединений в проблемных статусах
ss -tan state time-wait
ss -tan state close-wait
Стратегии борьбы с TIME_WAIT
- Connection Pooling (Пулинг постоянных соединений): Всегда используйте переиспользуемые HTTP-клиенты с пулом Keep-Alive. Настраивайте параметры
http.Transport:MaxIdleConns,MaxIdleConnsPerHostиIdleConnTimeout. Один постоянный TCP-канал, прокачивающий тысячи запросов, исключает постоянные циклы открытия/закрытия сокетов. - Включение
SO_REUSEADDR: Гарантирует, что перезапуск вашего Go-демона пройдет без ошибок биндинга портов. -
Расширение диапазона эфемерных портов: Увеличьте пул доступных портов ядра:
Bash sysctl -w net.ipv4.ip_local_port_range="1024 65535" -
Переход на HTTP/2 и gRPC: Мультиплексирование сотен параллельных запросов внутри единого TCP-соединения радикально разгружает подсистему сокетов ядра.
Стратегии борьбы с CLOSE_WAIT
-
Жесткий аудит закрытия дескрипторов: Всегда закрывайте сетевые ресурсы через идиоматический
defer:Go resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() // Обязательно! _, _ = io.Copy(io.Discard, resp.Body) // Вычитываем тело для возврата conn в пул -
Трассировка системных вызовов: Утилита
strace -p <pid> -e trace=closeпозволяет отследить, вызывается ли сисколл закрытия дескриптора при полученииEOF. - Настройка системного таймаута: Параметр
net.ipv4.tcp_fin_timeout(по умолчанию 60с) определяет, как долго ядро держит осиротевшие (орфанные) соединения в состоянииFIN_WAIT_2; кTIME_WAITон отношения не имеет. ДляTIME_WAITнастраивать почти нечего (длительность зашита в ядро; есть лишьnet.ipv4.tcp_tw_reuse, который разрешает повторно использовать такие сокеты для исходящих соединений).
Итог
TIME_WAIT— это естественное, защитное состояние активной стороны закрытия, длящееся 2MSL. Оно предотвращает искажение данных блуждающими пакетами и гарантирует доставку финального подтверждения. В Go бороться с ним нужно не отключением проверок, а пулингом соединений (http.Transport).CLOSE_WAIT— это маркер утечки файловых дескрипторов на пассивной стороне. Если сокет завис вCLOSE_WAIT, ищите незакрытыйconn.Close()или забытыйresp.Body.Close().- Рантайм Go эффективно использует современные механизмы ядра Linux (
SO_REUSEADDR,SO_REUSEPORT, неблокирующийepoll), но управление жизненным циклом протокола TCP требует от инженера глубокого понимания семантики закрытия соединений. - Инструментарий надежности: Знание утилит
ss, параметровSetLingerи мониторинг метрик сокетов позволяют проектировать бэкенды, устойчивые к экстремальным сетевым нагрузкам.
Мы завершили детальный разбор состояний и очередей протокола TCP. Но прежде чем пакет отправится по сети, приложение должно превратить доменное имя в IP-адрес. В следующей статье мы разберем: 51. DNS глазами ОС и libc, выясним, как Go резолвит хосты, почему системный net.LookupHost может заморозить потоки ОС и как устроена подсистема NSS в Linux.