Deep Engineering

ЗАМЕР

bench/gorace/detector.go

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

Цитируется в статье
/ru/interview/golang/data-races
Как запустить
go run bench/gorace/internals.go
go run bench/gorace/detector.go     # собирает racy/* сам
go run bench/gorace/limits.go       # тоже
go run bench/gorace/options.go
go run bench/gorace/practice.go

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

Замеры для урока «Гонки данных»

Файл Что делает
internals.go без -race: разорванные чтения структуры из двух слов и цикл по флагу без синхронизации
detector.go собирает программы из racy/ с -race и без: отчёт детектора, гонка на невыполненном пути, состязание без гонки данных, цена режима -race по времени и памяти
racy/counter гонка в чистом виде: две горутины пишут одну переменную
racy/branch гонка только на редком пути, включаемом аргументом
racy/checkact проверка и действие под разными захватами одного мьютекса
racy/workload нагрузка без гонок для замера цены детектора
limits.go границы детектора сверх detector.go: гонка без одновременности, выполненная гонка без отчёта, окно без runtime.Gosched(), go test -race на тесте с прошедшими проверками
options.go код выхода по умолчанию и через GORACE=exitcode=N; сборка -race без cgo
racy/seq запись и чтение, разнесённые на 100 мс, без синхронизации
racy/missed гонка, которую в этом чередовании случайно упорядочил замок
racy/testpkg тест с гонкой, все проверки которого проходят
practice.go источник ответов двух практических задач урока
go run bench/gorace/internals.go
go run bench/gorace/detector.go     # собирает racy/* сам
go run bench/gorace/limits.go       # тоже
go run bench/gorace/options.go
go run bench/gorace/practice.go

Записи прогонов — в runs/, go1.24.7 linux/amd64, GOMAXPROCS=2.

Что здесь важно прочитать правильно

В internals.go гонки намеренные. Это программа про эффект гонки, а не про её обнаружение; запускать её с -race незачем.

Доли разорванных чтений шумят. От круга к кругу — от половины до трёх четвертей прочитанных пар. Устойчиво только то, что их не ноль. Поэтому в практической задаче печатается факт, а не доля.

Цикл for !done {} на go1.24.7 завершился. Это наблюдение, а не гарантия: модель памяти про этот самый цикл говорит, что он может не завершиться никогда. Урок обязан называть это так.

Что получилось

Детектор находит гонку, которая не испортила результат. racy/counter без -race печатает правильные n = 2000; с -race — отчёт WARNING: DATA RACE и код выхода 66.

Детектор не находит гонку, которая не выполнилась. racy/branch с -race на обычном пути — код выхода 0 и ни одного отчёта; тот же бинарник с аргументом rare — отчёт с указанием на main.go:24.

Детектор не находит состязание без гонки данных. racy/checkact: баланс ушёл в минус в 936 попытках из 1000, отчётов ноль. Каждое обращение под замком; ошибка — в том, что проверка и списание разнесены по двум захватам. Число попыток зависит от явного runtime.Gosched() между проверкой и действием — он поставлен, чтобы окно было видно в каждом прогоне.

Цена режима -race. Та же нагрузка, три серии подряд: по памяти ×4,8–5,0, по времени ×7,6–12,0. Время гуляет заметно, память — нет; поэтому практическая задача спрашивает про память (×4,85 в её прогоне).

Одновременность детектору не нужна. racy/seq: запись и чтение разнесены на 100 мс, синхронизации между ними нет — отчёт в 5 запусках из 5, в том числе при GOMAXPROCS=1.

Гоночная программа может дать исполнение без гонки. racy/missed: запись и чтение мимо замка, но в этих запусках Unlock в горутине случается раньше Lock в main, обращения оказываются упорядочены, и в этом исполнении гонки данных нет — 0 отчётов за 5 запусков. Детектор проверяет исполнение, а не все чередования, которые программа допускает.

Без runtime.Gosched() окно остаётся. racy/checkact nogosched: 44, 20 и 2 ухода в минус из 1000 при нуле отчётов. С Gosched в том же прогоне — 943 (в записи detector.txt — 936: разброс между прогонами).

go test -race падает на гонке, а не на проверке. racy/testpkg: проверка теста не срабатывает, пакет testing пишет race detected during execution of test, код выхода go test — 1.

66 — значение по умолчанию. options.go: тот же отчёт с GORACE=exitcode=13 завершается с кодом 13.

На linux -race требует cgo. CGO_ENABLED=0 go build -race отказывается: -race requires cgo. По исходнику cmd/go go1.24.7 исключение — macOS.

Скрипт

177 строк
//go:build ignore

// Детектор гонок: что он находит, чего не находит и сколько стоит.
//
// ЗАЧЕМ ОТДЕЛЬНАЯ ПРОГРАММА. Отчёт детектора появляется только у бинарника,
// собранного с -race, а главные вопросы о детекторе — сравнительные: тот же
// код с флагом и без, тот же код с гонкой на выполненном пути и на
// невыполненном. Поэтому здесь программы из racy/ собираются дважды и
// запускаются как отдельные процессы, а их вывод и коды выхода печатаются.
//
// ЧЕТЫРЕ СЮЖЕТА:
//
//  1. Гонка, которая не испортила результат. Детектор её всё равно видит:
//     он ищет не неверный ответ, а доступы без happens-before.
//  2. Гонка на пути, который не выполнился. Детектор её не видит.
//  3. Состязание без гонки данных (проверка и действие под разными захватами
//     замка). Детектор молчит, а ошибка есть.
//  4. Цена: время и пиковая память той же нагрузки с -race и без.
//
// ОТЧЁТ ПЕЧАТАЕТСЯ НЕ ЦЕЛИКОМ. Адреса в памяти, абсолютные пути и смещения
// (+0x99) меняются от машины к машине и от сборки к сборке; они заменены, а
// строки с номерами строк исходника оставлены как есть.
//
// ЗАПУСК:
//
//	go run bench/gorace/detector.go
package main

