33. net/http. HTTP-клиент и HTTP-сервер

Философия «горутина на запрос» и production-ready стандарт

В классической серверной инженерии проектирование HTTP-серверов долгое время упиралось в компромисс между двумя крайностями. С одной стороны находились тяжеловесные пулы потоков операционной системы (Apache prefork, классический Java Tomcat), где на каждый входящий запрос выделялся тред с мегабайтным стеком, что вызывало жестокий кризис памяти C10K при десятках тысяч клиентов. С другой стороны стояли однопоточные event-loop архитектуры (Node.js, Nginx, Python asyncio), заставлявшие разработчика выворачивать код наизнанку через колбэки и мучительные конечные автоматы.

Пакет net/http в Go предложил миру третий, несравненно более гармоничный путь. Это не просто обертка над сокетами. Это высокоуровневая, production-ready реализация протоколов HTTP/1.1 и HTTP/2 (поддержка HTTP/3 развивается в пакете x/net/http3), спроектированная вокруг модели «одна горутина на одно соединение». В отличие от асинхронных event-loop подходов (Node.js, Python asyncio) или пулов потоков (Java Tomcat), Go делегирует управление конкурентностью планировщику рантайма. Это позволяет писать синхронно выглядящий код, который под капотом выполняется асинхронно через netpoller, не блокируя системные треды и масштабируясь до сотен тысяч одновременных соединений.

Для инженера уровня Senior понимание net_http означает умение управлять ресурсами процесса: предотвращать утечки горутин из-за неотмененных запросов, корректно настраивать таймауты на всех уровнях и использовать пулы соединений для минимизации TCP-handshake overhead.

1. Under the hood: Сервер, роутинг и жизненный цикл запроса

Эволюция ServeMux (Go 1.22+)

До Go 1.22 http.ServeMux поддерживал только точное совпадение путей и префиксов, из-за чего разработчики были вынуждены подключать тяжелые внешние библиотеки (chi, gorilla/mux). Начиная с Go 1.22, встроенный роутер претерпел фундаментальную модернизацию и получил возможности, покрывающие 95% потребностей веб-сервисов:

  • Методы в маршрутах: GET /users/{id}
  • Переменные в путях: PathValue(r, "id")
  • Автоматическую обработку 405 Method Not Allowed
  • Точное совпадение префиксов с trailing slash

Внутренне ServeMux использует дерево маршрутов с оптимизированным поиском. При совпадении вызывается handler.ServeHTTP(w, r). Контекст запроса r.Context() автоматически отменяется при закрытии соединения клиентом или по таймауту сервера.

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
    A["Клиент отправляет HTTP запрос"]:::entry --> B["net.Listener Accept"]
    B --> C["Создание net.Conn<br>Обертка в http.conn"]
    C --> D["Запуск новой горутины<br>Обработка соединения"]
    D --> E["Чтение заголовков<br>через bufio.Reader"]
    E --> F{"Метод и Path<br>совпали с маршрутом?"}
    F -->|"Да"| G["Вызов handler.ServeHTTP<br>r.Context() активен"]
    F -->|"Нет"| H["404 или 405<br>Ответ отправляется"]
    G --> I["Запись в ResponseWriter<br>через bufio.Writer"]
    I --> J["Закрытие или Keep-Alive<br>Возврат в пул соединений"]
    J --> D
  

Контекст и отмена

r.Context() содержит http.responseContext, который привязан к conn. При обрыве TCP-соединения (FIN/RST) или таймауте ReadTimeout, контекст закрывается. Все операции внутри хендлера, использующие ctx, мгновенно прерываются. Это фундаментальная часть graceful shutdown и защиты от slowloris-атак.

2. Механика http.Client: По умолчанию опасно

http.Client — это stateless-обертка над http.Transport. По умолчанию он не имеет таймаутов. Это означает, что при зависании целевого сервера или сетевой проблеме, горутина навсегда останется в состоянии ожидания.

Go
// ❌ Смерть для production: нет таймаутов, нет ограничения на редиректы
resp, err := http.Get("https://api.external.com/data")
// Может висеть бесконечно. При массовом вызове утечет память и горутины.

// ✅ Production-ready клиент
var defaultClient = &http.Client{
    Timeout: 15 * time.Second, // Глобальный таймаут на весь запрос+ответ
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 5 * time.Second,
    },
}

http.Transport управляет пулом соединений. Он поддерживает HTTP/1.1 Keep-Alive и HTTP/2 Multiplexing. При повторном запросе на тот же хост, Transport переиспользует существующий net.Conn, экономя время на DNS, TCP handshake и TLS negotiation.

3. Mechanical Sympathy: Аллокации, буферы и управление памятью

Пулы буферов в Transport

http.Transport использует sync.Pool для буферов bufio.ReadWriter размером 4 КБ. При активном соединении горутина берет буфер из пула, читает ответ, парсит JSON и возвращает буфер. Это минимизирует давление на GC при тысячах RPS.

