Перейти к содержимому

Goroutine leaks и дебаг concurrency

Зачем знать на Middle 1: Goroutine leak — это медленная смерть production-сервиса. Сначала вроде всё ок, потом OOM, потом сервис падает. Race condition — это бомба замедленного действия: тесты зелёные, prod падает раз в неделю с непонятной ошибкой. Middle-разработчик должен уметь: ловить leak через pprof, читать goroutine dump, использовать -race, отлаживать concurrent код через delve/runtime/trace. Без этого — нельзя писать production-grade Go.


  1. Базовая концепция (кратко)
  2. Под капотом: leak / deadlock / livelock / race
  3. Gotchas
  4. Производительность
  5. Когда использовать / альтернативы
  6. Вопросы на собесе
  7. Practice
  8. Источники

Goroutine leak — горутина, которая никогда не завершится. Каждая занимает ~2KB stack + sched overhead. Накапливаясь, leak’и убивают сервис.

Deadlock — все горутины заблокированы, ждут друг друга. Runtime ловит “all goroutines asleep” и падает с fatal error.

Livelock — горутины активны, но не делают прогресс. Hard to detect.

Starvation — горутина никогда не получает ресурс (mutex, CPU).

Race condition — недетерминистичный результат из-за неконтролируемого порядка доступа к shared state. Ловится go test -race.

Инструменты:

  • runtime.NumGoroutine() — счётчик.
  • pprof goroutine — stack trace всех.
  • uber-go/goleak — leak detection в тестах.
  • go test -race — race detector.
  • delve — debugger с поддержкой горутин.
  • runtime/trace — timeline исполнения.
  • testing/synctest (Go 1.24+) — детерминистичные тесты.

Любая горутина, которая блокируется навсегда. Типовые причины:

A. Receive из канала, в который никто не отправит

Заголовок раздела «A. Receive из канала, в который никто не отправит»
func leak1() {
ch := make(chan int)
go func() {
v := <-ch // никто не отправит — горутина висит
fmt.Println(v)
}()
// ch выйдет за scope, но горутина ссылается на него — GC не освободит.
}

B. Send в канал, из которого никто не читает (unbuffered)

Заголовок раздела «B. Send в канал, из которого никто не читает (unbuffered)»
func leak2() {
ch := make(chan int)
go func() {
ch <- 42 // блокировка
}()
// если caller не сделает <-ch, горутина висит
}
func leak3() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
// нет способа остановить
}
func leak4(ctx context.Context) {
go func() {
for {
select {
case data := <-dataChan:
process(data)
// нет case <-ctx.Done()!
}
}
}()
}

При cancel ctx — ничего не происходит, горутина продолжает крутиться.

resp, _ := http.Get(url)
// забыли resp.Body.Close()
io.ReadAll(resp.Body)

Тут утечка не goroutine, но соединения. Под капотом keep-alive может держать reader-горутину.

for range time.Tick(time.Second) { ... }

time.Tick создаёт Ticker, который никогда не Stop. До Go 1.23 это был серьёзный leak. С Go 1.23 timer-GC лучше, но идиома всё равно плохая.

var wg sync.WaitGroup
wg.Add(1)
go func() {
wg.Wait() // если кто-то забыл Done — leak
doNext()
}()

Самый простой способ:

func TestSomething(t *testing.T) {
before := runtime.NumGoroutine()
DoStuff()
time.Sleep(100 * time.Millisecond) // дать выйти
after := runtime.NumGoroutine()
if after > before {
t.Errorf("leak: %d%d", before, after)
}
}

⚠️ Минусы: flaky (timing-sensitive), не показывает где leak.

В production:

import _ "net/http/pprof"
func init() {
go http.ListenAndServe(":6060", nil)
}

Снять:

Окно терминала
go tool pprof http://localhost:6060/debug/pprof/goroutine

Или текстовый dump:

Окно терминала
curl http://localhost:6060/debug/pprof/goroutine?debug=2

