21. HTTP 1.1 под капотом. Keep Alive, Chunked Encoding, Pipelining

Введение: Почему HTTP/1.1 до сих пор важен

В компьютерных сетях есть удивительный парадокс: протоколы, созданные более четверти века назад в спешке и на коленке, продолжают нести на своих плечах львиную долю мирового трафика. Спецификация HTTP/1.1 (RFC 2616, а позже модульное семейство RFC 7230–7235 и современный RFC 9112) была стандартизирована в далёком 1997–1999 годах. Несмотря на триумфальное шествие HTTP/2 и HTTP/3, понимание внутренней механики HTTP/1.1 критически важно для Senior/Lead Go-инженера.

Почему?
Во-первых, большинство внутренних сервисов в Kubernetes-кластерах, межсервисных вызовов за пределами gRPC, интеграций с базами данных (Elasticsearch, CouchDB), очередей сообщений и периметровых балансировщиков (Nginx, Envoy, AWS ALB) на последней миле всё ещё общаются по классическому HTTP/1.1.
Во-вторых, стандартная библиотека Go — пакет net/http — была спроектирована именно так, чтобы выжать максимум производительности из ограничений HTTP/1.1. Разбирая Keep-Alive, Chunked и Pipelining, мы понимаем, почему современный стек отказался от однопоточного мультиплексирования и перешёл к кросспротокольным оптимизациям.

Если HTTP/1.0 напоминал телефонный разговор, где после каждого сказанного слова абонент бросал трубку и заново набирал номер, то HTTP/1.1 попытался научить собеседников оставаться на линии. Давайте заглянем под капот этой эволюции.

sequenceDiagram
    autonumber
    participant Client as Клиент (Go Client)
    participant Server as Сервер (Nginx / Go Server)

    Note over Client,Server: HTTP/1.0: Закрытие сокета на каждый запрос
    Client->>Server: SYN
    Server-->>Client: SYN-ACK
    Client->>Server: ACK + GET /index.html
    Server-->>Client: 200 OK (HTML) + FIN
    Client-->>Server: ACK + FIN

    Note over Client,Server: HTTP/1.1: Persistent Connection (Keep-Alive)
    Client->>Server: SYN
    Server-->>Client: SYN-ACK
    Client->>Server: ACK + GET /style.css
    Server-->>Client: 200 OK (CSS, сокет открыт)
    Client->>Server: GET /logo.png (в том же TCP-соединении!)
    Server-->>Client: 200 OK (PNG, сокет открыт)
    Note over Client,Server: Соединение возвращается в пул idleConn
  

Keep-Alive (Persistent Connections)

В HTTP/1.0 каждое взаимодействие было одноразовым: клиент создавал TCP-сокет, выполнял тройное рукопожатие TCP Handshake (подробно разобранное в статье 10. TCP. Устройство, гарантии и жизненный цикл соединения), отправлял запрос, сервер передавал ответ и немедленно инициировал закрытие сокета флагом FIN.

Если веб-страница содержала 50 картинок и скриптов, браузер совершал 50 последовательных TCP-рукопожатий и 50 раз проходил через алгоритм медленного старта TCP Slow Start, тратя сотни миллисекунд впустую. А с появлением шифрования к этому добавился тяжелый процесс TLS Negotiation (2 RTT в TLS 1.2), превращая открытие страницы в мучительное ожидание.

В HTTP/1.1 постоянное соединение (Persistent Connection) стало стандартом по умолчанию. Клиент и сервер не закрывают TCP-сокет после отправки ответа, а возвращают его в пул для повторного использования. Это устраняет накладные расходы на TCP Handshake (статья 10. TCP. Устройство, гарантии и жизненный цикл соединения) и TLS Negotiation для каждого запроса.

Механизм и заголовки

Управление жизненным циклом соединения осуществляется через семейство hop-by-hop заголовков (действующих только между двумя соседними сетевыми узлами):

  • Connection: keep-alive — исторический заголовок, указывающий на намерение сохранить соединение. В спецификации HTTP/1.1 он подразумевается неявно: любое соединение считается постоянным, пока явно не указано обратное.
  • Connection: close — принудительное завершение. Сообщает противоположной стороне, что после завершения передачи текущего ответа/запроса сокет будет закрыт. Используется при graceful shutdown, ошибках или достижении лимита Max-Forwards.
  • Keep-Alive: timeout=5, max=100 — устаревший заголовок (HTTP/1.0), сообщавший клиенту: «я подожду 5 секунд до закрытия сокета и готов принять не более 100 запросов». В HTTP/1.1 таймаут обычно контролируется через настройки веб-сервера (net/http или балансировщик) и системные таймауты операционной системы.

