Почему HTTP/2?

HTTP/2 — это крупное обновление по сравнению с HTTP/1.1, и в наши дни он практически везде используется по умолчанию. Если вы открывали Chrome DevTools для проверки сетевых запросов, то, скорее всего, уже видели соединения по протоколу HTTP/2.

Проверка подключений HTTP / 2 с помощью Chrome
Проверка подключений HTTP / 2 с помощью Chrome

Но почему HTTP/2 так важен? Что не так с HTTP/1.1?

В HTTP/1.1 была введена конвейерная обработка, которая на первый взгляд казалась значительным шагом вперед. Идея была проста: несколько запросов могли совместно использовать одно соединение и запускаться, не дожидаясь завершения предыдущего.

Конвейерная обработка HTTP / 1.1: Последовательная обработка запросов
Конвейерная обработка HTTP / 1.1: Последовательная обработка запросов

Проблема заключается в том, что запросы должны отправляться по порядку, а ответы приходить в том же порядке. Если один ответ задерживается — серверу требуется дополнительное время для его обработки, — все остальные в очереди должны ждать.

Это также происходит, если случается сетевой «сбой», который задерживает один запрос. Весь конвейер обработки запросов останавливается до тех пор, пока не будет обработан задержанный запрос.

Блокировка заголовка строки в HTTP/1.1
Блокировка заголовка строки в HTTP/1.1

Эта проблема называется блокировкой Head-of-Line (HoL).

Чтобы обойти это ограничение, клиенты HTTP/1.1 (например, ваш браузер) начинают открывать несколько TCP-соединений с одним и тем же сервером, что позволяет одновременно отправлять запросы.

Хоть это работает, но не совсем эффективно:

  • Чем больше подключений, тем больше ресурсов используется как на стороне клиента, так и на стороне сервера.

  • TCP должен проходить процесс установления соединения для каждого подключения, что увеличивает задержку.

“Итак, решает ли HTTP / 2 эту проблему?”

Работает.… в основном, хорошо.

HTTP/2 использует одно соединение и разделяет его на несколько независимых потоков. У каждого потока есть свой уникальный идентификатор, называемый идентификатором потока, и эти потоки могут работать параллельно. Эта настройка устраняет проблему блокировки Head-of-Line (HoL) на прикладном уровне (где находится HTTP). Если один поток задерживается, это не мешает остальным.

Кадры из нескольких потоков по одному соединению
Кадры из нескольких потоков по одному соединению

Однако HTTP/2 по-прежнему работает на TCP, поэтому полностью избежать блокировки HoL не удаётся.

На транспортном уровне TCP настаивает на доставке пакетов в порядке, необходимом для прикладного уровня. Если один пакет теряется или задерживается, TCP заставляет всех ждать, пока он не восстановит недостающий фрагмент. Как только задержанный пакет появляется, TCP доставляет пакеты в правильном порядке на уровень HTTP/2 (или прикладной уровень).

Таким образом, даже если все данные остальных потоков находятся в буфере и готовы к отправке, серверу всё равно придётся ждать поступления данных отложенного потока, прежде чем он сможет обработать остальные.

Если вы хотите полностью обойти ограничения TCP, вам понадобится что-то вроде QUIC, которое построено на базе UDP (User Datagram Protocol) и поддерживает HTTP/3.

Конечно, HTTP/2 не только устраняет недостатки HTTP/1.1, но и открывает новые возможности. Давайте подробнее рассмотрим, как всё это работает.

Как работает HTTP/2?

Когда клиент устанавливает TLS-соединение, процесс начинается с сообщения ClientHello. Это сообщение включает расширение ALPN (Application Layer Protocol Negotiation), которое по сути является списком протоколов, поддерживаемых клиентом. Обычно оно включает в себя как «h2» для HTTP/2, «http/1.1» в качестве резервного варианта, так и другие.

Затем стек TLS сервера проверяет этот список на соответствие протоколам, которые он поддерживает. Если обе стороны согласны с «h2», сервер подтверждает выбор в своём ServerHello ответе.

После этого рукопожатие TLS продолжается в обычном режиме: устанавливаются ключи шифрования, проверяются сертификаты и т.д.

Предисловие к предварительному соединению