Получишь stack trace каждой горутины:

goroutine 42 [chan receive, 5 minutes]:
main.handler.func1(...)
/app/main.go:123
created by main.handler in goroutine 1
/app/main.go:120 +0x45
goroutine 87 [chan receive, 5 minutes]:
main.handler.func1(...)
/app/main.go:123
created by main.handler in goroutine 1
/app/main.go:120 +0x45

10 горутин в одном стеке “chan receive” 5 минут — это leak. Иди на main.go:123 — там receive из канала, который никто не пишет.

Окно терминала
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine

В браузере увидишь flame graph: какие функции создают больше всего горутин.

import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
// Или per-test:
func TestFoo(t *testing.T) {
defer goleak.VerifyNone(t)
// ...
}

После теста проверит, что нет лишних горутин. Если есть — fail с stack trace.

В prod-системах:

  • Pyroscope (grafana/pyroscope) — open source.
  • DataDog Continuous Profiler.
  • Google Cloud Profiler.

Снимают pprof раз в N сек, хранят историю. Можешь видеть рост горутин со временем.

State горутины в stack trace:

StateЗначение
runnableготова к запуску, ждёт CPU
runningвыполняется
syscallв системном вызове
chan sendждёт send в канал
chan receiveждёт recv
selectв select-block
IO waitждёт сеть (netpoll)
semacquireждёт mutex/cond
sleeptime.Sleep
waitждёт сигнал (различные)

[chan receive, 5 minutes] — 5 минут в этом состоянии. Большие времена — потенциальный leak.

Группируй стеки: если 1000 горутин с одинаковым стеком в chan receive — это явный leak в этой точке.

Окно терминала
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 \
| grep -A 2 "chan receive" \
| sort | uniq -c | sort -rn

Каждая горутина — с context:

func work(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case v := <-ch:
process(v)
}
}
}
select {
case v := <-ch:
use(v)
case <-time.After(5*time.Second):
return ErrTimeout
}

⚠️ В горячем цикле — используй time.NewTimer + Reset, не time.After (см. файл 05).

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel() // ОБЯЗАТЕЛЬНО — иначе leak до timeout

Даже если успешно завершили — defer cancel освобождает таймер и goroutines контекста.

Вместо:

for _, item := range millionItems {
go work(item) // 1M горутин!
}

Используй пул:

sem := make(chan struct{}, 100)
for _, item := range millionItems {
sem <- struct{}{}
go func(item Item) {
defer func() { <-sem }()
work(item)
}(item)
}

Закрывает sender. Если sender’ов несколько — координируй (WaitGroup + закрывающая горутина).

resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()
io.ReadAll(resp.Body)

Иначе keep-alive держит соединение в pool, может block-нуть.

Go не делает static analysis на deadlock. Никаких compile-time проверок.

Если все горутины (включая main) заблокированы — runtime ловит и падает:

fatal error: all goroutines are asleep - deadlock!

Это работает только для FULL deadlock. Partial — где часть горутин крутится, а часть deadlocked — runtime не ловит.

Пример full deadlock:

func main() {
ch := make(chan int)
<-ch // main ждёт, никого больше нет → fatal
}
func main() {
ch := make(chan int)
go func() {
for {
// что-то делает, не использует ch
time.Sleep(time.Second)
}
}()
<-ch // main deadlock, но другая горутина живая — runtime молчит
}

Ловится через pprof goroutine dump или goleak в тестах.

var mu1, mu2 sync.Mutex
go func() {
mu1.Lock(); defer mu1.Unlock()
mu2.Lock(); defer mu2.Unlock()
}()
go func() {
mu2.Lock(); defer mu2.Unlock()
mu1.Lock(); defer mu1.Unlock()
}()

Classic ABBA deadlock. Решение: всегда захватывать mutex’ы в одном порядке.

Горутины активны, но не делают прогресс. Пример:

// Два transactor пытаются дать друг другу:
func transactor(other *Account) {
for {
mu1.Lock()
if other.tryAcquire() {
// ok
return
}
mu1.Unlock()
time.Sleep(time.Millisecond) // back off
}
}

Если оба одновременно retry, могут вечно друг друга отталкивать. Решение: random backoff (jitter).

Livelock ловится только наблюдением: CPU 100%, прогресс 0%. pprof покажет hot loop.

Когда горутина никогда не получает ресурс. Mutex starvation в Go 1.9+ предотвращена (см. файл 06). Но другие случаи:

// "богатый богаче": горутина с быстрой работой всегда возвращается первой
for {
select {
case <-fastCh:
// быстрая обработка
case <-slowCh:
// не успевает, fastCh всегда срабатывает первым
}
}

select random shuffle помогает, но если fastCh ВСЕГДА имеет данные — slowCh может starve.

Решение: приоритеты или раздельные горутины.

Два или более доступа к памяти, где хотя бы один — write, и нет synchronization между ними.

var counter int
go func() { counter++ }()
go func() { counter++ }()
// результат: 1 или 2, недетерминированно

go test -race, go build -race, go run -race.

Под капотом — ThreadSanitizer (TSan). Каждая загрузка/запись инструментируется. Хранит “shadow memory”:

  • Для каждого байта — последний writer (goroutine + clock).
  • При load/store проверяет — есть ли race с предыдущим.
  • CPU slowdown: 2-20x (зависит от concurrent access density).
  • Memory: 5-10x (shadow memory).
  • Goroutine creation: дороже.

Не для prod. Только dev/staging/CI.

  • Конкурентный write/write без mutex.
  • Read/write без mutex.
  • Race на initialization (init без guard).
  • Сам факт race может не сработать в данном run. Race detector только показывает реализованные races.
  • Logical race (например, неправильный порядок lock — но без actual data race).
  • Race в сторонних библиотеках без race-build.
m := map[int]int{}
go func() { m[1] = 1 }()
go func() { m[2] = 2 }()

go run -race:

==================
WARNING: DATA RACE
Write at 0x00c0000a0030 by goroutine 7:
runtime.mapassign_fast64()
/usr/lib/go/src/runtime/map_fast64.go:92 +0x0
main.main.func1()
/tmp/race.go:9 +0x80
Previous write at 0x00c0000a0030 by goroutine 6:
runtime.mapassign_fast64()
...
==================

Готово, ты знаешь где race.

⚠️ Особенность: concurrent map access — это runtime fatal error, даже без race detector:

fatal error: concurrent map writes
Окно терминала
dlv debug main.go
(dlv) break main.go:42
(dlv) continue
(dlv) goroutines # список всех горутин
(dlv) goroutine 5 # переключиться на горутину 5
(dlv) stack # stack trace
(dlv) goroutine 5 frame 0 # переключиться на её фрейм

goroutines показывает state и текущую строку. Полезно для “где зависла горутина”.

В Go нет легального API для goroutine id (нарочно — discourage использования). Но можно через runtime.Stack:

func goid() int {
var buf [64]byte
n := runtime.Stack(buf[:], false)
s := string(buf[:n])
s = strings.TrimPrefix(s, "goroutine ")
s = s[:strings.IndexByte(s, ' ')]
id, _ := strconv.Atoi(s)
return id
}

Полезно для debug print:

log.Printf("[g%d] step", goid())

⚠️ Только для debug. Никогда не используй в production logic — goroutine id может переиспользоваться.

import "runtime/trace"
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// ... работа ...
}

Анализ:

Окно терминала
go tool trace trace.out

Откроется браузер с timeline:

  • View trace — timeline горутин.
  • Goroutine analysis — статистика.
  • Scheduler latency — задержки планирования.

Полезно для:

  • Когда GC blocks goroutines.
  • Где syscalls тормозят.
  • Распределение нагрузки между P.

