voidlearn
 

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
}
Две горутины делают counter++ - по три машинные операции каждая время t1 t2 t3 t4 t5 t6 G1 load r1=0 add r1=1 store 1 G2 load r2=0 add r2=1 store 1 counter в памяти 0 0 0 0 1 1 Два инкремента, а counter вырос на единицу: G2 прочитала ноль до того, как G1 записала. Это lost update. Чем больше ядер, тем чаще: на 8 ядрах из миллиона теряются сотни тысяч.
Потерянное обновление. Гонка не требует «одновременности» в физическом смысле: достаточно, чтобы чтение одной горутины произошло до записи другой, а её собственная запись - после.

Три способа починить

// 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 горутины, её эпоха на момент доступа, размер обращения и флаг чтение/запись. Новый доступ сравнивается с тем, что лежит в теневых ячейках: если эпоха предыдущего писателя больше, чем то, что мой вектор знает про эту горутину, значит порядок между нами не установлен - гонка.
Один и тот же доступ к x: с синхронизацией и без happens-before есть - гонки нет G1: пишет x, свои часы {G1:5, G2:0} G1: mu.Unlock() - часы {5,0} кладутся в мьютекс G2: mu.Lock() - берёт max: {0,3} и {5,0} = {5,3} G2: пишет x, в тени видит «G1, эпоха 5» 5 <= моё знание о G1 (=5) - доступ упорядочен happens-before нет - DATA RACE G1: пишет x, свои часы {G1:5, G2:0} синхронизации между горутинами не было G2: часы {G1:0, G2:3} - про G1 не знает ничего G2: пишет x, в тени видит «G1, эпоха 5» 5 > моё знание о G1 (=0) - НЕ упорядочено, отчёт 8 байт памяти var x int64 G1 эпоха 5 запись, 8 Б G2 эпоха 3 чтение, 8 Б свободно свободно теневые ячейки: на каждые 8 байт данных их всего четыре Новый доступ вытесняет одну из старых ячеек - очень давние обращения детектор просто забывает. Эта теневая карта и есть причина, почему память под -race растёт в 5–10 раз.
Векторные часы и теневая память. Детектор не смотрит на «одновременность», он проверяет, установлен ли между двумя доступами порядок. Поэтому гонка находится, даже если доступы разнесены во времени на секунды.
Что из этого следует

Детектор работает через 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:    сколько прошлых доступов помнит горутина, больше = меньше пропусков, дороже
Почему -race обязателен в CI

Гонка не воспроизводится по требованию: зависит от числа ядер, нагрузки, версии 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 {}
}
Циклическое ожидание: граф «кто кого ждёт» замкнулся G1 держит muA G2 держит muB muA занят G1 muB занят G2 владеет владеет G1 просит muB - ждёт G2 просит muA - ждёт цикл в графе ожидания = дедлок Разорвать можно любым ребром: общий порядок / TryLock / таймаут
Дедлок - это цикл в графе ожидания. Пока оба ребра «просит» направлены навстречу друг другу, никто не сдвинется. Глобальный порядок захвата (всегда сначала muA, потом muB) делает такой цикл невозможным: рёбра «просит» всегда идут в сторону возрастания номера лока.

Как рантайм детектит дедлок и когда не детектит

Знаменитое fatal error: all goroutines are asleep - deadlock! печатает функция checkdead() в планировщике. И это не детектор дедлоков. Рантайм не строит граф ожидания и не разбирает, кто кого ждёт. Он проверяет одно глобальное условие: «во всей программе не осталось ни одного потока, который мог бы когда-нибудь получить работу».

checkdead вызывается, когда последний рабочий поток M собирается уйти в сон, и считает три числа: сколько есть runnable-горутин, сколько M сидит в системных вызовах и есть ли активные таймеры. Если работы нет нигде, а живые горутины при этом заблокированы (_Gwaiting) - значит, разбудить их некому, и рантайм честно падает, печатая стеки.

Когда «all goroutines are asleep» НЕ появится
  • Частичный дедлок. Пять горутин намертво стоят на двух мьютексах, а 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-дампом.»

