Дайджесты новостей
Сетевая трасса и буфер Go 1.27 с фоновым сбросом хвоста HTTP-ответа в idle-пул

Go 1.27: как фоновое дочитывание resp.Body ускоряет HTTP/1 в пять раз

Ты наверняка не раз писал в своих Go-сервисах привычную конструкцию: делаем http.Get, проверяем ошибку и сразу вешаем defer resp.Body.Close(). Выглядит канонично, линтеры довольны, тесты зелёные. Но если под капотом твой клиент общается с сервисом по протоколу HTTP/1.1, этот безобидный паттерн годами тихо убивал сетевую производительность.

Я сам долго наступал на эти грабли и удивлялся, почему сервис на проде захлёбывается в сотнях повторных TLS-рукопожатий, хотя пул соединений вроде бы настроен с запасом. В релизе Go 1.27 инженеры Nicholas Husin и Damien Neil из Go core team наконец-то выкатили изящное решение проблемы, которая мучила сообщество больше десяти лет.

Ловушка одного байта: почему сокет объявлялся грязным

Чтобы понять суть драмы, давай заглянем в механику протокола HTTP/1. В отличие от HTTP/2 или HTTP/3, где потоки мультиплексируются внутри одной сессии с помощью идентификаторов кадров, в HTTP/1 общение идёт строго последовательно в виде сплошного потока байт. Клиент отправляет запрос, сервер шлёт ответ. Если клиент хочет переиспользовать то же самое TCP-соединение для следующего запроса (механизм keep-alive), транспорт должен быть на сто процентов уверен: текущий ответ прочитан ровно до последнего байта.

До Go 1.26 включительно транспорт net/http.Transport вёл себя предельно консервативно. Если приложение вызывало resp.Body.Close(), не дочитав тело до маркера io.EOF, сокет помечался «грязным». Транспорт мгновенно рвал TCP-соединение и выбрасывал сокет из пула. А следующий запрос был вынужден платить полный штраф: сначала одно сетевое кругосветное путешествие (Round-Trip Time, RTT) на TCP-хэндшейк, затем ещё одно-два RTT на установление защищённого TLS-туннеля.

Самое коварное — поймать этот обрыв можно было совершенно случайно. Классический пример — потоковая распаковка JSON через стандартный json.NewDecoder:

func fetchUserProfile(resp *http.Response) (*UserProfile, error) {
    defer resp.Body.Close()

    var profile UserProfile
    // NewDecoder останавливается ровно на закрывающей фигурной скобке "}"
    if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
        return nil, err
    }
    // Если сервер передал перевод строки "\n" в конце, сокет разрывался!
    return &profile, nil
}

Парсер json.NewDecoder честно декодирует объект и останавливается на последнем символе }. А сервер почти всегда отправляет завершающий символ перевода строки \n. В буфере сокета остаётся ровно один непрочитанный байт. Для рантайма Go этого было достаточно: сокет считался засоренным, сессия уничтожалась, а производительность катилась вниз.

Асинхронный дренаж: как устроен фикс в Go 1.27

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

В Go 1.27 пакет net/http взял эту заботу на себя. Теперь при вызове resp.Body.Close() транспорт автоматически запускает фоновую горутину, которая аккуратно дочитывает «хвост» ответа в фоновом режиме:

// В Go 1.27 стандартный вызов стал безопасным для пула соединений
func checkHealth(client *http.Client, url string) (bool, error) {
    resp, err := client.Get(url)
    if err != nil {
        return false, err
    }
    // Транспорт в фоне дочитает остаток и вернет сокет в idle-пул
    defer resp.Body.Close()

    return resp.StatusCode == http.StatusOK, nil
}

У фонового дочитывания есть два строгих предохранителя:

  • Лимит объёма: не более 256 КиБ данных. Если сервер попытается скормить клиенту мегабайты забытого тела, транспорт не станет забивать память и корректно оборвёт связь.
  • Лимит времени: не более 50 миллисекунд. Если соединение лагает и пакеты еле ползут, сокет закрывается, не блокируя ресурсы.
  • Уважение к серверу: если сервер явно прислал заголовок Connection: close, фоновый дренаж даже не стартует.

Цифры бенчмарков и выводы

Результаты замеров авторов говорят сами за себя. В синтетическом тесте из 100 последовательных запросов к сервису чексумм index.golang.org по протоколу HTTP/1 время выполнения сократилось с 710 миллисекунд на Go 1.26 до 150 миллисекунд на Go 1.27 — ускорение почти в пять раз!

Мы уже подробно исследовали этот сетевой феномен, когда разбирали ранний разбор механики чистых сокетов в Go. Теперь, после официального релиза Go 1.27, тебе больше не нужно писать костыли с io.Discard: обновляй версию рантайма, оставляй стандартный defer resp.Body.Close() и получай бесплатный буст сетевого стека из коробки.