34. net/http под капотом. Transport, Connection Pool, Keep Alive

Философия повторного использования соединений

В высоконагруженных распределенных системах создание нового TCP-соединения для каждого отдельного HTTP-запроса — это непростительное расточительство аппаратных ресурсов. Чтобы передать крошечный JSON-документ размером в полкилобайта, процессор и сетевая карта вынуждены тратить сотни микросекунд на тройное рукопожатие TCP (SYN, SYN-ACK, ACK), тяжеловесный обмен сертификатами и криптографический расчет сессионных ключей TLS 1.3. При тысячах запросов в секунду такой подход неминуемо ведет к латентным задержкам, перегрузке сетевого стека и исчерпанию файловых дескрипторов.

Пакет net/http решает эту фундаментальную проблему через http.Transport — мощный, потокобезопасный диспетчер соединений, который абстрагирует пулинг, управление Keep-Alive, TLS-рукопожатия и прозрачное переключение протоколов.

Понимание внутренней механики Transport критически важно для инженера уровня Senior. Дефолтные настройки (MaxIdleConnsPerHost: 2) часто становятся невидимым bottleneck'ом в продакшене, а неправильная работа с пулом соединений приводит к утечкам FD, TIME_WAIT штормам и каскадным отказам микросервисов.

1. Under the hood: Структура Transport и persistConn

При вызове client.Do(req) запрос попадает в Transport.roundTrip(). Ключевая абстракция — persistConn (persistent connection). Это обертка над net.Conn, управляющая состоянием соединения, чтением/записью через bufio, обработкой Connection: keep-alive и сигнализацией пулу о освобождении ресурса.

Внутренний пул (http.connPool) управляет двумя типами очередей:

  1. Idle Connections: Свободные, готовые к повторному использованию соединения.
  2. Dialing Connections: Соединения в процессе установки (DNS resolve + TCP handshake + TLS).
Go
// Упрощенная схема пула соединений
type connPool struct {
    mu sync.Mutex
    idle map[string][]*persistConn      // Ключ: scheme://host. Слайс свободных коннектов
    idleWait map[string]chan *persistConn // Очередь ожидающих горутин
    dialing map[string][]chan *persistConn // Каналы для доставки новых соединений
}

Метод getConn() реализует логику получения соединения:

  1. Ищет в idle по ключу хоста.
  2. Если есть — возвращает немедленно.
  3. Если нет — проверяет dialing. Если уже идет установка, горутина блокируется на канале и ждет завершения handshake.
  4. Если нет активных диалов — создает новый через dialConn().

2. Механика Connection Pool: От запроса до пула

Когда ответ получен, Transport не закрывает сокет сразу. Он проверяет заголовки сервера:

  • Если Connection: keep-alive (или HTTP/1.1 без Connection: close) — сокет помечается как idle и кладется в пул.
  • Если сервер вернул Connection: close или тело прочитано не полностью — сокет закрывается принудительно.
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
    A["Вызов http.RoundTrip"]:::warning --> B{"Есть idle соединение<br>в пуле?"}
    B -->|"Да"| C["Получение persistConn<br>Сброс счетчиков"]
    B -->|"Нет"| D{"Уже идет dial?"}
    D -->|"Да"| E["Горутина ждет<br>на канале"]
    D -->|"Нет"| F["Вызов dialConn<br>DNS + TCP + TLS"]
    F --> C
    C --> G["Отправка запроса<br>Получение ответа"]
    G --> H{"Server: keep-alive<br>и тело прочитано?"}
    H -->|"Да"| I["Возврат в idle<br>Запуск таймера IdleConnTimeout"]
    H -->|"Нет"| J["Закрытие сокета<br>Освобождение FD"]
    I --> K["Следующий запрос<br>переиспользует conn"]
    J --> L["Повторный диал"]
  

3. Keep-Alive и TCP-состояния

HTTP Keep-Alive — это протокольная договоренность. На уровне ядра ОС она поддерживается через TCP-сокеты и опцию SO_KEEPALIVE.

  • HTTP уровень: Заголовок Connection: keep-alive разрешает переиспользование сокета для последующих запросов.
  • TCP уровень: SO_KEEPALIVE отправляет пустые пакеты каждые 2 часа (по умолчанию в Linux) для обнаружения разорванных соединений. В Go Transport включает SetKeepAlive(true) и SetKeepAlivePeriod(3 * time.Minute) по умолчанию.