После завершения рукопожатия клиент отправляет так называемое предварительное соединение (префикс). Оно начинается с очень специфической 24-байтовой последовательности: PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n. Эта последовательность подтверждает, что HTTP/2 — это используемый протокол. На этом этапе еще нет сжатия или кадрирования.

Сразу после отправки префикса подключения клиент отправляет кадр SETTINGS. Он не привязан ни к какому потоку; это управляющий кадр на уровне соединения — сообщение серверу, в котором говорится: «Вот мои предпочтения». Сюда входят такие настройки, как параметры управления потоком, максимальный размер кадра и т. д.

Фреймы НАСТРОЕК Обмена ДАННЫМИ Между сервером и Клиентом
Кадры SETTINGS обмена сервером и клиентом

Сервер распознает, что именно хочет получить клиент, и отвечает собственным префиксом соединения, которая включает в себя кадр SETTINGS.

После завершения обмена можно приступать к настройке соединения.

Кадр HEADERS и сжатие HPACK

Теперь клиент готов отправить запрос, он создаёт новый поток с уникальным идентификатором, который называется идентификатором потока. Идентификатор потока для потоков, инициированных клиентом, всегда является нечётным числом — 1, 3, 5,…

Вы можете задаться вопросом, почему идентификаторы потоков имеют нечётные значения, а не нумеруются как 1, 2, 3… На самом деле здесь есть небольшое аккуратное правило:

  • Потоки с нечётными номерами предназначены для запросов, инициированных клиентом.

  • Потоки с чётными номерами предназначены для сервера, часто для функций, инициируемых сервером, таких как push-уведомления от сервера.

  • Идентификатор потока 0 является специальным, он используется только для кадров управления на уровне соединения (не на уровне потока), которые применяются ко всему соединению.

Как только поток готов, клиент отправляет кадр HEADERS.

Этот кадр содержит информацию — эквивалент строки запроса HTTP/1.1 и заголовков (GET / HTTP/1.1 и все, что следует за этим). Однако в HTTP/2 заголовки структурированы и передаются немного иначе.

  • Структура: HTTP/2 вводит псевдозаголовки, которые помогают определять такие параметры, как метод, путь и статус. Затем следуют знакомые заголовки, такие как User-AgentContent-Type,…

  • Передача: заголовки сжимаются с помощью алгоритма HPACK и отправляются в двоичном формате.

— Псевдозаголовок? Сжатие HPACK? Что здесь происходит?

Давайте разберёмся с этим, начиная с псевдозаголовков.

Если вы пользовались DevTools в Chrome или любым другим инспектором, это может показаться вам знакомым.

В HTTP/2 псевдозаголовки — это способ отделить специальные заголовки от обычных. Эти специальные заголовки, такие как :method:path:scheme, и :status, всегда идут первыми. После них следуют обычные заголовки, такие как AcceptHost, и Content-Type, в обычном формате.

Формат заголовка HTTP / 1.1 против HTTP / 2
Формат заголовков HTTP/1.1 и HTTP/2

В HTTP/1.1 эта информация разбросана по строке запроса и заголовкам. Это не очень удобно, и для заполнения пробелов приходится полагаться на соглашения или контекст. Например:

  • Схема (HTTP или HTTPS) подразумевает тип соединения. Если это TLS на порту 443, то знаете, что это HTTPS .

  • Заголовок Host, добавленный в HTTP/1.1 для виртуального хостинга - это просто обычный заголовок, а не формальная часть структуры запроса.

С псевдозаголовками HTTP/2 (те, что начинаются с двоеточия, например :method или :path) вся эта неоднозначность исчезает.

“А как насчет сжатия HPACK?”

В отличие от HTTP/1.1, где заголовки представляют собой обычный текст и разделяются символами новой строки (\r\n), в HTTP/2 для кодирования заголовков используется двоичный формат. Именно здесь на помощь приходит сжатие HPACK — алгоритм, созданный специально для HTTP/2. Он не просто сжимает заголовки для экономии места, но и позволяет избежать повторной отправки одних и тех же данных заголовка.

HPACK использует две умные таблицы для управления заголовками: статическую таблицу и динамическую таблицу.

Статическая таблица похожа на общий словарь, который уже известен и клиенту, и серверу. Она содержит 61 наиболее распространённый HTTP-заголовок. Если вам интересны подробности, вы можете ознакомиться с файлом static_table.go в пакете net/http2.