Диагностика зависания в проде

ИнструментКак снятьЧто видно
SIGQUITkill -QUIT <pid> или Ctrl+\стеки всех горутин и аварийное завершение процесса. Работает без импортов и эндпоинтов, если процесс сам не перехватил SIGQUIT через signal.Notify
pprof goroutinecurl host/debug/pprof/goroutine?debug=2то же самое, включая время ожидания у стоящих дольше минуты, но без убийства процесса
pprof, агрегат?debug=1 или go tool pprofсгруппировано по стекам: «7412 горутин стоят вот на этой строке»
goroutineleakcurl host/debug/pprof/goroutineleak?debug=1 (Go 1.27)только те горутины, которые заблокированы навсегда: примитив, на котором они стоят, уже недостижим. Отделяет утечку от «просто долго ждёт»
mutex profileruntime.SetMutexProfileFraction(5)кто держал мьютекс, пока другие ждали, и сколько они прождали - контеншен, ещё не дедлок
block profileruntime.SetBlockProfileRate(1e6)блокировки на каналах, select, WaitGroup, семафорах
метрикаruntime.NumGoroutine() в Prometheusмонотонный рост - утечка; резкая полка - что-то встало
метрики планировщикаruntime/metrics: /sched/goroutines/* и /sched/threads/total (Go 1.26), /sched/latenciesсколько горутин реально исполняется, сколько готово, сколько ждёт, сколько сидит в syscall или cgo - и сколько потоков ОС завёл рантайм
delvedlv 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, а по хвосту латентности.

ДедлокLivelockStarvation
Состояние горутинзаблокированы, _Gwaitingактивно работают, жгут CPUrunnable, но не получают ресурс
Прогресснулевой навсегданулевой, но «работа кипит»есть у системы, нет у конкретной горутины
Как выглядит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 по каналу, который не закрылипродюсер упал по ошибке, не дойдя до closedefer close(out) у владельца канала
Бесконечный цикл без выходаfor { select { case v := <-in: ... } } без ctx.Done()обязательная ветка case <-ctx.Done(): return
Тикер без Stopt := 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()) },
))
Go 1.27: профиль 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 или канал завершения.

Вопросы

10
Суть: race condition - результат зависит от порядка выполнения конкурентных операций. counter++ - это load/add/store, три шага, между которыми может влезть кто угодно. Чинится мьютексом (универсально), atomic (быстро, одно слово) или горутиной-владельцем с каналом (когда вокруг состояния есть логика).

Как отвечать по шагам

  1. Назвать проблему точно. «counter++ не атомарен: по сути это чтение из памяти, инкремент и запись обратно (на arm64 три инструкции, на amd64 одна INCQ, но без префикса LOCK и она не атомарна). Две горутины читают одно и то же значение, обе прибавляют единицу, обе записывают - одно обновление потеряно. Это lost update.»
  2. Показать масштаб. «На 1000 горутин по 1000 инкрементов вместо миллиона получится случайное число, и чем больше ядер, тем оно меньше: на двух ядрах около 64% от ожидаемого, на 14 ядрах около 22%. И оно разное от запуска к запуску.»
  3. Доказать инструментом. go run -race main.go и показать WARNING: DATA RACE с двумя стеками.
  4. Починить и объяснить выбор. Три варианта из теории выше, и главное тут критерий выбора, а не список из трёх пунктов.

Компактный сравнительный бенчмарк

// 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 в цикле или мьютекс: атомарность одной операции не даёт атомарности последовательности.

Суть: data race - формальное свойство памяти (два конкурентных доступа без happens-before, хотя бы один на запись). Race condition - свойство логики (результат зависит от порядка). Каждая data race порождает race condition, обратное неверно. Первое ловит -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 ломает внутренние структуры, и продолжать выполнение опаснее, чем упасть.

Суть: ThreadSanitizer внутри рантайма. Компилятор вставляет вызов перед каждым доступом к памяти, детектор ведёт векторные часы на горутину и теневые ячейки на каждые 8 байт данных, и сравнивает: установлен ли happens-before между этим доступом и предыдущим. Ловит только выполненные пути, стоит ×5–10 по памяти и ×2–20 по времени, race condition не ловит принципиально.

Что говорить, если просят «в общих чертах»

«Каждая горутина носит вектор часов - по счётчику на каждую известную горутину. Синхронизация (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
Ожидание сети / файла / cgoM в 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 и печатает стеки всех горутин - это второй способ увидеть частичный дедлок.

Суть: снять goroutine-дамп. Мягко - через /debug/pprof/goroutine?debug=2, жёстко - kill -QUIT (процесс умрёт). Дальше сгруппировать горутины по состоянию и стеку и найти те, что стоят дольше любого разумного таймаута.

Порядок действий по инциденту

  1. Посмотреть метрики. go_goroutines - полка или рост? CPU - ноль (дедлок) или сотка (livelock)? RPS упал в ноль или частично?
  2. Снять дамп, не убивая под. curl host:6060/debug/pprof/goroutine?debug=2 > dump.txt. Если pprof не подключён - снимать через kill -QUIT на поде, который не жалко, предварительно выведя его из балансировки.
  3. Сгруппировать. Состояние указано в заголовке каждой горутины, а у стоящих дольше минуты - и время: goroutine 42 [sync.Mutex.Lock, 17 minutes].
  4. Найти пару. Для дедлока на локах характерны две-три группы, чьи стеки берут одни и те же мьютексы в разном порядке. Для утечки - одна разросшаяся группа с одинаковым стеком.
  5. Проверить внешние ресурсы. Если все висят в 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).
// Берут только там, где отменяемость важнее наносекунд.
Классическая ошибка с TryLock

Забыть отпустить уже взятый лок перед повтором. Код a.Lock(); if !b.TryLock() { continue } без a.Unlock() превращает попытку избежать дедлока в гарантированный дедлок плюс утечку. Вторая ошибка - повтор без задержки и без джиттера: две симметричные горутины входят в идеальный такт и получают livelock, который выглядит как 100% CPU и полное отсутствие прогресса.

Суть: на каналах - да, и это самый частый вид дедлока в Go. На внешних ресурсах - да, но рантайм их не видит (для него это syscall). Умышленно заблокированная горутина дедлоком не является, если существует событие, способное её разбудить; если такого события нет - это утечка, а при остановке всей программы рантайм назовёт её дедлоком.

Дедлоки на каналах - витрина случаев

// 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 с) и убивает одну транзакцию с SQLSTATE 40P01; 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 - все активно работают, состояние меняется, но полезного прогресса нет (жгут CPU впустую). Starvation - система в целом прогрессирует, но конкретная горутина систематически не получает ресурс.

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-профиль.»

Суть: утечка - горутина, которая никогда не завершится. Стоит от 2 КБ стека плюс всё, что она держит в замыканиях, плюс работа GC. Типовые причины: некому читать из канала, забытый cancel, канал не закрыли, цикл без ветки ctx.Done(). Ловится goleak в тестах, метрикой NumGoroutine и pprof в проде, а с Go 1.27 - ещё и штатным профилем goroutineleak, который большой класс утечек находит точно, а не по косвенным признакам.

Три вопроса к каждому go func

  1. Что её завершит? Ровно три допустимых ответа: закроется входной канал, сработает ctx.Done(), закончится конечная работа.
  2. Кто её дождётся? WaitGroup, errgroup или канал завершения. Горутина, которую никто не ждёт, при shutdown будет убита посреди работы.
  3. Что она удерживает? Замыкание держит все захваченные переменные. Горутина, которая «просто ждёт», может удерживать в живых мегабайтный буфер запроса и сетевое соединение.

Сценарии, помимо перечисленных в теории

  • Отправка в канал результата после отмены. Даже с 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 найдёт только конфликтующие доступы на выполненных путях, поэтому зелёный прогон не доказывает отсутствие гонок.