import (
	"bytes"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"sort"
	"strings"
	"syscall"
	"time"
)

var here string

func build(dir string, race bool) string {
	out := filepath.Join(os.TempDir(), "gorace-"+filepath.Base(dir)+map[bool]string{true: "-race", false: ""}[race])
	args := []string{"build", "-o", out}
	if race {
		args = append(args, "-race")
	}
	args = append(args, ".")
	cmd := exec.Command("go", args...)
	cmd.Dir = filepath.Join(here, "racy", dir)
	if b, err := cmd.CombinedOutput(); err != nil {
		panic(fmt.Sprintf("сборка %s: %v\n%s", dir, err, b))
	}
	return out
}

type result struct {
	out      string
	code     int
	wall     time.Duration
	maxrssKB int64
}

func run(bin string, args ...string) result {
	var buf bytes.Buffer
	cmd := exec.Command(bin, args...)
	cmd.Stdout, cmd.Stderr = &buf, &buf
	start := time.Now()
	err := cmd.Run()
	wall := time.Since(start)
	code := 0
	if ee, ok := err.(*exec.ExitError); ok {
		code = ee.ExitCode()
	} else if err != nil {
		panic(err)
	}
	ru := cmd.ProcessState.SysUsage().(*syscall.Rusage)
	return result{buf.String(), code, wall, ru.Maxrss}
}

var (
	addr   = regexp.MustCompile(`0x[0-9a-f]{6,}`)
	offset = regexp.MustCompile(` \+0x[0-9a-f]+$`)
	path   = regexp.MustCompile(`\S*/racy/`)
)

func clean(s string) string {
	var out []string
	for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
		line = addr.ReplaceAllString(line, "0x…")
		line = offset.ReplaceAllString(line, "")
		line = path.ReplaceAllString(line, "racy/")
		if strings.TrimSpace(line) == "" {
			out = append(out, "")
			continue
		}
		out = append(out, "    "+line)
	}
	return strings.Join(out, "\n")
}

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

func median(xs []float64) float64 {
	sort.Float64s(xs)
	return xs[len(xs)/2]
}

func main() {
	_, file, _, _ := runtime.Caller(0)
	here = filepath.Dir(file)
	fmt.Printf("%s %s/%s, GOMAXPROCS=%d | детектор гонок\n", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))

	show("1) ГОНКА, КОТОРАЯ НЕ ИСПОРТИЛА РЕЗУЛЬТАТ")
	plain := run(build("counter", false))
	fmt.Printf("  без -race: %s, код выхода %d\n", strings.TrimSpace(plain.out), plain.code)
	raced := run(build("counter", true))
	fmt.Printf("  с -race, код выхода %d:\n", raced.code)
	fmt.Println(clean(raced.out))

	show("2) ГОНКА НА ПУТИ, КОТОРЫЙ НЕ ВЫПОЛНИЛСЯ")
	bin := build("branch", true)
	common := run(bin)
	fmt.Printf("  с -race, обычный путь: код выхода %d, отчётов %d\n",
		common.code, strings.Count(common.out, "WARNING: DATA RACE"))
	rare := run(bin, "rare")
	fmt.Printf("  с -race, редкий путь:  код выхода %d, отчётов %d\n",
		rare.code, strings.Count(rare.out, "WARNING: DATA RACE"))
	for _, l := range strings.Split(clean(rare.out), "\n") {
		if strings.Contains(l, "main.go:") {
			fmt.Println("  в отчёте:", strings.TrimSpace(l))
			break
		}
	}

	show("3) СОСТЯЗАНИЕ БЕЗ ГОНКИ ДАННЫХ")
	ca := run(build("checkact", true))
	fmt.Printf("  с -race: %s; код выхода %d, отчётов %d\n",
		strings.TrimSpace(ca.out), ca.code, strings.Count(ca.out, "WARNING: DATA RACE"))
	fmt.Println("  Каждое обращение к балансу под замком, поэтому гонки данных")
	fmt.Println("  нет и детектору нечего сообщить. Ошибка — в том, что проверка")
	fmt.Println("  и списание сделаны под РАЗНЫМИ захватами.")

	show("4) ЦЕНА: ТА ЖЕ НАГРУЗКА С -race И БЕЗ")
	const rounds = 5
	plainBin, raceBin := build("workload", false), build("workload", true)
	measure := func(b string) (float64, float64) {
		var ts, ms []float64
		for i := 0; i < rounds; i++ {
			r := run(b)
			ts = append(ts, float64(r.wall.Microseconds())/1000)
			ms = append(ms, float64(r.maxrssKB)/1024)
		}
		return median(ts), median(ms)
	}
	fmt.Printf("  %-8s %10s %10s %9s %10s %10s %9s\n", "серия",
		"время", "с -race", "кратн.", "память", "с -race", "кратн.")
	for series := 1; series <= 3; series++ {
		t0, m0 := measure(plainBin)
		t1, m1 := measure(raceBin)
		fmt.Printf("  %-8d %7.0f мс %7.0f мс %8.1fx %6.1f МиБ %6.1f МиБ %8.1fx\n",
			series, t0, t1, t1/t0, m0, m1, m1/m0)
	}
	fmt.Println()
	fmt.Printf("  В каждой серии — медиана из %d запусков. Кратность зависит от\n", rounds)
	fmt.Println("  нагрузки и от прогона к прогону гуляет заметно — особенно по")
	fmt.Println("  времени. Число переносить нельзя, порядок — можно: -race — это")
	fmt.Println("  режим тестов и стендов, а не продакшена.")
}