Стоимость установления HTTP-соединений

Чтобы разобраться в причинах такого скачка производительности, стоит понять, что происходит в сети при выполнении HTTP-запроса.

Пакет net/http языка Go поддерживает HTTP/1.0, HTTP/1.1 и HTTP/2. Все три протокола работают поверх TCP и обычно над TLS при общении с HTTPS-точками. Запрос через новое соединение выглядит примерно так:

  1. TCP-рукопожатие (1 RTT): клиент отправляет пакет SYN, сервер отвечает SYN-ACK, клиент отвечает ACK. Поскольку клиент может отправить первое сообщение TLS вместе с финальным ACK, установление TCP-соединения занимает 1 round-trip time (RTT).
  2. TLS-рукопожатие (2 RTT): затем TLS-рукопожатие обменивается ключами шифрования и проверяет идентичность сервера; при использовании TLS 1.2 это занимает 2 RTT.
  3. HTTP-запрос: после обмена ключей HTTP-запросы наконец могут быть отправлены после суммарно 3 RTT.

Эти round-trip'ы (плюс некоторое время CPU на TLS-рукопожатия) быстро накапливаются. При трансрегиональной коммуникации односторонние задержки часто достигают или превышают 50 мс (100 мс RTT), показанные на диаграмме выше. По сравнению с 300 мс, потраченными только на настройку соединения, сам HTTP-запрос может быть дешевле, занимая чуть более 1 RTT при хорошей пропускной способности или небольшом HTTP-ответе.

Даже при более благоприятных условиях открытие нового соединения всё ещё может быть затратным. В исходном бенчмарке выполняются запросы к index.golang.org, которая распределена по миру и поддерживает TLS 1.3. Это означает, что задержка относительно низкая, и TLS-рукопожатие может завершиться всего в 1 RTT. Несмотря на это, разница в производительности всё ещё очень заметна.

Переиспользование соединений

Чтобы избежать затрат на настройку при каждом запросе, HTTP-клиенты и серверы стараются переиспользовать существующие соединения для множественных запросов, когда это возможно. Механика этого различается в зависимости от версии HTTP:

  • HTTP/1.0: хотя это и не часть официальной спецификации, клиенты и серверы часто включают переиспользование соединений, отправляя заголовок Connection: keep-alive. Переиспользование продолжается до тех пор, пока одна из сторон не опустит заголовок или не отправит Connection: close.
  • HTTP/1.1: соединения по умолчанию постоянны и могут переиспользоваться для следующих запросов и ответов, если не обе стороны не объявят об обратном, отправив заголовок Connection: close.
  • HTTP/2: несколько запросов и ответов могут быть мультиплексированы по одному TCP-соединению.

Когда переиспользование соединения не работает

В HTTP/1 (как HTTP/1.0, так и HTTP/1.1) несколько запросов и ответов на одном соединении отправляются последовательно по сети. Если два запроса GET отправлены к index.golang.org через одно TCP-соединение, ответы прибывают как единый поток байт:

Как показано на диаграмме, net/http.Get читает и парсит строку статуса и заголовки из потока байт, чтобы заполнить net/http.Response, затем немедленно возвращается. Тело ответа остаётся непрочитанным на соединении и потребляется только по мере того, как вызывающий код читает из net/http.Response.Body.

Поскольку ответы отправляются подряд в одном потоке, net/http может найти начало следующего ответа только после того, как тело текущего ответа прочитано до самого конца (как определено заголовком Content-Length или Transfer-Encoding).

Вернёмся к примеру программы, где resp.Body никогда не читается:

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    return resp.StatusCode, nil
}

В Go 1.26 и ранее, когда вызывается resp.Body.Close, net/http видит, что есть ещё непрочитанное тело ответа. Осознав, что переиспользовать соединение невозможно, происходит отказ от существующего соединения и создание новых соединений для следующих запросов, что приводит к многократному повторению затрат на установление новых соединений.

(HTTP/2 не имеет этой проблемы. Поскольку вместо отправки ответов строго подряд он мультиплексирует потоки фреймов через одно соединение, непрочитанное тело ответа в одном потоке не блокирует другие запросы от совместного использования соединения.)

Где неудача переиспользования соединения наносит вред

Эта тонкость переиспользования HTTP/1-соединений может привести к скрытой деградации производительности в казалось бы безобидном коде.

