Deep Engineering

MEASUREMENT

bench/gohttp/internals.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

279 lines
//go:build ignore

// HTTP-сервер стандартной библиотеки: что net/http делает сам и чего не
// делает, пока его не попросят.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. «Как сделать веб-сервер на Go» — вопрос на одну строку
// http.ListenAndServe. Вопросы, на которых проверяют, начинаются после неё:
// сколько запросов сервер обслуживает одновременно, что будет с медленным
// клиентом, что происходит с работой, когда клиент ушёл, как остановить
// сервер, не оборвав запросы. Каждый ответ здесь получен запуском настоящего
// сервера на loopback.
//
// ШЕСТЬ СЮЖЕТОВ:
//
//  1. Горутина на соединение: десять запросов по 200 мс обслуживаются
//     одновременно.
//  2. Таймауты сервера по умолчанию — ноль: клиент, не дославший заголовки,
//     держит соединение сколько угодно, пока не задан ReadHeaderTimeout.
//  3. Контекст запроса отменяется, когда клиент ушёл, — но обработчик,
//     который его не слушает, доделывает работу.
//  4. Shutdown дожидается запросов в полёте, Close — обрывает их, а Serve
//     возвращается сразу после вызова Shutdown, не дожидаясь их.
//  5. Паника в обработчике не роняет процесс: сервер её перехватывает и
//     закрывает соединение.
//  6. ServeMux с шаблонами Go 1.22: метод и параметр пути в шаблоне, 405 с
//     заголовком Allow без единой строки кода.
//
// ЗАПУСК:
//
//	go run bench/gohttp/internals.go
package main

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"runtime"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

func show(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

// serve поднимает сервер на свободном порту loopback и возвращает его адрес.
func serve(srv *http.Server) (string, chan error) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	done := make(chan error, 1)
	go func() { done <- srv.Serve(ln) }()
	return "http://" + ln.Addr().String(), done
}

// client без пула соединений: каждый запрос — своё соединение.
func client(timeout time.Duration) *http.Client {
	return &http.Client{Timeout: timeout, Transport: &http.Transport{DisableKeepAlives: true}}
}

func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }

