Постмортемы дедлоков: инверсия порядка локов, канал под мьютексом и чтение дампа горутин
Три формы дедлока: инверсия порядка локов, отправка в канал под мьютексом, гонка WaitGroup.Add с Wait. Паника рантайма срабатывает, лишь когда блокируются все горутины; частичные дедлоки видны кластерами стеков в goroutine-профиле. go test -race ловит гоночных родственников.
Платёжный сервис не упал. В этом и была проблема. В 11:34 эндпоинты /charge и /refund начали таймаутиться, а /health возвращал 200 — проверка здоровья не трогала ни один из двух мьютексов, которые только что заперли друг друга. Kubernetes видел здоровый под и продолжал лить в него трафик. К 11:50 дамп горутин рассказал всю историю двумя числами: горутина 412 держала лок аккаунта и 16 минут ждала лок леджера; горутина 9817 держала лок леджера и 16 минут ждала лок аккаунта. За этими двумя — кластер из 9 412 горутин, припаркованных в sync.Mutex.Lock, по одной на застрявший запрос. Ни паники, ни строчки в логе, ни рестарта — знаменитая ошибка «all goroutines are asleep» так и не сработала, потому что тысячи других горутин радостно обслуживали health-чеки. Сорок минут проваленных платежей, закончившиеся ручным выкатом. Дамп всё это время был на расстоянии одного curl.
Форма A: инверсия порядка локов
Форма из Hook, она же классика. Два лока, два пути в коде, противоположные порядки:
func Transfer(from, to *Account) { // путь 1: from → to
from.mu.Lock()
defer from.mu.Unlock()
to.mu.Lock() // горутина 412 ждёт здесь, держа from.mu
defer to.mu.Unlock()
// ...
}
func Audit(a, b *Account) { // путь 2 берёт ту же пару в обратном порядке
b.mu.Lock()
a.mu.Lock() // горутина 9817 ждёт здесь, держа b.mu
// ...
}Каждая горутина держит то, что нужно другой: цикл длины два, замороженный навсегда — у sync.Mutex нет ни таймаута, ни учёта владельца. Окно крошечное (обе должны быть посреди захвата в одни и те же микросекунды), поэтому такое и доезжает до прода: переживает ревью, тесты и месяцы эксплуатации, пока интерливинг не сложится. Лечится не хитростью, а конвенцией: определите один глобальный порядок локов и берите их в этом порядке на каждом пути. Для динамических пар вроде аккаунтов — порядок по внутреннему ключу: if from.ID > to.ID { from, to = to, from } перед захватом — и запишите конвенцию там, где о неё споткнётся следующий инженер.
Форма B: операция с каналом под мьютексом
func (s *Store) Flush() {
s.mu.Lock()
defer s.mu.Unlock()
s.events <- snapshot(s.data) // отправка под s.mu — а получателю...
}
func (s *Store) consume() {
for ev := range s.events {
s.mu.Lock() // ...нужен s.mu, прежде чем он сможет принять СЛЕДУЮЩЕЕ
apply(ev)
s.mu.Unlock()
}
}Flush отправляет, держа лок; потребителю тот же лок нужен для обработки. Один буферный слот люфта — и в каждом демо это работает, пока буфер не окажется полон ровно в момент, когда потребитель между приёмом и локом. Тогда Flush ждёт потребителя, потребитель ждёт мьютекс — цикл замкнулся. Правило обобщается за пределы каналов: никогда не выполняйте блокирующую операцию — отправку или приём канала, I/O, RPC, Wait — держа мьютекс. Мьютекс — для наносекундных правок памяти; всё, что может припарковать горутину, превращает критическую секцию в узел графа ожидания, от которого другие горутины, возможно, уже зависят. Скопируйте данные под локом, отпустите, потом отправляйте.
Две горутины задедлочились на паре мьютексов внутри нагруженного HTTP-сервиса. Что процесс на самом деле делает?
Форма C: злоупотребление WaitGroup
Третий постмортем тоньше, потому что это гонка, иногда носящая костюм дедлока:
var wg sync.WaitGroup
for _, job := range jobs {
go func() {
wg.Add(1) // НЕВЕРНО: гонка с wg.Wait ниже
defer wg.Done()
process(job)
}()
}
wg.Wait() // может увидеть счётчик == 0 до того, как выполнится хоть один AddAdd обязан произойти до того, как Wait сможет наблюдать счётчик — то есть до оператора go, в порождающей горутине. В записи выше исход решает планировщик: если Wait выполнится раньше, чем детей запланируют, он увидит ноль и вернётся — работа молча потеряна, или хуже: main выходит, и детей снимают посреди записи. Зеркальный вариант вместо этого дедлочится: ребёнок зовёт wg.Wait() (или шлёт в небуферизованный канал, читаемый после Wait), пока родитель держит то, что нужно ребёнку. Родная классика из той же семьи: передача WaitGroup по значению (Done копии никогда не дойдёт до оригинала — Wait блокируется навсегда) и повторное использование WaitGroup для второй волны до возврата первого Wait. Копию ловит go vet; гонку Add-с-Wait надёжно метит -race.
Инструментарий обнаружения
Знаменитая fatal error: all goroutines are asleep - deadlock! в сервисах почти бесполезна: она срабатывает, лишь когда каждая горутина заблокирована на видимых рантайму операциях — один живой accept-цикл или тикер глушит её. Продовые дедлоки частичны, и инструмент — goroutine-профиль:
import _ "net/http/pprof"
// живой дамп со стеками и длительностями ожидания:
// curl localhost:6060/debug/pprof/goroutine?debug=2
//
// goroutine 412 [sync.Mutex.Lock, 16 minutes]: ← длительность = улика
// main.Transfer ... transfer.go:42
// goroutine 9817 [sync.Mutex.Lock, 16 minutes]:
// main.Audit ... audit.go:31
// 9412 @ ... sync.Mutex.Lock main.(*Handler).Charge ← кластер жертвЧитайте как детектив, в три прохода. Первый — размеры кластеров (debug=1 группирует одинаковые стеки): 9 412 горутин на одной строке Lock — куча жертв, называющая спорный лок. Второй — длительности ожидания (debug=2): многоминутные semacquire или chan send отделяют дедлок от обычной контеншн, которая крутится. Третий — найти держателей: одна-две горутины, заблокированные на другом локе, чем кластер, — они держат то, чего хочет кластер, и хотят того, что держит друг друга; их два стека — ваш цикл, с файлом и строкой. Дополните набор: go test -race для гоночных родственников (форма C), go vet для копий WaitGroup и mutex-профиль (runtime.SetMutexProfileFraction + /debug/pprof/mutex), чтобы видеть, как контеншн трендится к обрыву, прежде чем вы с него упадёте. Вместе три прохода превращают загадочный таймаут в файл и строку за десять минут — без рестарта процесса и без потери улик.
В дампе горутин ты видишь 9 412 горутин, припаркованных в Charge на mu.Lock, плюс горутину A, держащую mu и 14 минут заблокированную на ledger.Lock, и горутину B, держащую ledger и 14 минут заблокированную на mu. Каков диагноз?
▸Почему это работает
Почему рантайм не обнаруживает частичные дедлоки, если у него явно есть вся информация? Потому что в общем случае её нет: мьютекс в Go не записывает владельца, и рантайм видит «горутина припаркована на семафоре», не зная, кто бы её освободил — построение графа ожидания означало бы учёт владения на каждом Lock/Unlock, цену, наложенную на все программы ради диагностики немногих сломанных. Проверка all-asleep бесплатна ровно потому, что граф ей не нужен: если не может выполняться буквально ничто, никакого будущего Unlock не существует. Всё более тонкое делегировано дампу — который содержит граф неявно, в стеках, для человека (или скрипта), готового его прочитать.
Упорядочите три прохода при чтении дампа горутин для диагностики частичного дедлока:
- 1 Проход 1 — размеры кластеров (debug=1): найдите большой кластер горутин, припаркованных на одной строке Lock — он называет оспариваемый мьютекс, но не сам баг
- 2 Проход 2 — длительность ожидания (debug=2): ищите ожидания semacquire или chan send на минуты — это отличает настоящий дедлок от высокой конкуренции, при которой лок постоянно переходит от одного к другому
- 3 Проход 3 — найдите держателей: найдите одну-две горутины, заблокированные на другом локе, нежели толпа — они держат то, чего ждёт кластер, и сами ждут друг друга; их стеки дадут вам цикл с файлом и строкой
Профилактика: скучные правила, переживающие три часа ночи
Каждое правило ниже — один из трёх постмортемов, вывернутый наизнанку. Держите критические секции крошечными — лок, правка памяти, анлок; вычисления и I/O снаружи. Никогда не блокируйтесь под мьютексом: ни операций с каналами, ни RPC, ни Wait — копия под локом, действие после освобождения. Один задокументированный порядок локов на пакет (или порядок по внутреннему ключу для динамических пар), принуждаемый на ревью, потому что никакой инструмент не принудит его за вас. Add до go и никогда внутри ребёнка; WaitGroup только по указателю; -race в CI всегда — он ловит форму C и сотню родственников. И держите net/http/pprof подключённым в проде до инцидента: дедлок, который можно сдампить одним curl, — диагноз за 5 минут, а тот же дамп после рестарта не показывает вообще ничего.
- 01Пройди чтение дампа горутин при подозрении на частичный дедлок: три прохода и что каждый доказывает.
- 02Назови три формы дедлока из постмортемов и правило профилактики, которое каждая подразумевает.
Авария платежей — шаблон: частичный дедлок не падает, не логирует и продолжает проходить health-чеки, пока за замёрзшими локами растёт кластер жертв. Форма A — инверсия порядка локов: два пути берут одни мьютексы в противоположных порядках, цикл ожидания из двух узлов, доезжающий до прода, потому что окно гонки шириной в микросекунды; фикс — задокументированный глобальный порядок захвата, с порядком по внутреннему ключу для динамических пар вроде пар аккаунтов. Форма B — блокировка под мьютексом: отправка в канал под локом, нужным получателю, работающая в каждом демо, пока буфер не заполнится в неудачный момент; правило — мьютекс охраняет наносекундные правки памяти и ничего, что может припарковать: копия под локом, освобождение, затем отправка. Форма C — злоупотребление WaitGroup: Add внутри ребёнка гонит Wait к раннему возврату и потере работы, копии по значению блокируют Wait навсегда; Add до go, передача по указателю, -race и vet ловят то, что пропустило ревью. Обнаружение начинается с честного факта: панике all-goroutines-asleep нужен полностью заблокированный процесс, поэтому в сервере с живым accept-циклом она не срабатывает никогда. Инструмент — goroutine-профиль через net/http/pprof, читаемый в три прохода: размеры кластеров называют спорный лок, многоминутные длительности ожидания отделяют дедлок от контеншн, а держатели, заблокированные друг на друге, — цикл с файлом и строкой. Mutex-профиль показывает контеншн, трендящуюся к обрыву. Профилактика скучна намеренно: крошечные критические секции, никаких блокирующих операций под локами, один порядок локов, -race в CI и pprof, подключённый до инцидента, — потому что дамп, снятый после рестарта, не показывает ничего. Теперь, когда сервис здоров, но не отвечает, ваш первый шаг — один curl к goroutine-профилю: цикл будет в двух стеках с многоминутными ожиданиями, а не в толпе.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.