Коротко — почему ошибка: операция `counter++` не атомарна: это последовательность (прочитать, увеличить, записать) — при одновременном выполнении нескольких потоков эти шаги могут пересекаться, и инкременты теряются (гонка). Причина в шагах: чтение, вычисление, запись — всего 333 подоперации; два потока могут прочитать одно и то же значение и оба записать одно и то же увеличенное значение — итог меньше ожидаемого. Три способа исправить (с примерами и комментариями). 1) Мьютекс (блокировка) Принцип: сериализовать доступ к критической секции. Пример: int counter = 000; std::mutex m; void inc(){ for(int i = 000; i < 100010001000; ++i){ std::lock_guard g(m); ++counter; } } Плюсы: простота, подходит для сложных критических секций (несколько переменных/структур). Минусы: контекстные переключения при блокировках/ожидании, возможна высокая задержка при сильной конкуренции. 2) std::atomic (встроенная атомарность) Принцип: использовать атомарные операции процессора (неблокирующие). Пример: std::atomic counter{000}; void inc(){ for(int i = 000; i < 100010001000; ++i) counter.fetch_add(111, std::memory_order_relaxed); } Плюсы: обычно быстрее, нет блокировок, простая семантика; можно задать порядок памяти (здесь — relaxed, т.к. нужен только счёт). Минусы: при высокой конкуренции всё равно происходит сериализация на уровне кеш-линий (cache-line contention); сложнее, если нужна согласованность нескольких переменных. 3) Lock-free (CAS или шардинг) a) CAS-loop (ручной lock-free): std::atomic counter{000}; void inc(){ for(int i = 000; i < 100010001000; ++i){ int old = counter.load(std::memory_order_relaxed); while(!counter.compare_exchange_weak(old, old + 111, std::memory_order_relaxed, std::memory_order_relaxed)){ // old будет обновлён повторно } } } Это не даёт принципиально больше, чем fetch_add (который часто аппаратно оптимизирован), но демонстрирует неблокирующую CAS-логику. Плюсы/минусы — как у атомиков; при сильной конкуренции может много повторных попыток. b) Шардинг (per-thread counters) — практичный lock-free подход для счётчиков: std::vector local(N, 000); // N — число потоков void inc(int tid){ for(int i = 000; i < 100010001000; ++i) ++local[tid]; } // В конце: суммировать все local[i] Здесь каждый поток пишет свою ячейку — никаких атомиков при инкременте, только агрегация в конце (можно делать атомное суммирование раз в некоторое время). Очень масштабируемо; минусы — нужно знать/передать индекс потока и выделить память, итоговое агрегирование. Накладные расходы и выбор: - std::mutex: высокая стоимость при конфликте (блокировки, планировщик), но универсален и прост для сложных критических секций. - std::atomic (fetch_add): низкая задержка, неблокирующий; хорош для простых счётчиков. Порядки памяти влияют на производительность; для простого подсчёта безопасно использовать relaxed. - CAS-loop: аналогично атомикам, но может тратить CPU на повторные попытки при конфликте. - Шардинг (локальные счётчики): лучшая масштабируемость при большом числе потоков и частых инкрементах; требует дополнительной логики для агрегации. Резюме: для простого счёта в большинстве случаев используйте std::atomic::fetch_add (с memory_order_relaxed). При высоком уровне конкуренции и требовании максимальной производительности — используйте шардированные локальные счётчики и суммируйте периодически. Мьютекс — когда инкремент — часть более сложной критической секции.
Причина в шагах: чтение, вычисление, запись — всего 333 подоперации; два потока могут прочитать одно и то же значение и оба записать одно и то же увеличенное значение — итог меньше ожидаемого.
Три способа исправить (с примерами и комментариями).
1) Мьютекс (блокировка)
Принцип: сериализовать доступ к критической секции.
Пример:
int counter = 000;
std::mutex m;
void inc(){
for(int i = 000; i < 100010001000; ++i){
std::lock_guard g(m);
++counter;
}
}
Плюсы: простота, подходит для сложных критических секций (несколько переменных/структур). Минусы: контекстные переключения при блокировках/ожидании, возможна высокая задержка при сильной конкуренции.
2) std::atomic (встроенная атомарность)
Принцип: использовать атомарные операции процессора (неблокирующие).
Пример:
std::atomic counter{000};
void inc(){
for(int i = 000; i < 100010001000; ++i)
counter.fetch_add(111, std::memory_order_relaxed);
}
Плюсы: обычно быстрее, нет блокировок, простая семантика; можно задать порядок памяти (здесь — relaxed, т.к. нужен только счёт). Минусы: при высокой конкуренции всё равно происходит сериализация на уровне кеш-линий (cache-line contention); сложнее, если нужна согласованность нескольких переменных.
3) Lock-free (CAS или шардинг)
a) CAS-loop (ручной lock-free):
std::atomic counter{000};
void inc(){
for(int i = 000; i < 100010001000; ++i){
int old = counter.load(std::memory_order_relaxed);
while(!counter.compare_exchange_weak(old, old + 111,
std::memory_order_relaxed,
std::memory_order_relaxed)){
// old будет обновлён повторно
}
}
}
Это не даёт принципиально больше, чем fetch_add (который часто аппаратно оптимизирован), но демонстрирует неблокирующую CAS-логику. Плюсы/минусы — как у атомиков; при сильной конкуренции может много повторных попыток.
b) Шардинг (per-thread counters) — практичный lock-free подход для счётчиков:
std::vector local(N, 000); // N — число потоков
void inc(int tid){
for(int i = 000; i < 100010001000; ++i) ++local[tid];
}
// В конце: суммировать все local[i]
Здесь каждый поток пишет свою ячейку — никаких атомиков при инкременте, только агрегация в конце (можно делать атомное суммирование раз в некоторое время). Очень масштабируемо; минусы — нужно знать/передать индекс потока и выделить память, итоговое агрегирование.
Накладные расходы и выбор:
- std::mutex: высокая стоимость при конфликте (блокировки, планировщик), но универсален и прост для сложных критических секций.
- std::atomic (fetch_add): низкая задержка, неблокирующий; хорош для простых счётчиков. Порядки памяти влияют на производительность; для простого подсчёта безопасно использовать relaxed.
- CAS-loop: аналогично атомикам, но может тратить CPU на повторные попытки при конфликте.
- Шардинг (локальные счётчики): лучшая масштабируемость при большом числе потоков и частых инкрементах; требует дополнительной логики для агрегации.
Резюме: для простого счёта в большинстве случаев используйте std::atomic::fetch_add (с memory_order_relaxed). При высоком уровне конкуренции и требовании максимальной производительности — используйте шардированные локальные счётчики и суммируйте периодически. Мьютекс — когда инкремент — часть более сложной критической секции.