В Go 1.21+:

ctx, task := trace.NewTask(ctx, "request")
defer task.End()
trace.WithRegion(ctx, "db-query", func() {
db.Query(...)
})

В trace tool увидишь иерархию: request → db-query.

// ПЛОХО
go startWorker()
time.Sleep(100*time.Millisecond) // надежд?
// проверка

Sleep — racing timer с реальной системой. CI ляжет, локально ок. Используй:

var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
wg.Wait() // гарантия что закончилось

testify/assert.Eventually:

assert.Eventually(t, func() bool {
return stats.Processed() == 100
}, 5*time.Second, 10*time.Millisecond)

Опрос с интервалом, до timeout. Менее flaky чем sleep.

type WorkerSuite struct {
suite.Suite
pool *Pool
}
func (s *WorkerSuite) SetupTest() {
s.pool = NewPool(s.T().Context())
}
func (s *WorkerSuite) TearDownTest() {
s.pool.Stop()
}
func (s *WorkerSuite) TestSubmit() {
s.NoError(s.pool.Submit(work))
}
func TestWorkerSuite(t *testing.T) {
suite.Run(t, &WorkerSuite{})
}

Новый пакет для детерминистичных тестов concurrency. Создаёт “bubble” с виртуальным временем, синхронизирует горутины.

import "testing/synctest"
func TestTimer(t *testing.T) {
synctest.Run(func() {
start := time.Now()
time.Sleep(time.Hour)
if time.Since(start) != time.Hour {
t.Fail()
}
})
}

Внутри synctest.Run все time.Sleep, time.After, time.NewTimer работают с фейковым временем. Тест выполняется мгновенно, но логически “проходит” час.

Доступно в Go 1.24+. Раньше использовали clockwork/quartz библиотеки.

Окно терминала
go test -race ./...

В CI обязательно. Если race detector что-то нашёл — баг.

Окно терминала
GOMAXPROCS=1 go test ./...
GOMAXPROCS=8 go test ./...

Некоторые race видны только при разной concurrency.

func TestStress(t *testing.T) {
if testing.Short() {
t.Skip()
}
for i := 0; i < 10000; i++ {
runConcurrentScenario()
}
}

В CI запускать с -count=10 или -count=100 для flushing race-ов.


3.1. ⚠️ runtime.NumGoroutine считает все, включая system

Заголовок раздела «3.1. ⚠️ runtime.NumGoroutine считает все, включая system»
fmt.Println(runtime.NumGoroutine()) // 5 при старте программы!

Системные горутины: GC, finalizer, scavenger, sysmon. Snapshot — не точно user count.

3.2. ⚠️ Goroutine id нельзя использовать в логике

Заголовок раздела «3.2. ⚠️ Goroutine id нельзя использовать в логике»
id := goid() // меняется между restart, переиспользуется

Это implementation detail рантайма. Если нужен logical id — генерируй сам и пробрасывай через context.

3.3. ⚠️ pprof goroutine?debug=2 stack может быть огромным

Заголовок раздела «3.3. ⚠️ pprof goroutine?debug=2 stack может быть огромным»

Под нагрузкой 100k goroutines — dump на десятки MB. Использовать debug=1 для compact формата (агрегация по stack).

Сторонние библиотеки могут содержать race-ы, видные только при сборке всей программы с -race. Поэтому CI должен запускать race tests на полном коде.

Иногда команды думают: “включим в production для безопасности”. Не делай — 10x slowdown, 10x memory. Используй в dev/staging.

3.6. ⚠️ “all goroutines asleep” не отловит partial deadlock

Заголовок раздела «3.6. ⚠️ “all goroutines asleep” не отловит partial deadlock»
go func() { for {} }() // живая
ch := make(chan int)
<-ch // deadlock, но runtime молчит

Используй goleak или pprof.

CI окружения тормознее локалки → 100ms sleep может не хватить. Используй WaitGroup / Eventually.

