Race condition, deadlock, livelock и starvation в Go

Зачем это на интервью

Интервьюер проверяет, отличаете ли вы логическую гонку от data race, умеете ли читать зависший goroutine dump и проектировать протокол остановки без «добавим mutex на всякий случай». В production эти ошибки проявляются как редкие неверные данные, исчерпание goroutine или запросы с бесконечной latency.

Минимум для E4

  • Race condition — результат зависит от недетерминированного порядка событий. Она возможна и без data race, например два конкурентных запроса проверяют остаток товара, а затем оба списывают его через отдельные транзакции.
  • Data race — две goroutine одновременно обращаются к одной ячейке памяти, хотя бы одна пишет, и доступы не упорядочены синхронизацией. Это ошибка по Go memory model.
  • Deadlock — все участники ждут событие, которое никто из них не может произвести. Типичные причины: несовпавшие send/receive, повторный захват Mutex, разный порядок нескольких locks.
  • Livelock — goroutine исполняются, но постоянно уступают/повторяют попытку и полезной работы не делают.
  • Starvation — работа готова, но долго не получает CPU, lock или ресурс. Это не обязательно вечная блокировка.
  • Запускайте тесты и воспроизведение с go test -race ./...; детектор динамический: он показывает только выполненные пути и не доказывает отсутствия race.
package counter
 
import "sync"
 
type Counter struct {
	mu sync.Mutex
	n  int
}
 
func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}
 
func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

Один и тот же mu защищает все чтения и записи n. Нельзя читать n без lock «только для логирования».

Углубление для E5/Senior

Синхронизация создаёт отношение happens-before: unlock mutex предшествует следующему lock того же mutex; send канала предшествует соответствующему receive; close канала предшествует receive, вернувшему zero value из закрытого канала. Без него компилятор и CPU вправе наблюдать записи в другом порядке.

Предотвращайте deadlock инвариантами: фиксированный глобальный порядок ресурсов, короткие критические секции, отсутствие внешнего I/O под mutex, контекст/таймаут в блокируемых операциях. sync.Mutex не reentrant; попытка вызвать метод, снова берущий тот же mutex, часто означает неверную декомпозицию API.

Планировщик Go стремится к прогрессу, но не даёт прикладной гарантии справедливости. Не строят correctness на «все когда-нибудь получат lock». Для очередей и ограничения нагрузки нужны явные очереди, bounded concurrency, метрики ожидания и cancellation. RWMutex полезен только при измеренном преобладании чтений; поток писателей может сделать его хуже обычного mutex.

При зависании снимайте goroutine profile/stack (SIGQUIT в Unix, runtime/pprof), ищите циклическую цепочку ожиданий, а не только строку, где goroutine остановилась. Для contention используйте mutex/block profile и измеряйте p95 ожидания, а не заменяйте синхронизацию atomics без доказанного протокола.

Ключевые понятия

ТерминПризнакОсновной ответ
Data race-race сообщает конфликтующий read/writeобщий lock, канал ownership или атомарный протокол
Race conditionкорректность зависит от interleavingсделать переход состояния атомарным/сериализованным
Deadlockвсе ждутизменить протокол и порядок ресурсов
LivelockCPU работает, прогресса нетbackoff с jitter, лидер/очередь, ограничение повторов
Starvationотдельная работа долго не обслуживаетсяfairness/очередь, лимиты и наблюдаемость

Типовые вопросы

  1. Чем data race отличается от race condition?
    • Data race — нарушение модели памяти на доступе к переменной; race condition — ошибка алгоритма порядка. Mutex уберёт data race, но не исправит проверку «прочитал баланс → позднее записал» без атомарного перехода.
  2. Почему go test -race не является доказательством безопасности?
    • Инструмент динамический и видит только реально выполненные interleaving. Нужны дизайн ownership, review и тесты с нагрузкой/разными путями.
  3. Почему нельзя лечить deadlock select { default: }?
    • Он меняет блокировку на потерю сообщения или busy loop. Нужно определить владельца, условие завершения и кто обязан получить/закрыть канал.
  4. Как избежать deadlock двух mutex?
    • Задать тотальный порядок, например по ID, и всегда брать min затем max; не держать lock во время RPC/DB-вызова.
  5. Может ли канал дать deadlock?
    • Да: send в unbuffered channel ждёт receiver, а buffered channel блокирует sender при полном буфере. close не освобождает sender и не должен выполняться receiver-ом.
  6. Когда starvation — реальная проблема?
    • При неограниченном потоке новых задач, тяжёлой критической секции или приоритетной обработке без квоты. Наблюдают возраст очереди и время ожидания, затем вводят bounded queue/справедливую диспетчеризацию.

Практика

  • Напишите тест с двумя goroutine, инкрементирующими общий счётчик без синхронизации; получите отчёт go test -race, затем исправьте через Mutex и подтвердите чистый запуск.
  • Смоделируйте перевод между двумя счетами. Критерии: одинаковый порядок захвата locks, сумма не меняется, 1 000 параллельных переводов завершаются по timeout и под -race.
  • Возьмите goroutine dump зависшей программы и нарисуйте граф ожиданий. Критерии: названы все блокирующие операции и единственное изменение, разрывающее цикл.
  • Создайте очередь с медленным consumer. Критерии: producer не создаёт busy loop, отмена освобождает ожидающих, есть метрика длины/возраста очереди.

Частые ошибки и ловушки

  • Копировать struct после первого использования содержащегося в ней Mutex, RWMutex или WaitGroup.
  • Считать, что атомарный Load/Store автоматически делает последовательность из нескольких шагов транзакционной.
  • Держать mutex на сетевом вызове, записи в канал или callback неизвестного кода.
  • Игнорировать error/timeout и принимать тест без зависания за доказательство отсутствия deadlock.
  • Использовать time.Sleep как синхронизацию: он делает тест флейковым и скрывает протокол.

Связанные темы

Go · Fan-out, fan-in и worker pool · Pipelines и cancellation · Graceful shutdown

Источники