Когда ты пишешь mu.Lock() в Go, ты, скорее всего, думаешь «блокировка». Когда пишешь atomic.AddInt64(&x, 1) — «атомарный инкремент». Два разных инструмента, две разные ментальные модели, два разных раздела документации стандартной библиотеки. Но вот странная вещь: это одно и то же. Точнее — это один стек абстракций, где каждый слой построен поверх предыдущего.

В этой статье я пройду по всему стеку — от самого низа до верха и обратно. Мы начнём со сломанного counter++, спустимся на уровень инструкций процессора, поднимемся обратно через CAS (compare-and-swap, сравнение с обменом), атомарные операции, futex (fast userspace mutex, «быстрый мьютекс пространства пользователя») и наконец придём к sync.Mutex — и обнаружим, что это в основном оптимизация поверх атомарного CAS плюс механизм честности, придуманный для исправления реального продакшен-бага из 2015 года.

Стек

Прежде чем нырять, вот картина, которую я хочу, чтобы ты держал в голове. Пять слоёв, каждый построен поверх предыдущего:


graph TB
    L5["Уровень 5: Что когда использовать
Бенчмарки, шпаргалка, ловушки"] L4["Уровень 4: sync.Mutex
state, sema, spinning, starvation"] L3["Уровень 3: Futex
userspace fast path + kernel slow path"] L2["Уровень 2: Атомарные операции
CAS, Load, Store, Add — sync/atomic"] L1["Уровень 1: Железо
LOCK CMPXCHG (x86), LDADD/CAS (ARM LSE)"] L0["Уровень 0: Проблема
data race, x++ это 3 инструкции"] L0 --> L1 --> L2 --> L3 --> L4 --> L5 style L0 fill:#c62828,color:#fff style L1 fill:#ad1457,color:#fff style L2 fill:#6a1b9a,color:#fff style L3 fill:#4527a0,color:#fff style L4 fill:#283593,color:#fff style L5 fill:#1565c0,color:#fff

Главное утверждение этой статьи: нельзя по-настоящему понять уровень 4 (sync.Mutex), не поняв уровень 2 (атомарные операции), и нельзя понять уровень 2, не взглянув хотя бы краем глаза на уровень 1 (железо). И наоборот — единственная причина интересоваться уровнем 1 в том, что он порождает практические рекомендации уровня 5.

Поехали снизу.

1. Проблема: почему x++ ломается

Вот простейший возможный data race (состояние гонки). Тысяча горутин инкрементирует общий счётчик:

var counter int64
var wg sync.WaitGroup

func main() {
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++
        }()
    }
    wg.Wait()
    fmt.Println(counter)
}

Запусти. Ответ не 1000. Что-то вроде 987, или 993, или 971. Иногда ровно 1000 — и это хуже, потому что значит баг прячется.

Почему? Потому что counter++ — не одна операция. В исходнике Go это выглядит как одна операция, но компилятор разворачивает её в три. Вот реальный ассемблер ARM64, который Go генерирует для counter++:

MOVD  main.counter(SB), R0    ; загрузить counter из памяти в регистр R0
ADD   $1, R0, R0              ; прибавить 1 к R0
MOVD  R0, main.counter(SB)    ; сохранить R0 обратно в память

Три инструкции: загрузка, модификация, сохранение. Каждая по отдельности атомарна, но последовательность — нет. Между загрузкой и сохранением может вклиниться другая горутина.

Вот таймлайн столкновения двух горутин:


sequenceDiagram
    participant G1 as Горутина 1
    participant Mem as Память (counter=5)
    participant G2 as Горутина 2

    G1->>Mem: MOVD counter → R0 (R0=5)
    Note over G1: сейчас будет ADD
    G2->>Mem: MOVD counter → R0 (R0=5)
    G1->>G1: ADD → R0=6
    G1->>Mem: MOVD R0 → counter (counter=6)
    G2->>G2: ADD → R0=6
    G2->>Mem: MOVD R0 → counter (counter=6)
    Note over Mem: Один инкремент потерян

Обе горутины прочитали 5, обе вычислили 6, обе сохранили 6. Мы сделали два инкремента, счётчик вырос на единицу. Умножь на тысячу горутин — получишь 987.

Это data race, и модель памяти Go (go.dev/ref/mem) определяет его формально. Read-write data race для памяти в локации x состоит из операции чтения r и операции записи w над x, хотя бы одна из которых не является синхронизирующей, и при этом ни одна не происходит-до (happens-before) другой. Перечитай это дважды: определение про порядок, а не про время. Две операции над одной локацией без синхронизации между ними — это гонка, даже если на практике они почти никогда не перекрываются во времени.

1.1. DRF-SC: обещание и цена

Модель памяти Go даёт одно большое обещание, называемое DRF-SC (data-race-free → sequentially consistent). Если программа свободна от data race, её выполнение эквивалентно некоторому последовательно-согласованному (sequentially consistent) чередованию горутин — как если бы все они мультиплексировались на один процессор по очереди. Не нужно беспокоиться о переупорядочивании компилятора, store buffers (буферах записи), эффектах кэша и прочей аппаратной странности, которая мучает программистов на C++.

Цена в том, что тебе действительно нужно устранить все data race. Детектор гонок Go (go test -race, построенный на ThreadSanitizer) поймает их на этапе выполнения, но код без гонок нужно писать изначально. А для этого нужна синхронизация.

Формальная модель под DRF-SC — та же, что в C++, Java, JavaScript, Rust и Swift. Она происходит из статьи Ханса-Й. Бёма и Сариты В. Адве “Foundations of the C++ Concurrency Memory Model” (PLDI 2008). Go привёл свою модель памяти в соответствие с этой формализацией в редакции от июня 2022 года, которая вышла вместе с Go 1.19.

1.2. Happens-before: отношение, на котором всё держится

Формальное определение data race опирается на отношение happens-before (происходит-до). Неформально: операция A происходит-до операции B, если можно доказать, что A гарантированно завершилась до того, как B началась. Отношение — транзитивное замыкание двух вещей:

  • Sequenced-before (следует-после в последовательности): внутри одной горутины операторы выполняются в порядке исходника. Оператор 5 следует-после оператора 4, оператор 6 — после 5.
  • Synchronized-before (синхронизировано-до): между горутинами определённые операции создают рёбра синхронизации. ch <- x происходит-до соответствующего <-ch. mu.Unlock() происходит-до следующего mu.Lock(). Атомарная запись происходит-до атомарного чтения, которое наблюдает записанное значение.

Если A происходит-до B, то всё, что A записал в память, видимо для B. Если ни одно не происходит-до другого — у тебя гонка.

Вот почему counter++ сломан: в программе нет ничего, что устанавливало бы happens-before отношение между инкрементами разных горутин. Они не упорядочены, поэтому чтение в горутине A может перекрыть запись в горутине B.

Чтобы исправить гонку, нужно добавить синхронизацию. Есть два основных пути:

  1. Сделать сам инкремент атомарным — единой операцией, которую нельзя прервать.
  2. Обернуть инкремент в мьютекс, чтобы только одна горутина могла выполнять его за раз.

Это уровни 2 и 4 в нашем стеке. Рассмотрим их по порядку.

2. Первый путь: атомарные операции

Самое чистое исправление для counter++ — сделать его атомарным:

var counter int64
// ...
atomic.AddInt64(&counter, 1)

atomic.AddInt64 выполняет загрузку, сложение и сохранение как одну неделимую операцию. Ни одна другая горутина не может наблюдать промежуточное состояние, ни одна не может вклиниться между загрузкой и сохранением — потому что «между» просто нет.

Как это возможно? Потому что под капотом atomic.AddInt64 компилируется в одну инструкцию процессора.

2.1. CAS: универсальный примитив

Фундаментальный строительный блок всех атомарных операций — CAS (Compare-And-Swap, сравнение с обменом). Его сигнатура в псевдокоде:

CAS(addr, expected, new) → bool
    если *addr == expected:
        *addr = new
        вернуть true
    иначе:
        вернуть false

Всё это атомарно: сравнение и сохранение происходят как один неделимый шаг. Если *addr равно expected, новое значение записывается и CAS возвращает true. Иначе ничего не записывается и CAS возвращает false.

CAS особенный. В 1991 году Морис Херлихи опубликовал статью “Wait-Free Synchronization” в ACM Transactions on Programming Languages and Systems (Транзакции ACM по языкам и системам программирования). Он классифицировал примитивы синхронизации по их consensus number (числу консенсуса) — максимальному числу потоков, для которых примитив может решить задачу консенсуса (все потоки соглашаются на одном значении). Иерархия безжалостна:

ПримитивConsensus number
Чтение/запись регистров1
Test-and-set2
Fetch-and-add2
Очередь, стек2
CAS

У CAS consensus number ∞. Это делает его универсальным примитивом: любую wait-free или lock-free структуру данных можно построить поверх CAS. Остальные — нет. Test-and-set решает консенсус для 2 потоков, fetch-and-add для 2, но только CAS масштабируется на произвольное число. Вот почему каждая современная архитектура предоставляет CAS в железе, и почему Go строит всё остальное поверх него.

Что это вообще значит, «consensus number»?

Задача консенсуса: N потоков начинают, у каждого есть своё входное значение v_i. Нужно, чтобы все потоки в итоге согласились на одном и том же значении — причём это значение должно быть равно v_i хотя бы для одного из потоков (то есть нельзя просто всегда возвращать константу). Это абстрактная модель для «кто-то предложил, остальные согласились» — то, на чём строятся выборы лидера, распределённые блокировки, реплицированные конечные автоматы.

Херлихи доказал: если у примитива consensus number равен N, то с его помощью можно решить консенсус для N потоков, но не больше. Test-and-set может решить для 2 — потому что ровно один поток увидит «старое» значение и поймёт, что выиграл; но для 3 потоков уже не получается. А CAS может для любого N — каждый поток CASает свою пару (proposal, my_value) в общую ячейку, и тот, чей CAS успешен, становится выбранным значением.

Практическое следствие: невозможно построить wait-free очередь или стек для произвольного числа потоков, используя только fetch-and-add. Нужен либо CAS, либо какой-то другой примитив с consensus number ∞. Поэтому все современные CPU предоставляют CAS — без него нельзя реализоватьscalабельные конкурентные структуры.

2.2. CAS-loop: как на самом деле реализован Add

Можешь задаться вопросом: если CAS — универсальный примитив, как из него построить Add? Нельзя «CAS-нуть и прибавить» одним действием, потому что CAS записывает только конкретное новое значение, а не «старое плюс один». Ответ — цикл:

func addInt64(addr *int64, delta int64) int64 {
    for {
        old := atomic.LoadInt64(addr)
        new := old + delta
        if atomic.CompareAndSwapInt64(addr, old, new) {
            return new
        }
        // CAS провалился — кто-то записал между нашей загрузкой и CAS.
        // Повторяем цикл, перечитываем текущее значение, пробуем снова.
    }
}

