2.6Проблемы: гонки, дедлоки, утечки
Отделим data race от логической ошибки порядка и разберём, что способен обнаружить race detector.
Как назвать поломку и чем её ловить
Сломаться конкурентная программа может тремя способами, а названий у поломок шесть,
и половина на слух похожа: гонка данных и состояние гонки, дедлок, livelock, голодание,
утечка горутин. Путать их дорого, потому что каждую ловят своим инструментом.
Data race ловит -race, дедлок - дамп горутин, livelock - профиль CPU,
starvation - хвост латентности, утечку - счётчик горутин и профиль
goroutineleak. Перепутал диагноз, будешь смотреть не туда и ничего не найдёшь.
Race condition и data race - это разные вещи
Вопрос «в чём разница» задают почти всегда, и почти всегда слышат в ответ, что это синонимы. Никакие не синонимы: Go memory model формально определяет только data race, а race condition - это уже про логику программы.
Проще всего почувствовать разницу на бумажной ведомости с остатком 100 ₽ и двух кассирах. Первый случай: оба одновременно пишут карандашом в одну и ту же клетку - грифель ложится поверх грифеля, и в клетке получается нечитаемая клякса. Испорчена сама запись, и уже неважно, что каждый по отдельности считал правильно. Это data race. Второй случай: кассиры вежливо ждут друг друга и пишут по очереди, разборчиво. Но каждый сначала посмотрел остаток, увидел 100, и только потом списал 100. Ведомость идеально читаемая, все записи целы, а со счёта ушло 200 ₽ при остатке 100. Это race condition: испорчена не запись, а решение, принятое по устаревшим данным.
Отсюда два вывода, ради которых различие и нужно. Первый: инструмент, который смотрит на клетки памяти, второй случай не поймает никогда, там нет ни одного некорректного обращения. Второй: чинятся они по-разному. От кляксы спасает любая синхронизация вокруг записи; от списанных 200 ₽ спасает одно - сделать «посмотреть и списать» одной неделимой операцией, а не двумя аккуратными.
| Data race (гонка данных) | Race condition (состояние гонки) | |
|---|---|---|
| Определение | два обращения к одной ячейке памяти из разных горутин, минимум одно - запись, и между ними нет отношения happens-before | корректность результата зависит от того, в каком порядке успели выполниться конкурентные операции |
| Уровень | память, определяется формально спецификацией | логика программы, определяется её смыслом |
| Последствия | не UB, как в C, но рваные интерфейсы и слайсы, порча map и памяти, падение рантайма | неверный бизнес-результат: списали дважды, потеряли заказ |
| Ловится инструментом | да, -race находит формально | нет, машина не знает вашей бизнес-логики |
| Пример | counter++ из двух горутин без синхронизации | проверил баланс, потом списал - между проверкой и списанием влез другой |
Каждая data race - это race condition, но не наоборот. Можно написать код, где вся память под мьютексом, детектор молчит, а логика всё равно неверна:
// Data race нет: каждое обращение под мьютексом. А race condition есть.
func (a *Account) Withdraw(sum int) error {
a.mu.Lock()
bal := a.balance // проверка
a.mu.Unlock()
if bal < sum { // между Unlock и Lock влезла другая горутина
return errNotEnough
}
a.mu.Lock()
a.balance -= sum // списание по устаревшему решению
a.mu.Unlock()
return nil
}
// Правильно: решение и действие в одной атомарной операции
func (a *Account) Withdraw(sum int) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < sum {
return errNotEnough
}
a.balance -= sum
return nil
}
«Data race - это про память и определяется формально: конкурентные доступы без
happens-before, минимум один на запись. Race condition - это про логику: результат зависит
от порядка. Первое ловит -race, второе - только голова и тесты. Классический
пример race condition без data race - TOCTOU: check-then-act под двумя отдельными
критическими секциями.» Дальше обычно просят пример - держите наготове проверку баланса
или if !exists { create() }.
counter++ из N горутин: почему ломается
counter++ выглядит одной операцией, но по сути это три шага:
прочитать значение из памяти, прибавить единицу, записать обратно. Между любыми
двумя из них планировщик может увести поток на другую горутину, а на многоядерной машине
горутины и вовсе выполняются физически одновременно на разных ядрах.
// go build -gcflags=-S на arm64: counter++ это три инструкции
// MOVD main.counter(SB), R1
// ADD $1, R1, R1
// MOVD R1, main.counter(SB)
// на amd64 одна INCQ main.counter(SB), но без LOCK и она не атомарна
var counter int
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() { // Go 1.25: wg.Add(1) + go func + wg.Done() в одном вызове
for j := 0; j < 1000; j++ {
counter++ // гонка
}
})
}
wg.Wait()
fmt.Println(counter) // ожидали 1000000, а выйдет что угодно.
// Пять запусков подряд на go1.27 дали:
// 236884 224124 239573 224828 231667
}
Три способа починить
// 1. Мьютекс: универсально
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.n++
c.mu.Unlock()
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n // читать тоже под локом
}
// 2. Atomic: быстро для одного слова
type Counter struct {
n atomic.Int64 // Go 1.19: типизированный
}
func (c *Counter) Inc() {
c.n.Add(1) // одна инструкция LOCK XADDQ
}
func (c *Counter) Value() int64 {
return c.n.Load()
}
// Не годится, когда надо менять
// два поля согласованно.
// 3. Канал: состоянием владеет одна горутина, остальные шлют ей сообщения
type Counter struct {
inc chan struct{}
read chan chan int
done chan struct{}
}
func NewCounter() *Counter {
c := &Counter{
inc: make(chan struct{}, 256), // буфер гасит всплески, но Value может
// не увидеть Inc, которые ещё лежат в буфере
read: make(chan chan int),
done: make(chan struct{}),
}
go func() {
n := 0 // состояние живёт только здесь
for {
select {
case <-c.inc:
n++
case reply := <-c.read:
reply <- n
case <-c.done:
return
}
}
}()
return c
}
func (c *Counter) Inc() { c.inc <- struct{}{} }
func (c *Counter) Value() int { r := make(chan int); c.read <- r; return <-r }
func (c *Counter) Close() { close(c.done) }
| Способ | Цена одного Inc (M4 Pro, замер в вопросе 1) | Плюсы | Минусы |
|---|---|---|---|
| Mutex | около 2 нс без конкуренции, около сотни нс под конкуренцией | работает для любого состояния, читается всеми, легко расширить на два поля | под высокой конкуренцией - парковка горутин и переключения |
| Atomic | почти как мьютекс без конкуренции, под конкуренцией десятки нс - борьба за кеш-линию | самый быстрый, без парковки, годится для метрик и флагов | только одно слово; согласованность двух полей не обеспечивает |
| Канал | десятки нс на сообщение, под конкуренцией - сотни | состояние в одной горутине, естественно расширяется до сложной логики | дороже на порядок, лишняя горутина, надо не забыть её остановить |
Счётчик или флаг - atomic. Структура из нескольких полей, которые меняются
вместе, - Mutex. Состояние, вокруг которого есть логика и очередь событий
(кеш, менеджер соединений, аккумулятор батча), - горутина-владелец с каналом. Канал ради
одного счётчика - это оверинжиниринг, и на собесе за такой ответ спросят «а зачем так
дорого?».
Защитили запись и забыли чтение. Inc() под мьютексом, а
return c.n без него - это по-прежнему data race, и детектор его найдёт.
То же со старыми функциями atomic: пишете через AddInt64, а читаете обычным
c.n (без LoadInt64) - гонка. Типизированный atomic.Int64 так прочитать
не даст: это ошибка компиляции. Правило: атомарность - свойство
всех обращений к переменной, а не одних записей.
Race detector: что происходит под капотом
-race - это не статический анализ и не эвристика «два потока трогают
переменную». Это динамический детектор на базе ThreadSanitizer, который прямо на ходу строит отношение
happens-before и проверяет каждый доступ к памяти на упорядоченность.
Три составные части
- Инструментация. С флагом
-raceкомпилятор вставляет перед каждым чтением и записью памяти (кроме локальных переменных на своём стеке) вызовraceread/racewrite, для структур и массивов - их range-варианты. Плюс рантайм сам сообщает детектору обо всех точках синхронизации:Lock/Unlock, отправка и приём в канале,WaitGroup.Wait,atomic-операции, запуск и завершение горутины. - Векторные часы. Способ ответить на вопрос «что горутина A знает о том, как далеко
продвинулась горутина B» - без всяких настоящих часов и без сравнения моментов времени.
Единица измерения тут - эпоха: просто счётчик шагов, который горутина увеличивает
на каждой своей точке синхронизации. У каждой горутины - вектор из таких счётчиков, по одному на
каждую известную горутину. Собственная компонента растёт после каждой такой точки. При
синхронизации часы передаются:
Unlockсохраняет вектор в мьютексе,Lockберёт поэлементный максимум со своим. Так формально фиксируется «всё, что было до Unlock, произошло раньше всего, что после Lock». - Теневая память. Отдельная служебная область, в которой детектор ведёт досье на память самой программы: не значения, а «кто и на каком своём шаге к этому месту прикасался». На каждые 8 байт памяти программы заводится несколько теневых ячеек, в каждой - id горутины, её эпоха на момент доступа, размер обращения и флаг чтение/запись. Новый доступ сравнивается с тем, что лежит в теневых ячейках: если эпоха предыдущего писателя больше, чем то, что мой вектор знает про эту горутину, значит порядок между нами не установлен - гонка.
Детектор работает через happens-before, а не через «поймал одновременность». Значит,
ему не нужно, чтобы гонка реально проявилась: хватит, чтобы обе конкурирующие
операции выполнились хотя бы по разу, в любом порядке и с любым разрывом во времени. Именно поэтому
-race находит гонки, которые падают в проде раз в месяц. Но только если тест
прошёл по обеим веткам кода.
Почему ловит не всё
- Только выполненные пути. Не покрытая тестом ветка инструментирована, но ни разу
не выполнилась, и её гонки невидимы. Значит, ценность
-raceпрямо пропорциональна покрытию и разнообразию нагрузки. - Только реально запущенные горутины. Если в тесте всего один воркер, а в проде их
сто, конкуренции не возникнет и сравнивать будет нечего. Поэтому конкурентные тесты
пишут с
-raceи-count=10, с реальным числом горутин, иногда сGOMAXPROCS>1явно. - Ёмкость теней и истории. Теневых ячеек на слово всего четыре, старые
вытесняются. Стек прошлого доступа детектор восстанавливает из истории горутины: если
между двумя доступами она успела сделать десятки тысяч других обращений к памяти, гонка
молча не попадёт в отчёт, пока не поднимешь
history_size. А лимита в 8128 живых горутин из старых статей с Go 1.19 (ThreadSanitizer v3) больше нет. - Гонки вне Go. Cgo-код, ассемблер без инструментации, память, которую трогает сторонняя C-библиотека, детектору не видны.
- Race condition он не ловит принципиально - только data race.
Цена
| Ресурс | Множитель | Почему |
|---|---|---|
| CPU / время | ×2–20, обычно ×5–10 | вызов в детектор перед каждым обращением к памяти плюс поиск по теням |
| Память | ×5–10 | теневая карта пропорциональна размеру используемой памяти |
| Размер бинаря | заметно больше | инструментация в каждой функции |
| Поведение | таймауты «плывут» | тесты, завязанные на реальное время, начинают моргать |
# стандартный набор в CI
go test -race ./... # обязательная ступень пайплайна
go test -race -count=5 ./internal/worker # многократный прогон конкурентных тестов
go build -race -o app-race ./cmd/app # бинарь для стенда, не для прода
# настройка детектора
GORACE="halt_on_error=1 history_size=4 log_path=/tmp/race" go test -race ./...
# halt_on_error=1: падать на первой гонке (в CI удобно)
# history_size: сколько прошлых доступов помнит горутина, больше = меньше пропусков, дороже
Гонка не воспроизводится по требованию: зависит от числа ядер, нагрузки, версии Go
и фазы луны. В проде она вылезает не «неправильным числом», а порчей внутренних структур:
fatal error: concurrent map read and map write, битый интерфейс, паника
в рантайме, которую не привязать к исходной строке. Детектор превращает невоспроизводимый
инцидент в детерминированный отчёт с двумя стеками, того, кто писал, и того, кто читал.
Больше в Go так не умеет никто, а стоит это одного шага в пайплайне. В прод бинарь
с -race не катят: цена по CPU и памяти слишком высока. Исключение - короткий
эксперимент на канареечном поде.
# так выглядит отчёт: два стека доступа и где запущена каждая из двух горутин
==================
WARNING: DATA RACE
Write at 0x00c000012188 by goroutine 9:
main.(*Counter).Inc()
/app/counter.go:14 +0x88
main.main.func2()
/app/main.go:22 +0x68
Previous read at 0x00c000012188 by goroutine 7:
main.(*Counter).Value()
/app/counter.go:19 +0x74
main.main.func1()
/app/main.go:16 +0x80
Goroutine 9 (running) created at:
main.main()
/app/main.go:19 +0x1a4
Goroutine 7 (finished) created at:
main.main()
/app/main.go:14 +0x100
==================
Дедлок: определение и четыре условия
Дедлок - набор горутин ждёт друг друга по кругу: каждая заблокирована на событии, произвести которое может только другая горутина из этого же набора. Ни одна не сдвинется, и никакое внешнее событие ситуацию не расшивает. Отличие от «просто заблокированной горутины» ровно одно: у дедлока принципиально нет события, которое его разбудит.
Классические условия Коффмана - четыре одновременно необходимых признака. Ломать дедлок можно, убрав любое из них, и в Go на практике ломают в основном четвёртое.
| Условие | Что значит | Как ломают в Go |
|---|---|---|
| Взаимное исключение | ресурс нельзя держать вдвоём | почти никак: в этом смысл мьютекса. Разве что заменить на atomic / immutable-копию / шардирование |
| Удержание и ожидание | горутина держит один лок и просит второй | брать все локи разом (один более крупный лок), либо не звать чужой код под локом |
| Отсутствие вытеснения | лок нельзя отобрать силой | TryLock + откат и повтор, таймауты, semaphore.Weighted.Acquire(ctx, n) |
| Циклическое ожидание | A ждёт B, B ждёт A | глобальный порядок захвата локов - основной рабочий приём |
Код, который дедлочится гарантированно
Просто взять два мьютекса в разном порядке мало: если горутины не пересеклись по времени, программа отработает. Чтобы дедлок случался каждый запуск, окно между двумя захватами приходится разводить руками.
func main() {
var muA, muB sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
go func() { // G1: порядок A -> B
defer wg.Done()
muA.Lock()
time.Sleep(50 * time.Millisecond) // окно: даём G2 взять muB
muB.Lock() // ждёт вечно: muB у G2
muB.Unlock()
muA.Unlock()
}()
go func() { // G2: порядок B -> A <-- зеркальный
defer wg.Done()
muB.Lock()
time.Sleep(50 * time.Millisecond)
muA.Lock() // ждёт вечно: muA у G1
muA.Unlock()
muB.Unlock()
}()
wg.Wait()
}
// Реальный вывод go1.27 (в stderr, сокращённый; exit status 2):
//
// fatal error: all goroutines are asleep - deadlock!
//
// goroutine 1 [sync.WaitGroup.Wait]:
// sync.runtime_SemacquireWaitGroup(...)
// sync.(*WaitGroup).Wait(0x...)
// main.main()
//
// goroutine 6 [sync.Mutex.Lock]:
// internal/sync.runtime_SemacquireMutex(...)
// internal/sync.(*Mutex).lockSlow(0x...)
// main.main.func1()
// created by main.main in goroutine 1
//
// goroutine 7 [sync.Mutex.Lock]:
// main.main.func2()
// created by main.main in goroutine 1
//
// Читать надо состояние в квадратных скобках: у main это ожидание WaitGroup
// (до Go 1.24 та же строка выглядела как [semacquire]), а у двух рабочих Mutex.Lock.
// Номера горутин не 2 и 3, потому что рантайм заводит служебные горутины раньше.
Минимальные однострочники, которые тоже валят рантайм и которые любят просить написать «за десять секунд»:
// 1. отправка в небуферизованный канал,
// читателя нет
func main() {
ch := make(chan int)
ch <- 1
}
// 2. рекурсивный Lock: у Mutex
// нет владельца и нет реентрантности
func main() {
var mu sync.Mutex
mu.Lock()
mu.Lock()
}
// 3. Add без Done
func main() {
var wg sync.WaitGroup
wg.Add(1)
wg.Wait()
}
// 4. пустой select: вечный сон
// без единого готового кейса
func main() {
select {}
}
Как рантайм детектит дедлок и когда не детектит
Знаменитое fatal error: all goroutines are asleep - deadlock! печатает
функция checkdead() в планировщике. И это не детектор дедлоков. Рантайм
не строит граф ожидания и не разбирает, кто кого ждёт. Он проверяет одно глобальное
условие: «во всей программе не осталось ни одного потока, который мог бы когда-нибудь
получить работу».
checkdead вызывается, когда последний рабочий поток M собирается
уйти в сон, и считает три числа: сколько есть runnable-горутин, сколько
M сидит в системных вызовах и есть ли активные таймеры. Если работы нет
нигде, а живые горутины при этом заблокированы (_Gwaiting) - значит,
разбудить их некому, и рантайм честно падает, печатая стеки.
- Частичный дедлок. Пять горутин намертво стоят на двух мьютексах, а
mainобслуживает HTTP. Программа жива, метрики зелёные, а часть функционала просто не работает. Это основной случай в проде, и рантайм тут молчит навсегда. - Есть живой таймер. Горутина спит в
time.Sleep, ждётtime.AfterилиTicker- это потенциальная будущая работа, рантайм считает прогресс возможным. Таймер, из канала которого никто не читает, с Go 1.23 не в счёт. Отсюда фокус: добавьте в дедлочащуюся программуgo func(){ for { time.Sleep(time.Second) } }()- и она просто зависнет вместо падения. - Кто-то крутится в цикле.
for {}, busy-wait на atomic, спиннинг - горутина runnable, условие не выполнено. - Блокировка в syscall или netpoll. Ожидание сокета, чтение файла, вызов в cgo: рантайм считает, что данные могут прийти извне. Поэтому сервер, зависший на ответе от БД, никогда не «задедлочится» с точки зрения Go.
- Дедлок на внешнем ресурсе. Взаимная блокировка в Postgres, распределённый лок
в Redis, файловый
flock- для рантайма это обычный сетевой вызов.
«Рантайм детектит не дедлок, а полное отсутствие прогресса во всей программе: нет runnable горутин, нет активных таймеров, никто не сидит в syscall. Это работает в учебных примерах и в тестах, но практически никогда не срабатывает в реальном сервисе - там всегда есть netpoll и тикеры. Реальные дедлоки в проде ловят не рантаймом, а goroutine-дампом.»
Диагностика зависания в проде
| Инструмент | Как снять | Что видно |
|---|---|---|
| SIGQUIT | kill -QUIT <pid> или Ctrl+\ | стеки всех горутин и аварийное завершение процесса. Работает без импортов и эндпоинтов, если процесс сам не перехватил SIGQUIT через signal.Notify |
| pprof goroutine | curl host/debug/pprof/goroutine?debug=2 | то же самое, включая время ожидания у стоящих дольше минуты, но без убийства процесса |
| pprof, агрегат | ?debug=1 или go tool pprof | сгруппировано по стекам: «7412 горутин стоят вот на этой строке» |
| goroutineleak | curl host/debug/pprof/goroutineleak?debug=1 (Go 1.27) | только те горутины, которые заблокированы навсегда: примитив, на котором они стоят, уже недостижим. Отделяет утечку от «просто долго ждёт» |
| mutex profile | runtime.SetMutexProfileFraction(5) | кто держал мьютекс, пока другие ждали, и сколько они прождали - контеншен, ещё не дедлок |
| block profile | runtime.SetBlockProfileRate(1e6) | блокировки на каналах, select, WaitGroup, семафорах |
| метрика | runtime.NumGoroutine() в Prometheus | монотонный рост - утечка; резкая полка - что-то встало |
| метрики планировщика | runtime/metrics: /sched/goroutines/* и /sched/threads/total (Go 1.26), /sched/latencies | сколько горутин реально исполняется, сколько готово, сколько ждёт, сколько сидит в syscall или cgo - и сколько потоков ОС завёл рантайм |
| delve | dlv attach <pid>, затем goroutines -t | то же плюс возможность посмотреть переменные в кадре |
# 1. дамп без остановки сервиса, с него и начинают
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' > /tmp/g.txt
# 2. кто где стоит: топ состояний
grep -oP '^goroutine \d+ \[\K[^\]]+' /tmp/g.txt | sort | uniq -c | sort -rn
# 7412 chan receive, 43 minutes <-- утечка или зависший конвейер
# 12 sync.Mutex.Lock, 43 minutes <-- вот он, дедлок
# 3 IO wait
# 3. SIGQUIT, когда pprof не подключён (стеки в stderr, процесс умирает)
kill -QUIT $(pgrep app) # все горутины, включая служебные; GOTRACEBACK=all для этого не нужен
Три сигнала. Первый - время в квадратных скобках: [chan receive, 43 minutes]
означает, что горутина стоит 43 минуты; всё, что дольше разумного таймаута запроса, уже
подозреваемый. Второй - много одинаковых стеков: тысячи горутин на одной строке это
всегда утечка или незакрытый канал. Третий - две горутины в sync.Mutex.Lock,
чьи стеки берут одни и те же локи в разном порядке: это и есть дедлок, и порядок захвата
виден прямо в кадрах стека.
С Go 1.27 в дампе появились ещё две подсказки. Первая: метки горутин из
runtime/pprof печатаются прямо в заголовке горутины (если в go.mod стоит go 1.27
или новее) - если вы навешиваете
pprof.SetGoroutineLabels с идентификатором запроса, строка выглядит как
goroutine 5 [chan receive, 43 minutes] {request_id: "abc-123", route: /orders},
и зависшая горутина сразу привязывается к конкретному запросу вместо гадания по стеку
(отключается GODEBUG=tracebacklabels=0). Вторая: горутины, которые рантайм
счёл утёкшими, когда снимали профиль утечек, помечены словом (leaked) в состоянии -
[chan receive (leaked)].
Стратегии предотвращения
- Глобальный порядок захвата. Все локи в системе нумеруются (например, по адресу, по имени, по слою), и захватывать их можно только по возрастанию. Циклическое ожидание становится невозможным. Это гарантия, а не снижение вероятности, и она работает даже там, где без нескольких локов сразу не обойтись.
- Не звать чужой код под локом. Колбэк, метод интерфейса, обращение в другой сервис под мьютексом - источник дедлоков, потому что вы не знаете, какие локи возьмут внутри. Правило: под локом только работа со своими полями.
- Один лок вместо двух. Часто два мьютекса заводят «для гранулярности», а в профиле никакого контеншена на них нет. Один более крупный лок проще и безопаснее.
- Копировать данные наружу. Взять лок, скопировать нужное в локальную переменную, отпустить, дальше работать без лока. Критическая секция в две строки почти не может участвовать в дедлоке.
- Таймауты вместо вечного ожидания. На каналах -
selectсctx.Done(); на семафоре -semaphore.Weighted.Acquire(ctx, n); в БД -lock_timeoutиstatement_timeout. Дедлок превращается в ошибку, которую видно в логах и метриках. TryLock(Go 1.18+) - взять лок, если свободен, иначе сразу вернуться. Позволяет откатиться и повторить. В документации стоит прямое предупреждение: правильное применение редко, и часто это признак проблемы в дизайне. Без рандомизированного бэкоффа легко получить livelock вместо дедлока.
// Порядок захвата по адресу: перевод между счетами, оба под локом.
// Без сортировки Transfer(a,b) и Transfer(b,a) дедлочатся на первой же коллизии.
func Transfer(from, to *Account, sum int) error {
if from == to {
return nil // иначе второй Lock того же мьютекса - дедлок
}
first, second := from, to
if uintptr(unsafe.Pointer(first)) > uintptr(unsafe.Pointer(second)) {
first, second = second, first // всегда меньший адрес первым
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
if from.balance < sum {
return errNotEnough
}
from.balance -= sum
to.balance += sum
return nil
}
// Вариант с откатом: держим первый лок, второй пробуем; не вышло, отпускаем всё.
func TransferTry(from, to *Account, sum int) error {
for attempt := 0; attempt < 100; attempt++ {
from.mu.Lock()
if to.mu.TryLock() {
defer from.mu.Unlock()
defer to.mu.Unlock()
return apply(from, to, sum)
}
from.mu.Unlock() // перед повтором обязательно отпускаем
// рандом здесь не для красоты: без него две горутины входят в livelock
time.Sleep(time.Duration(rand.Intn(1<<min(attempt, 10))) * time.Microsecond) // потолок ~1 мс
}
return errBusy
}
Livelock и starvation
Оба слова описывают состояние «прогресса нет», но ни одно из них не равно дедлоку, и между собой они тоже разные. Livelock (дословно «живая блокировка») - горутины вовсе не спят: они замечают конфликт, вежливо откатываются, пробуют снова и попадают в тот же конфликт, потому что делают это синхронно, в такт друг с другом. Работа кипит, процессор загружен на сто процентов, полезного результата ноль. Бытовая аналогия - двое в узком коридоре, которые одновременно шагают в одну и ту же сторону, чтобы разойтись, и снова оказываются нос к носу. Аналогия честная ровно до одного места: люди через пару раз сообразят и один остановится, а код без рандомизации задержки будет повторять это до конца процесса.
Starvation (голодание) - случай мягче: система в целом отлично прогрессирует, метрики зелёные, но конкретная горутина раз за разом проигрывает конкуренцию за ресурс и не продвигается. От дедлока отличается тем, что горутина могла бы работать в любой момент - ей просто не дают; от livelock - тем, что страдает не вся система, а невезучий участник, и видно это не по CPU, а по хвосту латентности.
| Дедлок | Livelock | Starvation | |
|---|---|---|---|
| Состояние горутин | заблокированы, _Gwaiting | активно работают, жгут CPU | runnable, но не получают ресурс |
| Прогресс | нулевой навсегда | нулевой, но «работа кипит» | есть у системы, нет у конкретной горутины |
| Как выглядит | 0% CPU, всё встало | 100% CPU, ничего не меняется | хвост латентности p99 улетает в небо |
| Типичная причина | циклическое ожидание локов | TryLock+повтор в такт, вежливые уступки | несправедливая очередь, писатель под потоком читателей |
| Лечение | порядок локов, таймауты | рандомизированный экспоненциальный бэкофф | честные очереди, ограничение читателей, приоритеты |
В самом Go со starvation борется sync.Mutex: если горутина не может получить
лок дольше 1 мс, мьютекс переключается в starvation mode и начинает передавать
владение строго по очереди FIFO, отключив спиннинг новых претендентов. Это лечит худший
случай ценой пропускной способности. У sync.RWMutex защита другая: как только
писатель встал в очередь, новые RLock() блокируются - иначе бесконечный поток
читателей никогда не пустил бы писателя. Побочный эффект известен: рекурсивный
RLock в этот момент дедлочится.
Утечки горутин
Горутина - это объект в куче: от 2 КБ стека на старте (растёт по мере надобности), запись в allgs, работа для
GC при каждом цикле. Горутина, которая никогда не завершится, - это утечка памяти, а вместе
с ней обычно утекает и то, что она держит: соединение, буфер, замыкание с большим объектом.
Сотня таких в секунду - и через сутки процесс уходит в OOM.
| Сценарий | Код-запах | Починка |
|---|---|---|
| Никто не читает из канала | горутина шлёт результат в небуферизованный канал, а получатель ушёл по таймауту | буфер размером 1 на канал результата, либо select с ctx.Done() на отправке |
Забыли cancel() | ctx, _ := context.WithCancel(parent) | defer cancel() всегда; go vet ловит это правилом lostcancel |
| range по каналу, который не закрыли | продюсер упал по ошибке, не дойдя до close | defer close(out) у владельца канала |
| Бесконечный цикл без выхода | for { select { case v := <-in: ... } } без ctx.Done() | обязательная ветка case <-ctx.Done(): return |
| Тикер без Stop | t := time.NewTicker(d) и выход из функции | defer t.Stop(); до Go 1.23 незастопленный тикер жил вечно |
| Тело ответа не закрыто | http.Get без defer resp.Body.Close() | закрывать всегда, даже если тело не читаете (плюс io.Copy(io.Discard, body)) |
| WaitGroup ждёт вечно | Add(n), а одна ветка вышла раньше Done() | defer wg.Done() первой строкой; в Go 1.25 - wg.Go(...) |
| Подписка без отписки | горутина на канале брокера/подписчика, которого забыли отменить | владелец подписки закрывает её в defer |
// Классическая утечка: таймаут у вызывающего, вечная блокировка у воркера
func leaky(ctx context.Context) (int, error) {
ch := make(chan int) // небуферизованный
go func() {
ch <- slowWork() // если ctx истёк, получателя нет и горутина стоит вечно
}()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return 0, ctx.Err() // ушли, горутина осталась. И так на каждый таймаут.
}
}
// Починка 1 (самая простая): буфер 1, отправка всегда проходит, горутина завершится
func fixedBuffer(ctx context.Context) (int, error) {
ch := make(chan int, 1)
go func() { ch <- slowWork() }()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
// Починка 2 лучше: работа сама отменяется и не жжёт CPU впустую
func fixedCancellable(ctx context.Context) (int, error) {
ch := make(chan int, 1)
go func() { ch <- slowWorkCtx(ctx) }() // slowWorkCtx уважает ctx.Done()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
// В тестах: goleak падает, если после теста остались лишние горутины
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
// известные вечные горутины библиотек заглушают явно, поимённо
goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start"),
)
}
func TestWorker(t *testing.T) {
defer goleak.VerifyNone(t) // проверка на уровне одного теста
...
}
// В проде: метрика + алерт на монотонный рост
prometheus.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{Name: "go_goroutines_custom"},
func() float64 { return float64(runtime.NumGoroutine()) },
))
goroutineleak - утечки теперь ищет сам рантайм
Раньше утечку выводили косвенно: «горутин стало больше, чем было», «тысяча горутин
застряла на одной строке - подозрительно». Отличить настоящую утечку от горутины, которая
просто долго ждёт по делу, инструменты не умели. Это делал глазами человек, глядя в дамп.
В Go 1.26 появился экспериментальный профиль goroutineleak, а в
Go 1.27 он стал общедоступным: лежит в pprof.Profiles() рядом с
goroutine, heap и block, отдаётся по
/debug/pprof/goroutineleak, если импортирован net/http/pprof.
Критерий тут не эвристический, а точный. Горутина считается утёкшей, если она
заблокирована на примитиве синхронизации (канал, WaitGroup, семафор),
до которого уже никто не может дотянуться. Считает сборщик мусора: не осталось
на этот канал ни одной ссылки ни из одной работающей горутины (и тех, кого они могут
разбудить), значит разбудить ждущего некому, и он не «ждёт», а висит навсегда. Отсюда и
граница применимости. Горутина, застрявшая на живом и достижимом канале (скажем, он лежит
в глобальной переменной), в профиль не попадёт: для рантайма прогресс там ещё возможен.
// В коде как любой другой профиль
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
// goroutineleak profile: total 2
// 1 @ 0x... 0x... 0x...
// # 0x... main.leakChan+0x2f /app/main.go:11
// По HTTP, если импортирован net/http/pprof:
// curl 'http://127.0.0.1:6060/debug/pprof/goroutineleak?debug=1'
Две практические детали, которые видно только при запуске. Первая: снятие профиля
запускает принудительный цикл GC - в GODEBUG=gctrace=1 он так и помечен,
(checking for goroutine leaks). Это не бесплатно, поэтому дёргать его на каждый
скрейп метрик не надо: место такому профилю рядом с heap-профилем, по требованию или по алерту.
Вторая: Count() у этого профиля до первого WriteTo возвращает
ноль - анализ выполняется в момент снятия, а не ведётся постоянно. Полагаться можно
только на снятый профиль. Заодно после снятия у найденных горутин и в обычном дампе
появляется пометка прямо в заголовке: goroutine 35 [chan receive (leaked)].
У каждой запущенной горутины должен быть явный ответ на вопрос «что её
завершит». Ответов ровно три: закроется входной канал, сработает ctx.Done(),
закончится конечная работа. Не подходит ни один? Утечка. Чинить надо сейчас, а не когда
вырастет график. И зеркальное правило: кто запустил горутину, тот обязан уметь её
дождаться, через WaitGroup, errgroup или канал завершения.
Вопросы
10counter++ - это load/add/store, три шага, между
которыми может влезть кто угодно. Чинится мьютексом (универсально), atomic (быстро,
одно слово) или горутиной-владельцем с каналом (когда вокруг состояния есть логика).Как отвечать по шагам
- Назвать проблему точно. «
counter++не атомарен: по сути это чтение из памяти, инкремент и запись обратно (на arm64 три инструкции, на amd64 одна INCQ, но без префикса LOCK и она не атомарна). Две горутины читают одно и то же значение, обе прибавляют единицу, обе записывают - одно обновление потеряно. Это lost update.» - Показать масштаб. «На 1000 горутин по 1000 инкрементов вместо миллиона получится случайное число, и чем больше ядер, тем оно меньше: на двух ядрах около 64% от ожидаемого, на 14 ядрах около 22%. И оно разное от запуска к запуску.»
- Доказать инструментом.
go run -race main.goи показатьWARNING: DATA RACEс двумя стеками. - Починить и объяснить выбор. Три варианта из теории выше, и главное тут критерий выбора, а не список из трёх пунктов.
Компактный сравнительный бенчмарк
// go test -bench=. -cpu=1,8 -benchmem (M4 Pro, go1.27; канал - горутина-владелец)
// BenchmarkMutex 1.898 ns/op BenchmarkMutex-8 122.9 ns/op
// BenchmarkAtomic 1.697 ns/op BenchmarkAtomic-8 31.58 ns/op
// BenchmarkChannel 51.03 ns/op BenchmarkChannel-8 163.5 ns/op
// (при -cpu=1 суффикса -1 у имени нет; 0 B/op и 0 allocs/op опущены)
//
// Порядок цифр важнее самих цифр: без конкуренции мьютекс почти не дороже atomic
// (быстрый путь Lock и Unlock - пара атомарных операций), а канал дороже на порядок.
// Под конкуренцией всё дорожает: atomic вчетверо дешевле мьютекса, канал ненамного
// дороже него. Atomic упирается в кеш-когерентность (одна кеш-линия на все ядра),
// а мьютекс в парковку/пробуждение горутин.
Если интервьюер скажет «а atomic на 64 ядрах всё равно медленный, что делать» -
правильный ответ: шардировать. Массив из N счётчиков, каждый в
своей кеш-линии (padding до 64 байт на x86, до 128 на Apple Silicon), горутина пишет
в шард по своему индексу, Value() суммирует все. Так устроен
xsync.Counter (по образцу LongAdder из Java), так же по P разложен
sync.Pool внутри.
Цена - Value() перестаёт быть мгновенным и точным снимком.
Упомянуть, что «починить гонку» и «сделать логику правильной» - разные задачи.
atomic.AddInt64 делает инкремент атомарным, но если бизнес-логика -
«прочитать, проверить лимит, увеличить», то нужен CompareAndSwap в цикле
или мьютекс: атомарность одной операции не даёт атомарности последовательности.
-race, второе - только голова.Два примера, которые стоит держать наготове
// A. Data race без очевидной race condition в бизнес-смысле:
// флаг, который просто «когда-нибудь» станет true.
var done bool // без sync
go func() { done = true }()
for !done { } // может крутиться вечно
// Почему вечно: компилятор имеет право поднять чтение done из цикла в регистр,
// потому что в отсутствие синхронизации он вправе считать, что никто извне
// переменную не меняет. Go 1.27 так не делает и перечитывает done на каждом витке,
// но модель памяти Go прямо пишет, что такой цикл не обязан завершиться.
// Правильно: atomic.Bool, канал или sync.Once.
// B. Race condition без data race: вся память под мьютексом, логика всё равно неверна.
if !cache.Has(key) { // Has() внутри под RLock, data race нет
v := expensiveLoad(key) // между Has и Set влезли ещё 50 горутин
cache.Set(key, v) // и все 50 сделали expensiveLoad
}
// Это TOCTOU (time-of-check to time-of-use). Чинится не мьютексом,
// а объединением проверки и действия: singleflight или LoadOrStore.
Формальное определение из Go Memory Model
«Программа с гонкой данных - это программа, в которой есть обращение на запись к переменной памяти, конкурентное с другим обращением к той же переменной, и хотя бы одно из них не является атомарным.» Конкурентные - значит не упорядоченные отношением happens-before. С 1.19 модель Go явно ограничивает и поведение при гонке: чтение значения размером в машинное слово вернёт одно из значений, записанных предшествующей или конкурентной записью, а не что-то «из воздуха». Для значений больше слова (интерфейс, строка, слайс) такой гарантии нет, и гонка на них может закончиться порчей памяти. Но это всё равно не C++, где UB тотальный.
На 64-битных платформах запись машинного слова с выравниванием на практике атомарна,
и int64 «порваться» не может. А вот интерфейс, строка и слайс - могут:
это два или три машинных слова (данные + тип, данные + длина, данные + длина + ёмкость).
Конкурентная запись двух разных значений в одну переменную типа error
способна дать указатель на данные от одного значения и дескриптор типа от другого -
и следующий вызов метода уронит процесс. Это самое убедительное объяснение, почему
«ну подумаешь, гонка на присваивании» - не аргумент.
«Гонка на map - это data race?» Да, и рантайм её ловит отдельно, без
-race: у map есть флаг записи (с Go 1.24 поле writing, раньше бит
hashWriting), и при конкурентной записи программа падает с
fatal error: concurrent map writes (при чтении во время записи - с
concurrent map read and map write).
Не паника, а fatal error, и перехватить его через recover
нельзя. Причина такой жёсткости: гонка на map ломает внутренние структуры,
и продолжать выполнение опаснее, чем упасть.
Что говорить, если просят «в общих чертах»
«Каждая горутина носит вектор часов - по счётчику на каждую известную горутину.
Синхронизация (Unlock/Lock, отправка/приём в канале,
atomic) передаёт эти часы от одной горутины к другой через максимум.
Каждые 8 байт памяти сопровождаются несколькими теневыми ячейками, где записано
"горутина G, эпоха E, чтение/запись, размер". При новом доступе детектор сверяет
эпоху из тени со своим знанием о той горутине: если моя копия часов уже
"видит" ту эпоху - доступы упорядочены, всё в порядке. Если нет - это гонка,
и в отчёт печатаются оба стека.»
Три следствия, которые редко проговаривают
- Гонке не нужно проявиться. Достаточно, чтобы обе операции выполнились хоть
раз, пусть даже с разрывом в минуты. Поэтому
-raceнаходит то, что в проде падает раз в квартал. - Ложных срабатываний практически не бывает. Отчёт детектора - это доказательство, а не подозрение. Если он что-то напечатал, это надо чинить, а не «перепроверять».
- Он проверяет и стандартную библиотеку. Гонки внутри неё
(неправильное использование
http.Request, запись вhttp.ResponseWriterиз горутины после выхода из хендлера) находятся тем же способом.
Где искать, что -race не найдёт
| Не найдёт | Почему | Чем ловить |
|---|---|---|
| race condition (TOCTOU, потерянное обновление на уровне логики) | машина не знает бизнес-смысла | ревью, тесты на инварианты, testing/synctest |
| гонки в непокрытом коде | инструментация работает только на выполненных путях | покрытие, нагрузочные тесты, -count=N |
| гонки, требующие много горутин | в тесте один воркер - конкуренции нет | тест с реальным числом воркеров и -cpu=8 |
| cgo, ассемблер, unsafe-трюки | вне зоны инструментации | санитайзеры на стороне C, ревью |
| очень давние доступы | история обращений горутины конечна: если между двумя доступами она сделала десятки тысяч других, гонка молча не попадёт в отчёт | GORACE=history_size=7 (дороже, но глубже) |
go test -race ./... - обязательный шаг CI, не опциональный.
Конкурентные тесты гоняют с -count=5..10 и явным -cpu.
В прод бинарь с -race не катят (память ×5–10, время ×2–20), исключение -
канареечный под на короткий эксперимент, когда гонка не воспроизводится нигде,
кроме прода. GORACE="halt_on_error=1" удобно в CI, чтобы падать
на первой находке.
time.Sleep между захватами, чтобы
окно точно совпало.Ключ к «гарантированно»
Формулировка вопроса не случайна: без принудительного окна код с двумя мьютексами
дедлочится вероятностно и на маленьком примере может отработать сто раз подряд.
Правильный ответ содержит time.Sleep (или runtime.Gosched(),
или синхронизацию на канале) между первым и вторым Lock - это делает
совпадение окон детерминированным. Проговорить это вслух - половина оценки за вопрос.
// Вариант с каналом вместо Sleep: строгая гарантия, без гонки по времени.
func main() {
var muA, muB sync.Mutex
ready := make(chan struct{}) // «я взял свой первый лок»
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
muA.Lock()
ready <- struct{}{} // сообщил
<-ready // дождался встречного
muB.Lock() // оба лока заняты крест-накрест
muB.Unlock(); muA.Unlock()
}()
go func() {
defer wg.Done()
muB.Lock()
<-ready // дождался G1 и только потом ответил:
ready <- struct{}{} // шли бы обе первыми - встали бы на канале
muA.Lock()
muA.Unlock(); muB.Unlock()
}()
wg.Wait()
}
Дедлоки, которые встречаются в реальном коде
- Реентрантный вызов. Метод берёт лок и зовёт другой публичный метод того же
типа, который берёт тот же лок.
sync.Mutexне реентрантный - мгновенный самодедлок. Лечение: приватные методыxxxLocked(), которые лок не берут, и соглашение о суффиксе. - Колбэк под локом. Под мьютексом вызывается пользовательская функция, которая сама берёт этот же лок (например, обращается к тому же объекту).
RLockвнутриRLock. Выглядит безобидно (читатели же не мешают друг другу), но если между ними встал писатель - дедлок, потому чтоRWMutexблокирует новых читателей ради писателя.WaitGroup.Wait()раньше чтения. Воркеры пишут в небуферизованныйresults,mainсначалаwg.Wait(), потом читает - воркеры стоят на отправке, main стоит на Wait. Классика конвейеров.- Один канал на запрос и на ответ. Обе стороны шлют, обе ждут.
Написать defer muA.Unlock() и defer muB.Unlock() в цикле:
defer срабатывает в конце функции, а не итерации, и вторая итерация
попробует взять уже удерживаемый лок и самозадедлочится. Вторая ошибка: «починить» дедлок,
поменяв Lock на RLock. Он тоже блокирующий, просто реже
конфликтует, и проблема вернётся под нагрузкой.
checkdead() в планировщике проверяет
не дедлок, а полное отсутствие возможного прогресса: ни одной runnable-горутины,
ни одного потока в syscall, ни одного активного таймера. Частичный дедлок, тикеры,
busy-loop и сетевые ожидания это условие ломают - в проде оно почти никогда
не выполняется.Механика
Планировщик вызывает checkdead в момент, когда последний
«рабочий» M собирается уснуть (в stopm, при
mput). Сначала он считает неспящие потоки: все M минус
простаивающие, минус простаивающие, но привязанные к горутине через
LockOSThread, минус служебные вроде sysmon. Поток в системном вызове не
простаивает, и на нём проверка заканчивается. Если неспящих M нет, он
обходит все горутины и смотрит таймеры в кучах P. Таймеров нет, а живые
горутины есть и все ждут - значит, ждут события, которое некому произвести. Рантайм печатает
fatal error: all goroutines are asleep - deadlock!, дампит стеки
и завершает процесс с кодом 2.
Дополнительно в дампе у частных случаев уточнённое состояние: отправка в
nil-канал и приём из него дают [chan send (nil chan)] и
[chan receive (nil chan)], select без единого кейса -
[select (no cases)]. Это лишь подсказка, а не отдельный алгоритм.
Пять ситуаций, когда детектор молчит
| Ситуация | Почему условие не выполнено |
|---|---|
| Частичный дедлок - 10 горутин встали, остальные работают | есть runnable-горутины; программа «жива» |
Горутина ждёт тика time.Ticker или спит в Sleep | активный таймер = будущая работа; тикер, из которого никто не читает, с Go 1.23 не в счёт |
Busy-loop for {} или спиннинг на atomic | горутина в состоянии running |
| Ожидание сети / файла / cgo | M в syscall, данные могут прийти извне |
| Дедлок в БД или в распределённом локе | с точки зрения Go это обычный сетевой вызов |
// Один и тот же дедлок: с падением и без.
func main() {
ch := make(chan int)
<-ch // fatal error: all goroutines are asleep
}
// stderr, go1.27:
// fatal error: all goroutines are asleep - deadlock!
//
// goroutine 1 [chan receive]:
// main.main()
// /tmp/x/main.go:6 +0x30
// exit status 2
// (вариант с отправкой в такой же канал отличается одним словом: [chan send])
func main() {
go func() { // «heartbeat», который живёт всегда
for range time.Tick(time.Second) {
}
}()
ch := make(chan int)
<-ch // зависнет молча: таймер = потенциальный прогресс.
} // Так ведёт себя любой реальный сервис.
// вывода нет вообще, ни в stdout, ни в stderr: процесс висит, пока его не убьют
// Это fatal error, а не паника, поэтому defer с recover не поможет.
func main() {
defer func() {
if r := recover(); r != nil { fmt.Println("поймали:", r) } // не выполнится никогда
}()
ch := make(chan int)
<-ch
}
// stderr: fatal error: all goroutines are asleep - deadlock! (строки «поймали:» нет,
// рантайм не разматывает стек и вообще не запускает отложенные функции)
Сказать, что детектор рантайма бесполезен в проде и полезен в тестах. В тестах
он превращает забытый Done() или незакрытый канал в громкое падение с
полными стеками. Именно поэтому конкурентные юнит-тесты пишут без фоновых тикеров,
а на «зависший» тест ставят -timeout: по таймауту тестовый бинарь,
собранный go test, сам падает с panic и печатает стеки всех горутин -
это второй способ увидеть
частичный дедлок.
/debug/pprof/goroutine?debug=2, жёстко - kill -QUIT
(процесс умрёт). Дальше сгруппировать горутины по состоянию и стеку и найти те,
что стоят дольше любого разумного таймаута.Порядок действий по инциденту
- Посмотреть метрики.
go_goroutines- полка или рост? CPU - ноль (дедлок) или сотка (livelock)? RPS упал в ноль или частично? - Снять дамп, не убивая под.
curl host:6060/debug/pprof/goroutine?debug=2 > dump.txt. Если pprof не подключён - снимать черезkill -QUITна поде, который не жалко, предварительно выведя его из балансировки. - Сгруппировать. Состояние указано в заголовке каждой горутины, а у стоящих
дольше минуты - и время:
goroutine 42 [sync.Mutex.Lock, 17 minutes]. - Найти пару. Для дедлока на локах характерны две-три группы, чьи стеки берут одни и те же мьютексы в разном порядке. Для утечки - одна разросшаяся группа с одинаковым стеком.
- Проверить внешние ресурсы. Если все висят в
IO wait/selectвокругdatabase/sql- это не дедлок Go, а исчерпанный пул соединений или дедлок в самой БД.
# Минимальный набор для прода: pprof на отдельном порту, не наружу
go func() { log.Println(http.ListenAndServe("127.0.0.1:6060", nil)) }()
# import _ "net/http/pprof"
# Снять и разобрать
curl -s '127.0.0.1:6060/debug/pprof/goroutine?debug=2' > dump.txt
curl -s '127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -40 # агрегат по стекам
# Топ состояний с временем ожидания
grep -o '\[[a-z A-Z.,0-9]*\]' dump.txt | sort | uniq -c | sort -rn | head
# 5120 [chan receive, 6 minutes]
# 25 [IO wait, 6 minutes]
# 2 [sync.Mutex.Lock, 6 minutes]
# 2 [IO wait]
# 1 [sleep, 6 minutes]
# 1 [running]
# Интерактивно, с деревом вызовов
go tool pprof -http=:8080 http://127.0.0.1:6060/debug/pprof/goroutine
# Контеншен и блокировки (включать точечно, они не бесплатны)
runtime.SetMutexProfileFraction(5) # /debug/pprof/mutex
runtime.SetBlockProfileRate(1_000_000) # /debug/pprof/block, 1 сэмпл на 1мс блокировки
В инциденте нет времени договариваться о доступе к поду. Рабочая схема: pprof слушает
на 127.0.0.1, рядом есть sidecar или kubectl port-forward,
а в runbook записана готовая строка curl. Дополнительно полезен автосбор:
по алерту «go_goroutines > N» хук сам складывает дамп в S3 - иначе
под перезапустится раньше, чем вы к нему подключитесь, и улика исчезнет.
Первое: net/http/pprof регистрирует хендлеры в
http.DefaultServeMux, и если основной сервер использует его же -
профили окажутся в публичном интернете. Второе: SIGQUIT, если процесс не перехватил
его сам, убивает процесс, поэтому в кубере под сначала выводят из балансировки. Третье:
debug=2 на 500 тысячах горутин упирается в потолок 64 МБ (дальше дамп
молча обрезается) и даёт заметные stop-the-world паузы на обход allgs;
на большом дампе безопаснее
debug=1.
ctx, TryLock с откатом и рандомным бэкоффом.Иерархия приёмов - от сильного к слабому
| Приём | Что даёт | Цена и риски |
|---|---|---|
| Не брать два лока вообще | дедлок невозможен по построению | иногда требует пересмотра модели данных |
| Глобальный порядок захвата | математическая гарантия: цикла в графе быть не может | нужна дисциплина и документация; порядок легко нарушить на ревью |
| Копировать данные наружу | критическая секция в 2 строки почти не участвует в дедлоках | копия может устареть - иногда это race condition |
| Не звать чужой код под локом | убирает целый класс неявных дедлоков | требует расщепления методов на xxxLocked |
Таймаут / ctx | дедлок становится наблюдаемой ошибкой в метриках | не лечит причину; надо решить, что делать при истечении |
TryLock + откат | ломает «отсутствие вытеснения» | без рандомного бэкоффа - livelock; в доке Go прямое предупреждение о редкости корректных применений |
Как выглядит документированная иерархия локов
// Порядок захвата в пакете storage (нарушение = дедлок):
// 1. Registry.mu (глобальный реестр)
// 2. Shard.mu (шард)
// 3. Entry.mu (запись)
// Захватывать только сверху вниз. Никогда не брать Registry.mu, держа Shard.mu.
type Shard struct {
mu sync.Mutex
m map[string]*Entry
}
// Публичный метод берёт лок и делегирует в *Locked-версию.
func (s *Shard) Get(k string) (*Entry, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.getLocked(k)
}
// Соглашение: суффикс Locked = «лок уже держит вызывающий, сам не бери».
func (s *Shard) getLocked(k string) (*Entry, bool) {
e, ok := s.m[k]
return e, ok
}
Таймаут вместо вечного ожидания
// У sync.Mutex нет LockContext. Если нужен лок с таймаутом,
// берут семафор на единицу или канал-мьютекс.
import "golang.org/x/sync/semaphore"
sem := semaphore.NewWeighted(1)
if err := sem.Acquire(ctx, 1); err != nil {
return fmt.Errorf("не дождались лока: %w", err) // ctx.Err(), а не вечный сон
}
defer sem.Release(1)
// Дешёвая альтернатива без зависимостей: буферизованный канал ёмкости 1.
type ChanMutex chan struct{}
func NewChanMutex() ChanMutex { return make(ChanMutex, 1) }
func (m ChanMutex) Lock(ctx context.Context) error {
select {
case m <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (m ChanMutex) Unlock() { <-m }
// Цена: без конкуренции на порядок дороже sync.Mutex (на M4 Pro 23 нс против 2).
// Берут только там, где отменяемость важнее наносекунд.
Забыть отпустить уже взятый лок перед повтором. Код
a.Lock(); if !b.TryLock() { continue } без a.Unlock()
превращает попытку избежать дедлока в гарантированный дедлок плюс утечку.
Вторая ошибка - повтор без задержки и без джиттера: две симметричные горутины
входят в идеальный такт и получают livelock, который выглядит как 100% CPU
и полное отсутствие прогресса.
Дедлоки на каналах - витрина случаев
// 1. Отправка без читателя (небуферизованный канал)
ch := make(chan int)
ch <- 1 // навсегда
// 2. Приём из канала, в который никто не пошлёт и который не закроют
<-make(chan int)
// 3. Полный буфер + потребитель, который ждёт продюсера
in := make(chan int, 2)
in <- 1; in <- 2
in <- 3 // буфер полон, потребитель ещё не стартовал
// 4. nil-канал: операция блокируется навсегда, а не паникует
var nilCh chan int
nilCh <- 1 // [chan send (nil chan)]
// 5. select без кейсов (на одних nil-каналах встанет так же, но в дампе [select])
select {} // [select (no cases)]
// 6. Взаимный обмен: обе горутины сначала шлют
go func() { a <- 1; <-b }()
go func() { b <- 1; <-a }() // оба стоят на отправке
// 7. WaitGroup.Wait() до чтения результатов
results := make(chan int) // небуферизованный
for i := 0; i < 3; i++ {
wg.Go(func() { results <- work() })
}
wg.Wait() // воркеры встали на отправке: дедлок
for r := range results { ... }
// Чинится закрытием канала в отдельной горутине:
// go func() { wg.Wait(); close(results) }()
// for r := range results { ... }
Внешние ресурсы
- База данных. Две транзакции берут строки в разном порядке - классический
дедлок. Postgres обнаруживает его сам (по умолчанию через
deadlock_timeout, 1 с) и убивает одну транзакцию с SQLSTATE40P01; MySQL/InnoDB - с ошибкой 1213. Задача приложения - распознать код и повторить транзакцию. Профилактика та же: единый порядок обновления строк, короткие транзакции,SELECT ... FOR UPDATEв детерминированном порядке (например,ORDER BY id). - Пул соединений. Отдельный, очень частый случай: хендлер держит соединение
и внутри просит второе из того же пула (например, вложенный запрос под
транзакцией). При
SetMaxOpenConns(N)и N параллельных запросах пул исчерпан, и все ждут друг друга. Симптом - все горутины вdatabase/sql.(*DB).conn. Лечение: не брать второе соединение под первым, передавать*sql.Txвниз явно. - Распределённые локи. Redis/etcd/Zookeeper: дедлок возможен, детектора нет ни у кого. Спасает только TTL на локе и единый порядок захвата.
- Файлы и ОС.
flock, именованные семафоры, полный pipe, который никто не читает.
select {} в конце main, чтобы сервис не завершился, -
не дедлок с точки зрения дизайна: программа делает ровно то, что задумано (работают
другие горутины). Воркер, стоящий на <-jobs в ожидании следующей
задачи, тоже не дедлок - событие, которое его разбудит, существует и наступит.
Формальный критерий один: существует ли в принципе событие, способное
разблокировать горутину. Если да - это ожидание. Если нет (канал потерян, писатель
умер, отменять некому) - это утечка, даже если программа продолжает работать.
А если такая горутина осталась единственной живой, рантайм назовёт это дедлоком
и уронит процесс - что и происходит с select{} в пустом
main.
Livelock: как получается
// Две горутины «вежливо уступают» друг другу и обе не двигаются.
func transfer(from, to *Account) {
for {
from.mu.Lock()
if to.mu.TryLock() {
defer from.mu.Unlock()
defer to.mu.Unlock()
return // успех
}
from.mu.Unlock() // уступил и сразу пробует снова, вот и livelock:
} // без паузы горутины легко входят в такт
} // и повторяют один и тот же конфликт
// Лечится рандомизированным экспоненциальным бэкоффом. Фиксированная пауза
// спасает лишь случайно: горутины разводит разброс пробуждений таймеров.
backoff := time.Duration(rand.Int63n(int64(base))) * (1 << attempt)
Другие источники livelock в реальных системах: цикл ретраев без джиттера (все клиенты бьют в один и тот же момент - «громовое стадо») и взаимные откаты транзакций, которые тут же перезапускаются. Общий признак - высокий CPU при нулевом прогрессе, что легко отличить от дедлока по графику загрузки.
Starvation в Go: где встроена защита
| Место | Как могло бы голодать | Что делает рантайм |
|---|---|---|
sync.Mutex | вновь прибывшие спиннингом перехватывают лок у тех, кто давно в очереди | если ожидание > 1 мс, мьютекс уходит в starvation mode: владение передаётся строго FIFO, спиннинг выключен |
sync.RWMutex | бесконечный поток читателей никогда не пустит писателя | после заявки писателя новые RLock блокируются (побочный эффект: рекурсивный RLock дедлочится) |
| очереди каналов | кто-то ждёт вечно, пока другие проскакивают | sendq/recvq строго FIFO - голодания нет by design |
| планировщик | горутина в тугом цикле не отдаёт P | асинхронная вытесняющая приостановка (Go 1.14+) по сигналу примерно раз в 10 мс; плюс глобальная очередь проверяется каждый 61-й тик, чтобы её не заморили |
select | всегда выбирается один и тот же готовый кейс | порядок кейсов случайный при каждом исполнении |
«Три состояния отсутствия прогресса отличаются наблюдаемо. Дедлок: CPU ноль,
горутины в _Gwaiting, в дампе стоят вечно. Livelock: CPU в потолок,
горутины бегают, прогресса нет - почти всегда ретраи без джиттера. Starvation:
прогресс есть, но у кого-то p99 улетел - обычно несправедливая очередь или
писатель под потоком читателей. Диагностика разная: на дедлок смотрят
goroutine-дамп, на livelock - CPU-профиль, на starvation - гистограмму
латентностей и mutex-профиль.»
cancel, канал не закрыли,
цикл без ветки ctx.Done(). Ловится goleak в тестах,
метрикой NumGoroutine и pprof в проде, а с Go 1.27 - ещё и штатным профилем
goroutineleak, который большой класс утечек находит точно, а не по
косвенным признакам.Три вопроса к каждому go func
- Что её завершит? Ровно три допустимых ответа: закроется входной канал,
сработает
ctx.Done(), закончится конечная работа. - Кто её дождётся?
WaitGroup,errgroupили канал завершения. Горутина, которую никто не ждёт, при shutdown будет убита посреди работы. - Что она удерживает? Замыкание держит все захваченные переменные. Горутина, которая «просто ждёт», может удерживать в живых мегабайтный буфер запроса и сетевое соединение.
Сценарии, помимо перечисленных в теории
- Отправка в канал результата после отмены. Даже с
selectнаctx.Done()в цикле, финальная отправкаout <- resв конце функции - незащищённая точка утечки. Отправлять тоже надо черезselect. time.Afterв циклеselect. Каждая итерация создаёт новый таймер, который до Go 1.23 удерживался рантаймом до срабатывания. В горячем цикле это была утечка памяти (не горутин, но эффект тот же). Так было до 1.23; стало - недостижимый таймер собирается GC, и утечки в прежнем виде больше нет. В Go 1.27 переключательGODEBUG=asynctimerchan, которым можно было вернуть старое поведение, удалён совсем: каналы таймеров теперь всегда небуферизованные, независимо от версии вgo.mod. С тикерами то же:TickerбезStop()соберут, когда пропадут ссылки, а тикер, из которого никто не читает, рантайм не нагружает. Утечкой остаётся горутина, которая читает из тикера в цикле без выхода, - и тикер вместе с ней.- Горутина, ждущая на
sync.Cond.Wait(). Если никто не позовётBroadcast, разбудить её нечем: уCondнет отмены по контексту вообще. Одна из причин, по которойCondредко берут. - Незакрытый
http.Response.Body. Если тело не дочитано, держит соединение и две горутины транспорта (чтения и записи), сборщик их не освободит. С лимитом соединений на хост новые запросы встают, без лимита копятся соединения и дескрипторы. - Подписка на события без отписки - брокер, watch в etcd, обработчик сигналов.
Инструменты обнаружения
| Где | Инструмент | Что даёт |
|---|---|---|
| тесты | go.uber.org/goleak | падение теста, если после него остались лишние горутины - самое дешёвое место поймать |
| тесты | testing/synctest (стабилен с Go 1.25) | фейковое время, которое идёт, только когда все горутины пузыря стоят; «пузырь» падает, если горутины навсегда заблокированы. В Go 1.27 добавили synctest.Sleep - time.Sleep плюс synctest.Wait одним вызовом |
| прод | метрика go_goroutines | монотонный рост при ровной нагрузке = утечка; алерт на производную |
| прод | pprof/goroutine?debug=1 | агрегат: «7412 горутин на этой строке» - сразу видно виновника |
| прод | pprof/goroutineleak (Go 1.27; в 1.26 эксперимент) | не косвенный признак, а вердикт: горутина стоит на примитиве, до которого уже никому не дотянуться. Снятие профиля форсирует цикл GC, так что не на каждый скрейп |
| прод | runtime/metrics: /sched/goroutines/waiting:goroutines (Go 1.26) | разбивка живых горутин по состояниям - running, runnable, waiting, not-in-go; растёт waiting при ровной нагрузке - почти наверняка утечка (при наплыве запросов он тоже растёт, но потом спадает) |
| прод | heap-профиль | рост runtime.malg косвенно подтверждает (это структуры горутин; сами стеки в heap-профиль не попадают) |
| CI | нагрузочный прогон + сравнение NumGoroutine до и после | ловит утечки, невидимые на юнит-тестах |
// Ручная проверка в тесте, если тянуть goleak не хочется
func TestNoLeak(t *testing.T) {
before := runtime.NumGoroutine()
srv := New()
srv.Start()
doWork(srv)
srv.Stop()
// Горутины завершаются не мгновенно: нужен опрос с дедлайном,
// а не единичная проверка сразу после Stop.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(10 * time.Millisecond)
}
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true) // all=true: стеки всех горутин
t.Fatalf("утечка: было %d, стало %d\n%s", before, runtime.NumGoroutine(), buf[:n])
}
Не знание списка сценариев, а рефлекс: увидев go func в чужом
коде, кандидат сразу спрашивает «а как она завершается?». В хорошем ответе обязательно
прозвучит, что утекает не одна память: вместе с горутиной уходят соединения, файловые
дескрипторы, места в пуле и ссылки на большие объекты. Поэтому симптом бывает не «растёт
RSS», а «кончились соединения к БД» или «too many open files».
Что стоит запомнить
- Data race требует конкурентного доступа с записью без синхронизации.
- Логическая race condition может существовать без data race.
- Race detector проверяет только исполненные пути.
Частые ошибки
- Sleep не задаёт happens-before.
- Успешный прогон -race не доказывает отсутствие гонок.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи выполнение
Два worker независимо увеличивают общую переменную без синхронизации. Можно ли назвать точное конечное значение? Как инструментально найти проблему?
var n int
for i := 0; i < 2; i++ { go func() { n++ }() }
time.Sleep(time.Millisecond); fmt.Println(n)
Ответ и разбор
Нет. Здесь data race, а Sleep не задаёт happens-before: worker могут не завершиться, а конкурентные read-modify-write могут потерять обновление. go test -race способен обнаружить гонку только на реально исполненном пути.
Код-ревью
2. Найди проблему и исправь
Код полагается на то, что goroutine успеет выполнить increment. Добавь корректную синхронизацию.
var n int
go func(){ n++ }()
fmt.Println(n)
Ответ и разбор
Для результата одной задачи дождись завершения через WaitGroup либо передай результат по каналу. Sleep не задаёт синхронизацию; если нужен общий счётчик, используй mutex или atomic.
Применение
3. Разбери рабочий сценарий
Consumer выходит после первого результата, producer продолжает отправлять в unbuffered channel. Найди утечку и исправь протокол остановки.
Ответ и разбор
После выхода consumer producer навсегда блокируется на следующей send. Передай context cancellation в producer и выбирай между send и <-ctx.Done(); consumer отменяет ctx перед выходом. Producer должен завершиться и владелец должен дождаться его.
Применение
4. Разбери граничный сценарий
Гонка воспроизводится только под нагрузкой. Как сделать проверку полезной и что нельзя заключать по зелёному результату?
Ответ и разбор
Запусти наиболее широкие релевантные тесты с go test -race и повтори сценарий с конкурентной нагрузкой. Race detector найдёт только конфликтующие доступы на выполненных путях, поэтому зелёный прогон не доказывает отсутствие гонок.