Deep Engineering

MEASUREMENT

bench/gopattern/semaphore.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/concurrency-patterns
How to run it
go run bench/gopattern/internals.go
/usr/local/go1.25.1/bin/go run bench/gopattern/internals.go
go run bench/gopattern/practice.go
go run bench/gopattern/cancel.go
go run bench/gopattern/semaphore.go
(cd bench/gopattern/cases && go run ./pipeline)

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

Замеры для урока «Паттерны конкурентности»

Файл Что делает
internals.go тринадцать наблюдений: пул воркеров на задаче для процессора (1, 2, 4, 8 воркеров при GOMAXPROCS=2 и 1, 8 при GOMAXPROCS=1, со столбцом «ядер занято») и на задаче со сном (1–16 воркеров); порядок результатов и индекс; конвейер gen → sq; забытый close без таймеров и с таймером; wg.Wait() до чтения результатов, в том числе с буфером; fan-in с close после wg.Wait() и без него; семафор на буферизованном канале; потребитель, ушедший раньше, без context и с ним; остановка по первой ошибке (свой минимальный аналог errgroup.WithContext на WaitGroup, Once и context.WithCancelCause); sync.WaitGroup.Go
cases/forgotclose/main.go конвейер, где sq забыла close; намеренно падает (раздел 5)
cases/waitbeforeread/main.go пул, где wg.Wait() стоит до чтения результатов; намеренно падает (раздел 6)
cases/waitbuffered/main.go та же ошибка с буфером результатов на 6; число задач — аргумент; на 7 падает (раздел 6)
cases/earlyclose/main.go слияние, где close(out) стоит без wg.Wait(); намеренно падает (раздел 8)
cases/wggo/main.go sync.WaitGroup.Go; на go1.24.7 не собирается (раздел 13)
cases/go.mod свой модуль для подопытных программ, go 1.24
cancel.go отмена стадии конвейера на четырёх сценариях (обычный поток, закрытый вход, отмена при открытом пустом и nil-входе, отмена при заблокированном выходе) для двух версий стадии: range по входу с select только на отправке и select в обеих точках ожидания; завершение определяется закрытием выхода за 500 мс, а не счётом горутин
semaphore.go семафор на канале: место внутри уже запущенной горутины, место до go, ожидание места с ctx.Done(); горутины считаются своим счётчиком живых обработчиков
cases/pipeline/main.go полный короткий пример урока: конвейер из двух стадий с отменой; файл без кириллицы — его показывают обе локали
practice.go источник ответов двух практических задач урока: утечка двух горутин и семафор ёмкостью 2; кратность 8 воркеров к одному на задаче для процессора
go run bench/gopattern/internals.go
/usr/local/go1.25.1/bin/go run bench/gopattern/internals.go
go run bench/gopattern/practice.go
go run bench/gopattern/cancel.go
go run bench/gopattern/semaphore.go
(cd bench/gopattern/cases && go run ./pipeline)