Статическая таблица с общими HTTP-заголовками
Статическая таблица с общими HTTP-заголовками

Допустим, вы отправляете GET-запрос с заголовком :method: GET.

Вместо передачи всего заголовка HPACK может просто отправить число 2. Это число относится к паре «ключ-значение» :method: GET в статической таблице, и все участники группы знают, что оно означает.

Если ключ совпадает, но значение не совпадает, например etag: some-random-value, HPACK может повторно использовать ключ (в данном случае 34) и просто отправить обновлённое значение. Таким образом, имя заголовка не будет передаваться повторно.

“Так что же происходит с some-random-value?”

Он кодируется с помощью кодирования Хаффмана и отправляется как 34: huffman("some-random-value") (псевдокод). Но что интересно, так это то, что весь заголовок, etag: some-random-value добавляется в динамическую таблицу.

Динамическая таблица начинается с пустого состояния и заполняется по мере отправки новых заголовков (не входящих в статическую таблицу). Это делает HPACK сохраняющим состояние, то есть и клиент, и сервер поддерживают собственные динамические таблицы на протяжении всего соединения.

Каждый новый заголовок, добавляемый в динамическую таблицу, получает уникальный индекс, начиная с 62 (поскольку индексы с 1 по 61 зарезервированы для статической таблицы). С этого момента вместо повторной передачи заголовка используется этот индекс. У такой настройки есть несколько ключевых особенностей:

  • Уровень соединения: динамическая таблица используется во всех потоках в рамках одного соединения. И сервер, и клиент поддерживают собственные копии.

  • Ограничение размера: по умолчанию максимальный размер динамической таблицы составляет 4 КБ (4096 октетов), который можно изменить с помощью параметра SETTINGS_HEADER_TABLE_SIZE в кадре SETTINGS . Когда таблица заполняется, старые заголовки удаляются, чтобы освободить место для новых.

Кадр DATA

Если есть тело запроса, оно отправляется в кадре DATA . Если размер тела превышает максимальный размер кадра (по умолчанию 16 КБ), оно разбивается на несколько кадров DATA с одним и тем же идентификатором потока.

Одно TCP-соединение, Передающее Несколько потоков
Одно TCP-соединение, передающее несколько потоков

“Итак, где идентификатор потока во кадре?”

Хороший вопрос. Мы еще не говорили о структуре кадра.

Кадры в HTTP/2 - это не просто контейнеры для данных или заголовков. Каждый кадр содержит 9-байтовый заголовок. Это не тот заголовок HTTP, о котором мы говорили ранее, это заголовок кадра.

Разбивка заголовка фрейма HTTP/ 2
Разбор заголовка кадра HTTP/2

Итак, вот разбивка: у нас есть length, которая сообщает нам размер полезной нагрузки кадра (исключая сам заголовок кадра). Затем идет type, который определяет тип кадра (например, DATA, HEADERS, PRIORITY и т. д.). Далее идут flags, которые предоставляют дополнительную информацию о кадре. Например, флаг (0x1) END_STREAM сигнализирует о том, что в этом потоке больше не будет кадров.

И, наконец, у нас есть идентификатор потока. Это 32-битное число, которое идентифицирует, к какому потоку принадлежит кадр (старший бит зарезервирован и всегда должен быть установлен в 0).

— Но как насчёт порядка кадров в потоке? Что, если они приходят не по порядку?

Хотя идентификатор потока сообщает нам, к какому потоку относится кадр, он не определяет порядок кадров.

Мы найдём ответ на уровне TCP. Поскольку HTTP/2 работает поверх TCP, протокол гарантирует последовательную доставку пакетов. Даже если пакеты проходят по сети разными путями, TCP гарантирует, что они дойдут до получателя в том порядке, в котором были отправлены.

Это связано с проблемой блокировки HoL, о которой мы говорили ранее.

Когда сервер получает кадр HEADERS, он создаёт новый поток с тем же идентификатором, что и у запроса.

Сначала он отправляет собственный HEADERS кадр, который содержит статус ответа и заголовки (сжатые с помощью HPACK). После этого тело ответа отправляется в DATA кадрах. Благодаря мультиплексированию сервер может чередовать кадры из нескольких потоков, одновременно отправляя фрагменты разных ответов по одному соединению.