Под капотом Go: http.Transport и пул соединений

Go не полагается на угадывание поведения сети. Он явно управляет жизненным циклом TCP-сокетов через http.Transport. Внутренне это структура, хранящая активные и idle-соединения:

Go
type Transport struct {
    MaxIdleConns        int           // Максимум idle-соединений в целом (в DefaultTransport: 100)
    MaxIdleConnsPerHost int           // На один host. По умолчанию DefaultMaxIdleConnsPerHost = 2!
    IdleConnTimeout     time.Duration // Таймаут ожидания в пуле (по умолчанию 90s)
    // ... другие поля, включая dialer и TLS config
}

Как устроен пул сокетов в net/http?
Каждое открытое TCP-соединение оборачивается во внутреннюю структуру persistConn. На каждое такое соединение рантайм Go запускает две фоновые горутины:

  1. readLoop() — читает входящие данные из сокета через буферизованный bufio.Reader.
  2. writeLoop() — сериализует исходящие запросы в сокет через bufio.Writer.
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
    Client["Клиентский код: client.Do(req)"]:::entry --> GetConn["getConn(req)"]
    GetConn --> CheckPool{"Есть idleConn в пуле?"}

    CheckPool -- "Да (O(1))" --> ReuseConn["Извлечь persistConn из map[connectMethodKey][]*persistConn"]
    CheckPool -- "Нет" --> DialNew["DialTCP() + Handshake + TLS"]

    ReuseConn --> WriteData["writeLoop: отправка запроса"]
    DialNew --> WriteData

    WriteData --> ReadResp["readLoop: чтение заголовков и Body"]
    ReadResp --> UserCode["Обработка resp.Body в пользовательском коде"]
    UserCode --> CloseBody["resp.Body.Close()"]

    CloseBody --> PutConn["putConn(persistConn)"]
    PutConn --> CheckConditions{"MaxIdleConns не превышен &&<br/>нет 'Connection: close'?"}

    CheckConditions -- "Да" --> NetpollerWait["Регистрация FD в netpoller (epoll/kqueue) на чтение"]
    CheckConditions -- "Нет / Ошибка" --> CloseFD["conn.Close(): закрытие TCP FD"]
  

Когда Response.Body закрывается, Go не вызывает Close() на сокете. Он проверяет:

  1. Есть ли доступные idleConn для этого хоста?
  2. Не истёк ли IdleConnTimeout?
  3. Не помечен ли запрос как Connection: close?

Если условия выполнены, сокет переводится в состояние ожидания следующего запроса и регистрируется в netpoller как читатель. При поступлении нового запроса сокет извлекается из пула за O(1) благодаря конкурентной мапе (sync.Mutex + map[connectMethodKey][]*persistConn).


Chunked Transfer Encoding

В классическом HTTP/1.0 сервер обязан был передать клиенту заголовок Content-Length, указывающий точный размер тела в байтах. Только зная точный размер, клиент понимал, где заканчивается текущее тело ответа и начинается следующее сообщение.

Но представьте себе выгрузку отчёта из базы данных на 10 гигабайт или потоковую передачу данных датчиков. Сервер не знает итоговый размер, пока не прочитает все строки из базы. Буферизовать 10 ГБ в оперативной памяти сервера только для того, чтобы посчитать Content-Length, — верный путь к Out Of Memory (OOM).

Для решения этой проблемы в HTTP/1.1 был внедрён механизм Chunked Transfer Encoding.
Используется, когда размер тела ответа неизвестен заранее (динамическая генерация, стриминг, веб-сокет-прокси). Вместо Content-Length используется Transfer-Encoding: chunked.

Структура пакета

При передаче чанками сервер разбивает тело ответа на цепочку самодостаточных блоков. Каждый блок предваряется шестнадцатеричным числом, обозначающим длину блока в байтах, за которым следует перевод строки \r\n (CRLF), затем сами байты данных и ещё один \r\n.

