Deep Engineering

MEASUREMENT

bench/gocontext/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/context

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

This measurement has no recorded run — only the script.

Script

221 lines
//go:build ignore

// Context изнутри: дерево отмены, утечка без cancel и цена Value.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Три утверждения про контекст повторяют все, и все три
// проверяемы:
//
//  1. «не вызвал cancel — утечка». Утечка чего именно? Ответ: горутины у
//     WithTimeout и записи в списке потомков у родителя. Считается.
//  2. «контекст — дерево, отмена идёт вниз». Проверяется тем, что у родителя
//     после отмены потомка Err() остаётся nil.
//  3. «Value дешёвый». Дёшево класть, дорого доставать, и цена зависит от
//     глубины.
//
// ЗАПУСК:
//
//	go run bench/gocontext/internals.go
package main

import (
	"context"
	"fmt"
	"runtime"
	"testing"
	"time"
)

type ctxKey string

const userKey ctxKey = "user"

var (
	sink  any
	sinkI int
)

// ------------------------------------------------- блок 1: утечка без cancel

// leak показывает, чем именно оборачивается забытый cancel.
//
// ПОЧЕМУ ЗДЕСЬ ПАМЯТЬ, А НЕ ГОРУТИНЫ. Первая редакция считала
// runtime.NumGoroutine() — и получила НОЛЬ прибавившихся горутин в обоих
// случаях. Утверждение «забытый cancel течёт горутинами» замер не подтвердил,
// и его пришлось убрать: WithTimeout заводит таймер рантайма, а не горутину.
//
// Течёт при этом настоящее и измеримое: пока cancel не вызван, потомок
// остаётся в СПИСКЕ ПОТОМКОВ родителя, и ни он сам, ни то, на что он
// ссылается, собрано быть не может. Родитель в реальном коде живёт долго —
// это контекст запроса или сервиса, — поэтому список растёт вместе с числом
// забытых cancel.
func leak() {
	fmt.Println("ЧТО ИМЕННО ТЕЧЁТ, КОГДА ЗАБЫЛИ CANCEL")
	fmt.Println("─────────────────────────────────────")

	const n = 10000

	// Долгоживущий родитель — как контекст сервиса или запроса.
	parent, cancelParent := context.WithCancel(context.Background())
	defer cancelParent()

	var m0, m1, m2 runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m0)

	// Десять тысяч потомков, у которых cancel НЕ вызван.
	for i := 0; i < n; i++ {
		_, cancel := context.WithCancel(parent)
		_ = cancel // намеренно не вызываем: это и есть предмет опыта
	}
	runtime.GC()
	runtime.ReadMemStats(&m1)
	leaked := (m1.HeapAlloc - m0.HeapAlloc) / n

	// Столько же потомков, но с честным cancel.
	for i := 0; i < n; i++ {
		_, cancel := context.WithCancel(parent)
		cancel()
	}
	runtime.GC()
	runtime.ReadMemStats(&m2)
	kept := int64(m2.HeapAlloc-m1.HeapAlloc) / n

	fmt.Printf("  %d потомков без cancel   осталось в куче: %d байт на потомка\n", n, leaked)
	fmt.Printf("  %d потомков с cancel     осталось в куче: %d байт на потомка\n", n, kept)
	fmt.Println()
	fmt.Println("  Вот это и есть «утечка контекста», и она не про горутины:")
	fmt.Println("  WithTimeout заводит таймер рантайма, а не горутину. Течёт")
	fmt.Println("  другое — пока cancel не вызван, потомок остаётся в СПИСКЕ")
	fmt.Println("  ПОТОМКОВ родителя, и ни он, ни то, на что он ссылается,")
	fmt.Println("  собрано быть не может.")
	fmt.Println()
	fmt.Println("  Родитель в реальном коде живёт долго — это контекст сервиса")
	fmt.Println("  или соединения, — поэтому список растёт вместе с числом")
	fmt.Println("  забытых cancel. Отсюда идиома defer cancel(): он вызывается")
	fmt.Println("  ВСЕГДА, даже когда работа закончилась успешно, потому что он")
	fmt.Println("  не «отменяет результат», а вычёркивает потомка из списка.")
	fmt.Println()
}

// ------------------------------------------------- блок 2: дерево

