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. По умолчанию он не имеет таймаутов. Это означает, что при зависании целевого сервера или сетевой проблеме, горутина навсегда останется в состоянии ожидания.
// ❌ Смерть для 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 в парсеры:
// ✅ Стриминг в БД или парсер без полной загрузки в память
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.
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
Используйте явное оборачивание для логирования, метрик и восстановления после паник:
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. |
Итог
net_httpиспользует модель «горутина на соединение», интегрированную сnetpoller. Это устраняет callback hell и event-loop сложность.- Никогда не используйте
http.Get()безhttp.ClientиTimeout. НастройтеTransportсMaxIdleConnsиIdleConnTimeout. - Всегда задавайте
ReadHeaderTimeoutна сервере для защиты от Slowloris и используйтеr.Context()для отмены долгих операций. io.ReadAll(r.Body)аллоцирует в куче. Для больших данных используйте стриминг (json.Decoder,io.Copy).- Graceful shutdown требует
srv.Shutdown(ctx)и обработки сигналов ОС. - В Go 1.22+
http.ServeMuxподдерживает методы и переменные в путях, упрощая маршрутизацию без внешних роутеров.
Разобрав базовый API клиента и сервера, необходимо понять, как Go управляет пулами соединений, поддерживает Keep-Alive и оптимизирует повторное использование TCP-соединений. В следующей статье мы глубоко погрузимся в механику Transport: 34. net/http под капотом. Transport, Connection Pool, Keep Alive.