TEXT
25\r\n
{ exactly 37 bytes of data }\r\n
1A\r\n
{ exactly 26 bytes of data }\r\n
0\r\n
\r\n

Каждый чанк содержит: размер_в_шестнадцатеричном_формате \r\n + данные \r\n. Завершает поток чанк нулевой длины (0\r\n\r\n), за которым опционально могут следовать трейлеры (HTTP Trailers) — заголовки, значение которых вычисляется во время передачи (например, контрольная сумма Trailer: Content-MD5 или цифровые подписи).

flowchart LR
    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
    subgraph Stream ["Поток Chunked Encoding"]
        C1["25\r\n(37 байт)\r\n[Данные блока 1]\r\n"]:::process --> C2["1A\r\n(26 байт)\r\n[Данные блока 2]\r\n"]
        C2 --> C3["0\r\n(Чанк завершения)\r\n"]
        C3 --> C4["\r\n(Опциональные Trailers / Финал)"]
    end
  

Взаимодействие с TCP и Nagle

Маленькие чанки могут столкнуться с алгоритмом Нагла (Nagle's Algorithm), который буферизует мелкие пакеты для отправки вместе. Это добавляет задержки (до 200-400мс).

Когда вы отправляете чанк в 20 байт, ядро операционной системы не отправляет его сразу, а ждет подтверждения (ACK) на предыдущий сегмент или заполнения полного пакета MSS (Maximum Segment Size). С другой стороны, на принимающем конце включен алгоритм Delayed ACK, который задерживает отправку подтверждения в ожидании обратного трафика. Возникает эффект смертельного тупика (Deadlock задержек), увеличивающий пинг на сотни миллисекунд.

Решение: Go по умолчанию устанавливает TCP_NODELAY на всех TCP-соединениях (не только keep-alive). Это отключает буферизацию Нагла, заставляя сокет отправлять байты немедленно, так что для стриминга Нагла в Go можно не опасаться.

Идиоматичная реализация в Go

В Go рантайм net/http автоматически включает chunked transfer encoding, если обработчик начинает писать данные в http.ResponseWriter, не установив предварительно заголовок Content-Length. Явно указывать заголовок Transfer-Encoding: chunked не нужно: Go включит его сам, и ответ получится идентичным. Но обратите внимание, что без Flush стриминга нет — данные накапливаются во внутреннем буфере (см. http.Flusher и ResponseController ниже). Пример с явным заголовком (он избыточен):

Go
func streamChunked(w http.ResponseWriter, data <-chan []byte) {
    w.Header().Set("Transfer-Encoding", "chunked")
    w.Header().Set("Content-Type", "application/octet-stream")

    // Чанки форматирует сам net/http (заголовок выше для этого не требуется);
    // для реальной потоковой отдачи после записи нужен Flush.
    for chunk := range data {
        _, err := w.Write(chunk)
        if err != nil {
            // В продакшене здесь нужен логгер и корректная обработка ошибок
            log.Printf("chunk write failed: %v", err)
            return
        }
    }
}

HTTP Pipelining: Теория и смерть

В конце 1990-х создатели протокола понимали, что даже с Keep-Alive сеть простаивает: клиент отправляет запрос, ждет RTT, ждет обработки на сервере, получает ответ, и только потом отправляет следующий запрос.

Возникла заманчивая идея: HTTP Pipelining (Конвейеризация).
Идея: клиент отправляет несколько HTTP-запросов подряд по одному TCP-соединению, не ожидая ответов. Сервер отвечает в том же порядке.

sequenceDiagram
    autonumber
    participant Client as Клиент
    participant Server as Сервер

    Note over Client,Server: Идея Pipelining (Запросы пачкой)
    Client->>Server: Запрос 1 (Тяжелый: генерация отчета 5 сек)
    Client->>Server: Запрос 2 (Легкий: style.css 2 мс)
    Client->>Server: Запрос 3 (Легкий: avatar.png 5 мс)

    Note over Server: Проблема Head-of-Line Blocking!
    Note over Server: Ответы 2 и 3 уже готовы, но сервер ОБЯЗАН ждать Запрос 1!
    Server-->>Client: Ответ 1 (через 5 сек)
    Server-->>Client: Ответ 2 (ждал 5 сек!)
    Server-->>Client: Ответ 3 (ждал 5 сек!)
  

Почему это не работает на практике

Теория выглядела элегантно, но на практике конвейеризация оказалась архитектурным тупиком по трем фундаментальным причинам:

  1. Head-of-Line Blocking (HOL) на уровне TCP: Если один пакет теряется, весь TCP-поток останавливается до retransmission. Все последующие запросы в пайплайне ждут.
  2. HOL на уровне приложения: Ответы должны приходить строго в порядке отправки. Если сервер обрабатывает запрос B медленнее, чем A, клиент не получит A, пока B не завершится. Если первый запрос в конвейере — это медленный SQL-запрос на 10 секунд, все последующие микросекундные статические файлы зависают за ним в очереди.
  3. Проблемы прокси: Любая промежуточная точка (load balancer, nginx) не может маршрутизировать или кешировать pipelined-запросы корректно без сложного state-tracking. Кроме того, сетевые посредники и дешевые роутеры (middleboxes) падали при виде нескольких запросов в одном TCP-пакете.

В результате Pipelining был официально de facto deprecated. Браузеры отключили его в 2010-х, а Go (net/http) никогда не поддерживал его полноценно. Вместо этого Go использует Connection Multiplexing на уровне хоста: по умолчанию клиент открывает столько параллельных TCP-соединений к одному хосту, сколько нужно (лимит задаёт MaxConnsPerHost, по умолчанию его нет), что полностью устраняет блокировки и проще в реализации, чем пайплайн. Не путайте с MaxIdleConnsPerHost: он ограничивает число простаивающих соединений в пуле и по умолчанию равен 2 (DefaultMaxIdleConnsPerHost) — лишние соединения после запроса закрываются.


Под капотом: Go и управление соединениями

Когда вы настраиваете http.Transport, вы фактически управляете поведением netpoller и планировщика горутин.

Жизненный цикл idle-соединения

Что происходит с сокетом, когда ваш HTTP-запрос завершился и вы вызвали resp.Body.Close()?

  1. Соединение возвращается в пул (putConn).
  2. Запускается таймер IdleConnTimeout.
  3. Файловый дескриптор (FD) регистрируется в epoll (Linux) или kqueue (BSD/macOS) через netpoll как читаемый.
  4. Если за время таймаута не пришло новых данных, netpoller пробуждает горутину-управляющую, которая вызывает conn.Close().
  5. Если данные пришли (HTTP/1.1 200 OK), сокет извлекается, контекст сохраняется, и горутина-клиент продолжает чтение.
stateDiagram-v2
    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
    [*] --> Active: dialConn (Создание сокета)
    Active --> ReadingBody: Запрос отправлен, читаем Body
    ReadingBody --> PutConn: resp.Body.Close()

    state PutConn {
        [*] --> CheckEligibility
        CheckEligibility --> InPool: Условия выполнены
        CheckEligibility --> Destroy: 'Connection#58; close' или переполнение
    }

    InPool --> EpollWait: Регистрация в netpoller (epoll/kqueue)
    EpollWait --> Active: Новый запрос (getConn за O(1))
    EpollWait --> Destroy: Истек IdleConnTimeout
    EpollWait --> Destroy: FIN от сервера (сервер закрыл сокет)

    Destroy --> [*]: conn.Close()
  

Итог

  1. Keep-Alive экономит RTT и CPU-циклы на handshakes, но требует тщательного управления пулом (http.Transport) и таймаутами, чтобы избежать утечки FD и memory leaks.
  2. Chunked Encoding позволяет стримить данные без предварительного знания размера. Взаимодействует с TCP_NODELAY для минимизации задержек.
  3. Pipelining был отвергнут индустрией из-за HOL blocking. Go решает эту проблему через множественные соединения на хост и переход на HTTP/2.
  4. Понимание этих механизмов позволяет правильно настраивать http.Transport, избегать context deadline exceeded из-за зависших соединений и проектировать надежные клиенты для высоконагруженных систем.

Мы разобрали, как HTTP/1.1 пытается преодолеть ограничения TCP. Но однопоточная природа соединения всё равно становится бутылочным горлышком. В следующей статье мы посмотрим, как HTTP/2 решает эту проблему через бинарный фрейминг и мультиплексирование: 22. HTTP 2. Multiplexing, Frames, HPACK.