func tree() {
	fmt.Println("ОТМЕНА ИДЁТ ВНИЗ, А НЕ ВВЕРХ И НЕ ВБОК")
	fmt.Println("──────────────────────────────────────")

	root, cancelRoot := context.WithCancel(context.Background())
	a, cancelA := context.WithCancel(root)
	b, cancelB := context.WithCancel(root)
	defer cancelRoot()
	defer cancelA()
	defer cancelB()

	cancelA()
	fmt.Printf("  отменили A:  A=%v  B=%v  корень=%v\n", errText(a.Err()), errText(b.Err()), errText(root.Err()))

	cancelRoot()
	// Небольшая пауза: отмена распространяется по дереву асинхронно.
	time.Sleep(10 * time.Millisecond)
	fmt.Printf("  отменили корень: A=%v  B=%v  корень=%v\n", errText(a.Err()), errText(b.Err()), errText(root.Err()))
	fmt.Println()
	fmt.Println("  Первая строка: отмена потомка не трогает ни родителя, ни")
	fmt.Println("  соседа. Вторая: отмена родителя доходит до ВСЕХ потомков.")
	fmt.Println("  Это и значит «контекст — дерево»: связь односторонняя.")
	fmt.Println()
	fmt.Println("  Отсюда практическое: отменить чужую работу можно, только")
	fmt.Println("  держа её контекст, — и поэтому контекст передают первым")
	fmt.Println("  аргументом, а не прячут в структуру.")
	fmt.Println()
}

func errText(err error) string {
	if err == nil {
		return "nil"
	}
	return err.Error()
}

// ------------------------------------------------- блок 3: цена Value

func buildChain(n int) context.Context {
	ctx := context.WithValue(context.Background(), userKey, "roman")
	for i := 0; i < n; i++ {
		ctx = context.WithValue(ctx, ctxKey(fmt.Sprintf("k%d", i)), i)
	}
	return ctx
}

func valueCost() {
	fmt.Println("ЦЕНА VALUE ПО ГЛУБИНЕ ВЛОЖЕНИЯ")
	fmt.Println("──────────────────────────────")

	put := testing.AllocsPerRun(1000, func() {
		sink = context.WithValue(context.Background(), userKey, "roman")
	})
	fmt.Printf("  положить одно значение        выделений: %.0f\n", put)
	fmt.Println()

	fmt.Printf("  %-22s %12s\n", "слоёв над значением", "Value")
	var base float64
	for _, n := range []int{0, 5, 10, 20} {
		ctx := buildChain(n)
		v := best(func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				sink = ctx.Value(userKey)
			}
		})
		if base == 0 {
			base = v
		}
		fmt.Printf("  %-22d %9.2f нс   ×%.1f\n", n, v, v/base)
	}
	fmt.Println()

	// Для сравнения — то, чем контекст НЕ является: обычное поле структуры.
	type req struct{ user string }
	r := &req{user: "roman"}
	field := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			sinkI += len(r.user)
		}
	})
	fmt.Printf("  поле структуры, для сравнения  %9.2f нс\n", field)
	fmt.Println()
	fmt.Println("  Класть в контекст дёшево — одно выделение. Дорого ДОСТАВАТЬ:")
	fmt.Println("  Value идёт вверх по цепочке родителей слой за слоем, и цена")
	fmt.Println("  растёт вместе с глубиной, как у errors.Is по цепочке обёрток.")
	fmt.Println()
	fmt.Println("  Последняя строка — мера того, насколько это не замена полю.")
	fmt.Println("  Контекст переносит то, что относится к ЗАПРОСУ и проходит")
	fmt.Println("  через границы пакетов: идентификатор трассировки, дедлайн,")
	fmt.Println("  данные аутентификации. Параметры функции в него не кладут.")
	fmt.Println()
}

// ------------------------------------------------------------ утилиты

const rounds = 7

func nsPerOp(r testing.BenchmarkResult) float64 {
	return float64(r.T.Nanoseconds()) / float64(r.N)
}

func best(f func(*testing.B)) float64 {
	out := 0.0
	for r := 0; r < rounds; r++ {
		v := nsPerOp(testing.Benchmark(f))
		if out == 0 || v < out {
			out = v
		}
	}
	return out
}

func main() {
	fmt.Printf("%s %s/%s | context изнутри\n\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH)
	leak()
	tree()
	valueCost()
}