func main() {
	fmt.Printf("%s %s/%s, GOMAXPROCS=%d | HTTP-сервер net/http\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))

	// ------------------------------------------------------------------ 1
	show("1) ГОРУТИНА НА СОЕДИНЕНИЕ")
	var inFlight, peak atomic.Int64
	srv := &http.Server{Handler: 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)
	})}
	base, _ := serve(srv)
	start := time.Now()
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			resp, err := client(0).Get(base)
			if err == nil {
				resp.Body.Close()
			}
		}()
	}
	wg.Wait()
	fmt.Printf("  10 запросов, обработчик спит 200 мс: всего %.0f мс\n", ms(time.Since(start)))
	fmt.Printf("  обработчиков одновременно, пик: %d\n", peak.Load())
	fmt.Println("  Каждое принятое соединение сервер обслуживает в своей горутине:")
	fmt.Println("  ни очереди, ни пула обработчиков, ни предела по умолчанию.")
	srv.Close()

	// ------------------------------------------------------------------ 2
	show("2) ТАЙМАУТЫ СЕРВЕРА ПО УМОЛЧАНИЮ")
	var zero http.Server
	fmt.Printf("  http.Server{}: ReadTimeout=%v ReadHeaderTimeout=%v WriteTimeout=%v IdleTimeout=%v\n",
		zero.ReadTimeout, zero.ReadHeaderTimeout, zero.WriteTimeout, zero.IdleTimeout)
	fmt.Println("  клиент прислал начало заголовков и замолчал:")
	for _, rht := range []time.Duration{0, 300 * time.Millisecond} {
		s := &http.Server{Handler: http.NotFoundHandler(), ReadHeaderTimeout: rht}
		b, _ := serve(s)
		c, err := net.Dial("tcp", strings.TrimPrefix(b, "http://"))
		if err != nil {
			panic(err)
		}
		fmt.Fprintf(c, "GET / HTTP/1.1\r\nHost: x\r\n")
		t0 := time.Now()
		c.SetReadDeadline(time.Now().Add(2 * time.Second))
		_, err = bufio.NewReader(c).ReadString('\n')
		state := "соединение закрыто сервером"
		var ne net.Error
		if errors.As(err, &ne) && ne.Timeout() {
			state = "соединение всё ещё открыто"
		}
		fmt.Printf("  ReadHeaderTimeout=%-6v через %4.0f мс: %s\n", rht, ms(time.Since(t0)), state)
		c.Close()
		s.Close()
	}
	fmt.Println("  2 с — предел ожидания самого клиента в этом замере. Сервер без")
	fmt.Println("  таймаута держал бы соединение и его горутину сколько угодно.")

	// ------------------------------------------------------------------ 3
	show("3) КОНТЕКСТ ЗАПРОСА, КОГДА КЛИЕНТ УШЁЛ")
	type outcome struct {
		label string
		after time.Duration
		note  string
	}
	results := make(chan outcome, 2)
	mux3 := http.NewServeMux()
	mux3.HandleFunc("/listens", func(w http.ResponseWriter, r *http.Request) {
		t0 := time.Now()
		select {
		case <-time.After(500 * time.Millisecond):
			results <- outcome{"слушает r.Context()", time.Since(t0), "доделал работу"}
		case <-r.Context().Done():
			results <- outcome{"слушает r.Context()", time.Since(t0), "остановился: " + r.Context().Err().Error()}
		}
	})
	mux3.HandleFunc("/ignores", func(w http.ResponseWriter, r *http.Request) {
		t0 := time.Now()
		time.Sleep(500 * time.Millisecond)
		results <- outcome{"не слушает контекст", time.Since(t0), "доделал работу; контекст: " + fmt.Sprint(r.Context().Err())}
	})
	s3 := &http.Server{Handler: mux3}
	b3, _ := serve(s3)
	fmt.Println("  клиент ждёт ответа 100 мс и уходит; работа обработчика — 500 мс")
	for _, path := range []string{"/listens", "/ignores"} {
		_, err := client(100 * time.Millisecond).Get(b3 + path)
		if err == nil {
			panic("клиент должен был уйти по таймауту")
		}
		o := <-results
		fmt.Printf("  %-22s через %4.0f мс: %s\n", o.label, ms(o.after), o.note)
	}
	s3.Close()

	// ------------------------------------------------------------------ 4
	show("4) ОСТАНОВКА: Shutdown, Close И ВОЗВРАТ ИЗ Serve")
	slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(300 * time.Millisecond)
		io.WriteString(w, "ok")
	})
	for _, how := range []string{"Shutdown", "Close"} {
		s := &http.Server{Handler: slow}
		b, served := serve(s)
		statuses := make(chan string, 5)
		for i := 0; i < 5; i++ {
			go func() {
				resp, err := client(0).Get(b)
				if err != nil {
					statuses <- "ошибка"
					return
				}
				resp.Body.Close()
				statuses <- fmt.Sprint(resp.StatusCode)
			}()
		}
		time.Sleep(100 * time.Millisecond)
		t0 := time.Now()
		stopped := make(chan time.Duration, 1)
		go func() {
			if how == "Shutdown" {
				s.Shutdown(context.Background())
			} else {
				s.Close()
			}
			stopped <- time.Since(t0)
		}()
		err := <-served
		servedAfter := time.Since(t0)
		_, newErr := client(time.Second).Get(b)
		counts := map[string]int{}
		for i := 0; i < 5; i++ {
			counts[<-statuses]++
		}
		fmt.Printf("  %s через 100 мс после старта пяти запросов по 300 мс:\n", how)
		fmt.Printf("    Serve вернул %v через %.0f мс после вызова\n", err, ms(servedAfter))
		fmt.Printf("    %s вернул управление через %.0f мс\n", how, ms(<-stopped))
		fmt.Printf("    запросы в полёте: 200 — %d, ошибка — %d\n", counts["200"], counts["ошибка"])
		fmt.Printf("    новый запрос после вызова: %s\n", map[bool]string{true: "отклонён", false: "принят"}[newErr != nil])
	}
	fmt.Println("  Serve возвращается сразу после вызова Shutdown, не дожидаясь")
	fmt.Println("  запросов в полёте: программа, которая завершается, как только")
	fmt.Println("  вернулся ListenAndServe, обрывает их так же, как Close.")

	// ------------------------------------------------------------------ 5
	show("5) ПАНИКА В ОБРАБОТЧИКЕ")
	var logBuf bytes.Buffer
	mux5 := http.NewServeMux()
	mux5.HandleFunc("/boom", func(w http.ResponseWriter, r *http.Request) { panic("boom") })
	mux5.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
	s5 := &http.Server{Handler: mux5, ErrorLog: log.New(&logBuf, "", 0)}
	b5, _ := serve(s5)
	_, err := client(time.Second).Get(b5 + "/boom")
	fmt.Printf("  GET /boom: ошибка у клиента: %v\n", err != nil)
	resp, err := client(time.Second).Get(b5 + "/ok")
	if err == nil {
		resp.Body.Close()
		fmt.Printf("  GET /ok после паники: %d\n", resp.StatusCode)
	}
	first := strings.SplitN(logBuf.String(), "\n", 2)[0]
	if i := strings.Index(first, "127.0.0.1:"); i >= 0 {
		if j := strings.Index(first[i:], ":"); j >= 0 {
			rest := first[i+j+1:]
			if k := strings.Index(rest, ":"); k >= 0 {
				first = first[:i] + "127.0.0.1:…" + rest[k:]
			}
		}
	}
	fmt.Printf("  журнал сервера: %s\n", first)
	fmt.Println("  Процесс жив: сервер перехватил панику этого запроса, записал её")
	fmt.Println("  в журнал и закрыл соединение. Ответа клиент не получил.")
	s5.Close()

	// ------------------------------------------------------------------ 6
	show("6) ServeMux С ШАБЛОНАМИ GO 1.22")
	mux6 := http.NewServeMux()
	mux6.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, "id="+r.PathValue("id"))
	})
	s6 := &http.Server{Handler: mux6}
	b6, _ := serve(s6)
	for _, rq := range [][2]string{{"GET", "/items/42"}, {"POST", "/items/42"}, {"GET", "/items/42/x"}} {
		req, _ := http.NewRequest(rq[0], b6+rq[1], nil)
		resp, err := client(time.Second).Do(req)
		if err != nil {
			panic(err)
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		extra := strings.TrimSpace(string(body))
		if allow := resp.Header.Get("Allow"); allow != "" {
			extra += " | Allow: " + allow
		}
		fmt.Printf("  %-4s %-12s -> %d %s\n", rq[0], rq[1], resp.StatusCode, extra)
	}
	s6.Close()
}