internals.go собирает и запускает cases/* тем же go, которым запущен сам (runtime.GOROOT()/bin/go), с GOTOOLCHAIN=local. Путь к cases в выводе заменён на <cases>, номера горутин — на <N>, смещения +0x… в стеке убраны.

Записи прогонов — в runs/, go1.24.7 linux/amd64, 2 ядра. runs/internals-go1.25.txt — тот же internals.go под go1.25.1: отличается раздел 13 (WaitGroup.Go есть, go vet предупреждает о версии в go.mod) и миллисекунды в разделах 1 и 2. cancel.go, semaphore.go и cases/pipeline на go1.25.1 дают тот же вывод, кроме строки версии (проверено diff), поэтому отдельных записей для них нет; cancel.go и semaphore.go дали одинаковый вывод в трёх запусках подряд.

Итоговая фраза раздела 9 internals.go — «Семафор ограничивает работу, а не число горутин: все 12 созданы» — верна для показанной там расстановки (место занимается внутри уже запущенной горутины). При месте, занятом до go, живых обработчиков не больше ёмкости — semaphore.go, раздел 2.

Функция sqCtx в internals.go читает вход через range и слушает отмену только на отправке; в связке раздела 11 этого хватает, а на открытом пустом входе — нет (cancel.go, раздел 1). Оставлена как есть, чтобы runs/internals.txt соответствовал коду.

Машину делят другие процессы. Времена — лучший из N кругов, конфигурации в разделе 1 и в practice.go прогоняются вперемешку. Столбец «ядер занято» — процессорное время процесса (getrusage), делённое на настенное: если соседи займут ядро, это видно в нём. Кратности — наблюдение этой конфигурации (go1.24.7/go1.25.1, linux/amd64, 2 ядра), а не константа: на другой машине, задаче и версии их нужно мерить заново.

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

На задаче для процессора больше GOMAXPROCS воркеров не ускоряет. При GOMAXPROCS=2 два воркера — 2,0 к одному, четыре и восемь — 1,9. Что потолок ставит именно GOMAXPROCS, видно по GOMAXPROCS=1: восемь воркеров — 1,0 при «ядер занято» 1,0, хотя свободных ядер два (выше 2,0 этот столбец на двух ядрах не поднимется при любом GOMAXPROCS).

На задаче со сном ускорение растёт с числом воркеров. 16 задач по 20 мс: 324 мс у одного воркера, 41 мс у восьми (8,0), 20 мс у шестнадцати (16,0).

Результаты приходят в порядке готовности. Задачи a b c d по 80, 20, 60, 40 мс пришли как [b d c a] во всех 20 повторах этого прогона; по индексу — [A B C D]. Порядок прихода не гарантирован: на загруженной машине он может поменяться, поэтому строка «совпал … из 20» от запуска к запуску может отличаться.

Забытый close роняет программу, только если спят все. cases/forgotclose: fatal error: all goroutines are asleep - deadlock!, код выхода 2. Та же стадия в программе с живым time.After: через 500 мс потребитель (горутина, запущенная из main.main) всё ещё в range, падения нет, +1 горутина. Причина — ранние выходы checkdead в runtime/proc.go (строки 6055–6058 и 6119–6124 на go1.24.7).

wg.Wait() до чтения результатов — взаимная блокировка. main в [sync.WaitGroup.Wait], четыре горутины в [chan send]. С буфером на 6 программа проходит на 6 задачах и падает на 7.

Ранний close в слиянии — паника. cases/earlyclose: получено 0 значений, затем panic: send on closed channel в строке out <- v.

Семафор ограничивает работу, а не горутины. Ёмкость 3: одновременно работали 3, горутин было 12, 81 мс против 20 без семафора.

Потребитель, ушедший без context, оставляет горутины. +2, обе в [chan send]; с select на ctx.Done() и defer cancel() — +0. Минимальная группа с первой ошибкой прерывает долгую работу на 10 с и возвращает первую ошибку, context.Cause отдаёт её же.

sync.WaitGroup.Go — с go1.25. На go1.24.7: wg.Go undefined. На go1.25.1 программа печатает сумму, а go vet при go 1.24 в go.mod пишет sync.Go requires go1.25 or later (module is go1.24).

Отмену стадия слышит только там, где её слушает. Стадия с range по входу и select только на отправке на открытом пустом входе и на nil-входе после cancel() выход за 500 мс не закрыла; стадия с select в обеих точках ожидания закрыла его во всех четырёх сценариях (cancel.go).

Семафор ограничивает и горутины, если место занимать до go. Место внутри горутины: обработчиков жило одновременно 12, работало 3. Место до go: жило 3, работало 3. С ctx.Done() в ожидании места раздача остановилась после трёх запусков с context canceled (semaphore.go).

Script

159 lines
//go:build ignore

// Семафор на буферизованном канале: где занимать место — внутри горутины
// или до go — и что от этого зависит.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. В internals.go (раздел 9) место в семафоре занималось
// ВНУТРИ уже запущенной горутины: работали одновременно трое, а горутин было
// двенадцать. Это свойство расстановки, а не семафора вообще. Если занять
// место ДО `go`, цикл раздачи сам встаёт на отправке в полный канал, и новых
// горутин не появляется, пока не освободится место. Третий вариант — то же
// с отменой: ожидание места тоже должно слушать ctx.Done().
//
// Горутины считаются своим счётчиком (живые обработчики), а не
// runtime.NumGoroutine(): в программе есть и служебные горутины, и
// отличить их по одному числу нельзя.
//
// ЗАПУСК:
//
//	go run bench/gopattern/semaphore.go
package main

import (
	"context"
	"errors"
	"fmt"
	"runtime"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

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

// counters — сколько обработчиков живо и сколько работает, и максимумы обоих.
type counters struct {
	alive, busy, maxAlive, maxBusy atomic.Int64
}

func raise(max *atomic.Int64, n int64) {
	for {
		m := max.Load()
		if n <= m || max.CompareAndSwap(m, n) {
			return
		}
	}
}

func (c *counters) enter() { raise(&c.maxAlive, c.alive.Add(1)) }
func (c *counters) leave() { c.alive.Add(-1) }
func (c *counters) work() {
	raise(&c.maxBusy, c.busy.Add(1))
	time.Sleep(20 * time.Millisecond)
	c.busy.Add(-1)
}

const tasks, limit = 12, 3

// acquireInside — горутина на задачу, место занимается внутри.
func acquireInside() *counters {
	var c counters
	sem := make(chan struct{}, limit)
	var wg sync.WaitGroup
	for i := 0; i < tasks; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.enter()
			defer c.leave()
			sem <- struct{}{}
			defer func() { <-sem }()
			c.work()
		}()
	}
	wg.Wait()
	return &c
}

// acquireBeforeGo — место занимается до go, освобождается в defer горутины.
func acquireBeforeGo() *counters {
	var c counters
	sem := make(chan struct{}, limit)
	var wg sync.WaitGroup
	for i := 0; i < tasks; i++ {
		sem <- struct{}{}
		wg.Add(1)
		go func() {
			defer wg.Done()
			defer func() { <-sem }()
			c.enter()
			defer c.leave()
			c.work()
		}()
	}
	wg.Wait()
	return &c
}

// acquireWithCancel — то же, что acquireBeforeGo, но ожидание места слушает
// ctx.Done(). Обработчики держат места, пока не закрыт release; когда трое
// запустились, контекст отменяется — четвёртое ожидание места должно
// закончиться отменой, а не вечным стоянием.
func acquireWithCancel() (started int, err error) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	sem := make(chan struct{}, limit)
	release := make(chan struct{})
	var running atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < tasks; i++ {
		select {
		case sem <- struct{}{}:
		case <-ctx.Done():
			err = ctx.Err()
		}
		if err != nil {
			break
		}
		started++
		wg.Add(1)
		go func() {
			defer wg.Done()
			defer func() { <-sem }()
			if running.Add(1) == limit {
				cancel() // все места заняты — отменяем раздачу
			}
			<-release
		}()
	}
	close(release)
	wg.Wait()
	return started, err
}

func main() {
	fmt.Printf("%s %s/%s | семафор: место занимать внутри горутины или до go\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH)

	show("1) ГОРУТИНА НА ЗАДАЧУ, МЕСТО ЗАНИМАЕТСЯ ВНУТРИ")
	c := acquireInside()
	fmt.Printf("  задач %d, ёмкость %d: обработчиков жило одновременно %d, работало %d\n",
		tasks, limit, c.maxAlive.Load(), c.maxBusy.Load())

	show("2) МЕСТО ЗАНИМАЕТСЯ ДО GO")
	c = acquireBeforeGo()
	fmt.Printf("  задач %d, ёмкость %d: обработчиков жило одновременно %d, работало %d\n",
		tasks, limit, c.maxAlive.Load(), c.maxBusy.Load())

	show("3) МЕСТО ДО GO, ОЖИДАНИЕ МЕСТА СЛУШАЕТ CTX.DONE()")
	started, err := acquireWithCancel()
	fmt.Printf("  запущено обработчиков: %d из %d\n", started, tasks)
	fmt.Printf("  раздача остановилась с ошибкой: %v (errors.Is context.Canceled: %v)\n",
		err, errors.Is(err, context.Canceled))
}