Общий счётчик в Go: почему обычный плюс не безопасен между goroutine

Несколько обработчиков увеличивают общий счётчик, и итог иногда меньше ожидаемого. Операция чтения, прибавления и записи не становится безопасной только потому, что выглядит одной строкой. Конкурентный доступ требует выбранной синхронизации.

Как разобрать ситуацию

Для небольшой составной операции можно использовать mutex. Он должен защищать все обращения к одному состоянию, а не только часть записей. Если вместе со счётчиком обновляются другие поля, важно сохранить их общий инвариант. Отдельная атомарная переменная подходит не для любой структуры и не заменяет проектирование состояния.

Пример и ожидаемый результат

Сто goroutine увеличивают защищённую переменную на единицу, затем main ждёт завершения всех работ. Ожидаемый результат — 100. Пример не пытается поймать случайную гонку на удачном запуске: в коде сразу показан согласованный доступ под одним mutex.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu sync.Mutex
	var wg sync.WaitGroup
	n := 0
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); mu.Lock(); n++; mu.Unlock() }()
	}
	wg.Wait()
	fmt.Println(n)
}

Обычный запуск программы выводит 100. Отдельный запуск с -race является дополнительной диагностикой и здесь не подменяется результатом обычного go run.

Проверка перед применением

  • Проверьте, что чтение счётчика во время работы тоже использует согласованную синхронизацию.
  • Запустите тесты с race detector в поддерживаемой среде, а не делайте вывод по одному правильному числу.
  • Не копируйте структуру с уже используемым mutex: владение состоянием должно оставаться понятным.

В реальном обработчике отдельно проверьте длительность критической секции. Сетевой вызов под общим mutex способен последовательно заблокировать всех клиентов, хотя гонка исчезнет. Хорошее исправление защищает нужное состояние коротко и оставляет внешние операции вне блокировки, когда это допускает бизнес-инвариант.

Границы решения

Правильный счётчик не делает распределённую метрику устойчивой к перезапуску и не гарантирует согласованность между несколькими процессами. Для агрегирования событий сервиса нужен соответствующий внешний механизм.

Если базовый Go уже знаком и хочется связать HTTP, хранение данных и обработку отказов в одну систему, посмотрите наш платный курс

Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker. Перед записью сравните программу со своей задачей и подготовкой.

Техническая справка: pkg.go.dev: документация, go.dev: документация.

Следующий шаг: Go backend: HTTP, конкурентность и устойчивость сервиса. По смежным вопросам: HTTP retry в Go: почему один список кодов не делает повтор безопасным, Как оборачивать ошибки в Go и не потерять машинную проверку причины.