На стороне клиента кадры ответа сортируются по идентификатору потока. Клиент распаковывает HEADERS кадр и обрабатывает DATA кадры по порядку.

Все остается согласованным, даже когда одновременно активны несколько потоков.

Управление потоком

Когда поступает кадр с установленным флагом END_STREAM (первый бит в поле флагов в заголовке кадра установлен в 1), это сигнал. Он сообщает получателю: «Всё, больше кадров в этом потоке не будет». В этот момент сервер может отправить запрошенные данные и завершить поток своим собственным флагом END_STREAM в ответе.

Но завершение потока не закрывает всё соединение. Соединение остаётся открытым, чтобы другие потоки могли продолжать работу.

Если серверу нужно закрыть соединение, он использует кадр GOAWAY. Это управляющий кадр уровня соединения, предназначенный для корректного завершения работы.

Когда сервер отправляет кадр GOAWAY, он включает в него последний идентификатор потока, который он планирует обработать. По сути, сообщение говорит: «Я завершаю работу, потоки с более высокими идентификаторами не будут обработаны, но всё остальное, что выполняется, может завершиться в обычном режиме».  Вот почему это считается корректным завершением работы.

После отправки GOAWAY отправитель обычно ждёт некоторое время, чтобы получатель обработал сообщение и перестал отправлять новые потоки. Эта короткая пауза помогает избежать жёсткого сброса TCP (RST), который в противном случае немедленно прервал бы все потоки и вызвал хаос.

В наборе инструментов HTTP/2 есть и другие полезные функции. В процессе соединения обе стороны могут отправлять кадры WINDOW_UPDATE для управления потоком, карды PING  для проверки, что соединение всё ещё активно, и кадры PRIORITY для точной настройки приоритетов потоков. А если что-то пойдёт не так, RST_STREAM кадры могут использоваться для отключения отдельных потоков без влияния на остальную часть соединения.

На этом мы завершаем рассказ об HTTP/2. Далее давайте посмотрим, как всё это работает в Go.

HTTP/2 в Go

Уточнение: код, содержащий части http-протоколов, основан на go 1.24, который еще не был выпущен. (спасибо @tumblerза замечание).

Возможно, вы даже не заметите этого, но пакет net/http в Go уже поддерживает HTTP/2 «из коробки».

“Подожди, так это просто включено по умолчанию?”

Ну, и да, и нет.

Если ваш сервис работает по протоколу HTTPS, то HTTP/2, скорее всего, используется автоматически. Но если он работает по простому протоколу HTTP, то, вероятно, нет. Вот несколько распространённых сценариев, в которых HTTP/2 может не использоваться:

  • Ваш сервис работает по протоколу HTTP, используя простой ListenAndServe.

  • Вы находитесь за прокси-сервером Cloudflare. В этом случае запросы пользователей к Cloudflare могут использовать HTTP/2, но соединение от Cloudflare к вашему сервису (источнику) обычно использует HTTP/1.1.

  • Вы находитесь за Nginx с поддержкой HTTP/2. Nginx выступает в качестве точки завершения TLS, расшифровывая запрос и повторно шифруя ответ, при этом перенаправляя все запросы к вашему сервису по HTTP/1.1.

Смешанные протоколы: HTTP/2 и HTTP/1.1
Смешанные протоколы: HTTP/2 и HTTP/1.1

Если вы хотите, чтобы ваш сервис напрямую использовал HTTP/2, вам нужно настроить его с помощью SSL/TLS.

Технически вы можете использовать HTTP/2 без TLS, но это не является хорошей практикой для внешнего трафика. Однако его можно использовать во внутренних средах, таких как микросервисы или частные сети.

Даже если вы используете HTTP/2 без TLS, клиент всё равно может по умолчанию использовать HTTP/1.1. Приведённое ниже решение не гарантирует, что клиенты (внешние сервисы) будут использовать HTTP/2 с вашим HTTP-сервером.

Давайте рассмотрим простой пример. Начнём с базового сервера, работающего по протоколу HTTP на порту 8080:

func getRequestProtocol(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Request Protocol: %s\n", r.Proto)
}

