Дайджесты новостей
Кинематографичный натюрморт рабочего места инженера с сетевым анализатором потока HTTP и сокетов в Go

Чистый сокет: как Go научился эффективно переиспользовать HTTP/1-соединения

Спорим, в твоем Go-коде прямо сейчас есть строчка defer resp.Body.Close()? Мы пишем ее на автомате, веря, что стандартная библиотека сама вернет сокет в пул Keep-Alive. А потом под нагрузкой удивляемся, почему сервис открывает тысячи лишних TCP-соединений в секунду и задыхается на TLS-рукопожатиях.

Инженеры команды Go Николас Хусин и Дэмиен Нил опубликовали разбор этой проблемы. Годами пакет net/http при малейшей неосторожности молча рвал соединения. В релизе Go 1.27 поведение рантайма изменили кардинально.

Невычитанный хвост: почему сокет объявлялся «грязным»

В HTTP/1.1 запросы и ответы идут по сокету последовательно единым байтовым потоком. Если приложение вызвало resp.Body.Close(), но не дочитало тело до маркера io.EOF, в сокете остаются байты предыдущего ответа.

До Go 1.26 логика транспорта была жесткой: сокет помечался «грязным», сессия немедленно закрывалась, а под следующий запрос открывалось новое TCP-подключение. Это приводило к потере времени на TCP handshake (1 RTT) и TLS handshake (1-2 RTT), перегружая CPU.

Коварство потокового JSON: цена одного байта

Самое неприятное — как легко было поймать этот сбой непреднамеренно:

  • Проверка статус-кода: функция проверяет resp.StatusCode == 200 и выходит, оставляя даже крошечное тело в сокете.
  • Декодирование через json.NewDecoder: парсер останавливается на закрывающей скобке }, оставляя в буфере символ перевода строки \n. Всего один непрочитанный байт приводил к разрыву сокета!

Разработчикам годами приходилось вычитывать тело в никуда через io.Copy(io.Discard, resp.Body) перед закрытием.

Революция в Go 1.27: умный фоновый дренаж

Начиная с Go 1.27, пакет net/http.Transport научился наводить порядок сам. При вызове resp.Body.Close() рантайм запускает асинхронное фоновое дочитывание:

  • Лимит объема: максимум 256 КБ.
  • Лимит времени: таймаут 50 мс.
  • Пропуск: при наличии заголовка Connection: close дочитывание пропускается.

Если сокет успевает очиститься, он сразу переиспользуется либо отправляется в idle pool.

Эффект в цифрах

В тесте на 100 запросов к index.golang.org время выполнения упало с ~710 мс в Go 1.26 до ~150 мс в Go 1.27 — ускорение почти в 5 раз!

Как писать идиоматичный код сегодня

На Go 1.27 костыли с io.Discard больше не нужны:

package main

import (
    "fmt"
    "net/http"
)

// net/http в Go 1.27 автоматически дочитает остаток тела до 256 КБ за 50 мс
func checkHealth(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()

    return resp.StatusCode, nil
}

А для тюнинга пула под высокой нагрузкой настраивай транспорт:

package main

import (
    "net"
    "net/http"
    "time"
)

func newCustomClient() *http.Client {
    transport := &http.Transport{
        DialContext: (&net.Dialer{
            Timeout:   10 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   20, // По умолчанию всего 2!
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   5 * time.Second,
        ResponseHeaderTimeout: 5 * time.Second,
    }

    return &http.Client{
        Transport: transport,
        Timeout:   15 * time.Second,
    }
}

Подъем MaxIdleConnsPerHost до 20–50 сокетов вместе с автодочитыванием Go 1.27 надежно защитит микросервисы от лишней нагрузки.