При закрытии пулом соединения (по таймауту IdleConnTimeout) происходит вызов conn.Close(). Ядро ОС переводит сокет в состояние FIN_WAIT2, а затем TIME_WAIT (обычно 60 секунд в Linux). Это не проблема для пула, но если вы создаете тысячи одноразовых соединений без Keep-Alive, TIME_WAIT быстро исчерпает лимит локальных портов (net.ipv4.ip_local_port_range).

4. HTTP/1.1 vs HTTP/2: Мультиплексирование пула

Логика пула кардинально меняется в зависимости от версии протокола:

  • HTTP/1.1: Одно соединение обрабатывает один запрос за раз. Чтобы обслуживать 100 одновременных запросов к одному хосту, пул должен держать до 100 соединений. Лимит MaxIdleConnsPerHost (по умолчанию 2) быстро вызывает очередь dialing.
  • HTTP/2: Использует http2.Transport, который мультиплексирует множество стримов (запросов) по одному TCP-соединению. Внутренний пул Go всё равно создает соединения, но их количество кратно меньше. http2.ConfigureTransports настраивает пул под специфику мультиплексирования, увеличивая MaxIdleConns и MaxConnsPerHost.

5. Mechanical Sympathy: Файловые дескрипторы, память и тюнинг

Каждое активное или idle-соединение потребляет ресурсы:

  1. File Descriptor: 1 FD на сокет. Лимит ulimit -n должен быть настроен с запасом.
  2. Kernel Memory: Каждому TCP-сокету ядро выделяет буферы чтения/записи (skbuff), структуры sock и таймеры. Idle-соединение держит эти ресурсы занятыми до закрытия.
  3. User Space Memory: persistConn содержит bufio.ReadWriter (~4-8 КБ), структуры синхронизации и указатели на Transport.

Тюнинг для Production:

Go
var tr = &http.Transport{
    // Лимит idle соединений на хост. Увеличьте для HTTP/1.1 микросервисов
    MaxIdleConnsPerHost: 50,
    // Общий лимит idle соединений. 0 = без лимита (следите за ulimit)
    MaxIdleConns:        200,
    // Время жизни idle соединения. 90s - хороший баланс между латентностью и памятью
    IdleConnTimeout:     90 * time.Second,
    // Таймаут установки соединения. Критичен для предотвращения долгих горутин
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext,
    // Лимит одновременных диалов на хост. 0 = без лимита
    MaxConnsPerHost:     100,
    TLSHandshakeTimeout: 10 * time.Second,
}

var client = &http.Client{
    Timeout:   15 * time.Second, // Таймаут на весь цикл запрос-ответ
    Transport: tr,
}

6. Ловушки и вопросы с собеседований

Сценарий Проблема Решение
MaxIdleConnsPerHost: 2 по умолчанию При скачке трафика к одному сервису создаются сотни новых соединений, растет latency Увеличьте до 50-100 для внутренних вызовов. Мониторьте netstat -an \| grep ESTABLISHED.
Утечка соединений при resp.Body.Close() Забытый defer или паника перед закрытием Всегда defer resp.Body.Close() сразу после проверки err == nil.
http2.Transport vs http.Transport http2.Transport не поддерживает http:// (только https://). Для смешанных схем используйте стандартный http.Transport, он сам делегирует http2 при TLS.
Transport.Clone() Клонирование небезопасно, если оригинал уже использовался. Используйте tr.Clone() только для инициализации. Никогда не мутируйте Transport после старта горутин.
Client.CloseIdleConnections() Закрывает все idle соединения, но не активные. При частом вызове разрушает пул. Вызывайте только при graceful shutdown или динамической смене DNS/прокси.

Итог

  1. http.Transport — единый, потокобезопасный диспетчер. Создавайте его один раз и передавайте по зависимостям.
  2. Пул соединений использует логику idle -> dialing -> new. MaxIdleConnsPerHost по умолчанию равен 2, что требует увеличения для продакшена.
  3. Всегда закрывайте resp.Body. Непрочитанное тело ломает Keep-Alive и вынуждает создавать новые соединения.
  4. HTTP/2 мультиплексирует запросы по одному TCP-соединению, HTTP/1.1 требует пула соединений, равного количеству параллельных вызовов.
  5. Idle-соединения держат FD и память ядра. Настройте IdleConnTimeout и MaxIdleConns под нагрузку и лимиты ОС.
  6. Мониторьте состояние пула. Используйте httptrace и сетевые метрики для предотвращения TIME_WAIT штормов.

Освоив управление сетевыми соединениями, мы переходим к критическому аспекту разработки: как тестировать HTTP-сервисы без реальных сетевых задержек, мокировать внешние API и писать стабильные интеграционные тесты. В следующей статье мы разберем стандартный инструментарий: 35. httptest. Тестирование HTTP-сервисов.