MEASUREMENT
bench/gohttp/protocols.go
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/golang/http-server
- How to run it
go run bench/gohttp/internals.go go run bench/gohttp/practice.go go run bench/gohttp/protocols.go
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для урока «HTTP-сервер стандартной библиотеки»
| Файл | Что делает |
|---|---|
internals.go |
поднимает серверы net/http на loopback и печатает шесть наблюдений: горутина на соединение, нулевые таймауты по умолчанию, контекст запроса после ухода клиента, Shutdown против Close и возврат Serve, паника в обработчике, шаблоны ServeMux Go 1.22 |
protocols.go |
добавлен после аудита 07.10.2026: keep-alive HTTP/1.1 и одновременные запросы HTTP/2 по одному соединению, паника в обработчике под HTTP/1.1 и HTTP/2, Close и перехваченное соединение, MaxHeaderBytes по умолчанию. HTTP/2 — без TLS, через Server.Protocols go1.24 |
practice.go |
источник ответов двух практических задач урока |
go run bench/gohttp/internals.go
go run bench/gohttp/practice.go
go run bench/gohttp/protocols.go
Записи прогонов — в runs/, go1.24.7 linux/amd64, GOMAXPROCS=2. Все серверы
слушают только 127.0.0.1 на свободном порту.
Что получилось
Горутина на соединение. Десять запросов к обработчику, который спит 200 мс, заняли 209 мс: пик — 10 обработчиков одновременно.
Таймауты сервера по умолчанию — ноль. http.Server{} не ограничивает ни
чтение заголовков, ни запись, ни простой. Клиент, приславший начало
заголовков, через 2 с всё ещё держит соединение; с ReadHeaderTimeout 300 мс
сервер закрыл его через 301 мс.
Контекст отменяется, работа — нет. Клиент ушёл через 100 мс. Обработчик,
слушающий r.Context(), остановился через 101 мс с context canceled;
обработчик, который его не слушает, доделал всю работу — 500 мс.
Serve возвращается сразу. Shutdown дождался пяти запросов в полёте
(все 200), Close оборвал все пять. В обоих случаях Serve вернул
http: Server closed через 0 мс после вызова — запросы в этот момент ещё
работали.
Паника в обработчике не роняет процесс. Клиент получил ошибку соединения,
сервер записал в журнал http: panic serving …: boom, следующий запрос — 200.
ServeMux Go 1.22. GET /items/{id}: GET /items/42 → 200,
POST → 405 с Allow: GET, HEAD, /items/42/x → 404. В практике — обратная
сторона: при шаблоне без метода /users/ метод, которого нет в более
конкретном шаблоне, уходит туда, и 405 не будет.
После аудита 07.10.2026 (protocols.go)
Соединение — не запрос. Десять запросов подряд одним клиентом HTTP/1.1 прошли по одному соединению. Десять одновременных запросов HTTP/2 — тоже по одному, с десятью обработчиками одновременно (202 мс при сне 200 мс).
Паника зависит от протокола. HTTP/1.1: соединение закрыто, всего
соединений 3, и обработчик /boom вызван дважды — клиентский Transport
повторил идемпотентный GET. HTTP/2: сброшен только поток, соединение
переиспользовано, обработчик вызван один раз.
Close не знает о перехваченных соединениях. После srv.Close()
соединение, забранное через Hijack, продолжило отвечать.
MaxHeaderBytes по умолчанию — 1 МиБ (http.DefaultMaxHeaderBytes = 1048576): заголовок 4096 байт — 200, 2097152 байт — 431.
Script
241 lines//go:build ignore
// Границы протокола: соединение — не запрос, HTTP/1.1 — не HTTP/2.
//
// ЗАЧЕМ ОТДЕЛЬНАЯ ПРОГРАММА. internals.go меряет то, что урок цитирует
// числами, и его перезапуск менял бы их. Здесь — уточнения, появившиеся после
// аудита 07.10.2026: что именно «горутина на соединение» значит при
// keep-alive и HTTP/2, чем паника в обработчике отличается между протоколами,
// что Close делает с перехваченным (hijacked) соединением и какой предел у
// заголовков по умолчанию.
//
// HTTP/2 здесь — без TLS (h2c): go1.24 умеет его штатно через Server.Protocols
// и Transport.Protocols. Протокол тот же, что и под TLS; TLS к вопросам этой
// программы отношения не имеет.
//
// ПЯТЬ СЮЖЕТОВ:
//
// 1. HTTP/1.1 keep-alive: десять запросов подряд одним клиентом — одно
// соединение.
// 2. HTTP/2: десять одновременных запросов — одно соединение, десять
// обработчиков одновременно.
// 3. Паника в обработчике: HTTP/1.1 закрывает соединение, HTTP/2 сбрасывает
// только поток — следующий запрос идёт по тому же соединению.
// 4. Close и перехваченное соединение: после Close оно живо.
// 5. MaxHeaderBytes по умолчанию: заголовок больше предела — 431.
//
// ЗАПУСК:
//
// go run bench/gohttp/protocols.go
package main
import (
"bufio"
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptrace"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
func show(title string) {
fmt.Println()
fmt.Println(title)
fmt.Println(strings.Repeat("─", len([]rune(title))))
}
// server поднимает сервер и считает новые соединения через ConnState.
func server(h http.Handler, h2 bool) (*http.Server, string, *atomic.Int64) {
var conns atomic.Int64
srv := &http.Server{
Handler: h,
ErrorLog: log.New(io.Discard, "", 0),
ConnState: func(c net.Conn, s http.ConnState) {
if s == http.StateNew {
conns.Add(1)
}
},
}
if h2 {
srv.Protocols = new(http.Protocols)
srv.Protocols.SetHTTP1(true)
srv.Protocols.SetUnencryptedHTTP2(true)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
go srv.Serve(ln)
return srv, "http://" + ln.Addr().String(), &conns
}
func client(h2 bool) *http.Client {
tr := &http.Transport{}
if h2 {
tr.Protocols = new(http.Protocols)
tr.Protocols.SetUnencryptedHTTP2(true)
}
return &http.Client{Transport: tr, Timeout: 5 * time.Second}
}
// get выполняет запрос и сообщает, переиспользовано ли соединение.
func get(c *http.Client, url string) (string, bool) {
var reused bool
trace := &httptrace.ClientTrace{GotConn: func(i httptrace.GotConnInfo) { reused = i.Reused }}
req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), "GET", url, nil)
resp, err := c.Do(req)
if err != nil {
return "ошибка", reused
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return fmt.Sprintf("%d %s", resp.StatusCode, resp.Proto), reused
}
func main() {
fmt.Printf("%s %s/%s, GOMAXPROCS=%d | границы протокола net/http\n",
runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
// ------------------------------------------------------------------ 1
show("1) HTTP/1.1 KEEP-ALIVE: ЗАПРОСЫ ПОДРЯД")
srv, base, conns := server(ok, false)
c := client(false)
for i := 0; i < 10; i++ {
get(c, base)
}
fmt.Printf(" 10 запросов подряд одним клиентом: соединений у сервера %d\n", conns.Load())
srv.Close()
// ------------------------------------------------------------------ 2
show("2) HTTP/2: ОДНОВРЕМЕННЫЕ ЗАПРОСЫ")
var inFlight, peak atomic.Int64
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
time.Sleep(200 * time.Millisecond)
inFlight.Add(-1)
io.WriteString(w, r.Proto)
})
srv, base, conns = server(slow, true)
c = client(true)
get(c, base) // установить соединение заранее
conns.Store(0)
peak.Store(0)
start := time.Now()
var wg sync.WaitGroup
var proto atomic.Value
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p, _ := get(c, base)
proto.Store(p)
}()
}
wg.Wait()
fmt.Printf(" 10 одновременных запросов: %.0f мс, новых соединений %d, обработчиков одновременно %d, ответ %s\n",
float64(time.Since(start).Microseconds())/1000, conns.Load(), peak.Load(), proto.Load())
fmt.Println(" Горутина у HTTP/2 — на каждый поток запроса, соединение одно.")
srv.Close()
// ------------------------------------------------------------------ 3
show("3) ПАНИКА В ОБРАБОТЧИКЕ: HTTP/1.1 ПРОТИВ HTTP/2")
var booms atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("/boom", func(w http.ResponseWriter, r *http.Request) { booms.Add(1); panic("boom") })
mux.Handle("/ok", ok)
for _, h2 := range []bool{false, true} {
booms.Store(0)
srv, base, conns = server(mux, h2)
c = client(h2)
first, _ := get(c, base+"/ok")
boom, _ := get(c, base+"/boom")
after, reused := get(c, base+"/ok")
name := "HTTP/1.1"
if h2 {
name = "HTTP/2 "
}
fmt.Printf(" %s: /ok -> %s; /boom -> %s; /ok -> %s\n", name, first, boom, after)
fmt.Printf(" соединение переиспользовано: %v, соединений всего %d, обработчик /boom вызван %d раз\n",
reused, conns.Load(), booms.Load())
srv.Close()
}
fmt.Println(" HTTP/1.1: сервер закрыл соединение, следующий запрос — по новому.")
fmt.Println(" Третье соединение HTTP/1.1 — повтор: клиент сам повторил")
fmt.Println(" идемпотентный GET /boom после сетевой ошибки, и он упал ещё раз.")
fmt.Println(" HTTP/2: сброшен только поток, соединение осталось жить.")
// ------------------------------------------------------------------ 4
show("4) Close И ПЕРЕХВАЧЕННОЕ СОЕДИНЕНИЕ")
hijacked := make(chan struct{})
hj := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, rw, err := http.NewResponseController(w).Hijack()
if err != nil {
panic(err)
}
close(hijacked)
go func() {
defer conn.Close()
for {
line, err := rw.ReadString('\n')
if err != nil {
return
}
rw.WriteString("эхо: " + line)
rw.Flush()
}
}()
})
srv, base, _ = server(hj, false)
raw, err := net.Dial("tcp", strings.TrimPrefix(base, "http://"))
if err != nil {
panic(err)
}
fmt.Fprintf(raw, "GET / HTTP/1.1\r\nHost: x\r\n\r\n")
<-hijacked // Close — строго после того, как обработчик перехватил соединение
srv.Close()
time.Sleep(50 * time.Millisecond)
fmt.Fprintf(raw, "после Close\n")
raw.SetReadDeadline(time.Now().Add(time.Second))
line, err := bufio.NewReader(raw).ReadString('\n')
if err != nil {
fmt.Printf(" после srv.Close(): ошибка %v\n", err)
} else {
fmt.Printf(" после srv.Close() перехваченное соединение ответило: %q\n", strings.TrimSpace(line))
}
raw.Close()
fmt.Println(" Close и Shutdown о перехваченных соединениях не знают:")
fmt.Println(" закрывать их — работа того, кто их перехватил.")
// ------------------------------------------------------------------ 5
show("5) MaxHeaderBytes ПО УМОЛЧАНИЮ")
srv, base, _ = server(ok, false)
fmt.Printf(" http.DefaultMaxHeaderBytes = %d\n", http.DefaultMaxHeaderBytes)
for _, size := range []int{4 << 10, 2 << 20} {
req, _ := http.NewRequest("GET", base, nil)
req.Header.Set("X-Big", strings.Repeat("a", size))
resp, err := client(false).Do(req)
if err != nil {
fmt.Printf(" заголовок %7d байт: ошибка %v\n", size, err)
continue
}
resp.Body.Close()
fmt.Printf(" заголовок %7d байт: %d %s\n", size, resp.StatusCode, http.StatusText(resp.StatusCode))
}
srv.Close()
}