Программирование GoТестированиеРазработчик Go, создающий конкурентные сервисы

Две горутины обновляют общий счётчик: все операции выполняются через атомарные примитивы, но итоговая логик...

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

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

Поэтому программа может не иметь data race и всё равно содержать логическую ошибку: два потока независимо прочитали одно значение и приняли решение на его основе.

Исторический контекст

Конкурентные программы часто должны обновлять небольшие общие значения без полной блокировки. Для этого в Go существует пакет sync/atomic, а race detector умеет учитывать атомарные операции при анализе доступа к памяти.

Исходная проблема состояла в необходимости разделять два свойства: безопасность доступа к памяти и корректность алгоритма. Атомарные примитивы решают первое для конкретных операций, но не заменяют проектирование критической секции или атомарного протокола.

Постановка проблемы

Обычная запись и чтение общей переменной из разных горутин без синхронизации создают гонку данных. Результат зависит от расписания, а race detector обычно сообщает о конфликтующих доступах.

Атомарное увеличение счётчика безопасно на уровне памяти, но проверка условия и изменение значения могут оставаться разными операциями. Например, два worker-а могут одновременно увидеть, что лимит ещё не достигнут, и оба увеличить счётчик сверх лимита.

Подробное решение

Атомарная операция выполняется как единое неделимое действие с точки зрения других горутин. Для счётчика это означает, что два вызова увеличения не потеряют изменения: каждый работает с актуальным значением согласно правилам памяти, заданным атомарным API.

package main import ( "fmt" "sync" "sync/atomic" ) func main() { var n atomic.Uint64 var wg sync.WaitGroup wg.Add(2) for i := 0; i < 2; i++ { go func() { defer wg.Done() n.Add(1) }() } wg.Wait() fmt.Println(n.Load()) }

Здесь и изменение, и чтение счётчика атомарны, поэтому доступ к n не образует гонку данных. Ожидаемый результат — два, но это свойство конкретной операции Add, а не гарантия корректности любой последовательности действий.

Если алгоритм требует проверки условия с последующим изменением, нужны Compare-And-Swap, mutex или другой механизм, объединяющий эти действия в один защищённый протокол. Нельзя смешивать атомарный доступ к переменной с обычным чтением или записью: одного атомарного обращения недостаточно, если другое обращение не синхронизировано.

Следует также отличать отсутствие data race от корректности результата. Race detector может не сообщить об ошибке в алгоритме, который использует атомарные операции, но допускает неверный порядок действий или нарушает бизнес-инвариант.

Ситуация из практики

Сервис ограничивал число одновременно принятых задач. Разработчик заменил mutex на атомарный счётчик: сначала загружал текущее значение, затем отдельно проверял лимит, после чего выполнял атомарное увеличение. Тесты и race detector проходили, однако под нагрузкой число принятых задач иногда превышало лимит.

Вариант с одним атомарным счётчиком был быстрым и простым, но не защищал составную операцию. Mutex гарантировал корректность, но создавал избыточную сериализацию при высокой конкуренции. В итоге применили CAS-цикл: значение увеличивалось только при успешной проверке прежнего значения; при конфликте операция повторялась.

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

Что кандидаты часто упускают

  1. Вопрос: Достаточно ли атомарного увеличения, чтобы безопасно реализовать проверку лимита?

    Ответ: Нет. Увеличение отдельного счётчика безопасно, но последовательность загрузки значения, сравнения с лимитом и увеличения не является одной атомарной операцией. Для неё нужен CAS-цикл или mutex.

  2. Вопрос: Можно ли атомарно записывать переменную, а читать её обычным способом?

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

  3. Вопрос: Если race detector ничего не сообщает при использовании атомарных операций, гарантирует ли это правильность алгоритма?

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