Deep Engineering

ЗАМЕР

bench/gohttp/protocols.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/http-server
Как запустить
go run bench/gohttp/internals.go
go run bench/gohttp/practice.go
go run bench/gohttp/protocols.go

Запись прогона

Замеры для урока «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.

Скрипт

241 строк
//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()
}