Забыл Done в одной ветке (например, ошибка раньше) — Wait висит.

Решение: всегда defer wg.Done() сразу после wg.Add(1) (или внутри goroutine).

Системные горутины (например, runtime test framework) тоже считаются. Используй goleak.IgnoreCurrent() для baseline.

CGo goroutine в syscall — pprof покажет state syscall, но stack из C-кода не виден. Профилируй через perf/dtrace.

ctx, _ := context.WithCancel(parent)
// ctx используется, но cancel никто не вызовет — leak до GC родителя

vet “lostcancel” ловит это. Не игнорируй warning.

Не race detector — runtime fatal error. Падает без recover.

fatal error: concurrent map writes

Защита: Mutex или sync.Map.


  • Stack initial: 2 KB (grows by doubling до 1 GB).
  • Sched overhead: ~100 ns per gocall.
  • Context switch: ~200-500 ns.

1M goroutines = ~2 GB stack минимум.

runtime.NumGoroutine() — атомарный счётчик, ~10 ns. Безопасно вызывать часто (для метрики).

runtime.Stack для goroutine dump — берёт stopTheWorld короткое время. Под нагрузкой 100k goroutines — 50-200 ms блокировки всей программы. Не делай в hot path.

МетрикаSlowdown
CPU time5-15x
Memory5-10x
Goroutine create2-3x

Запускай unit-tests с -race, не bench.

trace.Start записывает events. ~3-5% overhead. Файл растёт быстро (10-100 MB/s под нагрузкой). Использовать для коротких профилей (10-30 сек).


Что детектимИнструмент
Goroutine leak (dev)goleak в тестах
Goroutine leak (prod)pprof goroutine + alert на NumGoroutine
Racego test -race
Deadlock полныйruntime автоматически (fatal)
Deadlock partialpprof + manual review
Livelockpprof CPU + manual review
Slow concurrent coderuntime/trace
Mutex contentiongo test -mutexprofile
Block (channels, semaphore)go test -blockprofile

Лёгкий случай (один сервис, маленький trafic):

  • Race в CI.
  • pprof endpoint в prod.
  • Alert на NumGoroutine > 10k.

Серьёзный случай (high-load backend):

  • Continuous profiling (Pyroscope).
  • goleak в integration tests.
  • runtime/trace для деградаций.
  • Distributed tracing (для multi-service).

Прод инциденты:

  • Скачай pprof goroutine dump.
  • Группируй по stack.
  • Найди подозрительный stack (long state).

1. Что такое goroutine leak? Горутина, которая никогда не завершится, удерживая память и ресурсы. Типичные причины: receive из канала без sender, forever loop без cancel, context.Done не проверяется.

2. Как поймать leak в проде? pprof goroutine profile: curl /debug/pprof/goroutine?debug=2. Stack trace всех горутин, ищи длительные state (chan receive, semacquire).

3. Чем debug=1 отличается от debug=2? debug=1 — агрегированный (sample stacks с count). debug=2 — полный dump каждой горутины с stack trace.

4. Что такое goleak? Библиотека uber-go/goleak. В тестах вызывает goleak.VerifyNone(t) — проверяет что после теста нет утёкших горутин (кроме baseline).

5. Как ловить deadlock? Полный — runtime сам падает с “all goroutines are asleep”. Частичный — pprof + анализ stack trace.

6. Что такое livelock? Горутины активны (CPU занят), но прогресса нет (например, бесконечный retry без backoff). Pprof CPU profile покажет hot loop.

7. Когда возникает starvation? Когда одна горутина никогда не получает ресурс. В Go 1.9+ mutex starvation предотвращена (starvation mode). Но select может starve если один канал всегда быстрее.

8. Race condition vs data race? Data race — конкретное явление: 2+ доступа к памяти, хоть один — write, без sync. Race condition — более общее: результат зависит от тайминга.