Это называется CAS-loop (цикл с CAS) или compare-and-swap retry loop (цикл повторных попыток compare-and-swap). Загружаем текущее значение, вычисляем новое, пытаемся CAS. Если CAS провалился — значит, другая горутина модифицировала память между нашей загрузкой и CAS, поэтому повторяем с новым текущим значением.


flowchart TD
    Start([Прибавить delta к addr]) --> Load["old = *addr"]
    Load --> Compute["new = old + delta"]
    Compute --> CAS{"CAS(addr, old, new)"}
    CAS -->|успех| Done([готово])
    CAS -->|неудача — кто-то записал| Load

    style Done fill:#2e7d32,color:#fff
    style CAS fill:#1565c0,color:#fff

Вот что делает CAS универсальным. Из CAS можно построить Add, Subtract, Exchange, Min, Max — любую операцию. Все они становятся атомарными за счёт повторения до успешного CAS.

Есть цена. Под тяжёлой нагрузкой (много горутин одновременно пытаются обновить один адрес) CAS-loop может тратить CPU впустую. Каждая неудачная CAS — потраченная попытка, и цикл крутится, пока не выиграет. Мы вернёмся к этому в разделе 4.

2.3. Железо: LOCK CMPXCHG и ARM LSE

Когда ты пишешь atomic.CompareAndSwapInt64, компилятор Go генерирует одну инструкцию процессора. На x86 — это LOCK CMPXCHG:

lock cmpxchg [addr], new

Префикс LOCK важен. В ранних процессорах x86 LOCK буквально активировал аппаратный пин LOCK#, который замораживал всю шину памяти на время инструкции. Это было катастрофически дорого. В современных x86 (Pentium Pro и новее) LOCK работает умнее: если целевой адрес помещается в одну строку кэша (cache line), процессор использует блокировку строки кэша — инвалидирует эту одну строку на всех остальных ядрах и выполняет операцию, не трогая шину. И только если операция跨越 две строки кэша (чего не должно быть при правильном выравнивании), он откатывается к старой блокировке шины.

На ARM64 эволюция прошла в два этапа. До LSE (Large System Extensions, до ARMv8.1) CAS эмулировался парой инструкций LDXR/STXR (exclusive load и exclusive store, эксклюзивная загрузка и сохранение). Загрузка помечала строку кэша для мониторинга; если ни одно другое ядро не записало в неё до момента сохранения, сохранение успешно. Иначе — провал и повтор. Это в точности CAS-loop, но в железе.

ARMv8.1 добавил LSE (Large System Extensions, расширения для больших систем), который ввёл одноинструкционные атомарные операции: CAS, LDADD, LDCLR, STSET и компанию. Они быстрее, потому что нет цикла повторов внутри CPU — операция завершается за один раз. Apple Silicon (M1 и новее), AWS Graviton 3 и 4 — все поддерживают LSE, поэтому атомарно-тяжёлый код на этих чипах намного быстрее, чем в до-LSE эру.

Главный вывод: каждый вызов atomic.X в Go — это одна инструкция CPU. Никакого вызова функции, никакого цикла (если CAS не провалился), никакого syscall. Просто одна инструкция. Это и делает атомарные операции быстрыми.

2.4. Пакет sync/atomic в Go

Пакет sync/atomic предлагает два стиля API.

Функции (исходная форма, с Go 1):

var x int64
atomic.LoadInt64(&x)
atomic.StoreInt64(&x, 42)
atomic.AddInt64(&x, 1)
atomic.SwapInt64(&x, 99)
atomic.CompareAndSwapInt64(&x, expected, new)

Плюс эквиваленты для int32, uint32, uint64, uintptr, unsafe.Pointer и особый случай atomic.Value для произвольных типов.

Типы (введены в Go 1.19, август 2022):

var x atomic.Int64
x.Load()
x.Store(42)
x.Add(1)
x.Swap(99)
x.CompareAndSwap(expected, new)

var p atomic.Pointer[Config]   // обобщённый, типобезопасный
var f atomic.Bool
var u atomic.Uint64
var flags atomic.Uint32

Типы новее и приятнее. Они оборачивают те же инструкции процессора, но с тремя преимуществами:

  1. Больше никакого &x — ты вызываешь методы прямо у значения, что проясняет владение.
  2. Никаких проблем с выравниванием — см. следующий раздел.
  3. Типобезопасность указателейatomic.Pointer[T] обобщённый, без кастинга unsafe.Pointer.

Release notes к Go 1.19 (go.dev/doc/go1.19) описывают эти типы как прямое следствие ревизии модели памяти: «Вместе с обновлением модели памяти Go 1.19 вводит новые типы в пакете sync/atomic, которые упрощают использование атомарных значений, такие как atomic.Int64 и atomic.Pointer[T]». До 1.19 на 32-битных платформах (GOARCH=386, GOARCH=arm) int64 должен был быть выровнен по 8-байтовой границе для атомарных операций — иначе atomic.LoadInt64 паниковал в рантайме. Нужно было вручную располагать поля структуры или добавлять padding. Новый тип atomic.Int64 гарантирует выравнивание автоматически, что убивает целый класс багов.

2.5. Memory ordering: в Go только один режим

Это одно из главных отличий Go от C++/Rust. C++11 ввёл шесть упорядочиваний памяти: memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst. Rust наследует те же шесть. Более слабые упорядочивания (relaxed, acquire, release) позволяют компилятору и CPU агрессивнее переупорядочивать операции, что может дать производительность ценой более аккуратного рассуждения.

В Go — одно. Sequentially consistent (последовательная согласованность), всегда. Каждая атомарная операция в Go — полный барьер памяти. Нет atomic.LoadAcquire, нет atomic.StoreRelease. Как пояснял Рассел Кокс, пересматривая модель памяти для Go 1.19: Go намеренно не предоставляет расслабленные упорядочивания, потому что их слишком легко применить неправильно, а прирост производительности обычно невелик.

Это тот же компромисс, что и на уровне 1: проще модель — меньше производительность — меньше багов. Если ты приходишь из C++ и ищешь acquire/release в Go — остановись. Используй sync/atomic как есть, прими цену полного барьера, и твой код будет корректен по умолчанию.

Одно тонкое, но важное следствие: атомарные операции синхронизируют не только атомарную переменную, но и всё, что произошло-до атомарной записи в той же горутине. Это основа паттерна публикации (publication pattern):

type Config struct {
    Hosts []string
    TTL   time.Duration
    // ... много полей
}

var configPtr atomic.Pointer[Config]

// Горутина-писатель (применяет обновление конфигурации)
func updateConfig(c *Config) {
    // Все записи в c.Hosts, c.TTL и т.д. происходят-до этого Store.
    configPtr.Store(c)
}

// Горутина-читатель (миллионы вызовов)
func getConfig() *Config {
    // Load синхронизируется с соответствующим Store.
    // Возвращаемый *Config полностью инициализирован — никаких частичных чтений.
    return configPtr.Load()
}

Поля Config — не атомарные. Обычный []string, обычный time.Duration. Но писатель полностью конструирует c перед вызовом Store, а Load читателя синхронизируется с этим Store, поэтому читатель видит полностью инициализированную структуру. Никаких блокировок, никакого копирования на пути чтения, никакой конкуренции. Это канонический способ работы с конфигурацией, которую часто читают.

Сравнение с C++ и Rust: почему в Go только sequentially consistent

Если ты пришёл из C++, тебе может быть непривычно. В C++ у std::atomic<T> есть шесть упорядочиваний:

std::atomic<int> x{0};
x.store(42, std::memory_order_relaxed);      // нет барьеров вообще
x.store(42, std::memory_order_release);      // release: записи до этого станут видны загрузчику
int v = x.load(std::memory_order_acquire);   // acquire: загрузки после этого увидят release
int v = x.load(std::memory_order_seq_cst);   // полный барьер (как в Go)

Эти различия позволяют тонко оптимизировать. Например, счётчик посещений, для которого не важен точный порядок инкрементов между потоками, можно делать memory_order_relaxed — и компилятор с CPU могут переупорядочивать окружающий код гораздо свободнее, что иногда даёт 20-30% прироста на hot paths.

В Rust те же шесть упорядочиваний через std::sync::atomic::Ordering:

use std::sync::atomic::{AtomicU64, Ordering};
let x = AtomicU64::new(0);
x.store(42, Ordering::Relaxed);
x.store(42, Ordering::Release);
let v = x.load(Ordering::Acquire);
let v = x.load(Ordering::SeqCst);

В Go — только SeqCst. Всегда. Любой вызов atomic.AddInt64, atomic.LoadPointer, atomic.CompareAndSwap — полный барьер. Не существует atomic.LoadInt64Acquire или atomic.StorePointerRelease.

Почему? Из объяснения Рассела Кокса при ревизии модели памяти Go 1.19: расслабленные упорядочивания слишком легко применить неправильно. В C++-сообществе накоплены годы опыта, статьи, инструменты (TSan, лит тесты), и всё равно эксперты регулярно ошибаются. Go целенаправленно отказался от этой гибкости ради простоты. Цена — иногда лишний барьер, который можно было бы избежать. Выгода — модель памяти, которую можно удержать в голове.

Это не значит, что Go «медленнее». На практике разница между seq_cst и acquire/release почти всегда теряется в шуме. Зато код, который компилируется и проходит go test -race, ведёт себя так, как написан — без сюрпризов от переупорядочивания. Для backend-сервисов с пайплайном CI и код-ревью это важнее теоретических 20%.

Если тебе действительно нужна предельная производительность и ты готов платить внимательностью — в Go можно через unsafe и ручной ассемблер сделать что угодно, но это уже не Go-стиль. Для 99% задач sequentially consistent — то, что нужно.

3. Второй путь: Mutex, построенный поверх atomic

Теперь мы наконец можем объяснить sync.Mutex. Короткая версия: это в основном оптимизация поверх атомарного CAS плюс механизм честности. Посмотрим, как именно.

3.1. Структура Mutex

Вот реальное определение из Go 1.24 (исходник):

type Mutex struct {
    state int32
    sema  uint32
}

Два поля. Восемь байт всего. state — упакованное битовое поле, несущее четыре фрагмента информации. sema — семафор, который рантайм использует для парковки и пробуждения горутин.

Поле state устроено так:

┌───────────────────────────────────────────────────────────────┐
│                          state (int32)                          │
├───────┬────────┬─────────────┬─────────────────────────────────┤
│ бит 0 │ бит 1  │   бит 2     │  биты 3 .. 31                   │
│       │        │             │                                 │
│Locked │ Woken  │ Starving    │  Счётчик waiter'ов (29 бит, ~536M)│
└───────┴────────┴─────────────┴─────────────────────────────────┘
    1      2         4                   8, 16, 32, ...

Константы в исходнике:

const (
    mutexLocked      = 1 << iota // 1
    mutexWoken                   // 2
    mutexStarving                // 4
    mutexWaiterShift = iota      // 3

    starvationThresholdNs = 1e6  // 1 миллисекунда
)

Четыре вещи, закодированные в 32 битах:

  • Locked (бит 0): сейчас заперт мьютекс или нет? 1 = да.
  • Woken (бит 1): был ли waiter разбужен и теперь пытается захватить? Это подсказка-оптимизация для Unlock — он знает не будить ещё одного waiter’а, потому что один уже активен.
  • Starving (бит 2): находится ли мьютекс в режиме голодания? См. раздел 3.6.
  • Счётчик waiter’ов (биты 3-31): сколько горутин сейчас припарковано в ожидании этого мьютекса. 29 бит, до ~536 миллионов. Ты никогда не упрёшься в потолок.

Поле sema не имеет внутренней структуры. Это непрозрачный токен, который код семафоров рантайма использует для управления очередью припаркованных горутин.

3.2. Fast path: одна CAS, без syscall

Горячий путь через Lock() короткий:

func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        // ... бухгалтерия детектора гонок ...
        return
    }
    // Slow path (вынесен, чтобы fast path можно было инлайнить)
    m.lockSlow()
}

Это всё. Одна CAS. Если мьютекс свободен (state == 0), CAS’аем в mutexLocked (state == 1) и возвращаемся. Если CAS провалился — попадаем в lockSlow.

Fast path инлайнится. Можешь проверить сам:

$ go build -gcflags="-m" main.go
./main.go:13:12: inlining call to sync.(*Mutex).Lock
./main.go:15:14: inlining call to sync.(*Mutex).Unlock

Инлайнинг здесь важен. Он означает, что в неконкурируемом случае вызов mu.Lock() не несёт даже накладных расходов на вызов функции — инструкция CAS встаёт прямо в месте вызова. Поэтому uncontended mu.Lock() — это примерно 20-60 наносекунд: по сути одна инструкция CAS плюс несколько циклов бухгалтерии.

Если ты не запомнишь из этой статьи ничего больше, запомни это: uncontended блокировка мьютекса — это одна инструкция CAS. Та же инструкция, что использует atomic.CompareAndSwapInt32. Разница в стоимости между uncontended мьютексом и атомарной операцией сводится к нескольким лишним циклам бухгалтерии, а не к качественному различию.

3.3. Slow path: spinning

Когда CAS на fast path проваливается, мьютекс уже захвачен, и мы входим в lockSlow. Здесь начинается самое интересное.

Первое, что пробует lockSlow, — spinning (спиннинг, активное ожидание): активное ожидание в плотном цикле CPU в надежде, что текущий владелец скоро освободит блокировку. Обоснование простое: если владелец собирается отпустить блокировку в ближайшие несколько сотен наносекунд, дешевле покрутиться и захватить её сразу, чем парковать горутин и потом будить (цикл парковки/пробуждения горутины через рантайм стоит сотни наносекунд до микросекунд).

Вот релевантный кусок lockSlow:

func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
        // Не spinning в режиме голодания — владение передаётся
        // waiter'ам, поэтому мы всё равно не сможем захватить мьютекс.
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            // Активный spinning имеет смысл.
            // Установим флаг woken, чтобы сообщить Unlock, что он нам понадобится.
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            runtime_doSpin()
            iter++
            old = m.state
            continue
        }
        // ... пытаемся захватить или встать в очередь ...
    }
}

Функция runtime_doSpin в конечном счёте вызывает runtime.procyield(cycles), которая на ARM64 разворачивается в такой ассемблер:

TEXT runtime·procyield(SB),NOSPLIT,$0-0
    MOVWU cycles+0(FP), R0
again:
    YIELD
    SUBW  $1, R0
    CBNZ  R0, again
    RET

Плотный цикл, который выдаёт инструкцию YIELD (подсказку CPU, что это цикл ожидания — ядро может временно освободить ресурсы выполнения для собрата по hyperthreading или снизить собственный приоритет) и считает от заданного числа циклов. Go вызывает procyield(30) — 30 YIELD за одну итерацию спина.

runtime_canSpin(iter) ограничивает spinning: максимум 4 итерации, и только если GOMAXPROCS > 1 (spinning на одноядерной машине — чистая потеря, работать больше нечему, можно сразу парковаться), и есть больше одной runnable горутины (чтобы рантайму было что делать, если спин провалится). В худшем случае — 4 спина × 30 YIELD = 120 YIELD до сдачи. Это порядка нескольких сотен наносекунд реального времени — достаточно коротко, чтобы ты почти не заметил, но достаточно длинно, чтобы владелец с короткой критической секцией вероятно освободил блокировку в этом окне.

После spinning горутина строит новое значение state (отмечая себя как waiter при необходимости, возможно отмечая себя как starving, если ждёт слишком долго), CAS’ает его на место, и если всё ещё не может захватить — попадает в самый медленный путь: парковку.

3.4. Самый медленный путь: futex и парковка

Когда spinning провалился, горутине нужно действительно уснуть, пока блокировку не освободят. Здесь вступает futex.

Futex — сокращение от “fast userspace mutex” («быстрый мьютекс пространства пользователя») — это системный вызов Linux (futex(2)), появившийся в Linux 2.6 (2003) благодаря Хубертусу Франке, Мэттьё Кирквуду, Инго Молнару и Ульриху Дрепперу (да, тому самому Дрепперу, который написал “What Every Programmer Should Know About Memory”). Оригинальная статья “Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux” (OLS 2002) — каноническая ссылка.

Идея гениальна в своей простоте. Большую часть времени блокировка либо не конкурируется, либо конкурируется лишь кратко. В этих случаях можно захватывать и отпускать её целиком в пространстве пользователя через CAS, без участия ядра. Только когда действительно нужно уснуть (потому что блокировку держат долго) — мы обращаемся к ядру. Задача ядра — только поддерживать очередь waiter’ов и будить их в нужный момент.

Две основные операции futex:

  • FUTEX_WAIT(addr, expected): проверить, что *addr == expected. Если да — припарковать вызывающий поток в очередь, ассоциированную с addr, и уснуть. Если нет — немедленно вернуться. Проверка-и-парковка атомарны — нет гонки, где значение меняется между проверкой и засыпанием.
  • FUTEX_WAKE(addr, n): разбудить не более n потоков, припаркованных в очереди, ассоциированной с addr.

Рантайм Go строит собственную систему семафоров поверх futex (на Linux) или эквивалентного syscall на других ОС (umtx на FreeBSD, futex на OpenBSD и т.д.). Функция runtime_SemacquireMutex(&m.sema, ...) в конечном счёте паркует горутину; runtime_Semrelease(&m.sema, ...) — будит одну.


graph LR
    subgraph US["Userspace"]
        CAS["CAS(state, 0, locked)"]
        Spin["Spin: procyield(30) × 4"]
        Slow["lockSlow: строим новый state"]
    end

    subgraph K["Ядро"]
        Wait["FUTEX_WAIT(sema)"]
        Queue["Очередь waiter'ов"]
        Wake["FUTEX_WAKE(sema)"]
    end

    Lock["mu.Lock()"] --> CAS
    CAS -->|успех| Done["готово (без syscall)"]
    CAS -->|неудача — занято| Spin
    Spin -->|получилось| Done
    Spin -->|всё ещё занято| Slow
    Slow --> Wait
    Wait --> Queue
    Queue -.->|припаркован, спит| SleepZzz["..."]
    Wake -.->|от Unlock другой горутины| Queue
    Queue -->|разбужен| CAS

    style CAS fill:#2e7d32,color:#fff
    style Done fill:#2e7d32,color:#fff
    style Wait fill:#c62828,color:#fff
    style Wake fill:#c62828,color:#fff

Разница в стоимости между этими путями колоссальна. Неконкурируемая CAS — одна инструкция, несколько наносекунд. Круг futex (парковка и пробуждение) — два syscall плюс переключения контекста, порядка микросекунд. Это разница в 1000 раз. Поэтому Mutex так старается оставаться в userspace: spinning, оптимистичная CAS, всё это спроектировано, чтобы не попадать в ядро.

M:N планировщик и почему парковка горутины — не то же, что парковка потока

Тут есть тонкость, которую легко упустить. Futex в Linux работает с потоками ядраFUTEX_WAIT усыпляет вызывающий поток. Но Go использует M:N планировщик: много горутин (M) мультиплексируются на меньшее число потоков ядра (N, по умолчанию равно числу CPU).

Прежде чем разбирать механику парковки, посмотрим на сами компоненты планировщика Go — так называемую GMP-модель:


graph TB
    subgraph Gs["G — горутины (тысячи)"]
        G1["G1 running"]
        G2["G2 runnable"]
        G3["G3 runnable"]
        Gn["... Gn"]
    end

    subgraph Ps["P — логические процессоры (GOMAXPROCS)"]
        P1["P1
local runq: [G2, G3]"] P2["P2
local runq: [G4, G5]"] end subgraph Ms["M — потоки ядра (число ≤ GOMAXPROCS + блокированных)"] M1["M1 ← P1"] M2["M2 ← P2"] end subgraph SYS["Ядро ОС"] SCHED["Linux scheduler
управляет M"] FUTEX["futex syscall
паркует M"] end G1 -.->|выполняется на| M1 G2 -.->|в очереди| P1 G3 -.->|в очереди| P1 M1 ===>|связан с| P1 M2 ===>|связан с| P2 M1 -.->|системные вызовы| SCHED M2 -.->|системные вызовы| SCHED SCHED --- FUTEX style G1 fill:#2e7d32,color:#fff style P1 fill:#1565c0,color:#fff style P2 fill:#1565c0,color:#fff style M1 fill:#c62828,color:#fff style M2 fill:#c62828,color:#fff style FUTEX fill:#6a1b9a,color:#fff

Три буквы:

  • G (Goroutine) — пользовательская горутина. Лёгкая, стартовый стек 2КБ, растёт по надобности. Их могут быть сотни тысяч на процесс.
  • M (Machine) — поток ядра ОС. Настоящий, тяжёлый, с собственным стеком (минимум несколько КБ). Число M ограничено — обычно равно числу CPU плюс несколько spare для заблокированных в syscall.
  • P (Processor) — логический процессор. Несёт локальную очередь runnable-горутин (runq) и контекст для выполнения Go-кода. Число P = GOMAXPROCS. Чтобы M мог выполнять Go-код, ему нужен P.

Связь: M хватает P → берёт G из runq этого P → выполняет → берёт следующий G. Если runq пуста, P пытается украсть горутины у другого P (work stealing) или берёт из глобальной очереди.

Теперь — что происходит, когда G1 на M1/P1 вызывает mu.Lock(), а мьютекс уже занят. Рантайм Go проходит по такому дереву решений:


flowchart TD
    Start([G1 вызывает mu.Lock
мьютекс занят]) --> Mark["Рантайм:
G1 → состояние _Gwaiting
убрать из runq P1
добавить в sema-очередь мьютекса"] Mark --> Check{"На P1 есть
другие runnable G?"} Check -->|да — например G2, G3| Switch["M1 переключается на G2
~200 нс, без syscall
P1 не освобождается"] Check -->|нет| Global{"В глобальной runq
есть G?"} Global -->|да| Take["M1 берёт G из globalq
или ворует у другого P"] Global -->|нет| ParkM["M1 отпускает P1
другой M может его забрать
M1 вызывает FUTEX_WAIT
kernel thread спит"] Switch --> Work["G2 выполняется"] Take --> Work ParkM --> Kernel["... ядро держит M1 спящим
в очереди futex"] Kernel -.->|"где-то в другом потоке:
Gx отпускает мьютекс"| Wake Wake["runtime_Semrelease
G1 → _Grunnable
кладётся в runq P
(того же или другого)"] Wake --> Rerun["G1 снова в очереди
дождётся своего M/P
и продолжит"] style Start fill:#c62828,color:#fff style Switch fill:#2e7d32,color:#fff style Take fill:#2e7d32,color:#fff style ParkM fill:#ad1457,color:#fff style Wake fill:#1565c0,color:#fff

Ключевой момент — первая проверка: «есть ли на P1 ещё runnable G?». В типичной Go-программе ответ почти всегда да — в локальной очереди сидят ещё несколько горутин. Поэтому M1 не засыпает, а просто переключается на G2. Это переключение контекста происходит целиком в user-space (сотня наносекунд), без захода в ядро.

Только если локальная очередь пуста и глобальная пуста и нечего украсть у других P — только тогда M1 вызывает FUTEX_WAIT и засыпает на уровне ядра. Это и есть та самая оптимизация, которая отличает Go от C++/Java: вместо парковки тяжёлого потока ядра (с переключением контекста ОС, порядка микросекунд) мы парковываем лёгкую горутину и переиспользуем поток ядра для другой работы.

Сравни:

ПлатформаЧто паркуется при Lock под нагрузкойСтоимостьМасштаб
C++ std::mutexПоток ядра (через futex)~1-5 мкс (syscall + ctx switch)≤ тысячи потоков
Java synchronizedПоток ядра (через JVM monitor)~1-5 мкс≤ тысячи потоков
Go sync.MutexГорутина (в user-space)~100-200 нссотни тысяч горутин

Поэтому Go может иметь сотни тысяч горутин, ожидающих мьютексов, не расходуя сотни тысяч потоков ядра — это была бы катастрофа по памяти (каждый поток ядра имеет стек минимум несколько КБ) и по планировщику ОС (ему пришлось бы бегать по огромной очереди потоков).

Стоимость «парковки горутины» в Go — это не стоимость syscall, а стоимость переключения контекста в user-space планировщике, порядка сотни наносекунд. Syscall FUTEX_WAIT случается только когда поток ядра действительно не нашёл другой работы. Это ещё одна причина, почему mu.Lock() под нагрузкой в Go часто быстрее, чем эквивалент в C++/Java — мы платим только за то, что реально используем.

Подробнее о планировщике Go — в design doc Дмитрия Вюкова “Scalable Go Scheduler Design Doc” (май 2012, до сих пор основа runtime/sched).

3.5. Unlock: симметричный, с сюрпризом handoff

Unlock выглядит симметрично Lock, но с одним поворотом:

func (m *Mutex) Unlock() {
    // ... бухгалтерия детектора гонок ...
    // Fast path: drop lock bit.
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // Вынесенный slow path, чтобы позволить инлайнинг fast path.
        m.unlockSlow(new)
    }
}

Fast path — atomic.AddInt32(&m.state, -mutexLocked) — одна атомарная инструкция вычитания, очищающая бит блокировки. Если результирующее состояние нулевое (нет waiter’ов, нет флагов) — мы готовы: никакого syscall, никакого пробуждения. Поэтому uncontended unlock, как и uncontended lock, — это всего несколько наносекунд.

Если есть waiter’ы, запускается unlockSlow, и его поведение зависит от того, находится ли мьютекс в режиме голодания:

func (m *Mutex) unlockSlow(new int32) {
    if (new+mutexLocked)&mutexLocked == 0 {
        fatal("sync: unlock of unlocked mutex")
    }
    if new&mutexStarving == 0 {
        // Нормальный режим.
        old := new
        for {
            // Если waiter'ов нет, или кто-то уже одного разбудил,
            // или кто-то захватил блокировку — никого будить не нужно.
            if old>>mutexWaiterShift == 0 ||
                old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                return
            }
            // Хватаем право кого-то разбудить.
            new = (old - 1<<mutexWaiterShift) | mutexWoken
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                runtime_Semrelease(&m.sema, false, 2)
                return
            }
            old = m.state
        }
    } else {
        // Режим голодания: напрямую передаём владение мьютексом
        // следующему waiter'у и отдаём наш квант времени, чтобы
        // следующий waiter мог немедленно начать работу.
        // Замечание: mutexLocked не установлен, waiter установит его после пробуждения.
        runtime_Semrelease(&m.sema, true, 2)
    }
}

Посмотри на последний аргумент runtime_Semrelease: false в нормальном режиме, true в режиме голодания. Этот булев флаг — флаг handoff (передачи владения). Когда true, рантайм напрямую передаёт владение мьютексом разбуженному waiter’у — тот просыпается уже владея блокировкой, с предустановленным битом locked. Когда false — разбуженный waiter должен конкурировать за блокировку наравне со всеми.

Этот единственный булев флаг — вся суть режима голодания. Чтобы понять, почему он важен, нужно посмотреть на баг, для которого он был придуман.

3.6. Режим голодания: баг за issue #13086

28 октября 2015 года Рассел Кокс открыл Go issue #13086 с заголовком “runtime: fall back to fair locks after repeated sleep-acquire failures” («рантайм: откатываться к честным блокировкам после повторных неудач засыпание-захват»). Открывающая строка была прямолинейной: «Блокировки Go не дают гарантий справедливости».

Баг-репорт описывал простую программу с двумя горутинами. Горутина 1 держит блокировку почти всё время, отпуская её лишь на 100 микросекунд за раз. Горутина 2 хочет блокировку лишь ненадолго, каждые 100 микросекунд. Наивное ожидание — что горутина 2 должна получать блокировку хоть иногда, возможно в течение секунды-двух.

Реальность, на Linux-станции Рассела, — горутина 2 тратила от 100 до 600 секунд на одну попытку захвата. Не миллисекунды. Секунды. Минуты. Десять минут на одно получение блокировки.

Анализ Рассела точно идентифицировал проблему. Когда горутина 1 вызывает Unlock, она помечает блокировку свободной и сообщает рантайму «разбуди горутину 2». Но горутина 2 не запускается немедленно. Горутина 1 продолжает работать, проходит цикл, снова вызывает Lock — и поскольку блокировка теперь свободна и горутина 1 уже на CPU, горутина 1 захватывает её обратно. К моменту, когда горутина 2 наконец получает квант и пытается захватить, горутина 1 снова держит блокировку. Цикл повторяется. Миллионы раз.

Рассел назвал проблему barging («втесание»). Альтернативу, при которой Unlock оставляет блокировку запертой и явно передаёт владение разбуженному waiter’у, он назвал handoff («передача»). В более ранних работах Дага Ли по java.util.concurrent (статья AQS, 2003) barging улучшал пропускную способность. Измерения Ли были на потоках ОС с планировщиком Linux 2.4 NPTL, и его аргумент: barging помогает избежать плохих решений планировщика ОС — если ОС медленно планирует разбужденный поток, то оставление блокировки свободной позволяет другому потоку делать полезную работу тем временем.

Но Go не использует потоки ОС напрямую. Он использует горутины и собственный планировщик в пространстве пользователя, где переключение горутины стоит десятки наносекунд, а не микросекунды переключения контекста потока. Компромисс, оправдывавший barging в Java, не обязательно применим к Go.

Рассел предложил гибрид. Напомню два режима, которые он противопоставлял:

  • Barging (от «barge in» — врываться): при Unlock мьютекс помечается свободным, и любой новый кандидат может его захватить. Тот, кого только что разбудили, должен соревноваться с новоприбывшими. Если он не успел — возвращается в очередь. Это быстрее в среднем (не нужно ждать пробуждения), но может бесконечно ущемлять конкретного waiter’а.
  • Handoff (передача владения): при Unlock мьютекс остаётся запертым, и владение напрямую передаётся следующему в очереди. Никакой конкуренции — следующий waiter просыпается уже владельцем. Это честно, но требует yield’а кванта времени от текущей горутины, что в теории дороже.

Идея Рассела: по умолчанию жить в режиме barging (быстрее), но переключаться на handoff, когда unfairness становится патологической. Конкретный критерий «патологичности», который он предложил в issue: waiter, который был разбужден, но обнаружил блокировку уже занятой (то есть проиграл barging-конкуренцию), увеличивает внутренний счётчик. После нескольких таких последовательных проигрышей мьютекс переключается в режим handoff — чтобы гарантированно дать голодному waiter’у дождаться своего.

Ниже на схеме — как эти два режима выглядят рядом. После неё разберём, как финальная реализация Вюкова в Go 1.9 воплотила эту идею (с одним изменением: вместо подсчёта проигрышей — замер времени ожидания).


graph LR
    subgraph Normal["Нормальный режим (по умолчанию)"]
        N1["Unlock: пометить свободной"] --> N2["Разбудить waiter W"]
        N2 --> N3["Продолжить работу"]
        N3 --> N4["Другая горутина вызывает Lock"]
        N4 --> N5["Barge! Захватить блокировку"]
        N2 -.->|W спланирован слишком поздно| NLost["W просыпается, блокировка уже занята"]
        NLost --> N1
    end

    subgraph Starvation["Режим голодания (после 1мс ожидания)"]
        S1["Unlock: оставить запертой"] --> S2["Передать владение следующему waiter W"]
        S2 --> S3["Отдать квант времени"]
        S3 --> S4["W просыпается уже владея"]
        S4 --> S5["W выполняет критическую секцию"]
        S5 --> S1
    end

    Normal -.->|"waiter ждал > 1мс"| Starvation
    Starvation -.->|"последний waiter ИЛИ ждал < 1мс"| Normal

    style Normal fill:#1565c015
    style Starvation fill:#c6282815
    style N5 fill:#c62828,color:#fff
    style S4 fill:#2e7d32,color:#fff

Реализация, попавшая в Go 1.9 (август 2017), написанная Дмитрием Вюковым, отличалась в деталях, но та же по духу. Вместо подсчёта неудач отслеживается время ожидания по стенным часам (wall-clock wait time). Если горутина ждёт дольше starvationThresholdNs = 1e6 (1 миллисекунда), чтобы захватить блокировку, она устанавливает бит mutexStarving. В режиме голодания:

  • Новые прибывшие не пытаются захватить блокировку. Они идут прямо в конец очереди waiter’ов.
  • Unlock напрямую передаёт владение (тот самый true, что мы видели в runtime_Semrelease(&m.sema, true, 2)), и отдаёт квант времени, чтобы разбужденный waiter запустился немедленно.
  • Spinning отключается — он бесполезен, потому что блокировку передают, а не втесаются.