func main() {
	http.HandleFunc("/", getRequestProtocol) // Root endpoint
	if err := http.ListenAndServe(":8080", nil); err != nil {
		fmt.Printf("Error starting server: %s\n", err)
	}
}

А вот базовый HTTP-клиент для его тестирования:

func main() {
	resp, _ := (&http.Client{}).Get("http://localhost:8080")
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	fmt.Println("Response:", string(body))
}

// Response: Request Protocol: HTTP/1.1

Здесь мы пропустим обработку ошибок, чтобы сосредоточиться на основной идее.

Из вывода видно, что и запрос, и ответ, как и ожидалось, используют HTTP/1.1. Без HTTPS или специальной конфигурации HTTP/2.

По умолчанию HTTP-клиент Go использует DefaultTransport, который уже настроен для работы как с HTTP/1.1, так и с HTTP/2. Есть даже удобное поле под названием ForceAttemptHTTP2, которое включено по умолчанию:

var DefaultTransport RoundTripper = &Transport{
	...
	ForceAttemptHTTP2:     true, // <---
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

«Значит, наш клиент и сервер поддерживают HTTP/2? Почему они его не используют?»

Да, оба готовы к HTTP/2, но только через HTTPS. Для простого HTTP не хватает одного элемента: поддержки незашифрованного HTTP/2. Вот как можно включить незашифрованный HTTP/2 с помощью быстрой настройки:

var protocols http.Protocols
protocols.SetUnencryptedHTTP2(true)

// server
server := &http.Server{
    Addr:      ":8080",
    Handler:   http.HandlerFunc(rootHandler),
    Protocols: &protocols,
}

// client
client := &http.Client{
    Transport: &http.Transport{
        ForceAttemptHTTP2: true,
        Protocols:         &protocols,
    },
}

// Response: Request Protocol: HTTP/2.0

Если включить незашифрованный HTTP/2 с помощью protocols.SetUnencryptedHTTP2(true), клиент и сервер будут взаимодействовать по HTTP/2 даже без HTTPS. Это небольшая настройка, но она позволяет всему встать на свои места.

Интересно, что Go также поддерживает HTTP/2 через пакет golang.org/x/net/http2, который даёт вам ещё больше возможностей для контроля. Вот пример его настройки:

// server
h2s := &http2.Server{
    MaxConcurrentStreams: 250,
}
h2cHandler := h2c.NewHandler(handler, h2s)
server := &http.Server{
    Addr:    ":8080",
    Handler: h2cHandler,
}

// client
client := &http.Client{
	Transport: &http2.Transport{
		AllowHTTP: true,
		DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
			return net.Dial(network, addr)
		},
	},
}

Таким образом, мы видим, что HTTP/2 на самом деле не нуждается в TLS, это просто протокол, который работает на основе HTTP/1.1. Однако в большинстве случаев, если на вашем сервере уже включён TLS, HTTP-клиент Go по умолчанию автоматически использует HTTP/2 и при необходимости возвращается к HTTP/1.1. Никаких дополнительных действий не требуется.

Комментарии (3)


  1. qrdl
    31.01.2025 07:41

    Ни в http.Server (https://pkg.go.dev/net/http#Server), ни в http2.Server (https://pkg.go.dev/golang.org/x/net/http2#Server) нет поля Protocol, которое предлагается менять.
    И вот что говорит официальная документация (https://pkg.go.dev/net/http#hdr-HTTP_2):

    The http package's Transport and Server both automatically enable HTTP/2 support for simple configurations. To enable HTTP/2 for more complex configurations, to use lower-level HTTP/2 features, or to use a newer version of Go's http2 package, import "golang.org/x/net/http2" directly and use its ConfigureTransport and/or ConfigureServer functions. Manually configuring HTTP/2 via the golang.org/x/net/http2 package takes precedence over the net/http package's built-in HTTP/2 support.

    Статья - галлюцинация AI?


    1. tumbler
      31.01.2025 07:41

      Из комментария к оригиналу:

      I would recommend to specify, that the code containing http protocols parts relies on go 1.24, which haven't been released yet.

      Короче ждем Go1.24


      1. qrdl
        31.01.2025 07:41

        Да, в 1.24 действительно есть/будет - https://pkg.go.dev/net/http@go1.24rc2#Server

        В переводе по какой-то причине это упущено.

        UPD. Уже и в перевод добавлено