9. Как работает race detector? ThreadSanitizer (TSan). Инструментирует load/store. Shadow memory хранит last writer (goroutine + clock). При каждом доступе проверяет hb-relation с предыдущим.

10. Стоимость race detector? CPU 5-15x, memory 5-10x. Только для dev/staging/CI. НЕ для prod.

11. Что ловит и не ловит race detector? Ловит: data races, видные в данном run. НЕ ловит: logical race без data race, races не сработавшие в текущем run, races в кодах без -race флага.

12. Как go test -race запустить? go test -race ./.... В CI обязательно. Можно стрессить: go test -race -count=100 ./....

13. Как избежать leak в context’е? Всегда defer cancel() после context.WithCancel/Timeout. Все горутины проверяют ctx.Done() в select.

14. Что делает go vet для concurrency?

  • copylocks — копирование Mutex/WaitGroup.
  • lostcancel — потерянный cancel из WithCancel.
  • loopclosure — capture переменной цикла в горутине (старая семантика до Go 1.22).

15. Зачем defer cancel(), если ctx уже expired? cancel освобождает ресурсы контекста (таймеры, дочерние goroutines). Без cancel — leak до timeout родительского ctx.

16. State горутины в dump: что такое semacquire? Горутина ждёт semaphore — внутри Mutex.Lock, sync.Cond.Wait, или channel parking.

17. State IO wait что значит? Горутина в netpoll: ждёт ready на сетевом socket. Не блокирует thread.

18. Что такое stack trace created by? Где была создана горутина. goroutine X [chan receive]: ... created by Y at file:line — горутина создана функцией Y.

19. Партиал deadlock — как ловить? pprof goroutine + visual inspection. Если несколько горутин в chan receive или semacquire долго — подозрение.

20. Что делает runtime.NumGoroutine? Возвращает количество active goroutines. Включает системные (GC, finalizer и т.д.). Атомарный счётчик, ~10ns.

21. Можно ли получить goroutine id? Через runtime.Stack — parse string. Но это не публичное API, нельзя в логике. Только для debug log.

22. testing/synctest зачем? Go 1.24+. Детерминистичные тесты concurrency: фейковое время, синхронизированные горутины. Заменяет sleep-based testing.

23. Что показывает go tool trace? Timeline всех горутин, GC pauses, syscalls, lock contention, goroutine state changes. Для очень глубокого анализа.

24. block profile vs mutex profile?

  • Block — ВСЕ блокировки (channel, mutex, semacquire).
  • Mutex — только sync.Mutex contention.

25. Когда time.Tick опасен? Не освобождается после выхода из range. До Go 1.23 — leak. Используй NewTicker + defer Stop().

26. Закрытие resp.Body — почему важно? HTTP keep-alive держит соединение в pool. Без Close — connection (а с ней read goroutine) остаётся открытой.

27. testify Eventually vs sleep? Eventually — polling с интервалом, до timeout. Менее flaky чем sleep (не недосыпает на медленном CI).

28. concurrent map writes — что случится? Runtime panic: fatal error: concurrent map writes. Без recover. Защита: Mutex или sync.Map.

29. Как stop GoRoutine “снаружи”? Нельзя силой. Нужно — сама горутина должна проверять сигнал (context.Done, канал done). Goroutine это не thread с интерфейсом kill.

30. Что такое stop-the-world (STW)? Все горутины приостанавливаются. Бывает при GC marking start/finish, при runtime.Stack для всех горутин (all=true), при pprof goroutine?debug=2.


// Сломанная версия
func brokenFetch(url string) string {
ch := make(chan string)
go func() {
resp, _ := http.Get(url)
body, _ := io.ReadAll(resp.Body)
ch <- string(body)
}()
select {
case s := <-ch:
return s
case <-time.After(1*time.Second):
return "" // leak: горутина продолжает работу, ch unbuffered
}
}

Fix:

func fixedFetch(ctx context.Context, url string) string {
ch := make(chan string, 1) // буферизованный — sender не leak
go func() {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
ch <- ""
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
ch <- string(body)
}()
select {
case s := <-ch:
return s
case <-ctx.Done():
return ""
}
}

Изменения:

  1. chan string, 1 — sender не блокируется.
  2. NewRequestWithContext — отмена request при cancel.
  3. defer resp.Body.Close().
func TestPipeline(t *testing.T) {
defer goleak.VerifyNone(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out := pipeline(ctx, []int{1, 2, 3})
for range out { }
}

Если в pipeline что-то leak’нёт — тест упадёт с stack trace.

// Race
type Counter struct {
n int
}
func (c *Counter) Inc() { c.n++ }
func (c *Counter) Get() int { return c.n }
func TestCounterRace(t *testing.T) {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
if c.Get() != 100 {
t.Fail() // не всегда: может быть 90, 95, ...
}
}

go test -race — найдёт.

Fix:

type Counter struct {
n atomic.Int64
}
func (c *Counter) Inc() { c.n.Add(1) }
func (c *Counter) Get() int64 { return c.n.Load() }
func deadlockABBA() {
var muA, muB sync.Mutex
done := make(chan struct{}, 2)
go func() {
muA.Lock()
time.Sleep(10*time.Millisecond)
muB.Lock()
muB.Unlock()
muA.Unlock()
done <- struct{}{}
}()
go func() {
muB.Lock()
time.Sleep(10*time.Millisecond)
muA.Lock()
muA.Unlock()
muB.Unlock()
done <- struct{}{}
}()
<-done; <-done // зависнет
}

Запусти — runtime fatal “all goroutines are asleep” (если main тоже завис на recv).

Fix: всегда захватывать в одном порядке (A, потом B).

Установи проект с leak, запусти, открой:

http://localhost:6060/debug/pprof/goroutine?debug=2

Найди:

  • Сколько горутин всего.
  • Группы по одинаковому stack.
  • Top по state длительности.
func TestBackgroundProcess(t *testing.T) {
proc := startProcessor()
defer proc.Stop()
proc.Submit(work{})
require.Eventually(t, func() bool {
return proc.Processed() >= 1
}, time.Second, 10*time.Millisecond, "should process within 1s")
}

Лучше чем time.Sleep(time.Second).

func main() {
f, _ := os.Create("trace.out")
defer f.Close()
if err := trace.Start(f); err != nil { panic(err) }
defer trace.Stop()
runWorkload()
}

Запусти, открой:

Окно терминала
go tool trace trace.out

Изучи:

  • View trace: горутины по timeline.
  • Goroutine analysis: top по runtime.
  • Synchronization blocking profile.

Найди bottleneck. Часто это lock contention или GC.

func TestRetry(t *testing.T) {
synctest.Run(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := retryWithBackoff(ctx, func() error {
return errors.New("fail")
})
if err == nil {
t.Fatal("expected error")
}
})
}

time.Sleep внутри retry будет fake — тест выполнится мгновенно, но retry “сделает” свои паузы.


  1. uber-go/goleakhttps://github.com/uber-go/goleak.
  2. Russ Cox — “Data Race Detector”https://go.dev/blog/race-detector.
  3. Go Diagnosticshttps://go.dev/doc/diagnostics (pprof, trace, race).
  4. Profiling Go programshttps://go.dev/blog/pprof.
  5. runtime/trace docshttps://pkg.go.dev/runtime/trace.
  6. delve documentationhttps://github.com/go-delve/delve/tree/master/Documentation.
  7. testing/synctest proposalhttps://go.dev/issue/67434.
  8. Common Go Concurrency Pitfallshttps://go.dev/wiki/CommonMistakes.
  9. The Go Memory Modelhttps://go.dev/ref/mem.
  10. dave.cheney.net — “Never start a goroutine without knowing how it will stop”https://dave.cheney.net/2016/12/22/never-start-a-goroutine-without-knowing-how-it-will-stop.