Мьютекс выходит из режима голодания, когда выполняется любое из двух условий:

  1. Текущий waiter — последний в очереди (счётчик waiter’ов после этого захвата стал бы 0), ИЛИ
  2. Текущий waiter ждал менее 1мс в этом раунде.

Это самокорректирующийся механизм. Когда нагрузка спадает, мьютекс естественно возвращается к быстрому режиму barging. Когда патологическая нагрузка вызывает голодание, он переключается в режим handoff ровно настолько, чтобы честно опустошить очередь.

Эффект, измеренный Расселом на его бенчмарке lockskew, — ускорение в 500000 раз в патологическом случае: от 100+ секунд на захват до 162 микросекунд. А на бенчмарке общего случая (генерация случайных чисел под тяжёлой конкуренцией) производительность даже слегка выросла (на 1-12%), опровергая мудрость эпохи Java о том, что handoff всегда вредит пропускной способности. Планирование горутин достаточно дёшево, что стоимость handoff незаметна на фоне остальной работы.

Вот почему твой Go-код почти никогда не голодает: внутри sync.Mutex тихо сидит порог в 1мс, защищая тебя от целого класса багов, которые вырубали реальные продакшен-системы в 2015 году.

Цифры из issue #13086: насколько всё было плохо

Чтобы почувствовать масштаб проблемы, посмотри на исходные замеры Рассела Кокса с его бенчмарка lockskew. Две горутины, одна держит мьютекс по 100мкс и отпускает на 100мкс, вторая хочет его на мгновение каждые 100мкс. Время до получения блокировки для горутины 2:

lock#0  взяла 180.773982s
lock#1  взяла 484.603155s   (среднее 332s)
lock#2  взяла 107.852508s
lock#3  взяла 223.592777s
lock#4  взяла 600.067715s   ← 10 минут на один lock
lock#5  взяла 579.896472s

Не 100 миллисекунд. Не 100 секунд. До 10 минут на одно получение блокировки. И это не toy-пример — Рассел упоминает, что два независимых реальных репорта привели к этому issue.

После применения решения (в final виде — порог в 1мс с переключением в starvation), те же самые замеры:

lock#0     взяла 0.000155s
lock#2934  взяла 0.000165s   (среднее)
lock#5861  взяла 0.000163s

500000x ускорение в патологическом случае. И при этом на стандартном бенчмарке пропускной способности (go test -bench) регрессии нет — местами даже небольшой прирост.

Это, кстати, хороший инженерный урок: «общепринятое правило» может быть основано на измерениях для другой платформы и не переноситься на твою. Doug Lea измерял barging vs handoff для Java на Linux 2.4 с потоками ядра. Go измеряет на горутинах с собственным планировщиком — и компромисс оказывается иным. В enterprise-разработке такие перекрёстные ссылки на «авторитеты» без проверки применимости — частый источник мифов.

3.7. Правила: не копировать, не реентерить

Два операционных правила, на которых люди спотыкаются:

Никогда не копируй Mutex. Вот это:

type Server struct {
    mu sync.Mutex
    // ...
}

func (s Server) Handle(req Request) {  // БАГ: приёмник по значению
    s.mu.Lock()
    defer s.mu.Unlock()
    // ...
}

…компилируется, но содержит баг. Приёмник метода — копия Server, а значит s.mu — копия оригинального мьютекса: свежий, незапертый мьютекс без связи с оригинальным state или sema. Две горутины, вызывающие Handle на одном Server, каждая заблокируют свою собственную приватную копию — нулевая взаимная эксклюзия. go vet ловит это:

$ go vet
./server.go:12:20: Handle passes lock by value: Server contains sync.Mutex

Исправление: сделай приёмник указателем (func (s *Server) Handle(...)), или встраивай *sync.Mutex вместо sync.Mutex.

То же касается типов, встраивающих Mutex, — sync.WaitGroup, sync.RWMutex, чего угодно с ним внутри. Общее правило: значение, содержащее Mutex, нельзя копировать после первого использования.

Мьютексы не реентерабельны. Сначала — что вообще значит «реентерабельный» (reentrant, «с возможностью повторного входа»). Реентерабельная функция или объект — тот, который можно безопасно вызвать ещё раз до того, как закончился предыдущий вызов. Например, чистая функция sum(a, b) реентерабельна: её можно вызвать рекурсивно, прервать, вызвать из другого потока — ничего не сломается, потому что у неё нет состояния, которое могло бы «запутаться».

К мьютексам это применяется так: реентерабельный мьютекс (recursive mutex, как PTHREAD_MUTEX_RECURSIVE в POSIX, или synchronized в Java) позволяет той же горутине/потоку вызывать Lock несколько раз подряд без дедлока. Внутри ведётся счётчик глубины: первый Lock действительно захватывает мьютекс, второй — увеличивает счётчик до 2, третий — до 3. Каждый Unlock уменьшает счётчик, и только когда он доходит до 0, мьютекс реально отпускается. Это удобно для рекурсивного кода:

// Гипотетический пример с реентерабельным мьютексом
// (в Go такого нет — это псевдокод)
func (s *Server) Outer() {
    s.reentrantMu.Lock()      // счётчик: 0 → 1 (мьютекс захвачен)
    defer s.reentrantMu.Unlock()  // счётчик: 1 → 0 (мьютекс отпущен)
    s.Inner()
}

func (s *Server) Inner() {
    s.reentrantMu.Lock()      // счётчик: 1 → 2 (та же горутина, нет дедлока)
    defer s.reentrantMu.Unlock()  // счётчик: 2 → 1
    // ...
}

В Go мьютекс не реентерабельный — sync.Mutex ведёт себя как PTHREAD_MUTEX_NORMAL в POSIX: второй вызов Lock из той же горутины, не отпустив первый, сразу дедлочит. Никакого счётчика глубины нет:

func (s *Server) Outer() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.Inner()  // БАГ: Inner снова вызывает s.mu.Lock — deadlock
}

func (s *Server) Inner() {
    s.mu.Lock()
    defer s.mu.Unlock()
    // ...
}

Второй Lock блокирует навсегда в ожидании первого Unlock, который никогда не наступит — потому что горутина застряла во втором Lock и не дойдёт до defer Unlock. Классический self-deadlock.

Разработчики Go намеренно отказались от рекурсивного мьютекса и неоднократно отклоняли предложения его добавить. Их аргумент: если функция берёт блокировку, которую вызывающая сторона уже держит, — это почти всегда симптом непродуманной дисциплины блокировок. Реентерабельный мьютекс не лечит, а прячет проблему: код компилируется, «работает», но логика блокировок запутана, и в следующий раз (когда кто-то добавит ещё одну функцию) дедлок вернётся в менее очевидной форме. Лучше сразу увидеть проблему и переструктурировать код.

Типичные исправления:

  • Разделить функцию на «внешнюю» (берёт блокировку) и «внутреннюю» (предполагает, что блокировка уже взята). Это самый частый паттерн в стандартной библиотеке Go — например, sync.Map имеет публичные методы Load/Store и приватные loadLocked/storeLocked:
func (s *Server) Outer() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.innerLocked()  // блокировка уже взята — innerLocked её не трогает
}

// innerLocked предполагает, что s.mu уже захвачен вызывающим.
// Имя *Locked — конвенция, общепринятая в Go-кодбейзах.
func (s *Server) innerLocked() {
    // ... работа с защищённым состоянием ...
}
  • Перенести блокировку выше — туда, где понятна её область. Если Outer и Inner оба хотят блокировку, возможно, блокировку должен брать только Outer, а Inner должна принимать уже защищённые данные по значению.
  • Переструктурировать так, чтобы одной горутине не нужно было брать одну блокировку дважды. Часто это признак того, что критическая секция слишком широка или что данные надо разделить.

Исправление структурное: разбей Inner на две функции — одна предполагает, что блокировка уже взята, другая берёт её. Или документируй дисциплину блокировок явно. Или переструктурируй так, чтобы одной горутине не приходилось брать одну блокировку дважды.

4. Что когда использовать

Теперь ты понимаешь стек. Оставшийся вопрос — практический: глядя на кусок кода, тянешься ли ты к atomic, к мьютексу или к чему-то ещё? Разберём.

4.1. Публичные бенчмарки: цифры, которые стоит запомнить

Большинство опубликованных бенчмарков mutex-vs-atomic на Go сходятся вокруг одних и тех же чисел. Из бенчмарков thecodinggopher, goperf.dev и других, на современном x86:

ОперацияБез конкуренцииПод нагрузкой
atomic.AddInt64(&x, 1)4-8 нсдесятки нс до мкс (повторы CAS)
atomic.LoadInt64(&x)1-2 нс1-2 нс (только чтение)
mu.Lock(); mu.Unlock()20-60 нссотни нс до мкс
mu.Lock(); работа; mu.Unlock() (короткая кс)~30 нс + работамасштабируется с конкуренцией
CAS-loop под сильной нагрузкоймикросекунды сжигания CPU

Под нагрузкой картина инвертируется тонким образом. CAS-loop под тяжёлой конкуренцией может сжигать сотни наносекунд до микросекунд CPU на операцию, потому что каждая неудачная CAS — потраченная работа. Мьютекс же, припарковав проигравшего, позволяет победителю продолжать без постоянных помех. Для устойчивой конкуренции мьютекс часто быстрее наивного атомарного подхода.

Откуда берётся разрыв в 5-10x

Выше я писал, что uncontended mu.Lock() — это «почти одна CAS, как atomic». И в то же время таблица выше показывает разрыв в 5-10x. Кажется противоречием — давайте разберём честно, по инструкциям.

atomic.AddInt64(&x, 1) компилируется в одну инструкцию LOCK XADD на x86 (LDADD на ARM LSE). Это read-modify-write за один такт, всегда успешный с первого раза — нет ни retry, ни проверок.

А пара mu.Lock(); mu.Unlock() делает минимум две атомарные операции:

// Lock, fast path:
atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)   // CAS

// Unlock, fast path:
new := atomic.AddInt32(&m.state, -mutexLocked)          // atomic subtract
if new != 0 {                                            // branch
    m.unlockSlow(new)                                    // потенциальный slow path
}

