В sync есть блокировки (Mutex, RWMutex), средства координации (WaitGroup, Once, Cond) и два специальных контейнера (Map, Pool); sync/atomic добавляет операции без блокировок над одиночными значениями. Вторым инструментом служат каналы: они нужны, чтобы передавать данные от одной горутины к другой.
**Mutex: в критической секции одновременно только одна горутина; основной способ защитить общее состояние.
RWMutex: много читателей или один писатель; окупается, только когда чтения долгие, а записи редкие.
WaitGroup**: дождаться завершения набора горутин; начиная с Go 1.25 есть wg.Go(f).
**Once, OnceValue: выполнить инициализацию ровно один раз, сколько бы горутин её ни вызвало.
Cond: спать, пока не изменится условие над защищённым состоянием; используется редко, канал обычно справляется лучше.
Map**: конкурентная map для сценариев «записал один раз» или непересекающихся ключей, а не универсальная замена map.
**Pool: переиспользовать короткоживущие объекты вроде буферов, чтобы сократить аллокации; GC может опустошить его в любой момент.
sync/atomic**: atomic.Int64, atomic.Bool, atomic.Pointer[T] для счётчиков, флагов и подмены одного указателя.
Нулевое значение каждого из них готово к работе, и ни один нельзя копировать после первого использования (копия становится отдельной, никак не связанной блокировкой; go vet ловит это проверкой copylocks).
GoMiddle
sync, атомики и модель памяти
Мьютексы, sync.Once, атомики, happens-before, детектор гонок
Закрыто вопросов
0 / 22
- 0Повторить
- 6Часто спрашивают
Вопросы
**wg.Add(1) выполняется внутри горутины, поэтому может случиться уже после того, как wg.Wait() увидел нулевой счётчик.** Тогда fetchAll возвращает rows, пока горутины ещё пишут в него: результаты теряются, и возникает data race, о которой сообщит -race.
go func() только планирует горутину. Цикл может запустить их все и дойти до Wait раньше, чем хотя бы одна вызовет Add.
Правило: Add вызывают на горутине, которая запускает работу, до оператора go, а первой строкой горутины ставят defer wg.Done(), чтобы ранний return тоже уменьшал счётчик.
В Go 1.25 появился wg.Go(f): он делает Add(1), go и отложенный Done за один вызов, так что поставить что-то не туда уже нельзя. f не должна паниковать.
Начиная с Go 1.25 go vet ловит именно эту ошибку: WaitGroup.Add called from inside new goroutine.
Захватывать i и id в замыкании безопасно начиная с Go 1.22: у каждой итерации цикла свои переменные.
**defer mu.Unlock() закрывает все выходы**: ранний return, тот, который кто-то добавит через месяц, и панику посередине. Явный Unlock закрывает только те пути, что были на момент написания.
return, перепрыгнувший через Unlock, оставляет мьютекс захваченным навсегда, и каждый следующий Lock виснет.
Цена defer в области действия: он держит блокировку до возврата из функции, а это неправильно, если дальше функция идёт в сеть или делает другой медленный ввод-вывод.
Тогда разделяйте вручную: взять блокировку, скопировать нужное, отпустить; сделать медленную работу; снова взять блокировку и записать результат.
При deadlock горутины ждут друг друга и ничего не движется; при livelock они продолжают работать, но постоянно сводят на нет прогресс друг друга; при starvation (голодании) система движется, но одна горутина редко или вообще никогда не получает ресурс.
Deadlock: две горутины держат по блокировке, нужной другой, или горутина ждёт канал, в который никто никогда не отправит. CPU простаивает. Go печатает fatal error: all goroutines are asleep - deadlock!, только когда заблокированы все горутины; в сервере с другими живыми горутинами всё просто молча висит.
Livelock: два воркера оба видят конфликт, отступают и повторяют попытку синхронно, бесконечно, например через TryLock с одинаковой фиксированной задержкой. CPU занят, ничего не завершается. Случайный разброс (jitter) в задержке ломает эту симметрию.
Starvation: жадная горутина раз за разом выигрывает блокировку, пока другая ждёт. sync.Mutex от этого защищён: если ожидающий ждал дольше 1 мс, мьютекс переходит в режим голодания и передаёт блокировку строго по очереди (FIFO).
Соответственно, и лечатся они по-разному: единый порядок блокировок от deadlock, jitter от livelock, честная передача или более короткие критические секции от starvation.
**Горутина заблокирует сама себя: sync.Mutex не реентерабелен, поэтому Lock внутри Get ждёт Unlock, который может выполниться только после него.** Если больше ничего не работает, рантайм остановится с fatal error: all goroutines are asleep - deadlock!; в сервере просто повиснет каждый вызывающий.
У мьютекса в Go нет владельца. Он не запоминает, какая горутина его захватила (Unlock может вызвать даже другая горутина), поэтому отличить рекурсивный вызов от конкурирующего он не может.
Так сделано намеренно. Пока вы держите блокировку, инвариант, который она защищает, может быть обновлён наполовину; реентерабельный вызов отработал бы на этом сломанном состоянии и скрыл бы баг.
Идиома: экспортируемые методы берут блокировку, а неэкспортируемый хелпер делает работу и документирует, что вызывающий уже держит c.mu (часто его называют getLocked). Методы под блокировкой никогда не вызывают другие методы, берущие блокировку.
TryLock (Go 1.18) возвращает false вместо ожидания. Проблему реентерабельности он не решает, а его собственная документация предупреждает: если он понадобился, это часто признак более глубокой проблемы в дизайне.
Снятие незахваченного мьютекса тоже фатально, а не обычная паника: fatal error: sync: unlock of unlocked mutex, recover его не перехватит.
**Publish держит mu, пока делает блокирующую отправку в небуферизованный канал, поэтому один подписчик, который не читает, держит мьютекс вечно.**
Publish застревает в ch <- msg для зависшего клиента при взятом mu; каждый Subscribe и каждый другой Publish ждут этот мьютекс, и хаб мёртв.
С самой блокировкой всё в порядке, а -race и go vet молчат: программа корректна, она просто ждёт то, что никогда не случится.
Критическая секция должна быть короткой и не должна ждать того, что вы не контролируете: отправку или чтение из канала, сетевой или дисковый ввод-вывод, другую блокировку, вызов пользовательского колбэка. Время под блокировкой означает время, которое блокированы все остальные.
Исправление: скопировать нужное под блокировкой (slices.Collect(maps.Values(h.subs))), отпустить её и только потом отправлять. Теперь mu охраняет лишь map.
Сама отправка тоже не должна блокироваться: дайте каждому подписчику буферизованный канал и используйте select с default (отбросить сообщение или отключить медленного клиента) либо отдельную горутину на подписчика с context.
Как узнать симптом: дамп горутин (kill -QUIT или /debug/pprof/goroutine?debug=2) показывает много горутин в sync.Mutex.Lock и одну в chan send, у которой выше по стеку взята та же блокировка.
Копия может гоняться с Unsubscribe: сообщение может прийти в канал сразу после его удаления. Никогда не закрывайте канал подписчика из Publish; закрывает его владелец.
**Используйте sync.Once или, начиная с Go 1.21, sync.OnceValue: функция инициализации выполняется ровно один раз для всех вызывающих, и каждый из них возвращается только после того, как она завершилась.** Обычная проверка на nil не даёт ни одной из этих гарантий.
С if client == nil { client = newClient() } две горутины могут обе увидеть nil и обе создать клиента. К тому же это data race, так что читатель может увидеть указатель раньше, чем поля за ним.
Once ещё и публикует результат: всё, что записала f, видно каждой горутине, чей Do вернулся.
Do считает вызов выполненным, даже если f запаниковала, так что провалившаяся инициализация не повторится. OnceValue и OnceValues при каждом вызове снова паникуют тем же значением.
OnceValues кэширует и ошибку, причём навсегда. Если инициализация должна повторяться после сбоя, берите мьютекс с проверкой на nil, а не Once.
Когда инициализация дешёвая и не может упасть, ещё проще пакетная var x = newX(): инициализация пакета выполняется один раз, до main.
Гонка данных (data race) возникает, когда две горутины одновременно обращаются к одной памяти, хотя бы одна пишет, и синхронизации нет; race condition означает логическую ошибку, при которой результат зависит от порядка выполнения. -race находит только первое, поэтому код, где каждое обращение под блокировкой, всё равно может быть неверным.
if a.Balance() >= n { a.add(-n) } берёт блокировку дважды. Две горутины обе проходят проверку на балансе 100 и обе снимают по 80: каждое обращение было под блокировкой, а пара не была.
Ищите связку «проверил, потом сделал», разнесённую по двум захватам блокировки: if !exists { create } или чтение через геттер с последующей записью через сеттер.
Чинится так: решение и изменение делаются под одним удержанием блокировки. Метод Withdraw на самом типе, а не два аксессора под блокировкой, которые комбинирует вызывающий.
Блокировка защищает инвариант, а не поле.
Гонка данных (data race) не сводится к устаревшему значению: компилятор и процессор вправе выдать результат, который никакой последовательностью устаревших чтений не объяснить.
Ничто не обязывает перечитывать переменную. for !ready {} может один раз загрузить ready в регистр и крутиться вечно.
Между payload = build() и ready = true нет порядка, поэтому читатель, увидевший ready == true, всё ещё может получить payload == nil и упасть. На arm64 эти записи переставляет ещё и процессор, поэтому такие баги проявляются только на продовом парке машин.
Аргумент «не порвётся» верен только для значений размером в машинное слово. Интерфейс, заголовок среза или string могут прочитаться наполовину из одной записи и наполовину из другой, а порванный интерфейс способен уронить программу.
Как чинить: останавливайте через ctx.Done() или close(stop); для простого флага берите atomic.Bool (stop.Store(true) / stop.Load()); чтобы передать данные, опубликуйте их через один atomic.Pointer[T].
atomic.Bool стоит одну инструкцию на пути чтения. Аргумента производительности в пользу гоночной версии нет.
Атомик делает одно чтение, запись или read-modify-write одного значения неделимым и видимым другим горутинам, без блокировки. Берите его для счётчика, флага или подмены одного указателя; переходите на Mutex, как только два значения или проверка с обновлением должны меняться вместе.
API: начиная с Go 1.19 типизированные atomic.Int32, atomic.Int64, atomic.Uint64, atomic.Bool и atomic.Pointer[T] с методами Load, Store, Add, Swap и CompareAndSwap. Предпочитайте их atomic.AddInt64(&x, 1): случайного обычного чтения не будет, а о 64-битном выравнивании они заботятся сами.
Стоимость: атомик без конкуренции сводится к одной инструкции, что дешевле пары Lock/Unlock, и он никогда не паркует горутину. Но при сильной конкуренции он всё равно гоняет одну кеш-линию между ядрами.
Ограничения: атомарна каждая операция, но не их последовательность: Load, а затем Store теряет обновления. Составной логике нужен цикл повторов с CompareAndSwap, а всему, что охватывает несколько полей, нужна блокировка.
Порядок: атомики в Go последовательно согласованы: Store происходит до Load, который его увидел, поэтому атомик может публиковать и другие данные. Ослабленных вариантов или acquire/release для тонкой настройки нет.
Lock-free структуры: строятся из таких CAS-циклов; писать их самому редко имеет смысл.
По умолчанию берите мьютекс; к атомикам переходите, когда состояние действительно умещается в одно слово или когда профиль показывает узкое место в блокировке.
Атомарна каждая операция, но не пара. Read-modify-write должен быть одним шагом, а это CompareAndSwap в цикле повторов.
Две горутины с 90 и 100 обе читают старый максимум 50 и обе проходят сравнение. Записывается 100, затем его затирает 90. Ни одно обращение к памяти не гонялось, поэтому -race молчит.
CompareAndSwap(old, new) пишет, только если значение всё ещё равно old. Если его кто-то изменил, вы снова делаете Load и решаете заново: цикл и есть алгоритм, и этот же цикл служит основой любой lock-free структуры.
Add и Swap уже выполняются за один шаг, так что обычному счётчику цикл не нужен.
Предпочитайте типы из Go 1.19 вроде atomic.Int64 вызову atomic.AddInt64(&x, n) на обычном поле: они остаются выровненными на 64 бита на 32-битных платформах, где невыровненный int64 заставляет старые функции паниковать.
Mutex пускает одну горутину за раз; RWMutex пускает много читателей одновременно, а писателям даёт эксклюзивный доступ. Но каждый RLock и RUnlock атомарно обновляет один общий счётчик читателей, поэтому при коротком чтении читатели всё равно борются за одну кеш-линию, и выигрыша нет.
RWMutex окупается, когда читатели держат блокировку достаточно долго, чтобы реально перекрываться (обход большой структуры, сборка ответа), и записи редки. Для одного поиска по map он не быстрее Mutex и может оказаться медленнее.
Писатели платят больше: Lock берёт внутренний мьютекс, а затем ждёт, пока выйдут все активные читатели.
Ожидающий писатель блокирует новых читателей, чтобы писатели не голодали. Следствие: рекурсивный захват на чтение может привести к дедлоку. Горутина A держит RLock, в очередь встаёт писатель, A снова вызывает RLock и навсегда застревает за писателем.
Перед выбором измерьте оба варианта на разном числе ядер: go test -bench . -cpu=1,4,8,16.
Если одна блокировка остаётся узким местом в любом случае, перестаньте её делить: шардируйте map или публикуйте неизменяемый снимок через atomic.Pointer[T].
Mutex состоит из двух полей: слова state (биты «захвачен», «разбужен», «голодание» и счётчик ожидающих) и семафора. Lock сначала пробует один compare-and-swap из 0 в «захвачен»; только если не вышло, он недолго активно ждёт в цикле (спин), а затем паркует горутину в очереди семафора.
Быстрый и медленный путь
Быстрый путь: Lock без конкуренции выполняет один CAS, встроенный (inline) в вызывающий код; Unlock выполняет одно атомарное сложение.
Спин: при конкуренции он крутится до 4 коротких раундов, и только на многоядерной машине, где работают другие P, а локальная очередь пуста, в расчёте на то, что владелец вот-вот отпустит блокировку.
Парковка: после этого горутина засыпает на семафоре. Планировщик выполняет другие горутины; ни один поток ОС не блокируется.
Нормальный режим
Ожидающие стоят в очереди FIFO, но разбуженному приходится соревноваться с только что пришедшими горутинами, которые уже выполняются на CPU и обычно выигрывают.
Это даёт высокую пропускную способность (работающая горутина может взять блокировку несколько раз подряд) ценой несправедливых хвостовых задержек.
Режим голодания
Если ожидающий не может получить блокировку дольше 1 мс, мьютекс переключается: Unlock передаёт владение напрямую первому в очереди, а новые горутины не крутятся и не хватают блокировку, а встают в конец очереди.
Обратно в нормальный режим он возвращается, когда ожидающий получает блокировку и либо он последний в очереди, либо ждал меньше 1 мс.
sync.Map устроена как конкурентная map, настроенная под два сценария, которые называет её документация: ключи, записанные один раз и читаемые много раз, как кэш, который только растёт, и горутины, работающие с непересекающимися наборами ключей. Для всего остального документация советует обычную map со своей блокировкой.
Чем вы платите: ключи и значения имеют тип any, поэтому каждое чтение требует приведения типа, а значения упаковываются в интерфейс. Метода Len нет: посчитать элементы можно только через Range, который обходит всё и не даёт согласованного снимка.
Инварианты: блокировка позволяет держать map согласованной с чем-то ещё: счётчиком размера, вторым индексом, связкой «проверил, потом вставил» по нескольким ключам. sync.Map даёт только атомарные операции над одним ключом: LoadOrStore, LoadAndDelete, Swap и CompareAndSwap.
Где выигрывает: преимущественно чтение со многих ядер: загрузка существующего ключа не берёт блокировку, поэтому масштабируется там, где общий счётчик читателей одного RWMutex не справляется.
Go 1.24: реализацию переписали на конкурентное хеш-дерево (hash-trie), так что интенсивные вставки и удаления больше не платят прежний штраф. Теперь производительность служит слабым доводом против sync.Map; типобезопасность и отсутствие Len остаются доводами.
Когда одна блокировка слишком горячая, а типы и подсчёт всё ещё нужны, берите шардированную map: N обычных map, у каждой свой RWMutex, выбор по хешу ключа.
-race на этапе компиляции инструментирует каждое обращение к памяти, а во время работы отслеживает, какие обращения упорядочены блокировками, каналами и атомиками. Два обращения к одному адресу, хотя бы одно на запись, без порядка между ними попадают в отчёт WARNING: DATA RACE с обоими стеками.
Только динамика: он видит гонки только между обращениями, которые действительно произошли в этом запуске. Тест с одной горутиной или путь за флагом, выключенным в тестах, не найдут ничего, так что зелёный прогон ничего не доказывает.
Ограниченная история: он помнит лишь несколько последних обращений к каждому слову памяти, поэтому два обращения, далеко разнесённых по времени, могут проскочить.
Не его задача: дедлоки, утечки горутин и логические гонки вроде «проверил, потом сделал» через две блокировки для него невидимы.
Стоимость: по документации Go это 5–10x памяти и 2–20x CPU, и собирается другой бинарник. Ему место в CI и, может быть, на одном канареечном инстансе в стейджинге, но никогда не на продовом парке.
Заставьте тесты создавать конкурентность: много горутин на одном ключе, go test -race -count=10 по подозрительному пакету и GORACE=halt_on_error=1, чтобы остановиться на первом отчёте.
Cond позволяет горутинам спать, пока не изменится защищённое состояние: Wait атомарно отпускает L и паркует горутину, Signal будит одного ожидающего, Broadcast будит всех, а Wait перед возвратом снова берёт блокировку. Канал делает то же самое и вдобавок работает внутри select, а Cond так не умеет.
Предикат всегда перепроверяют в цикле for, никогда в if: к моменту, когда Wait снова взял блокировку, другая горутина могла уже забрать то, ради чего вас разбудили.
Cond нельзя положить в select, поэтому у Wait не может быть таймаута, ветки ctx.Done() или завершения работы. Тогда команды добавляют горутину, которая вызывает Broadcast по тикеру: busy-poll, замаскированный под синхронизацию.
Перевод прямой: Signal соответствует отправке в канал, Broadcast означает его закрытие, а ограниченная очередь становится буферизованным каналом.
Cond всё ещё уместен, когда много горутин ждут сложного предиката над общим состоянием и их будят снова и снова.
**Snapshot возвращает собственную map реестра, а не копию, поэтому блокировка на чтение защищает только оператор return.** Вызывающий код перебирает map без всякой блокировки, пока Add пишет в неё, и рантайм падает с concurrent map iteration and map write.
Map, как слайс или указатель, ссылается на общие данные: вернув её, вы отдаёте вызывающему ровно то, что охраняет мьютекс. RUnlock срабатывает при возврате, и каждое чтение после этого уже не синхронизировано.
Исправление копирует данные, пока блокировка ещё взята: maps.Clone(r.peers) (Go 1.21+). Копия принадлежит вызывающему, её можно перебирать, сортировать и менять.
Мьютекс защищает доступы, а не значения: любой геттер у типа с блокировкой, который возвращает map, слайс или указатель внутрь защищённого состояния, выносит его за пределы критической секции. На ревью такие геттеры стоит проверять именно на это.
Клонирование стоит O(n) на вызов. Для большой map, которую читают гораздо чаще, чем пишут, храните неизменяемую map в atomic.Pointer, а Add (по-прежнему под мьютексом) пусть публикует свежую копию; тогда Snapshot сводится к одной атомарной загрузке.
-race поймает это, если тест перебирает Snapshot(), пока другая горутина вызывает Add. Утёкший слайс ломается так же, но молча, без падения рантайма.
Pool держит на каждый процессор списки свободных объектов, которые вы вернули через Put, так что следующий Get переиспользует объект вместо аллокации; это снижает нагрузку на GC от короткоживущих объектов вроде буферов для кодирования. Это не кэш: GC выбрасывает его содержимое максимум через два цикла, а ещё он может удерживать в памяти раздутые объекты.
Как устроен пул
У каждого P свой локальный список, поэтому Get и Put на текущем P не берут блокировку; если у P пусто, он крадёт у других, прежде чем вызвать New.
Каждая сборка мусора переносит содержимое пула в victim-кэш, а следующая выбрасывает его, так что объект живёт максимум два цикла GC.
Когда вредит
Мало трафика: между запросами проходят две сборки мусора и пул опустошается, доля попаданий падает почти до нуля, и New становится обычным путём. Вреда нет, но и пользы тоже.
Неограниченный размер: возвращённый буфер сохраняет ёмкость, до которой дорос. Один огромный запрос паркует огромный буфер на каждом P, пока не пройдут две сборки; всё, что больше порога, выбрасывайте, а не кладите через Put.
Старые данные: Get может вернуть уже использованный объект, поэтому сбрасывайте его сразу после Get, а не только перед Put.
Не те объекты: соединения и всё, чему нужен Close или жёсткий лимит: элементы исчезают без уведомления. Нужен настоящий пул, например тот, что внутри database/sql.
Значения вместо указателей: Put не-указателя упаковывает его в интерфейс и аллоцирует, сводя смысл на нет.
Добавляйте пул, только когда -benchmem или профиль аллокаций показывает, что эти аллокации имеют значение.
Happens-before обозначает порядок, который гарантирует модель памяти: если запись w происходит до чтения r, то r увидит w или более позднюю запись. Внутри одной горутины его даёт порядок программы; между горутинами его дают только каналы, блокировки, Once, WaitGroup и атомики.
Каналы: отправка происходит до завершения соответствующего приёма; close происходит до приёма, который вернулся из-за закрытия канала; на небуферизованном канале приём происходит до завершения отправки. k-й приём из канала ёмкости C происходит до завершения (k+C)-й отправки, поэтому буферизованный канал работает как семафор.
sync: Unlock происходит до возврата из следующего Lock; f в once.Do(f) происходит до возврата из любого Do; Done происходит до Wait, который он разблокировал; атомарный Store происходит до Load, который его увидел.
Горутины: оператор go происходит до старта горутины, а вот её завершение не упорядочено ни перед чем. «Она наверняка уже закончила», time.Sleep и runtime.Gosched рёбер не создают.
Почему это важно: без ребра компилятор вправе переставить или закэшировать две обычные записи, а процессоры arm64 фиксируют записи не по порядку. Так код, корректный на ноутбуке, раз в неделю падает на arm64-парке.
Контракт: программа без data race ведёт себя так, будто она последовательно согласована. Программа с гонками эту гарантию теряет; Go ограничивает ущерб, но рассуждать о её поведении нельзя.
go test -race проверяет ровно это отношение: отчёт означает два обращения без ребра между ними.
**Внешняя проверка cfg == nil означает несинхронизированное чтение указателя, который пишет другая горутина, то есть data race: можно увидеть наполовину собранную структуру. Публикуйте конфиг через atomic.Pointer[Config]: соберите новое значение, сделайте Store, читатели делают Load.**
Мьютекс упорядочивает только внутреннюю проверку и запись; читатель, который блокировку не берёт, целиком вне этого порядка.
Опаснее всего публикация. load() аллоцирует Config, заполняет поля и только потом пишет указатель; без точки упорядочивания указатель может стать видимым раньше полей, и запрос прочитает нулевой Rate.
Load() на пути запроса обходится одной инструкцией без блокировки, а Store(c) публикует все поля, достижимые через указатель.
Вместе с этим идёт контракт: собрать свежий *Config, опубликовать его и никогда не менять после Store. Неудачная перезагрузка продолжает отдавать старое значение.
RWMutex вокруг указателя тоже работает, но тогда каждый запрос платит за общий счётчик читателей.
Собирается один раз и никогда не перезагружается? Это sync.OnceValue или sync.OnceValues.
**Инверсия порядка блокировок: transfer(x, y) берёт x, затем y, а transfer(y, x) берёт y, затем x, и каждая ждёт блокировку, которую держит другая.** Чинится одним фиксированным порядком, которому подчиняется каждый путь захвата.
Детектор дедлоков в Go срабатывает, только когда заблокированы все горутины, поэтому сервер с сотнями здоровых горутин о нём не сообщит. Выглядит это как медленная утечка зависших запросов; пошлите SIGQUIT или откройте /debug/pprof/goroutine?debug=2 и ищите горутины, припаркованные в sync.Mutex.Lock.
Задайте порядок ключом, который у объектов уже есть, например id счёта, и всегда захватывайте по возрастанию. Тогда две горутины выстраиваются в очередь за одной и той же первой блокировкой, а не образуют цикл.
Случай a == b обработайте отдельно: с одним счётом код дважды захватил бы один и тот же нереентерабельный мьютекс.
Структурное правило, которое предотвращает большинство таких случаев: не вызывайте код за пределами своего пакета, удерживая блокировку; именно так внутри вашей критической секции берётся неизвестная вторая блокировка.
Ложное разделение (false sharing): соседние счётчики лежат в одной кеш-линии, а когерентность кешей процессора работает по линиям, а не по переменным. Запись в один элемент инвалидирует всю линию в кешах всех остальных ядер, вместе с соседями.
Ядро не может владеть половиной линии. Атомарный Add забирает всю линию в эксклюзивное владение и выбивает копии, которые другие ядра держали для своих счётчиков.
Логически элементы раздельны, но физически соседствуют, поэтому чем больше ядер пишут, тем сильнее линия скачет между ними. Стоимость операции, которая растёт с -cpu=1,2,4,8,16, и есть характерный признак, а -race молчит, потому что гонки на самом деле нет.
Дополняйте каждый элемент до полной кеш-линии, причём дополняйте тип элемента, а не срез, чтобы make([]padded, n) разложил их по одному на линию. С паддингом тот же бенчмарк остаётся ровным при добавлении ядер.
Часто правильнее вообще перестать делить: считать в локальной переменной и добавить в общий счётчик один раз в конце.