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
  

Зачем ядру нужно это состояние?

  1. Гарантия доставки финального ACK: Если последний ACK от активной стороны потеряется из-за сбоя в сети, пассивная сторона сочтет свой FIN недошедшим и отправит повторный FIN. Если бы сокет активной стороны был немедленно уничтожен, ядро ответило бы на повторный FIN жестким пакетом RST (сброс), посчитав его ошибочным. Состояние TIME_WAIT позволяет серверу корректно переотправить ACK и дать партнеру спокойно закрыться.
  2. Очистка «призрачных» пакетов: В глобальной маршрутизации пакеты могут задерживаться и приходить с опозданием в десятки секунд. Если бы операционная система сразу выдала этот же локальный порт для нового соединения к тому же удаленному сервису, задержавшийся пакет из прошлой сессии попал бы в новое соединение, вызвав искажение данных. Тайм-аут 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% случаев грубый баг в коде вашего приложения:

  1. Забытый conn.Close(): Горутина прочитала данные из net.Conn, наткнулась на ошибку io.EOF, завершила цикл обработки, но разработчик забыл вызвать conn.Close() (например, не поставил defer conn.Close() в начале обработчика).
  2. Непрочитанное тело ответа (resp.Body): В стандартном клиенте http.Client, если приложение прочитало только часть заголовков или данных, но не вычитало тело до конца через io.Copy(io.Discard, resp.Body) или drain и не вызвало resp.Body.Close(), базовое TCP-соединение не может вернуться в пул и зависает в CLOSE_WAIT.
  3. Утечка в пуле соединений 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:

Go
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:

Bash
# 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

  1. Connection Pooling (Пулинг постоянных соединений): Всегда используйте переиспользуемые HTTP-клиенты с пулом Keep-Alive. Настраивайте параметры http.Transport: MaxIdleConns, MaxIdleConnsPerHost и IdleConnTimeout. Один постоянный TCP-канал, прокачивающий тысячи запросов, исключает постоянные циклы открытия/закрытия сокетов.
  2. Включение SO_REUSEADDR: Гарантирует, что перезапуск вашего Go-демона пройдет без ошибок биндинга портов.
  3. Расширение диапазона эфемерных портов: Увеличьте пул доступных портов ядра:

    Bash
    sysctl -w net.ipv4.ip_local_port_range="1024 65535"
    
  4. Переход на HTTP/2 и gRPC: Мультиплексирование сотен параллельных запросов внутри единого TCP-соединения радикально разгружает подсистему сокетов ядра.


Стратегии борьбы с CLOSE_WAIT

  1. Жесткий аудит закрытия дескрипторов: Всегда закрывайте сетевые ресурсы через идиоматический defer:

    Go
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close() // Обязательно!
    _, _ = io.Copy(io.Discard, resp.Body) // Вычитываем тело для возврата conn в пул
    
  2. Трассировка системных вызовов: Утилита strace -p <pid> -e trace=close позволяет отследить, вызывается ли сисколл закрытия дескриптора при получении EOF.

  3. Настройка системного таймаута: Параметр net.ipv4.tcp_fin_timeout (по умолчанию 60с) определяет, как долго ядро держит осиротевшие (орфанные) соединения в состоянии FIN_WAIT_2; к TIME_WAIT он отношения не имеет. Для TIME_WAIT настраивать почти нечего (длительность зашита в ядро; есть лишь net.ipv4.tcp_tw_reuse, который разрешает повторно использовать такие сокеты для исходящих соединений).

Итог

  1. TIME_WAIT — это естественное, защитное состояние активной стороны закрытия, длящееся 2MSL. Оно предотвращает искажение данных блуждающими пакетами и гарантирует доставку финального подтверждения. В Go бороться с ним нужно не отключением проверок, а пулингом соединений (http.Transport).
  2. CLOSE_WAIT — это маркер утечки файловых дескрипторов на пассивной стороне. Если сокет завис в CLOSE_WAIT, ищите незакрытый conn.Close() или забытый resp.Body.Close().
  3. Рантайм Go эффективно использует современные механизмы ядра Linux (SO_REUSEADDR, SO_REUSEPORT, неблокирующий epoll), но управление жизненным циклом протокола TCP требует от инженера глубокого понимания семантики закрытия соединений.
  4. Инструментарий надежности: Знание утилит ss, параметров SetLinger и мониторинг метрик сокетов позволяют проектировать бэкенды, устойчивые к экстремальным сетевым нагрузкам.

Мы завершили детальный разбор состояний и очередей протокола TCP. Но прежде чем пакет отправится по сети, приложение должно превратить доменное имя в IP-адрес. В следующей статье мы разберем: 51. DNS глазами ОС и libc, выясним, как Go резолвит хосты, почему системный net.LookupHost может заморозить потоки ОС и как устроена подсистема NSS в Linux.