То есть структурно уже 2x — операций вдвое больше. Откуда остальное до 5-10x?

  1. CAS дороже Add. CAS — это «прочитать, сравнить, может быть записать». Add — «прочитать, прибавить, записать». На x86 LOCK CMPXCHG дороже LOCK XADD из-за дополнительной логики сравнения. Под капотом LOCK CMPXCHG в худшем случае делает две попытки (сравнение провалилось — ничего не пишет), а LOCK XADD всегда завершает работу за один проход.
  2. Ветки даже на fast path. После CAS в Lock и после Add в Unlock стоят проверки — «получилось ли?» и «есть ли waiter’ы?». Это дополнительные branch prediction такты. На горячем цикле предсказатель обычно угадывает, но это не бесплатное угадывание.
  3. Race-детектор хуки. В каждый Lock/Unlock вшиты вызовы race.Acquire/race.Release. Когда -race выключен — они no-op, но вызов всё равно сгенерирован компилятором. В атомарных операциях этих хуков значительно меньше.
  4. Inlining overhead. Хотя fast path инлайнится, граница функции оставляет след — компилятор сохраняет часть регистров, перестраховывается. У atomic.AddInt64 inline-след минимальный.

Итого: вместо одной плотной atomic-инструкции (LOCK XADD, 4-8 нс) мы получаем связку «CAS + branch + atomic Add + branch + race hooks + inline overhead» (20-60 нс на пару Lock/Unlock). На «uncontended» benchmark-е, где нет вообще никакой конкуренции. Под реальной нагрузкой, когда появляются другие горутины, разрыв может и вырасти — CAS начнёт периодически проваливаться, требуя retry.

Парадокс разрешается так: на одиночной операции atomic выигрывает в 5-10x. Но на нагрузке с многими писателями mutex выигрывает — он паркует проигравших и не даёт им жечь CPU на retry-циклах. Это два разных режима, и выбирать надо по профилю реальной работы, а не по микробенчмарку без contention.

4.2. Когда atomic — правильно

Используй atomic, когда операция над одной ячейкой памяти и выразима как одна из атомарных примитивов.

СценарийРекомендация
Счётчик (запросы/сек, ошибки/сек)atomic.Int64.Add(1)
Флаг done/startedatomic.Bool.Store(true) / .Load()
Single-word состояние (idle/active/stopped)atomic.Int32 или atomic.Uint32
Указатель на иммутабельный снэпшотatomic.Pointer[T]
Sequence numberatomic.Uint64.Add(1)

Если твой горячий путь — счётчик, инкрементируемый из множества горутин, atomic в 5-10 раз быстрее мьютекса и столь же корректен. Не оборачивай счётчик в мьютекс «для безопасности» — это медленнее без выгоды.

Хитрее случай, когда нужно более сложная атомарная операция, вроде «атомарно обновить два счётчика вместе». Это напрямую не выразимо как один атомарный примитив. У тебя два варианта:

  1. Упаковать оба значения в один uint64 через битовые сдвиги и CASать объединённое значение.
  2. Использовать мьютекс.

Упаковка быстрее, но ограничивает 64 битами суммарно. Мьютекс проще и работает для любого размера. Большую часть времени мьютекс — правильный ответ.

4.3. Когда мьютекс — правильно

Используй мьютекс, когда операция охватывает несколько ячеек памяти, или когда нужно поддерживать инвариант между несколькими полями.

Классический пример: обновление map.

type Cache struct {
    mu    sync.Mutex
    items map[string]*Entry
    size  int
}

func (c *Cache) Put(k string, e *Entry) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if _, ok := c.items[k]; !ok {
        c.size++
    }
    c.items[k] = e
}

Нельзя атомарно обновить items и size вместе. Это отдельные поля. Если пробовать atomic для каждого, другая горутина может увидеть size обновлённым, а items — нет, или наоборот. Мьютекс гарантирует, что вся операция Put атомарна относительно других горутин: они видят либо состояние до, либо состояние после — никогда смесь.

Это обобщается: любая операция, требующая read-modify-write нескольких полей при сохранении инварианта между ними, требует мьютекса. Атомарные операции тут не помогают, потому что синхронизируют только одно слово.

4.4. atomic.Value vs RWMutex для read-heavy данных

Для данных, которые много читают — конфигурация, feature flags, справочные таблицы — есть два распространённых паттерна:

Паттерн A: atomic.Pointer[T] (copy-on-write).

var config atomic.Pointer[Config]

func Get() *Config { return config.Load() }

func Update(c *Config) { config.Store(c) }

Чтения — одна атомарная загрузка: несколько наносекунд, никакой конкуренции никогда. Записи требуют построения полного нового *Config и атомарного сохранения; старый указатель собирается GC, когда последний читатель с ним закончит.

Паттерн B: sync.RWMutex.

var (
    mu     sync.RWMutex
    config *Config
)

func Get() *Config {
    mu.RLock()
    defer mu.RUnlock()
    return config
}

func Update(c *Config) {
    mu.Lock()
    defer mu.Unlock()
    config = c
}

Чтения берут read lock — дешевле write lock, но не бесплатно (всё равно атомарный add на счётчик читателей, и записи должны ждать, пока все читатели отпустят).

Что быстрее? Зависит от соотношения чтение/запись:

  • Миллионы чтений в секунду, пара записей в минуту: atomic решает decisively. Чтения в 5-10 раз дешевле, и no contention на запись.
  • Много записей в секунду, данные большие: мьютекс может выиграть, потому что строить полную новую копию на каждую запись дорого.
  • Нужно мутировать данные на месте (не заменять целиком): мьютекс, без вопросов. atomic.Value/Pointer работает только для тотальной замены.

Для большинства конфигурационных use case’ов atomic.Pointer[T] — правильный ответ.

4.5. False sharing: когда независимые атомарные операции воюют

Вот тонкий баг производительности. Допустим, у тебя два счётчика, обновляемые независимо из разных горутин:

type Stats struct {
    Requests int64
    Errors   int64
}

var stats Stats
// горутина A: atomic.AddInt64(&stats.Requests, 1)
// горутина B: atomic.AddInt64(&stats.Errors, 1)

Выглядит нормально. Счётчики независимые. Должны линейно масштабироваться по ядрам.

Не масштабируются. Чтобы понять почему, надо посмотреть на размеры. Сначала — базовые единицы.

Машинное слово (machine word) — это разрядность регистра CPU. На 64-битных системах (x86-64, ARM64 — все современные серверы, десктопы, Apple Silicon) машинное слово = 8 байт = 64 бита. Это также размер указателя (unsafe.Sizeof(uintptr(0)) == 8), размер int на 64-битных (int в Go platform-dependent, но GOARCH=amd64/arm64 → 8 байт), и размер int64.

Строка кэша (cache line) — минимальная порция данных, которую CPU читает из памяти в L1-кэш. На всех современных x86 и ARM — 64 байта = 8 машинных слов. CPU не умеет читать из памяти один байт или одно слово; он всегда тащит целиком 64 байта. И когда пишет — тоже инвалидирует (помечает устаревшей) целиком строку в 64 байта на всех остальных ядрах.

Посмотрим, как наша структура Stats без padding ложится на строки кэша:

                    ←─────── 64 байта = 1 cache line ───────→

  смещение  0   1   2   3   4   5   6   7   8   9  ...  15  16  17 ...  63
           ┌───────────────────────────────────┬──────────────────┬─────┐
  байты    │           Requests (8 байт)       │  Errors (8 байт) │ ... │
           └───────────────────────────────────┴──────────────────┴─────┘
            ↑                                   ↑
            int64                               int64
            машинное слово                      машинное слово
            (атомарный Add трогает              (атомарный Add трогает
             эти 8 байт)                         эти 8 байт)

           ↑─────── обе переменные в ОДНОЙ строке кэша ───────↑

Структура без padding занимает 16 байт — Requests (8) + Errors (8). Оба поля уютно умещаются в одну строку кэша. Когда горутина A на ядре 0 делает atomic.AddInt64(&stats.Requests, 1), CPU инвалидирует всю строку (все 64 байта) на всех остальных ядрах — даже если A трогала только первые 8 байт. Когда горутина B на ядре 1 делает atomic.AddInt64(&stats.Errors, 1), ей приходится перезагружать строку из памяти, делать add, инвалидировать её на ядре 0. Две горутины перекидывают строку кэша туда-сюда, хотя касаются разных байтов. Это и есть false sharing (ложное разделение), и оно может замедлить параллельный код в 5-10 раз.

Исправление — padding (выравнивание): вставить неиспользуемые байты между горячими полями, чтобы вытолкнуть Errors за границу 64-байтной строки:

type Stats struct {
    Requests int64     //  8 байт
    _        [56]byte  // 56 байт — добить так, чтобы Errors попал в следующую строку
    Errors   int64     //  8 байт
}
// Размер структуры: 8 + 56 + 8 = 72 байта

Почему именно 56? Нам нужно, чтобы поле Requests (8 байт) + padding заняли ровно 64 байта — целую строку кэша. Тогда Errors начнётся со смещения 64, то есть с начала следующей строки. Считаем: 64 − 8 = 56. Это и есть размер padding-массива.

Как это выглядит в памяти:

      ←─────── 64 байта = cache line #1 ───────→  ←─── cache line #2 ───→

  0   ...   7    8  ...                   63    64  ...  71
 ┌───────────┬─────────────────────────────────┬───────────┬─────
 │ Requests  │  _ [56]byte  (мусор, padding)   │  Errors   │  ...
 │  (8 байт) │       (56 байт)                 │  (8 байт) │
 └───────────┴─────────────────────────────────┴───────────┴─────
  ↑ atomic.Add                                          ↑ atomic.Add
  трогает только cache line #1                          трогает только cache line #2

 ↑─── ядро 0 инвалидирует только эту строку ───↑  ↑─── ядро 1 — только эту ───↑

Теперь Requests и Errors гарантированно на разных строках кэша. Ядро 0, делая add к Requests, инвалидирует только line #1 на остальных ядрах — line #2 остаётся валидной на ядре 1. И наоборот. Две горутины могут обновлять свои счётчики независимо, не мешая друг другу.

Цена — структура занимает 72 байта вместо 16 (в 4.5 раза больше), и в каждой строке кэша 56 байт «мусора». Для нескольких структур это незаметно; для массива из миллионов Stats — может раздуть потребление памяти и ухудшить cache locality при последовательном чтении. Поэтому padding применяют точечно, только для горячих полей.

Это забота уровня 1 (железо), но проявляется на уровне 5 (практика). Большая часть Go-кода никогда об этом не думает. Но если ты пишешь высоконагруженный коллектор метрик или горячую lock-free структуру данных, false sharing — одно из первых, что стоит поискать.

4.6. Капкан double-checked locking

Вот классический баг конкурентности, в инициализаторе синглтона:

var (
    instance *Config
    mu       sync.Mutex
)

func GetConfig() *Config {
    if instance == nil {            // БАГ: неатомарное чтение
        mu.Lock()
        defer mu.Unlock()
        if instance == nil {
            instance = loadConfig() // БАГ: неатомарная запись
        }
    }
    return instance
}

«Двойная проверка» — это две проверки instance == nil. Идея в том, чтобы избегать взятия мьютекса на общем пути (после инициализации), оставаясь при этом потокобезопасным во время инициализации.

