Как обеспечить корректное обновление счетчика в многопоточной среде в Go
Что проверяют
Понимаете ли вы, почему counter++ из нескольких горутин теряет обновления, и умеете ли осознанно выбрать между атомиком и мьютексом. Следом спрашивают, что будет под сильной конкуренцией и как доказать, что гонки нет.
Как отвечать
Начните с причины: counter++ это три шага, чтение, прибавление, запись. Две горутины читают одно значение, обе записывают его плюс один, одно обновление потеряно. По модели памяти Go это гонка данных, и поведение программы не определено.
Варианты:
atomic.Int64:c.Add(1)иc.Load(). Лучший выбор для одиночного счётчика: блокировок нет, а сам тип гарантирует выравнивание по 64 битам, о котором на 32-битных платформах приходится помнить при работе со старыми функциями вродеatomic.AddInt64;sync.Mutex: когда счётчик меняется вместе с другими полями и инвариант охватывает несколько переменных, например число событий и время последнего. Отдельный атомик на каждое поле такой инвариант не держит;- канал с горутиной-владельцем: корректно, но каждый инкремент платит за передачу через канал и переключение горутин. Уместно, если у владельца есть и другая логика;
- шардированный счётчик: на многих ядрах и миллионах инкрементов в секунду одна переменная упирается в борьбу за кэш-линию. Держат массив счётчиков с выравниванием по кэш-линии, каждая горутина пишет в свой, а при чтении их суммируют.
Корректность доказывают тестом: сотни горутин под go test -race, итог сравнивается с ожидаемым. Скорость вариантов сравнивают бенчмарком с b.RunParallel, а не на глаз.
Где спотыкаются
Инкрементируют атомарно, а читают обычным обращением к полю. Чтение тоже должно идти через Load(), иначе гонка остаётся.
Берут RWMutex для счётчика, где почти каждая операция это запись. Выходит медленнее обычного мьютекса без всякой выгоды.
Считают, что sync.WaitGroup синхронизирует данные. Он только ждёт завершения горутин, а доступ к счётчику не защищает никак.