Покажу простой пример на Go: как сделать код, который специально страдает от false sharing, и как это быстро исправить.
False sharing — это когда разные горутины работают с разными переменными, но эти переменные лежат в одной и той же cache line (обычно 64 байта). В результате процессоры тратят время на синхронизацию и инвалидирование кэш-линий — и программа неожиданно тормозит. Ниже — короткий и наглядный эксперимент на Go.
Идея эксперимента (коротко)
Мы сделаем две версии:
- Проблемная — два счётчика расположены рядом в памяти (в одном cache line). Много горутин каждую итерацию увеличивают эти счётчики.
- Исправленная — между счётчиками вставлен паддинг, чтобы они оказались в разных cache line.
Затем сравним время выполнения.
Важные детали перед запуском
- Запускай на многопроцессорной машине (GOMAXPROCS >= 2).
- Для бенчей использую простую time-метрику (можно и testing.B, но тут нагляднее простой runnable-пример).
- False sharing особенно заметен при большом числе итераций и нескольких горутинах-писателях.
Код — пример с false sharing (намеренно «сломанный»)
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
)
type Counters struct {
A int64
B int64
}
func main() {
runtime.GOMAXPROCS(4) // ставим несколько ОС-потоков
var c Counters
var wg sync.WaitGroup
const goroutines = 4
const iters = 50_000_000
start := time.Now()
// Запускаем writers: половина пишет в A, половина — в B
for i := 0; i < goroutines; i++ {
wg.Add(1)
if i%2 == 0 {
go func() {
defer wg.Done()
for j := 0; j < iters; j++ {
atomic.AddInt64(&c.A, 1)
}
}()
} else {
go func() {
defer wg.Done()
for j := 0; j < iters; j++ {
atomic.AddInt64(&c.B, 1)
}
}()
}
}
wg.Wait()
elapsed := time.Since(start)
fmt.Printf("A=%d B=%d time=%v\n", c.A, c.B, elapsed)
}
Что делает код: много горутин интенсивно увеличивают два поля A и B, которые, скорее всего, окажутся в одном cache line → будет false sharing.
Код — исправленный вариант с паддингом
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
)
// Размер кэш-линии обычно 64 байта. Структура ниже ставит поля в разные линии.
type PaddedCounters struct {
// Паддинг так, чтобы A занял свою линию
_ [56]byte
A int64
_ [56]byte
B int64
_ [56]byte
}
func main() {
runtime.GOMAXPROCS(4)
var c PaddedCounters
var wg sync.WaitGroup
const goroutines = 4
const iters = 50_000_000
start := time.Now()
for i := 0; i < goroutines; i++ {
wg.Add(1)
if i%2 == 0 {
go func() {
defer wg.Done()
for j := 0; j < iters; j++ {
atomic.AddInt64(&c.A, 1)
}
}()
} else {
go func() {
defer wg.Done()
for j := 0; j < iters; j++ {
atomic.AddInt64(&c.B, 1)
}
}()
}
}
wg.Wait()
elapsed := time.Since(start)
fmt.Printf("A=%d B=%d time=%v\n", c.A, c.B, elapsed)
}
Идея: вставляем паддинг (примерно 56 байт — т.к. int64 ещё 8 байт → в сумме 64) между полями, чтобы они оказались в разных 64-байтных блоках.
Как запускать и что ждать
1 Скомпилируй и запусти сначала «сломанный» пример, затем исправленный.
2 Пример:
go run broken.go
go run padded.go
3 На реальной машине исправленный вариант обычно работает значительно быстрее (разница зависит от CPU, числа ядер и нагрузки — от ~2× до десятков раз).
Почему это работает (коротко и по существу)
- CPU оперирует cache line (обычно 64 байта) целиком.
- Когда одно ядро записывает в cache line, другие ядра должны получить уведомление и/или инвалидировать свои копии → синхронизация по шине (MESI/MOESI).
- Даже если горутины пишут в разные переменные внутри одной линии, аппаратная когерентность вынуждает делать «пересылку» всей линии — вот false sharing.
- Решение: разнести часто записываемые поля так, чтобы они не попадали в одну cache line (паддинг) или уменьшить конкуренцию (менее частые обновления, шардинг, batching).
Дополнительные способы вызвать false sharing (намеренно)
- Малые структуры с множеством полей-счетчиков, которые активно пишутся разными потоками.
- Интенсивные атомарные операции (atomic.Add*, atomic.Store*) по соседним адресам.
- Большое число одновременно работающих потоков/горутин (GOMAXPROCS > 1).
- Компактные буферы/карты, где несколько горутин пишут в элементы, расположенные подряд.
Как избежать false sharing в реальном коде
- Добавлять паддинг (как в примере).
- Использовать []struct{ ... } с выравниванием так, чтобы элементы были на отдельных линиях.
- Разделять данные по шардам (sharding): каждый поток пишет в свою часть.
- Минимизировать частые атомарные операции на общих адресах (агрегировать).
- Для Go: использовать runtime/pprof, perf, go test -bench и профилирование, чтобы найти горячие точки.
Небольшая оговорка
Паддинг — «грубый» инструмент: он увеличивает память. Применять его стоит там, где критична скорость и где профайлер показал проблему. False sharing — аппаратный эффект, и его влияние зависит от CPU/архитектуры и реальной нагрузки.
Заключение
False sharing — легко воспроизводимая и хорошо объяснимая проблема производительности. С простым примером на Go можно показать читателю «магическое» торможение и столь же простое решение. Такой материал хорошо заходить на Дзене: понятный заголовок, демонстрация «до/после», код и конкретные инструкции по запуску.