Это сломано двумя способами:

  1. Первая instance == nil — неатомарное чтение. Модель памяти Go не гарантирует, что оно увидит запись, сделанную под мьютексом. Компилятор и CPU свободно переупорядочивают, кэшируют и иным образом плохо себя ведут.
  2. Даже если бы чтение сработало, instance = loadConfig() — неатомарная запись. Другая горутина в первой проверке может наблюдать частично сконструированный *Config.

Правильный ответ в Go — sync.Once:

var (
    instance *Config
    once     sync.Once
)

func GetConfig() *Config {
    once.Do(func() {
        instance = loadConfig()
    })
    return instance
}

sync.Once использует атомарные операции внутренне, чтобы сделать общий путь (после инициализации) lock-free, и корректно обрабатывает все вопросы упорядочивания памяти. Это канонический ответ на «ленивая инициализация в конкурентном контексте».

Если по какой-то причине нельзя использовать sync.Once, корректный ручной вариант использует атомарные load и store:

var instanceP atomic.Pointer[Config]

func GetConfig() *Config {
    if c := instanceP.Load(); c != nil {
        return c
    }
    // ... берём мьютекс, перепроверяем через Load, строим, Store ...
}

Атомарные Load и Store дают гарантии упорядочивания памяти, которых нет у обычных чтения и записи.

4.7. Шпаргалка

Вот одностраничная выжимка. Когда ты смотришь на кусок кода и спрашиваешь «atomic или мьютекс?», пройдись по этой таблице.

СценарийРекомендацияПочему
Счётчик (requests++)atomic.Int64.Add(1)В 5-10 раз быстрее мьютекса, столь же корректен
Булев флаг (stopped)atomic.BoolОднословное состояние, нет инварианта
Single-word конечный автоматatomic.Int32 или atomic.Uint32Кодируй состояния маленькими int
Указатель на иммутабельный снэпшотatomic.Pointer[T]Чтения без блокировок, атомарная замена
Обновить map + счётчик вместеsync.MutexМультиполевой инвариант требует критической секции
Обновить slice на местеsync.MutexЗаголовок slice + backing array — несколько слов
Read-heavy конфигурация (редкие обновления)atomic.Pointer[T] или sync.RWMutexatomic.Pointer если можешь заменить целиком; RWMutex если мутируешь на месте
Per-goroutine состояниеБез синхронизацииЕсли трогает одна горутина — нет гонки
Ленивая инициализация синглтонаsync.OnceРучной double-checked locking — сломан
Горячий путь с двумя независимыми счётчикамиatomic.Int64 × 2 с paddingFalse sharing, если в одной структуре

Если из статьи ничего больше не запомнишь, запомни это: single-word состояние — atomic. Мультиполевой инвариант — мьютекс. Всё остальное — измеряй и думай.

4.8. Как работает go test -race

Мы уже несколько раз упоминали детектор гонок. Стоит понять, как он устроен, — это знание часто помогает быстрее находить причину ложных срабатываний или, наоборот, понимать, почему детектор что-то пропустил.

Детектор гонок Go основан на ThreadSanitizer (TSan) — технологии, изначально разработанной в Google для C++ (статья Константина Серебряного и Тимура Исходжанова, WBIA 2009, “ThreadSanitizer – data race detection in practice”). Идея TSan: для каждого доступа к памяти сохранять в shadow memory (теневой памяти) след — какой поток, в какой момент, под какой блокировкой касался этой ячейки. При каждом новом доступе проверяем: было ли когда-то раньше конкурирующее обращение без happens-before отношения? Если да — сообщаем о гонке.

Конкретно в Go реализация описана в официальном посте Go Blog “Introducing the Go Race Detector” (соавтор — Дмитрий Вюков, июнь 2013) и в документации Go. Она интегрирована в компилятор:

  • При сборке с -race компилятор instrumentation каждую инструкцию чтения/записи памяти, добавляя вызовы в runtime-часть TSan.
  • TSan отслеживает happens-before отношения, опираясь на синхронизационные операции: mu.Lock/Unlock, ch <- / <-ch, atomic.*, sync.Once.Do, создание горутины.
  • Shadow memory тратит примерно 8x RAM — поэтому детектор гонок дорогой по памяти (обычно 5-10x overhead на CPU и RAM). Запускать с -race в продакшене — плохая идея; в CI и нагрузочных тестах — обязательно.

Типичные ловушки:

  • Ложные срабатывания на коде, который не является гонкой по бизнес-логике, но нарушает формальную модель памяти. Например, чтение общей переменной без atomic «потому что мы знаем, что другой поток уже закончил» — TSan сработает, потому что формально гонка есть, даже если она benign. Правильно: либо закрыть синхронизацией (channel close + receive), либо atomic.
  • Пропуск гонок из-за недостаточного покрытия тестами. TSan ловит только те гонки, которые фактически произошли во время тестового прогона. Если в продакшене есть редкий путь, который не покрыт тестами, — гонка ускользнёт. Поэтому детектор гонок — это дополнение к тестам, не замена им.
  • Очень длинные трейсы. TSan выдаёт два стека для каждой гонки — оба доступа. Стеки могут быть глубокими, особенно в HTTP/gRPC обработчиках. Читать их нужно с конца (точка доступа) к началу (точка входа).

Полезный паттерн: запускать go test -race в CI всегда, на каждый PR. И отдельно — нагрузочные тесты с -race на staging-е перед релизом, потому что многие гонки проявляются только под определённой нагрузкой.

4.9. Сравнение с другими языками

Полезно понимать, как то же самое решается в других mainstream-языках. Это и расширяет кругозор, и помогает при ревью мультиязычного кода.

Ruststd::sync::Mutex<T>. Идеологически близок к Go: тоже non-reentrant, тоже не копируемый (что в Rust ещё и enforced через ownership систему — Mutex не реализует Copy). Главное отличие — Rust хранит защищаемые данные внутри Mutex<T>:

let counter = std::sync::Mutex::new(0u64);
*n.lock().unwrap() += 1;  // lock() возвращает MutexGuard, который解锁ается при drop

Типобезопасность на уровне компилятора: невозможно получить доступ к внутренним данным без вызова lock(). В Go ты можешь случайно обратиться к полю мимо мьютекса, и компилятор промолчит.

Javasynchronized блоки и java.util.concurrent.locks.ReentrantLock. Мьютекс реентерабельный по умолчанию — тот же поток может войти в synchronized метод несколько раз. Это удобнее в некоторых случаях (recursive AST walkers), но скрывает ошибки проектирования. На уровне JVM есть встроенная поддержка мониторов (instrinsics), в HotSpot оптимизировано до lock-biased и thin-lock паттернов — фактически Java идёт по тому же пути userspace CAS + kernel slow path, что и Go.

C++std::mutex. Non-reentrant (как в Go), копирование запрещено через = delete. Главный плюс C++ — std::lock_guard и RAII: блокировка освобождается автоматически при выходе из scope. В Go приходится писать defer mu.Unlock(), что эквивалентно, но более verbose. Зато в C++ есть и std::recursive_mutex, и std::shared_mutex (аналог RWMutex) — больше выбора, но и больше шансов ошибиться.

Pythonthreading.Lock(). GIL (Global Interpreter Lock) делает большинство операций фактически однопоточными, поэтому мьютекс нужен в основном при работе с I/O или C-расширениями. Семантика простая, но производительность в CPython ограничена GIL.

Общий паттерн: во всех языках мьютексы построены поверх CAS и оптимизированы для uncontended case. Идея одна — userspace fast path + kernel slow path. Разница в API и гарантиях: Rust строгий, Java реентерабельный, C++ гибкий, Go минимальный. Выбор Go — минимум примитивов, отсутствие реентерабельности, последовательно-согласованное упорядочивание — последовательный набор компромиссов, направленных на простоту рассуждения ценой иногда потерянной производительности.

4.10. Частые баги из практики

Несколько антипаттернов, которые я видел в реальном Go-коде. Большая часть — variations на темы из этой статьи, но стоят отдельного упоминания, потому что встречаются часто.

Антипаттерн 1: sync.Mutex в field структур, которая передаётся по значению.

type Task struct {
    mu    sync.Mutex
    state int
}

func process(t Task) {  // БАГ: приёмник по значению
    t.mu.Lock()
    defer t.mu.Unlock()
    t.state++
}

go vet ловит, но если поле скрыто в nested struct (например, t.inner.mu), проверка может не сработать на старых версиях Go. Исправление — всегда pointer receiver для типов с Mutex.

Антипаттерн 2: sync.WaitGroup после копирования.

WaitGroup содержит noCopy маркер и go vet его ловит, но копирование в closure тоже может пройти мимо:

func runAll(tasks []Task) {
    var wg sync.WaitGroup
    for _, t := range tasks {
        wg.Add(1)
        go func(wg sync.WaitGroup, t Task) {  // БАГ: wg передан по значению
            defer wg.Done()
            t.Do()
        }(wg, t)
    }
    wg.Wait()  // зависнет, потому что Add делался на другой копии
}

Правильно — передавать указатель, или использовать defer в outer scope:

go func(t Task) {
    defer wg.Done()
    t.Do()
}(t)

Антипаттерн 3: чтение map под RLock, запись под Lock, без дополнительных гарантий.

type Store struct {
    mu   sync.RWMutex
    data map[string]int
}

func (s *Store) Get(k string) int {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.data[k]  // OK под RLock
}

func (s *Store) Inc(k string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.data[k]++  // OK под Lock
}

Само по себе это не баг — RWMutex корректно разделяет readers и writer. Проблема появляется, когда кто-то начинает оптимизировать: «у нас же 99% чтений, давайте RLock и для записи». Если несколько горутин одновременно делают s.data[k] = v под RLock — concurrent map writes, panic. RWMutex не делает map потокобезопасным; он лишь сериализует writers относительно readers.

Антипаттерн 4: блокировка, захваченная в одной горутине, отпущенная в другой.

func (s *Server) Handle(req Request) {
    s.mu.Lock()
    go func() {
        // ... долгая работа ...
        s.mu.Unlock()  // формально разрешено, но капкан
    }()
}

Go разрешает отпустить мьютекс из другой горутины, но это нарушает принцип «владелец блокировки — тот, кто её взял», и часто приводит к:

  • Гонкам, если кто-то между Lock и goroutine start решит, что владеет критической секцией.
  • Дедлокам, если родительская горутина завершится раньше, чем успеет запустить goroutine.
  • Сложностям в рассуждении: при ревью непонятно, кто же владеет блокировкой.

Исправление — либо держать блокировку в той же горутине, либо использовать канал для явной передачи владения.

4.11. Кратко про sync.RWMutex

В этой статье мы intentionally не разбираем sync.RWMutex подробно — это тема для отдельного материала. Но для полноты картины стоит понимать главное отличие от sync.Mutex.

