Конкурентность · 16 мин
Контент v0.12.0Конкурентные структуры, барьеры памяти, false sharing
Один мьютекс на всё и почему он тормозит
Самый прямой способ сделать структуру данных безопасной для нескольких потоков — обернуть её мьютексом. Такую структуру называют потокобезопасной (thread-safe): ей можно пользоваться из многих потоков одновременно, и данные не испортятся.
type SafeMap struct {
mu sync.Mutex
m map[string]int
}
func (s *SafeMap) Inc(key string) {
s.mu.Lock()
s.m[key]++
s.mu.Unlock()
}Это корректно, и с этого почти всегда стоит начинать. Но представьте кэш сессий
в сервисе на 32 ядрах процессора: каждый запрос делает Get или Set, и все они проходят
через один s.mu. В каждый момент внутри может быть только одна горутина,
остальные ждут. Ситуация, когда много потоков одновременно хотят один и тот же
лок, называется contention (борьба за лок). При сильной борьбе
параллельный код вырождается в последовательный: ядер 32, а работает фактически
одно. Плюс каждое ожидание — это спин или парковка, которые мы разбирали в
предыдущей главе.
Как увидеть contention в Go: профиль блокировок pprof с
runtime.SetMutexProfileFraction показывает, на каких мьютексах горутины ждут
дольше всего. Это первое, что стоит снять, прежде чем что-то оптимизировать.
Шардирование: много маленьких локов
Если ключи независимы, им не нужен общий замок. Разобьём map на N частей (шардов), у каждой свой мьютекс. Ключ попадает в шард по хешу.
ключ "user:42" ── hash % 16 = 5 ──► shard[5] (свой lock)
ключ "order:7" ── hash % 16 = 12 ──► shard[12] (свой lock)
┌────────┬────────┬────────┬─────┬─────────┐
│ shard0 │ shard1 │ shard2 │ ... │ shard15 │ у каждого свой mutex
└────────┴────────┴────────┴─────┴─────────┘Две горутины с разными ключами почти всегда попадают в разные шарды и не мешают друг другу. Этот приём называют striped locks или sharding, а в общем виде говорят о гранулярности блокировок: крупная (один лок на всё) проще, мелкая (много локов на части) лучше масштабируется.
type ShardedMap struct {
shards [16]struct {
mu sync.Mutex
m map[string]int
}
}
func (s *ShardedMap) shard(key string) int {
h := fnv.New32a()
h.Write([]byte(key))
return int(h.Sum32() % 16)
}У мелкой гранулярности есть цена. Операции «по всей структуре» (посчитать
Len, обойти все элементы, сделать снимок) теперь должны пройти по всем шардам и
взять все локи, и если брать их в разном порядке, можно получить
взаимоблокировку. А если ключи распределены неравномерно
(один горячий ключ), шардирование не поможет: все запросы всё равно упрутся в
один шард.
В стандартной библиотеке Go есть sync.Map. С Go 1.24 внутри у неё
конкурентное хеш-дерево (hash trie), где разные ключи живут в разных узлах, то
есть по сути та же идея мелкой гранулярности. Документация по-прежнему советует
её для двух сценариев: ключ записывается один раз и много раз читается, или
разные горутины работают с непересекающимися наборами ключей. В остальных
случаях обычная типизированная map с мьютексом проще и не хуже по скорости.
Lock-free: CAS вместо замка
Иногда без лока можно обойтись совсем. Нужна операция compare-and-swap (CAS) из прошлой главы: «запиши новое значение, если там всё ещё старое». На ней строится цикл повторов:
func incr(cnt *atomic.Int64) {
for {
old := cnt.Load()
if cnt.CompareAndSwap(old, old+1) {
return
}
// между Load и CAS кто-то успел изменить cnt — пробуем снова
}
} A: Load → 5, CAS(5→6) успех
B: Load → 5, CAS(5→6) неудача (там уже 6) → Load → 6, CAS(6→7) успехТакие структуры называют lock-free: ни один поток не держит замок, и если какой-то поток уснул на полпути, остальные всё равно продвигаются. Чем это отличается от мьютекса:
- при неудаче мьютекс со временем усыпит поток, а CAS-цикл сразу пробует снова, без похода в ядро ОС;
- при слабой борьбе это быстрее; при сильной потоки тратят CPU на повторы, которые всё равно проиграют.
Частое заблуждение: «atomic.AddInt64 внутри — такой же CAS-цикл». Нет. Для
сложения у процессоров есть отдельная атомарная инструкция: на amd64 Go
компилирует Add в LOCK XADD, на arm64 с расширением LSE — в LDADDAL. Цикл
повторов появляется только там, где такой инструкции нет. CAS-цикл нужен, когда
новое значение вычисляется сложнее, чем «плюс n»: например, «увеличить, но не
больше лимита».
Ловушка lock-free структур на указателях — ABA-проблема. Поток прочитал указатель на узел A и собирается сделать CAS. Пока он медлил, другой поток снял A, снял B, освободил их и положил новый узел, который аллокатор разместил по тому же адресу, что был у A. CAS видит «там A, как и было» и проходит, хотя структура уже совсем другая. Лечат счётчиком версий рядом с указателем или отложенным освобождением памяти. В Go ABA встречается реже, потому что сборщик мусора не отдаст адрес узла, пока на него кто-то ссылается, но для индексов в массиве (вместо указателей) проблема остаётся.
Практическое правило: lock-free пишут для одной-двух горячих переменных
(счётчики, флаги, указатель на текущий конфиг через atomic.Pointer).
Lock-free очередь или map собственной разработки на ревью почти всегда хуже
готового мьютекса.
Барьеры памяти: запись не обязана стать видна в том порядке
Теперь коварная часть. Один поток готовит данные и поднимает флаг, второй ждёт флаг и читает данные:
Поток 1 Поток 2
data = 42 while ready == 0 { }
ready = 1 print(data)Кажется очевидным, что раз поток 2 увидел ready == 1, то data уже 42. Это не
так. Переставлять операции имеют право двое.
Компилятор. Для потока 1 порядок двух независимых записей неважен, и
оптимизатор может их поменять. Он же может прочитать ready в цикле потока 2
один раз и крутиться на значении из регистра вечно.
Процессор. Здесь всё зависит от архитектуры. На x86 действует довольно строгая модель (TSO): записи становятся видны другим ядрам в том порядке, в каком их сделал поток, и чтения тоже не обгоняют друг друга. Единственная разрешённая перестановка — более поздняя загрузка может выполниться раньше предыдущей записи (запись в это время ждёт в store buffer — очереди записей ядра, которые ещё не дошли до кэша). На ARM и POWER модель слабее: и записи, и чтения могут стать видны в другом порядке. Код, который «всегда работал» на ноутбуке с Intel, иногда ломается на сервере с ARM (Graviton, Ampere) или на Mac с Apple Silicon.
Запрещает перестановки барьер памяти (memory barrier, fence) — инструкция,
через которую операции не переносятся: всё, что до барьера, станет видно раньше
всего, что после. Барьер нужен с обеих сторон: у пишущего (не выпустить ready
раньше data) и у читающего (не прочитать data раньше ready).
В Go барьеры руками не ставят. Их вставляют операции синхронизации: атомики,
мьютексы, каналы. Модель памяти Go описывает это через отношение
happens-before: если запись в канал случилась до чтения из канала, то всё,
что горутина сделала до отправки, видно горутине после приёма. То же для
Unlock → следующий Lock и для атомарной записи → атомарного чтения, которое
её увидело. Подробно — в главе модель памяти Go.
Правильный вариант примера:
var (
data int
ready atomic.Bool
)
// поток 1
data = 42
ready.Store(true) // публикация: data станет видна вместе с флагом
// поток 2
for !ready.Load() {
runtime.Gosched()
}
fmt.Println(data) // гарантированно 42Обычный bool вместо atomic.Bool — это гонка данных, и go run -race её
поймает. Синхронизация нужна не только чтобы «не толкаться» в критической
секции, но и чтобы записи одного потока стали видны другому в правильном порядке.
Как ядра процессора договариваются о кэшах: MESI
В этом разделе и следующем «ядро» — всегда ядро процессора (core), а не ядро ОС.
Откуда вообще берётся проблема видимости? У каждого ядра свои кэши L1 и L2 — быстрые локальные копии кусков памяти. Обмениваются ядра не байтами, а кэш-линиями (cache line): на x86 линия 64 байта, на Apple M1/M2 и некоторых серверных ARM — 128.
Чтобы два ядра не держали разные значения одной ячейки, они соблюдают протокол когерентности. Классический вариант — MESI, по первым буквам четырёх состояний линии в кэше ядра:
M (Modified) — линия есть только у меня, я её изменил, в памяти старое
E (Exclusive) — линия только у меня, совпадает с памятью
S (Shared) — копии у нескольких ядер, все только читают
I (Invalid) — моя копия устарела, пользоваться нельзя
Ядро 0 хочет записать в линию, которая есть у ядра 1:
ядро 0 рассылает invalidate → у ядра 1 линия становится I
ядро 0 получает линию в M и пишет
ядро 1 при следующем чтении заново запрашивает линиюГлавное следствие: запись в линию, которую держат другие ядра, не бесплатна. Это обмен сообщениями между ядрами и ожидание ответов, десятки наносекунд вместо одной. Чем больше ядер пишут в одну линию, тем больше этого трафика. Поэтому один атомарный счётчик, который инкрементируют все ядра, масштабируется плохо, хотя никаких локов в нём нет.
False sharing
Теперь неочевидный случай. Два счётчика логически независимы: каждый инкрементирует своё ядро. Но если они лежат в памяти рядом и попали в одну кэш-линию, ядра всё равно гоняют линию друг другу:
┌──────────────── одна кэш-линия ────────────────┐
│ counterA (8 Б) │ counterB (8 Б) │ ... │
└────────────────────────────────────────────────┘
▲ ядро 0 пишет ▲ ядро 1 пишет
ядро 0 пишет A → линия у ядра 1 становится I
ядро 1 пишет B → линия у ядра 0 становится I
... линия скачет между кэшами, хотя A и B не связаныЭто false sharing (ложное разделение): данные не общие, а линия общая. Лечится padding (набивкой): между переменными вставляют пустые байты, чтобы каждая заняла свою линию.
Проверим на бенчмарке. Четыре горутины, каждая инкрементирует свой счётчик. В первом варианте счётчики лежат вплотную, во втором каждый добит до 128 байт.
package fs
import (
"sync"
"sync/atomic"
"testing"
)
const workers = 4
type packed struct {
n [workers]atomic.Int64 // 4 × 8 байт подряд, одна линия
}
type padded struct {
n [workers]struct {
v atomic.Int64
_ [120]byte // 8 + 120 = 128 байт: своя линия и на x86, и на ARM
}
}
func BenchmarkPacked(b *testing.B) {
var c packed
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < b.N; i++ {
c.n[w].Add(1)
}
}(w)
}
wg.Wait()
}
func BenchmarkPadded(b *testing.B) {
var c padded
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < b.N; i++ {
c.n[w].v.Add(1)
}
}(w)
}
wg.Wait()
}На ноутбуке с Apple M2 Pro (go test -bench ., Go 1.25) получилось так:
BenchmarkPacked-10 10033843 115.4 ns/op
BenchmarkPadded-10 289468153 4.270 ns/opРазница почти в 30 раз, а код отличается одним полем. Для сравнения: если все четыре горутины честно инкрементируют один общий счётчик, выходит те же ~115 нс. То есть с точки зрения железа «четыре независимых счётчика в одной линии» ничем не лучше одного общего. На вашей машине числа будут другими, но порядок разницы обычно сохраняется.
Где это встречается в Go: шардированные счётчики метрик, массивы
per-worker-состояний, статистика по ядрам. Для набивки есть готовый тип
cpu.CacheLinePad в пакете golang.org/x/sys/cpu (размер под текущую
архитектуру). Рантайм Go внутри делает то же самое для своих структур на P.
Частая ошибка — паддить «на 64 байта» вслепую: на ARM-серверах и Mac линия может
быть 128 байт, и тогда набивка не помогает.
Что выбрать на практике
| Симптом | Что на самом деле | Что делать |
|---|---|---|
| Сервис не ускоряется с ростом ядер, в mutex-профиле один горячий лок | contention | шардирование, сократить критическую секцию |
| Значение «иногда не видно» другой горутине | нет синхронизации, гонка данных | atomic, mutex или канал; прогнать с -race |
| Независимые счётчики тормозят под нагрузкой | false sharing | padding до размера кэш-линии |
| Один общий atomic-счётчик стал горячим | трафик когерентности | шардированный счётчик, суммировать при чтении |
Порядок действий: начать с обычного мьютекса; если профиль показал contention — шардировать или уменьшить критическую секцию; атомики — для одиночных горячих переменных; padding — когда независимые данные пишутся с разных ядер.
Почему один глобальный мьютекс на всю структуру плохо масштабируется на многоядерном сервере?
Что делает приём striped (sharded) locks?
Как устроен lock-free инкремент на CAS?
Мьютекс защищает данные от одновременной записи. Какую ещё гарантию даёт цепочка «unlock в одном потоке, затем lock в другом» (happens-before)?
Что такое false sharing?
Сколько байт обычно занимает одна кэш-линия на x86?
Чем CAS-цикл (lock-free) отличается от мьютекса при провале попытки?
Частые ошибки
- Оптимизировать без профиля: заменять мьютекс на lock-free структуру, не увидев contention в mutex-профиле.
- Использовать обычную переменную как флаг между горутинами «потому что на x86 работает». Это гонка данных; на ARM и после оптимизаций компилятора она проявится.
- Считать, что
atomic.AddInt64— это CAS-цикл и потому медленный под нагрузкой; медленным его делает борьба за кэш-линию. - Паддить до 64 байт вслепую: на Apple Silicon и части ARM-серверов линия 128 байт.
- Ожидать, что шардирование поможет при одном горячем ключе.
Проверьте себя
- Почему 32 горутины с одним общим мьютексом работают не быстрее одной?
- Что может переставить процессор x86, а что — ARM, в примере с
dataиready? - Почему четыре независимых atomic-счётчика, лежащих рядом, работают так же медленно, как один общий?
- В бенчмарке из главы замените
[120]byteна[56]byte. На x86 с 64-байтной линией результат почти не изменится, а на Apple Silicon вариант станет примерно вдвое медленнее варианта со[120]byte, хотя и намного быстрееpacked. Сколько счётчиков теперь делят одну линию?
Показать ответы
Ответы. (1) В каждый момент внутри только одна, остальные ждут: работа сериализуется. (2) x86 сохраняет порядок записей и порядок чтений, поэтому там переставляет только компилятор; ARM может переставить и записи, и чтения в железе. (3) Они в одной кэш-линии, и каждая запись инвалидирует линию у остальных ядер (false sharing). (4) Структура стала 64 байта, а линия 128, поэтому линию делят два счётчика: ложного разделения меньше, чем у packed, но оно есть.
Что спрашивают на собесе
- Чем плох один мьютекс на всю структуру? При сильной борьбе горутины стоят в очереди, работа сериализуется. Лечится шардированием (striped locks) и сокращением критической секции.
- Как сделать lock-free счётчик на CAS и в чём ловушка? Цикл
«Load → вычислить → CAS → при неудаче повторить». Ловушка структур на
указателях — ABA. И уточнение: простой
atomic.Addна amd64/arm64 — одна инструкция, а не цикл. - Зачем барьеры памяти, если есть мьютекс? Мьютекс как раз и даёт барьеры. Синхронизация обеспечивает не только взаимное исключение, но и порядок видимости записей (happens-before).
- Что x86 может переупорядочить? Только запись с последующим чтением другого адреса (store buffer). ARM переупорядочивает больше, поэтому баги видимости чаще всплывают там.
- Что такое MESI? Протокол когерентности кэшей: Modified, Exclusive, Shared, Invalid. Запись требует инвалидировать чужие копии, отсюда цена записи в общую линию.
- Что такое false sharing и как лечить? Независимые переменные в одной кэш-линии, ядра гоняют линию друг другу. Лечится padding до размера линии (64 байта на x86, 128 на части ARM).