Программирование GoГорутины и каналыGo-разработчик серверных систем

В чём ошибка в логике, если атомарно прочитать счётчик, проверить лимит и затем атомарно увеличить его?

В чём ошибка в логике, если атомарно прочитать счётчик, проверить лимит и затем атомарно увеличить его?

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

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

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

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

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

Обычное изменение общего счётчика состоит из чтения, вычисления нового значения и записи. При конкурентном выполнении эти шаги разных горутин могут перемешаться, из-за чего возникают потерянные обновления и некорректные проверки.

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

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

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

Даже если чтение и увеличение атомарны, между ними остаётся окно гонки. В результате можно получить значение одиннадцать или допустить две операции при доступном только одном слоте.

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

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

Атомарность гарантирует неделимость конкретной операции, например загрузки, сложения или сравнения с заменой. Она не распространяется на соседние операции, если явно не используется составной примитив вроде Compare-And-Swap.

Безопасный вариант — прочитать текущее значение, вычислить ожидаемое новое значение и заменить его только в том случае, если значение всё ещё равно прочитанному. Если другая горутина успела изменить счётчик, замена не выполняется, и цикл повторяет попытку.

package main import ( "fmt" "sync/atomic" ) func acquire(n *int64, limit int64) bool { for { old := atomic.LoadInt64(n) if old >= limit { return false } if atomic.CompareAndSwapInt64(n, old, old+1) { return true } } } func main() { var active int64 fmt.Println(acquire(&active, 10)) }

Вызов CompareAndSwapInt64 успешен только при сохранении ожидаемого значения. Поэтому две горутины не смогут обе занять один и тот же последний слот.

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

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

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

Сервис ограничивает число одновременно выполняемых фоновых задач. В первом варианте разработчик атомарно читает число активных задач, сравнивает его с лимитом, запускает новую горутину и затем атомарно увеличивает счётчик. При всплеске запросов несколько горутин проходят проверку одновременно, и реальное число задач превышает лимит.

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

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

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

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

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

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

  1. Может ли цикл CAS голодать при постоянной конкуренции?

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

  1. Когда атомарный счётчик подходит, а когда нужен mutex?

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