False sharing в Go: почему счётчики убили производительность
Восемь горутин инкрементировали соседние поля одной структуры и упёрлись в 40% от ожидаемой пропускной способности. Разбираем, как кеш процессора превратил безобидные счётчики в узкое место.

Что видел профиль
Симптом простой: линейного масштабирования нет. Бенчмарк агрегатора на разном числе воркеров дал такую картину (Go 1.22, x86-64, 8 физических ядер, счётчики uint64 в срезе структур):
| Воркеров | Ops/сек (условно) | Ускорение к 1 |
|---|---|---|
| 1 | 100 | 1.0x |
| 2 | 150 | 1.5x |
| 4 | 230 | 2.3x |
| 8 | 320 | 3.2x |
Цифры относительные — важна форма кривой, а не абсолют. Идеально было бы 8x на восьми ядрах, минус накладные расходы. Получилось 3.2x. При этом go test -blockprofile показал пустой профиль блокировок: воркеры не ждали друг друга на синхронизации. Каждый писал в собственный элемент среза. Логически конфликта нет — а по факту ядра мешают друг другу.
Механизм: кеш работает линиями, а не байтами
Процессор не таскает из памяти отдельные байты. Он оперирует кеш-линиями — блоками фиксированного размера, на большинстве x86-64 это 64 байта. Когда ядро читает переменную, в кеш попадает вся линия целиком, вместе с соседними по памяти данными.
Дальше вступает протокол когерентности кеша (на Intel — семейство MESI). Он гарантирует, что все ядра видят согласованное состояние памяти. Правило грубо такое: если одно ядро хочет записать в линию, оно должно получить её в эксклюзивное владение, а копии этой линии в кешах других ядер объявляются недействительными.
Два соседних поля в структуре — это две логически независимые переменные. Но если они лежат в одной кеш-линии, для процессора это один неделимый блок. Запись в одно поле инвалидирует линию у всех, кто держит соседнее.
Вот и разгадка. Восемь счётчиков uint64 — это 8 байт каждый, 64 байта на восемь штук. Они умещаются ровно в одну-две кеш-линии. Воркер на ядре 0 инкрементирует свой счётчик — линия становится эксклюзивной у ядра 0, а у ядер 1–7 копия помечается недействительной. Следующий инкремент на ядре 1 отбирает линию себе и инвалидирует у остальных. Линия мечется между ядрами, каждый инкремент тянет за собой межъядерную синхронизацию. Это и есть false sharing: данные не разделяемые, а тормозит так, будто разделяемые.
Почему это не видно в обычном профиле
pprof показывает, где тратится CPU-время и где горутины блокируются. False sharing не блокирует горутину — она исполняется, просто каждая запись стоит десятки-сотни тактов вместо единиц. В профиле это размазывается по функции инкремента как «медленный код», без явного виновника. Поймать эффект помогают аппаратные счётчики через perf stat в Linux: смотрите на cache-misses и события когерентности. Резкий рост промахов при увеличении числа воркеров — сильный сигнал.
Как чинили: разнести по линиям
Лечение — сделать так, чтобы каждая часто записываемая переменная жила в своей кеш-линии. Между полезными полями добавляют паддинг до размера линии. Схематично структура счётчика выглядит так:
// Go 1.22, x86-64, размер кеш-линии 64 байта
type paddedCounter struct {
value uint64 // 8 байт полезной нагрузки
_ [56]byte // паддинг до 64 байт: 8 + 56 = 64
}
// Счётчики теперь не делят линию: каждый элемент
// занимает ровно одну кеш-линию целиком.
counters := make([]paddedCounter, numWorkers)Поле _ — анонимная заглушка, компилятор её не оптимизирует, потому что она влияет на размер структуры. Размер линии здесь захардкожен как 64: это верно для типичных x86-64, но на части ARM линия 128 байт. Если код должен работать на разных архитектурах, паддинг придётся закладывать под максимум либо определять на этапе сборки.
После разнесения счётчиков по отдельным линиям та же кривая масштабирования выпрямилась: восемь воркеров дали около 7x вместо 3.2x. Атомарность инкремента, если она нужна, обеспечивайте через atomic.AddUint64 — паддинг решает проблему кеша, но не гонок данных. Это ортогональные вещи: выравнивание убирает false sharing, atomic убирает race.
Есть путь проще — не шарить вовсе
Часто лучший фикс — вообще не держать счётчики рядом. Дайте каждому воркеру локальную переменную на стеке, копите там, а в общий агрегат слейте один раз в конце. Тогда горячая запись идёт в приватную память горутины, межъядерной синхронизации нет по определению. Паддинг нужен, когда разделяемый доступ по делу — например, счётчики читаются другими горутинами в процессе работы.
Где подход ломается
- Память дороже, чем кажется. Паддинг по 56 байт на каждый
uint64— это в восемь раз больше памяти под данные. На восьми счётчиках незаметно, на срезе из миллионов элементов вы сожжёте память и, парадоксально, ухудшите локальность при последовательном чтении. Выравнивайте только реально горячие, конкурентно записываемые поля. - Компилятор и GC могут переставлять. Полагаться на то, что паддинг гарантированно выровняет начало каждого элемента по границе линии, можно только для срезов значений (не указателей). Для массива структур с паддингом до 64 байт это работает, но проверяйте
unsafe.Sizeof, что размер получился ожидаемым. - Ложный диагноз. Не всякое плохое масштабирование — false sharing. Упор в пропускную способность памяти, общий мьютекс, atomic на одной переменной дают похожую кривую. Разнесли переменные, замерили — не помогло? Значит причина другая, возвращайтесь к профилю.
- Границы NUMA. На многосокетных серверах к межъядерной синхронизации добавляется межсокетная, и цена ошибки выше. Но это отдельная тема — здесь разбираем один сокет.
Что делать дальше
Если ваш многопоточный Go-код не масштабируется с числом ядер, а профиль блокировок чист, действуйте по шагам:
- Снимите
perf stat -e cache-misses,cache-referencesна одном и на многих воркерах. Рост промахов с параллельностью — кандидат на false sharing. - Посмотрите на layout горячих структур: лежат ли конкурентно записываемые поля ближе 64 байт друг к другу. Помогает
unsafe.Offsetofдля проверки смещений. - Сначала попробуйте убрать шаринг архитектурно — локальные аккумуляторы с финальным слиянием. Это бесплатно по памяти.
- Если разделяемый доступ обязателен — добавьте паддинг до кеш-линии и замерьте снова. Одно изменение за раз, иначе не поймёте, что сработало.
Дальше стоит почитать про модель памяти Go и гарантии sync/atomic: паддинг лечит производительность, но корректность конкурентного доступа обеспечивают именно атомарные операции и синхронизация. Смешивать эти два вопроса — частая ошибка.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Go Memory Model, sync/atomic package documentation
Частые вопросы
А если у меня одно ядро или GOMAXPROCS=1?
Работает ли это в Go 1.20 или старее?
Как понять, что тормозит именно false sharing, а не что-то другое?
Насколько большой должен быть паддинг?
А если разнести переменные не помогло?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.