Аллокации тела запроса

io.ReadAll(r.Body) всегда аллоцирует новый []byte в куче. Если вы читаете большие payload (например, >1MB файлы), это вызывает скачки heap_in_use. Для стриминга используйте io.Copy или прямую передачу io.Reader в парсеры:

Go
// ✅ Стриминг в БД или парсер без полной загрузки в память
err := json.NewDecoder(r.Body).Decode(&payload)

Влияние на CPU Cache

Обработка HTTP-запроса — это линейный проход по памяти. http.ServeMux маршрутизация и json.Unmarshal работают последовательно, что улучшает prefetching. Однако создание замыканий в middleware цепочке (func next http.Handler { return http.HandlerFunc(...) }) генерирует аллокации замыканий в куче. Для высоконагруженных шлюзов (>50k RPS) предпочтительна flat-структура без вложенных замыканий или кодогенерация роутеров.

4. Идиомы и паттерны для Production

Graceful Shutdown

Сервер должен корректно завершать активные запросы при получении SIGTERM.

Go
func runGracefulServer(addr string, handler http.Handler) error {
    srv := &http.Server{
        Addr:              addr,
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second, // Защита от Slowloris
        IdleTimeout:       120 * time.Second,
    }

    errCh := make(chan error, 1)
    go func() {
        errCh <- srv.ListenAndServe()
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit // Ожидание сигнала

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    log.Println("Shutting down server...")
    return srv.Shutdown(ctx)
}

Middleware Chain

Используйте явное оборачивание для логирования, метрик и восстановления после паник:

Go
func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("Panic recovered: %v", rec)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
            log.Printf("%s %s %d %v", r.Method, r.URL.Path, http.StatusOK, time.Since(start))
        }()
        next.ServeHTTP(w, r)
    })
}

5. Ловушки и хардкорные вопросы с собеседований

Ловушка Описание Решение
Отсутствие ReadHeaderTimeout Сервер висит вечно, ожидая заголовки от клиента (Slowloris) Всегда задавайте ReadHeaderTimeout (5-10 сек).
http.Client без Transport Создаются новые соединения на каждый запрос, лимит FD исчерпывается Используйте глобальный &http.Client{Transport: &http.Transport{...}} или http.DefaultTransport.
Игнорирование r.Body.Close() Соединение не возвращается в пул Transport, FD утекает Всегда defer r.Body.Close() или читайте до EOF. http.Client закрывает автоматически, но серверные хендлеры — нет.
http.TimeoutHandler vs Server.Timeout TimeoutHandler создает копию контекста и буферизует ответ. ReadTimeout работает на уровне IO. Используйте Server.ReadTimeout для защиты инфраструктуры, TimeoutHandler для бизнес-логики с graceful fallback.
HTTP/2 Head-of-Line Blocking Один медленный запрос блокирует мультиплексированный поток В Go 1.22+ исправлено. Для старых версий используйте GODEBUG=http2server=0 или настройте MaxConcurrentStreams.

6. Сравнение с экосистемами

Язык / Фреймворк Модель Особенности в сравнении с Go
Node.js (Express) Event-loop, один поток Быстрый на IO, но CPU-блокировки тормозят все запросы. Нет встроенного пула соединений.
Python (FastAPI) Asyncio, GIL Требует async/await везде. GIL ограничивает CPU-параллелизм. Go использует истинную многопоточность через G-M-P.
Java (Spring/Tomcat) Thread-pool (BIO/NIO) Тяжелый overhead на создание потоков. Требует тонкой настройки maxThreads. Go использует легкие горутины.
Go (net_http) Goroutine-per-request Баланс скорости, памяти и простоты. Встроенные таймауты, пулы, HTTP/2, контекст. Минимальный boilerplate.

Итог

  1. net_http использует модель «горутина на соединение», интегрированную с netpoller. Это устраняет callback hell и event-loop сложность.
  2. Никогда не используйте http.Get() без http.Client и Timeout. Настройте Transport с MaxIdleConns и IdleConnTimeout.
  3. Всегда задавайте ReadHeaderTimeout на сервере для защиты от Slowloris и используйте r.Context() для отмены долгих операций.
  4. io.ReadAll(r.Body) аллоцирует в куче. Для больших данных используйте стриминг (json.Decoder, io.Copy).
  5. Graceful shutdown требует srv.Shutdown(ctx) и обработки сигналов ОС.
  6. В Go 1.22+ http.ServeMux поддерживает методы и переменные в путях, упрощая маршрутизацию без внешних роутеров.

Разобрав базовый API клиента и сервера, необходимо понять, как Go управляет пулами соединений, поддерживает Keep-Alive и оптимизирует повторное использование TCP-соединений. В следующей статье мы глубоко погрузимся в механику Transport: 34. net/http под капотом. Transport, Connection Pool, Keep Alive.