Как в исходном примере, клиент может заботиться только о коде статуса ответа API. Однако API-точка, к которой он обращается, может отправлять тела, предоставляющие более детальную, но в итоге неиспользованную информацию, например {"success": true, "message": "update processed"}. К сожалению, это приведёт к отказу в переиспользовании соединения.

Даже программы, которые читают тело ответа, могут столкнуться с этой проблемой, если они остановятся прямо перед концом тела. Например, encoding/json.Decoder перестаёт читать, как только видит закрывающую } JSON-объекта. Если сервер отправляет JSON-ответ, завершённый новой строкой, декодирование JSON-объекта оставит новую строку непрочитанной. Соединение не может быть переиспользовано для нового запроса, пока эта новая строка не будет потреблена. Таким образом, здоровое соединение может быть заброшено, хотя оно было всего одним байтом от переиспользуемости.

Ручное решение

Проблема непрочитанного тела была острым углом на протяжении многих лет. Со временем многие разработчики Go придумали свои собственные обходные пути. Наиболее часто это делается путём дренирования тела ответа перед его закрытием:

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    drainAndCloseBody(resp.Body)
    return resp.StatusCode, nil
}

func drainAndCloseBody(body io.ReadCloser) {
    io.Copy(io.Discard, body) // Drain the response body before closing it.
    body.Close()
}

Хотя это работает для небольших ответов, если непрочитанное тело велико, установление нового TCP-соединения может быть быстрее, чем загрузка и отброс остального тела. Лучший подход — ограничить количество байт, которые мы готовы дренировать:

func drainAndCloseBody(body io.ReadCloser) {
    io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
    body.Close()
}

Однако сервер может отправить тело ответа, которое медленно поступает, поэтому нам также нужно ограничить время, потраченное на дренирование:

func drainAndCloseBody(body io.ReadCloser) {
    drainDone := make(chan struct{}, 1)
    go func() {
        io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
        drainDone <- struct{}{}
    }()
    // Only spend at most 50 ms trying to drain the response body.
    select {
    case <-drainDone:
    case <-time.After(50 * time.Millisecond):
        // If we reach the 50 ms timeout, body.Close will run and interrupt the
        // io.CopyN, preventing goroutine leaks.
    }
    body.Close()
}

Конечно, мы также не хотим блокировать до 50 миллисекунд при закрытии тела ответа. Вместо этого мы можем дренировать тело асинхронно в фоне и дать ему гоняться с последующими запросами:

func drainAndCloseBody(body io.ReadCloser) {
    drainDone := make(chan struct{}, 1)
    go func() {
        io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
        drainDone <- struct{}{}
    }()
    go func() {
        // Only spend at most 50 ms trying to drain the response body.
        select {
        case <-drainDone:
            // If the drain can finish before a new request is sent, the
            // connection will be reused; otherwise, a new connection is
            // created, and the current connection will be put in a pool of
            // idle connections, potentially being reused for another future
            // request.
        case <-time.After(50 * time.Millisecond):
            // If we reach the 50 ms timeout, body.Close will run and interrupt
            // the io.CopyN, preventing goroutine leaks.
        }
        body.Close()
    }()
}

Как мы видим, безопасное дренирование тел ответов удивительно сложно и громоздко. Хуже того, по мере того как практика дренирования тел ответов распространялась, некоторые нюансы часто терялись. Это привело к недопониманиям, таким как попытки дренирования тел ответов для HTTP/2-соединений, когда это совершенно не нужно.

Текущее состояние и выводы

Начиная с Go 1.27, net/http.Transport автоматически делает всё, что мы реализовали в коде выше: он асинхронно дренирует непрочитанное тело HTTP/1-ответа на протяжении до 50 мс или 256 КБ. Это также реализует дополнительные оптимизации; например, когда ответ содержит заголовок Connection: close, дренирование тела гарантированно будет потраченной впустую работой, и это поэтому пропускается. Учтите, что это поведение может развиваться в будущих версиях Go, по мере появления других возможных улучшений.

Как общая лучшая практика, мы теперь рекомендуем пользователям просто закрывать свои HTTP-тела, когда они им больше не нужны, как в исходном примере:

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    return resp.StatusCode, nil
}

Go — это всё о простоте. Пусть ваш код остаётся чистым и простым, а net/http справляется со сложностями от вашего имени.