sync.RWMutex разделяет блокировки на два типа:

  • RLock/RUnlock — разделяемая блокировка для читателей. Несколько читателей могут держать RLock одновременно.
  • Lock/Unlock — эксклюзивная блокировка для писателей. Только один writer, и ни одного reader, пока writer держит Lock.

Идея: для read-heavy данных (90%+ чтений) RWMutex позволяет параллельное чтение, что должно быть быстрее, чем serialize всех через Mutex.

Реальность — интереснее:

type RWMutex struct {
    w           Mutex        // serialize writers
    writerSem   uint32       // semaphore для writers
    readerSem   uint32       // semaphore для readers
    readerCount atomic.Int32 // количество активных readers (или -1 если есть writer)
    readerWait  atomic.Int32 // количество readers, которых должен дождаться writer
}

Поле readerCount тут — ключ. При RLock читатель делает atomic.AddInt32(&readerCount, 1). Если результат ≥ 0 — reader спокойно работает. Если результат < 0 — значит, есть активный writer (writer при Lock делает atomic.AddInt32(&readerCount, -rwmutexMaxReaders), делая все новые RLock’и видимыми как отрицательные), и читатель паркуется.

Подводные камни:

  • RWMutex не всегда быстрее Mutex. На каждый RLock делается atomic add — это не бесплатно. Если критическая секция короткая (несколько наносекунд), Mutex может быть быстрее за счёт более простого кода. RWMutex выигрывает только когда читателей много и критическая секция длинная.
  • Writer starvation. В старых версиях Go writer мог ждать бесконечно, если постоянно приходили новые readers. С Go 1.0 RWMutex решает это: новый reader, увидев ожидающего writer, паркуется, давая writer’у шанс.
  • Не используй RWMutex «на всякий случай». Если профайлинг не показывает contention на Mutex — RWMutex скорее потеряет, чем выиграет. Сначала измерь.

Подробнее о RWMutex internals — в исходниках Go src/sync/rwmutex.go (хорошо прокомментированы) и в общем в серии статей VictoriaMetrics про sync.

4.12. TryLock: используй осторожно

С Go 1.18 у sync.Mutex появился метод TryLock() bool — попытаться захватить блокировку без блокирования, сразу вернуться с результатом. Если удалось — true, владение получено, нужно вызвать Unlock. Если нет — false, ничего не произошло.

Документация Go предупреждает прямо: «правильные применения TryLock существуют, но редки, и его использование часто признак более глубокой проблемы в работе с мьютексами». Вот типичные сценарии, которые хочется решать через TryLock, и почему обычно это плохая идея:

Сценарий 1: «попробовать обновить кэш, не блокируя обработку запросов».

func (c *Cache) MaybeRefresh() {
    if c.mu.TryLock() {  // попробуем захватить
        defer c.mu.Unlock()
        c.refresh()
    }
}

Кажется разумным: если кэш уже обновляется, не ждём — просто пропускаем. Проблема в том, что TryLock под нагрузкой будет часто проваливаться даже когда блокировка свободна — из-за нечестности (см. barging в разделе 3.6), TryLock может проигрывать конкурентам. Если обновление критично, нужно ждать; если некритично — лучше вообще не делать его в горячем пути.

Сценарий 2: «проверить, занят ли ресурс, без ожидания».

if !resource.mu.TryLock() {
    return errors.New("resource busy")
}
defer resource.mu.Unlock()
// ... работа с ресурсом ...

Это легитимный use case — особенно в административных API (например, «не запускать миграцию, если уже идёт»). Но чаще правильный ответ — использовать явный флаг atomic.Bool, обозначающий состояние «занято», а не блокировку как индикатор занятости. Блокировка — это механизм эксклюзии, а не состояние; смешивать их — частый источник багов.

Когда TryLock действительно нужен:

  • deadlock detection в сложных иерархиях блокировок (reverse lock ordering checks)
  • реализация cancellable operations (когда ждать блокировку нужно с таймаутом)
  • библиотечный код, где нельзя блокироваться надолго (например, внутри finalizer)

В большинстве прикладного Go-кода TryLock — лишнее. Если ты дотягиваешься до него, остановись и подумай: возможно, правильный ответ — переструктурировать код, чтобы не нужно было «пробовать без блокировки».

4.13. Практический пример: счётчик запросов в HTTP-сервисе

Чтобы закрепить материал, вот типичная задача из backend-разработки и три способа её решить, с объяснением, что когда выбирать.

Задача: HTTP-сервис обрабатывает миллионы запросов в секунду. Нужно считать:

  • общее количество запросов
  • количество ошибок (4xx + 5xx)
  • количество запросов в полёте (active)

Метрики экспонируются через /metrics endpoint раз в 15 секунд.

Решение 1: atomic для всего.

type Metrics struct {
    Total   atomic.Int64
    Errors  atomic.Int64
    Active  atomic.Int64
}

func (m *Metrics) Handle(req *http.Request, next http.Handler) {
    m.Total.Add(1)
    m.Active.Add(1)
    defer m.Active.Add(-1)

    rw := &statusRecorder{ResponseWriter: w, status: 200}
    next.ServeHTTP(rw, req)

    if rw.status >= 400 {
        m.Errors.Add(1)
    }
}

Это лучшее решение для данного сценария. Три независимых счётчика, каждый обновляется атомарно. Никаких блокировок, минимальный overhead (несколько наносекунд на запрос). На hot path — atomic.Add × 3, что в сумме даёт ~20-30 наносекунд на запрос — незаметно на фоне обработки.

Решение 2: Mutex для всего.

type Metrics struct {
    mu      sync.Mutex
    total   int64
    errors  int64
    active  int64
}

func (m *Metrics) Handle(req *http.Request, next http.Handler) {
    m.mu.Lock()
    m.total++
    m.active++
    m.mu.Unlock()

    defer func() {
        m.mu.Lock()
        m.active--
        m.mu.Unlock()
    }()

    // ... обработка ...
}

Хуже — на каждый запрос две пары Lock/Unlock. Под высокой нагрузкой (~1M RPS) это может вылиться в 5-10% CPU только на управление блокировками. atomic быстрее в 5-10 раз. Не делай так для счётчиков.

Решение 3: RWMutex для чтения метрик.

type Metrics struct {
    Total  atomic.Int64
    Errors atomic.Int64
    Active atomic.Int64
}

func (m *Metrics) Snapshot() (total, errors, active int64) {
    // Читаем три значения. Между ними могут быть гонки —
    // total может быть прочитан до errors, а errors после.
    // Это нормально для метрик — небольшая несогласованность допустима.
    return m.Total.Load(), m.Errors.Load(), m.Active.Load()
}

Здесь не нужен RWMutex — метрики tolerate eventual consistency (несогласованность в несколько мс между total и errors не критична). Snapshot через три atomic.Load — самое дешёвое решение.

Если бы нужна была строгая согласованность (например, total >= errors всегда, чтобы избежать артефактов на графиках), тогда — Mutex вокруг чтения трёх значений. Но для метрик это overkill.

Вывод: для счётчиков в горячем пути — atomic всегда. Для редко обновляемой конфигурации — atomic.Pointer[T]. Mutex — когда инвариант между полями важен (например, в кэше: размер и содержимое map должны быть согласованы).

5. Что почитать дальше

Прежде чем перейти к заключению — annotated список для углублённого изучения. Я разделил на категории.

Классические papers:

  • Maurice Herlihy, Nir Shavit, “The Art of Multiprocessor Programming” (книга, 2012, 2nd edition 2020). Библия lock-free и wait-free программирования. Herlihy — тот самый автор статьи про consensus number. Книга требует математической зрелости, но даёт фундаментальное понимание.
  • Hans-J. Boehm, “Threads Cannot Be Implemented as a Library” (PLDI 2005). Аргумент, почему threading должен быть на уровне языка, а не библиотеки. Объясняет, почему C++0x потребовалась memory model.
  • Paul McKenney, “Memory Barriers: a Hardware View for Software Hackers” (2010). Глубокий разбор того, что на самом деле делает CPU при memory barrier. Для тех, кто хочет понять железо.

Go-specific:

  • Russ Cox, “Hardware Memory Models” и последующие статьи в серии — модель памяти от железа до языка. Best explanation из существующих, на любом языке.
  • Dmitry Vyukov, “Scalable Go Scheduler Design Doc” (May 2012) — design doc планировщика, основа всего runtime.
  • Блог VictoriaMetrics — серия статей про sync: Mutex, WaitGroup, Pool, Cond, Map, Once. Очень качественный разбор исходников (статьи про RWMutex пока нет).

Benchmarking и performance:

  • goperf.dev — официальный Go Optimization Guide от команды Go. Раздел Atomic Operations — must-read.
  • benchstat — инструмент для статистически корректного сравнения бенчмарков. Без него сравнивать ns/op бессмысленно.
  • Dave Cheney, “High Performance Go” — talks и статьи про оптимизацию Go-кода.

Для продвинутых — lock-free структуры:

  • Dmitry Vyukov, “Mpsc queue” — каноническая multi-producer single-consumer очередь, на которой построены многие Go runtime internals.
  • Tony Tene, “Lock-free hash table” и блог 1024cores.net в целом — глубокий материал по lock-free программированию.

6. Заключение

Мы прошли весь стек. От counter++, разворачивающегося в три инструкции ARM64, через CAS как универсальный примитив (Херлихи, 1991), через LOCK CMPXCHG и ARM LSE, через пакет sync/atomic с его единственным последовательно-согласованным упорядочиванием памяти, через futex как мост между userspace и ядром, через упакованное поле state в sync.Mutex с его spinning и режимом голодания — и наконец к практическим рекомендациям.

Два главных вывода:

  1. Mutex и atomic — один стек, а не два инструмента. Mutex построен на атомарном CAS, со слоем честности поверх. Uncontended mu.Lock() — это одна инструкция CAS — едва дороже, чем atomic.CompareAndSwapInt32. Разница в стоимости появляется только под нагрузкой, и именно там парковка мьютекса (вместо активного spinning) реально выигрывает.
  2. Режим голодания — не теория. Он существует, потому что реальный продакшен-код в 2015 году получал время захвата блокировки в 100 секунд (issue #13086). Порог в 1мс внутри sync.Mutex молча защищает твой код от этой патологии каждый день.

Если хочешь копнуть глубже, ссылки ниже — канонический список для чтения. Исходники Go (sync/mutex.go, runtime/sema.go) читаемые, хорошо прокомментированные и не такие страшные, как выглядят. Заметки Рассела Кокса о ревизии модели памяти и дискуссия в issue #13086 — два исторических контекста, объясняющих, почему код выглядит именно так.

Следующая статья серии Go Internals будет про sync.Pool — он строится поверх sync/atomic и per-P локального хранилища, давая Go одну из самых дешёвых стратегий аллокации объектов среди мейнстримных языков. Оставайтесь с нами.

Ссылки