Коротко — потому что ++ не атомарен. Операция counter++ разлагается на загрузку значения из памяти, увеличение и запись обратно. Два потока могут одновременно загрузить одно и то же значение, оба увеличить и записать — одно из приращений «теряется». Это классическая гонка данных dataracedata racedatarace.
Ниже — несколько способов исправления с кратким обоснованием и влиянием на производительность.
Как исправляет: критическая секция вокруг counter++ гарантирует взаимное исключение, только один поток выполняет ++ одновременно.Пример: include
int counter = 0; pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; void incvoid</em>void</em>void</em>{ for(int i=0;i<1000000;i++){ pthread_mutex_lock(&m); counter++; pthread_mutex_unlock(&m); } return NULL; }
Производительность: простая и надёжная, но при высокой частоте коротких критических секций даёт серьёзную деградацию. Издержки: захват/освобождение мьютекса лок/уловкалок/уловкалок/уловка, возможные контекстные переключения при ожидании, сильное касание кеш-линий cache−linebouncingcache-line bouncingcache−linebouncing — фактически сериализация всех приращений.
2) Атомарные операции C11stdatomicилиGCC/ClangintrinsicsC11 stdatomic или GCC/Clang intrinsicsC11stdatomicилиGCC/Clangintrinsics
Как исправляет: атомарный инкремент выполняется как одна атомарная инструкция илиспомощьюCASили с помощью CASилиспомощьюCAS, предотвращая потерю инкрементов.Пример C11C11C11: include
Можно использовать GCC: atomic_fetch_add(&counter, 1, ATOMIC_RELAXED) или старые __sync_fetch_and_add.Производительность: быстрее, чем мьютекс в большинстве случаев, особенно если использован memory_order_relaxed нетлишнихбарьеровнет лишних барьеровнетлишнихбарьеров. Но при сильной конкуренции атомик всё равно вызывает кеш-бег cachecoherencytrafficcache coherency trafficcachecoherencytraffic и становится узким местом — инкременты логически сериализуются на уровне кэш-контроля.
3) Батчинг / per-thread локальные счётчики рекомендуемыйподходпривысокомнасыщениирекомендуемый подход при высоком насыщениирекомендуемыйподходпривысокомнасыщении
Идея: каждый поток инкрементирует локальную переменную без синхронизации, и только периодически или в конце одним атомиком добавляет накопленное значение в глобальный counter. Это сильно уменьшает количество синхронизаций.Пример: int counter = 0; // глобальный void incvoid</em>void</em>void</em>{ int local = 0; for(int i=0;i<1000000;i++){ local++; if local==1000local == 1000local==1000{ // flush каждые 1000 atomic_fetch_add(&counter, local, ATOMIC_RELAXED); local = 0; } } if locallocallocal atomic_fetch_add(&counter, local, ATOMIC_RELAXED); return NULL; }Производительность: очень хорошая при больших объёмах инкрементов — минимизирует трафик кеш-линии и уменьшает количество атомик-операций.
4) Шардирование/striped counters
Идея: иметь массив счетчиков по числу потоков илипоядрамили по ядрамилипоядрам: каждый поток пишет только в свой слот, а финальная сумма вычисляется суммированием всех слотов.Производительность: как per-thread локальные счётчики, но более систематично предотвращает конфликт записи в одну кэш-линию надовыравниватьслоты,чтобыизбежатьfalsesharingнадо выравнивать слоты, чтобы избежать false sharingнадовыравниватьслоты,чтобыизбежатьfalsesharing.
5) Детерминированное планирование / сериализация
Что это значит: сделать исполнение потоков детерминированным/последовательным например,запускатьодинпотокдоконца,затемдругой;илииспользоватьruntime,которыйобеспечиваетдетерминированныеинтерлевингинапример, запускать один поток до конца, затем другой; или использовать runtime, который обеспечивает детерминированные интерлевингинапример,запускатьодинпотокдоконца,затемдругой;илииспользоватьruntime,которыйобеспечиваетдетерминированныеинтерлевинги.Как исправляет: устраняет гонки за счёт отсутствия одновременного доступа, даёт строго детерминированный counter.Производительность: обычно значительно хуже, т. к. лишает программу параллелизма — если цель была ускорение за счёт многопоточности, этот подход сводит его на нет. Может быть полезен для отладки или воспроизводимого тестирования record−replay,Dthreadsит.п.record-replay, Dthreads и т.п.record−replay,Dthreadsит.п..
Дополнения по выбору памяти/порядка memoryorderingmemory orderingmemoryordering
Если глобальный счётчик используется только для учёта суммарных инкрементов и вам не требуется упорядоченность относительно других операций — atomic_fetch_add с memory_order_relaxed достаточно и быстрее недаётлишнихбарьеровне даёт лишних барьеровнедаётлишнихбарьеров.Если нужен строгий глобальный порядок, используйте memory_order_seq_cst поумолчаниюпо умолчаниюпоумолчанию или stronger ordering, но это может быть дороже.
Коротко о производительности и выборе
Для корректности: достаточно атомарной операции или мьютекса.Для простоты и совместного доступа с другими данными: мьютекс — самый прямой, но может стать узким местом.Для высокой производительности при большом количестве инкрементов: per-thread буферизация или sharded counters + редукция суммированиевконцесуммирование в концесуммированиевконце — наилучший компромисс.Атомики — удобны и обычно быстрее мьютекса, но при очень высокой конкуренции всё равно будут замедляться из‑за кеш‑кооперации.Детерминизация решает проблему согласованности только ценой потери параллелизма; в реальных рабочих нагрузках это редко оптимально, разве что для отладки/воспроизводимости.
Резюме: причина — гонка данных. Простое исправление — использовать atomic_fetch_add илимьютексили мьютексилимьютекс. Для максимальной производительности при интенсивных инкрементах — агрегировать в локальные счётчики или использовать полосные shardedshardedsharded счётчики, чтобы уменьшить конкуренцию за одну память/кэш-линию.
Коротко — потому что ++ не атомарен. Операция counter++ разлагается на загрузку значения из памяти, увеличение и запись обратно. Два потока могут одновременно загрузить одно и то же значение, оба увеличить и записать — одно из приращений «теряется». Это классическая гонка данных dataracedata racedatarace.
Ниже — несколько способов исправления с кратким обоснованием и влиянием на производительность.
1) Мьютекс pthreadmutextpthread_mutex_tpthreadm utext
Как исправляет: критическая секция вокруг counter++ гарантирует взаимное исключение, только один поток выполняет ++ одновременно.Пример:include
int counter = 0;
Производительность: простая и надёжная, но при высокой частоте коротких критических секций даёт серьёзную деградацию. Издержки: захват/освобождение мьютекса лок/уловкалок/уловкалок/уловка, возможные контекстные переключения при ожидании, сильное касание кеш-линий cache−linebouncingcache-line bouncingcache−linebouncing — фактически сериализация всех приращений.pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
void incvoid</em>void</em>void</em>{
for(int i=0;i<1000000;i++){
pthread_mutex_lock(&m);
counter++;
pthread_mutex_unlock(&m);
}
return NULL;
}
2) Атомарные операции C11stdatomicилиGCC/ClangintrinsicsC11 stdatomic или GCC/Clang intrinsicsC11stdatomicилиGCC/Clangintrinsics
Как исправляет: атомарный инкремент выполняется как одна атомарная инструкция илиспомощьюCASили с помощью CASилиспомощьюCAS, предотвращая потерю инкрементов.Пример C11C11C11:include
atomic_int counter = ATOMIC_VAR_INIT000;
Можно использовать GCC: atomic_fetch_add(&counter, 1, ATOMIC_RELAXED) или старые __sync_fetch_and_add.Производительность: быстрее, чем мьютекс в большинстве случаев, особенно если использован memory_order_relaxed нетлишнихбарьеровнет лишних барьеровнетлишнихбарьеров. Но при сильной конкуренции атомик всё равно вызывает кеш-бег cachecoherencytrafficcache coherency trafficcachecoherencytraffic и становится узким местом — инкременты логически сериализуются на уровне кэш-контроля.void incvoid</em>void</em>void</em>{
for(int i=0;i<1000000;i++)
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
return NULL;
}
3) Батчинг / per-thread локальные счётчики рекомендуемыйподходпривысокомнасыщениирекомендуемый подход при высоком насыщениирекомендуемыйподходпривысокомнасыщении
Идея: каждый поток инкрементирует локальную переменную без синхронизации, и только периодически или в конце одним атомиком добавляет накопленное значение в глобальный counter. Это сильно уменьшает количество синхронизаций.Пример:int counter = 0; // глобальный
void incvoid</em>void</em>void</em>{
int local = 0;
for(int i=0;i<1000000;i++){
local++;
if local==1000local == 1000local==1000{ // flush каждые 1000
atomic_fetch_add(&counter, local, ATOMIC_RELAXED);
local = 0;
}
}
if locallocallocal atomic_fetch_add(&counter, local, ATOMIC_RELAXED);
return NULL;
}Производительность: очень хорошая при больших объёмах инкрементов — минимизирует трафик кеш-линии и уменьшает количество атомик-операций.
4) Шардирование/striped counters
Идея: иметь массив счетчиков по числу потоков илипоядрамили по ядрамилипоядрам: каждый поток пишет только в свой слот, а финальная сумма вычисляется суммированием всех слотов.Производительность: как per-thread локальные счётчики, но более систематично предотвращает конфликт записи в одну кэш-линию надовыравниватьслоты,чтобыизбежатьfalsesharingнадо выравнивать слоты, чтобы избежать false sharingнадовыравниватьслоты,чтобыизбежатьfalsesharing.5) Детерминированное планирование / сериализация
Что это значит: сделать исполнение потоков детерминированным/последовательным например,запускатьодинпотокдоконца,затемдругой;илииспользоватьruntime,которыйобеспечиваетдетерминированныеинтерлевингинапример, запускать один поток до конца, затем другой; или использовать runtime, который обеспечивает детерминированные интерлевингинапример,запускатьодинпотокдоконца,затемдругой;илииспользоватьruntime,которыйобеспечиваетдетерминированныеинтерлевинги.Как исправляет: устраняет гонки за счёт отсутствия одновременного доступа, даёт строго детерминированный counter.Производительность: обычно значительно хуже, т. к. лишает программу параллелизма — если цель была ускорение за счёт многопоточности, этот подход сводит его на нет. Может быть полезен для отладки или воспроизводимого тестирования record−replay,Dthreadsит.п.record-replay, Dthreads и т.п.record−replay,Dthreadsит.п..Дополнения по выбору памяти/порядка memoryorderingmemory orderingmemoryordering
Если глобальный счётчик используется только для учёта суммарных инкрементов и вам не требуется упорядоченность относительно других операций — atomic_fetch_add с memory_order_relaxed достаточно и быстрее недаётлишнихбарьеровне даёт лишних барьеровнедаётлишнихбарьеров.Если нужен строгий глобальный порядок, используйте memory_order_seq_cst поумолчаниюпо умолчаниюпоумолчанию или stronger ordering, но это может быть дороже.Коротко о производительности и выборе
Для корректности: достаточно атомарной операции или мьютекса.Для простоты и совместного доступа с другими данными: мьютекс — самый прямой, но может стать узким местом.Для высокой производительности при большом количестве инкрементов: per-thread буферизация или sharded counters + редукция суммированиевконцесуммирование в концесуммированиевконце — наилучший компромисс.Атомики — удобны и обычно быстрее мьютекса, но при очень высокой конкуренции всё равно будут замедляться из‑за кеш‑кооперации.Детерминизация решает проблему согласованности только ценой потери параллелизма; в реальных рабочих нагрузках это редко оптимально, разве что для отладки/воспроизводимости.Резюме: причина — гонка данных. Простое исправление — использовать atomic_fetch_add илимьютексили мьютексилимьютекс. Для максимальной производительности при интенсивных инкрементах — агрегировать в локальные счётчики или использовать полосные shardedshardedsharded счётчики, чтобы уменьшить конкуренцию за одну память/кэш-линию.