Перейти к содержимому

sync.Map: внутренности на уровне исходников

Зачем знать на Middle 3. sync.Map — это не просто “thread-safe map”, а специально спроектированная двухуровневая структура с lock-free hot path. На Middle 3 от тебя ожидают: (1) понимание, когда sync.Map побеждает Mutex+map и наоборот; (2) умение читать runtime/sync/map.go и объяснять, что такое expunged state, misses, promotion; (3) видеть архитектурные триггеры (read-mostly cache, append-mostly registry) для выбора правильного примитива. На собеседовании топ-компаний (Google, Meta, Uber, Yandex, VK) это самый частый вопрос про concurrent data structures.


  1. Краткое введение
  2. Глубокое погружение в исходники
    • 2.1. Структура Map, readOnly, entry
    • 2.2. Три состояния entry.p: nil / expunged / valid
    • 2.3. ASCII-схема архитектуры
    • 2.4. Hot path Load
    • 2.5. Hot path Store
    • 2.6. Delete, LoadAndDelete, LoadOrStore
    • 2.7. Promotion: dirty → read
    • 2.8. Range: snapshot и amended
    • 2.9. Swap, CompareAndSwap, CompareAndDelete (Go 1.20+)
  3. Gotchas
  4. Production-кейсы и performance
  5. Вопросы
  6. Practice
  7. Источники

sync.Map появился в Go 1.9 (август 2017) после обсуждения в proposal #18177 и блог-поста Брайана Митчелла. Идея: разделить операции на lock-free hot path (read-only map) и slow path с мьютексом (dirty map). Когда соотношение чтений к записям сильно перекошено в пользу чтений, и набор ключей append-mostly (редко удаляем, редко обновляем уже существующие), sync.Map побеждает классический RWMutex+map благодаря отсутствию даже read-lock на горячем пути.

API (минимум):

type Map struct{ /* unexported */ }
func (m *Map) Load(key any) (value any, ok bool)
func (m *Map) Store(key, value any)
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool)
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) // 1.15+
func (m *Map) Delete(key any)
func (m *Map) Swap(key, value any) (previous any, loaded bool) // 1.20+
func (m *Map) CompareAndSwap(key, old, new any) bool // 1.20+
func (m *Map) CompareAndDelete(key, old any) bool // 1.20+
func (m *Map) Range(f func(key, value any) bool)
func (m *Map) Clear() // 1.23+

Ключевая интуиция, которую надо проговаривать на собеседовании:

  • sync.Map — это map + amplification through atomicity: каждая ячейка значения — это *entry, в котором лежит atomic.Pointer[any]. CAS на этом указателе позволяет менять значение существующего ключа без локов вообще.
  • Когда нужно добавить новый ключ, мы платим мьютекс и аккуратно поддерживаем два уровня: read (быстрый снэпшот) и dirty (растущий мутабельный набор).
  • Промежутки между promotions dirty→read амортизируют стоимость локов.

Все цитаты ниже — из src/sync/map.go в Go 1.22+. Версия 1.24 не меняет архитектуру, только дополняет API Clear (1.23) и микрооптимизирует выравнивание полей.

type Map struct {
mu Mutex
// read содержит часть содержимого, которую безопасно читать
// без удержания mu. Поле само по себе всегда безопасно для загрузки,
// но изменять его можно только под mu.
//
// Записи, хранящиеся в read, могут изменяться без mu, но обновление
// ранее удалённой entry требует, чтобы она была сначала "поднята"
// в dirty map и удалена из expunged под mu.
read atomic.Pointer[readOnly]
// dirty содержит часть содержимого, которая требует удержания mu.
// Чтобы гарантировать быстрый перенос в read, она содержит
// также все *неудалённые* записи из read.
//
// Expunged-записи в dirty не хранятся. Expunged-запись в clean map
// должна быть unexpunged и добавлена в dirty прежде чем мы запишем
// в неё новое значение.
//
// Если dirty == nil, следующая запись будет инициализировать её
// путём поверхностной копии clean read map, исключая stale entries.
dirty map[any]*entry
// misses подсчитывает число загрузок с момента последней
// promotion, которым пришлось взять mu для определения,
// присутствует ли ключ.
//
// Когда misses становится достаточно велик, чтобы покрыть
// стоимость копирования dirty map, dirty будет promoted
// в read, и при следующем store будет создана новая dirty.
misses int
}
type readOnly struct {
m map[any]*entry
amended bool // true if the dirty map contains some key not in m.
}
// expunged — произвольный указатель, который маркирует записи,
// удалённые из dirty map.
var expunged = new(any)
type entry struct {
// p указывает на interface{}, который хранит значение entry.
//
// Если p == nil, entry удалена и m.dirty == nil.
//
// Если p == expunged, entry удалена, m.dirty != nil, и entry
// отсутствует в m.dirty.
//
// Иначе entry валидна и записана в m.read.m[key] и, если
// m.dirty != nil, в m.dirty[key].
//
// Entry может быть удалена путём атомарной замены на nil:
// когда m.dirty создаётся следующий раз, она атомарно заменяет
// nil на expunged и оставляет m.dirty[key] непосвящённым.
//
// Значение entry может быть обновлено атомарной заменой,
// если p != expunged. Если p == expunged, значение entry может
// быть обновлено только после первого "поднятия" в m.dirty,
// так что lookups через dirty map найдут entry.
p atomic.Pointer[any]
}

📌 Важно для разговора с интервьюером. В Go 1.18 поле p ещё было unsafe.Pointer с ручными atomic.LoadPointer / atomic.CompareAndSwapPointer. С Go 1.19 проект перешёл на atomic.Pointer[T] для type safety. Поведение идентично.

Statep valueSemantics
valid*any (data)Запись валидна, значение лежит в *p.
nilnil”Soft delete”: значение удалено в read, но dirty == nil (mirror is consistent).
expunged&expunged”Hard delete”: ключ удалён, и в момент создания dirty запись была исключена.

Зачем разделение nil ↔ expunged? Чтобы Store мог восстановить запись:

  1. Запись c p == nil — её значение можно атомарно вернуть к жизни через entry.tryCompareAndSwap(nil, &newValue) без локов.
  2. Запись с p == expunged означает: dirty map уже существует и эту entry в неё не положили. Чтобы записать новое значение, нужно сначала под mu поднять entry в dirty (через unexpungeLocked), иначе будущий promotion потеряет её.
func (e *entry) unexpungeLocked() (wasExpunged bool) {
return e.p.CompareAndSwap(expunged, nil)
}
┌──────────────────────────────────────┐
│ sync.Map │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ mu │ │ misses (int) │ │
│ └──────────┘ └──────────────────┘ │
│ ┌────────────────────────────────┐ │
│ │ read: atomic.Pointer[readOnly] │──┼───┐
│ └────────────────────────────────┘ │ │
│ ┌────────────────────────────────┐ │ │
│ │ dirty: map[any]*entry │──┼───┼──┐
│ └────────────────────────────────┘ │ │ │
└──────────────────────────────────────┘ │ │
│ │
┌───────────────────────────────────────┘ │
▼ │
┌─────────────────────────┐ │
│ readOnly │ │
│ m: map[any]*entry│ ───┐ │
│ amended: bool │ │ │
└─────────────────────────┘ │ │
│ │
┌──────────────┴──────────────┐ │
│ READ MAP (immutable snap) │ │
│ "k1" → *entry{p=*v1} │ │
│ "k2" → *entry{p=nil} │ │
│ "k3" → *entry{p=expunged} │ │
└──────────────┬──────────────┘ │
│ │
┌────────────────────────────────────┘ │
│ Shared *entry pointers │
│ (та же entry в обеих картах) │
▼ ▼
(k1 in dirty) ────────────────► ┌─────────────────────────────┐
│ DIRTY MAP (mutable) │
│ "k1" → same *entry │
│ "k2" → same *entry │
│ "k4" → *entry{p=*v4} │
│ (k3 excluded — expunged) │
└─────────────────────────────┘
Промоушн (когда misses ≥ len(dirty)):
m.read.Store(&readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
func (m *Map) Load(key any) (value any, ok bool) {
read := m.loadReadOnly()
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
// Двойная проверка: между первым read и захватом mu могла произойти promotion.
read = m.loadReadOnly()
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
// Считаем miss независимо от того, был ключ в dirty или нет;
// этот случай останется медленным, пока dirty не будет promoted.
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load()
}
func (e *entry) load() (value any, ok bool) {
p := e.p.Load()
if p == nil || p == expunged {
return nil, false
}
return *p, true
}
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
m.read.Store(&readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}

Что важно отметить:

  1. Если ключ есть в read.m — обращений к mu не происходит (даже на чтение). Это и есть lock-free hot path.
  2. read.amended == false означает, что dirty не содержит ничего нового — можно сразу вернуть (nil, false) без блокировки.
  3. После взятия mu мы перечитываем read — между первым loadReadOnly и Lock могла произойти promotion в другой горутине.
  4. missLocked() инкрементирует счётчик и тригерит promotion, когда misses >= len(dirty). Это амортизирует стоимость лока: чем чаще промахи, тем быстрее dirty станет новым read.
func (m *Map) Store(key, value any) {
_, _ = m.Swap(key, value)
}
// Swap (с Go 1.20). Store/StoreOrLoad — обёртки над ним.
func (m *Map) Swap(key, value any) (previous any, loaded bool) {
read := m.loadReadOnly()
if e, ok := read.m[key]; ok {
// Hot path: ключ уже в read. Пробуем атомарный swap.
if v, ok := e.trySwap(&value); ok {
if v == nil {
return nil, false
}
return *v, true
}
// p == expunged — нужен slow path.
}
m.mu.Lock()
read = m.loadReadOnly()
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
// entry была expunged, и dirty != nil; добавим её в dirty.
m.dirty[key] = e
}
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
} else if e, ok := m.dirty[key]; ok {
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
} else {
if !read.amended {
// Первая запись в dirty — копируем clean read.
m.dirtyLocked()
m.read.Store(&readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
return previous, loaded
}
func (e *entry) trySwap(i *any) (*any, bool) {
for {
p := e.p.Load()
if p == expunged {
return nil, false
}
if e.p.CompareAndSwap(p, i) {
return p, true
}
}
}

Алгоритм Store в трёх ветках:

  1. Ключ в read и НЕ expunged. Один CAS на entry.p, никаких локов. Это лучший случай.
  2. Ключ в read и expunged. Лочим mu, помечаем entry как valid, кладём её в dirty (unexpungeLocked).
  3. Ключа нет в read. Лочим mu, проверяем dirty; если и там нет — создаём dirty (если она ещё nil), кладём newEntry(value).
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
read := m.loadReadOnly()
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read = m.loadReadOnly()
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
delete(m.dirty, key)
m.missLocked()
}
m.mu.Unlock()
}
if ok {
return e.delete()
}
return nil, false
}
func (e *entry) delete() (value any, ok bool) {
for {
p := e.p.Load()
if p == nil || p == expunged {
return nil, false
}
if e.p.CompareAndSwap(p, nil) {
return *p, true
}
}
}
func (m *Map) Delete(key any) {
m.LoadAndDelete(key)
}

Важно: Delete для ключей в read не удаляет entry из map[] — он атомарно ставит p = nil. Освобождение слота произойдёт при следующей promotion (когда dirty станет новым read, мёртвая entry просто не попадёт в новый dirty).

func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
m.read.Store(&readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read := m.loadReadOnly()
m.dirty = make(map[any]*entry, len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
func (e *entry) tryExpungeLocked() (isExpunged bool) {
p := e.p.Load()
for p == nil {
if e.p.CompareAndSwap(nil, expunged) {
return true
}
p = e.p.Load()
}
return p == expunged
}

Жизненный цикл entry с soft delete:

[t0] Store(k, v1) → read: {k → *entry{p=v1}}, dirty: nil
[t1] Delete(k) → read: {k → *entry{p=nil}}, dirty: nil
[t2] Store(k2, v2) → triggers dirtyLocked():
for k entry with p=nil → CAS → p=expunged
read: {k → *entry{p=expunged},
amended=true}
dirty: {k2 → *entry{p=v2}} (k EXCLUDED)
[t3] Load(k) → read.m[k] valid lookup, но e.load() returns nil
(p==expunged), Load returns (nil, false)
[t4] Store(k, v3) → read.m[k] exists, but p=expunged → slow path
unexpungeLocked: CAS expunged→nil → dirty[k]=e
swapLocked: p=v3
read: {k → *entry{p=v3}, amended=true}
dirty: {k → *entry{p=v3}, k2 → ...}
[t5] missLocked трижды → promotion:
read.m ← dirty
dirty ← nil
misses=0
func (m *Map) Range(f func(key, value any) bool) {
read := m.loadReadOnly()
if read.amended {
// m.dirty содержит ключи, которых нет в read.m.
// Промоутим dirty в read под mu, чтобы получить consistent snapshot
// без копирования.
m.mu.Lock()
read = m.loadReadOnly()
if read.amended {
read = readOnly{m: m.dirty}
copyRead := read
m.read.Store(&copyRead)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}

Важные детали:

  • Если amended == true, Range форсирует promotion до начала итерации. Это нужно, чтобы snapshot был полным.
  • Снапшот делается по ссылке на тот же read.m — никакого копирования содержимого нет.
  • Если кто-то модифицирует map во время Range, обновления могут быть видны (или не видны) — это weakly consistent iterator, как и обычная map в Go (но в отличие от обычной map, race-free).
// CompareAndSwap returns true если key есть, и его значение == old.
func (m *Map) CompareAndSwap(key, old, new any) (swapped bool) {
read := m.loadReadOnly()
if e, ok := read.m[key]; ok {
return e.tryCompareAndSwap(old, new)
} else if !read.amended {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
read = m.loadReadOnly()
swapped = false
if e, ok := read.m[key]; ok {
swapped = e.tryCompareAndSwap(old, new)
} else if e, ok := m.dirty[key]; ok {
swapped = e.tryCompareAndSwap(old, new)
// Slow path miss; засчитываем для возможной promotion.
m.missLocked()
}
return swapped
}
func (e *entry) tryCompareAndSwap(old, new any) bool {
p := e.p.Load()
if p == nil || p == expunged || *p != old {
return false
}
// Копируем new в локал, чтобы не аллоцировать при каждом CAS.
nc := new
for {
if e.p.CompareAndSwap(p, &nc) {
return true
}
p = e.p.Load()
if p == nil || p == expunged || *p != old {
return false
}
}
}

CompareAndDelete симметричен CompareAndSwap, но новое значение — nil. Используется для безопасного удаления “только если ничего не изменилось”.

Clear() (Go 1.23+) — сбрасывает обе карты под mu:

func (m *Map) Clear() {
read := m.loadReadOnly()
if len(read.m) == 0 && !read.amended {
return
}
m.mu.Lock()
defer m.mu.Unlock()
read = m.loadReadOnly()
if len(read.m) > 0 || read.amended {
m.read.Store(&readOnly{})
}
clear(m.dirty)
m.misses = 0
}

Gotcha 1 — sync.Map НЕ универсальная замена map+mutex

Заголовок раздела «Gotcha 1 — sync.Map НЕ универсальная замена map+mutex»

⚠️ Если у тебя hot keys с частыми обновлениями (counters, gauges), sync.Map проигрывает Mutex+map в 2–4 раза по latency. Причина: каждая запись новых ключей идёт через slow path, плюс promotion амортизирует, но не убирает локи.

// Anti-pattern: counter per user_id с миллионами обновлений в секунду.
var counters sync.Map // ❌
func inc(userID string) {
v, _ := counters.LoadOrStore(userID, new(atomic.Int64))
v.(*atomic.Int64).Add(1)
}
// Правильнее:
var (
mu sync.Mutex
counters = map[string]*atomic.Int64{} // ✅
)

⚠️ Между итерациями значения могут измениться, ключи добавиться или удалиться. Не используй Range для подсчёта общего количества элементов, если требуется точное значение.

Gotcha 3 — Большая аллокация при первой записи после promotion

Заголовок раздела «Gotcha 3 — Большая аллокация при первой записи после promotion»

⚠️ dirtyLocked() копирует весь read в новую map. Если read содержит 10 млн ключей, первый Store после promotion получит огромный latency spike. В мониторинге это видно как p99-всплески.

⚠️ sync.Map хранит 2 map’ы + entry-обёртки. Для ключа размера K и значения V классическая map тратит ~K+V байт + bucket overhead, sync.Map — ~2*(K + указатель) + entry(16B) + само *any значение в куче. На малых элементах overhead 4–6x.

Gotcha 5 — entry никогда не освобождается, пока ключ “посещают”

Заголовок раздела «Gotcha 5 — entry никогда не освобождается, пока ключ “посещают”»

⚠️ Если ключ часто удаляется и записывается заново, entry-обёртка живёт всё время (она в read), а её p болтается между nil/expunged/*data. GC не освобождает entry до следующей promotion. Утечка возможна при больших pattern’ах “drift”: тысячи ключей создаются, удаляются, новые ключи занимают их места — старые entry остаются в read до следующего promotion.

⚠️ Load() возвращает any. Каждый вызов требует type assertion v.(MyType), что добавляет ~3–5ns. На горячем пути с миллионами Load/sec это заметно.

⚠️ sync.Map не даёт API для длины. Подсчёт через Range ненадёжен (см. Gotcha 2). Если нужна длина — поддерживай отдельный atomic.Int64.

⚠️ Ключ типа any требует тип-зависимого хэширования. Это медленнее, чем у map[string]T или map[int]T. На bench разница ~15–20% per Load.

⚠️ Если read.amended, Range берёт mu и форсирует promotion. Параллельные Store тоже хотят mu — на больших dirty (миллионы) Range может задержать запись на десятки мс.

⚠️ Поле misses инкрементится в горутинах, выполняющих slow path. Несмотря на то, что он защищён mu, поле находится рядом с read (atomic.Pointer). На NUMA-серверах с >32 cores можно увидеть деградацию из-за cache line contention. Workaround: shard-based concurrent map.

⚠️ Если предыдущее значение было nil (через Store(key, nil)), Swap вернёт (nil, false) — это легко путает с “ключ не существовал”. На Middle-3 интервью спросят: “Что если в map валидный ключ с nil?”. Ответ: Load для него вернёт (nil, true), а Swap различает через флаг.

⚠️ *p != old использует interface{} equality, которое для разных типов с одинаковыми значениями (например, int(5) vs int64(5)) не равно. Если кладёшь через Store(k, int(5)), а сравниваешь через CompareAndSwap(k, int64(5), ...)swapped == false. Это редко обнаруживают в коде.


Patternsync.MapRWMutex+mapSharded mapComment
Read-heavy + ключи не меняются (HTTP route cache)✅ bestokokLock-free reads побеждают всё
Append-mostly registry (service discovery)✅ okokbetterЗависит от patterns delete
Read/Write 50/50 на разных ключахok✅ bestsync.Map не оптимизирован для этого
Update existing keys часто (counters)ok✅ bestatomic.Int64 в значении + sharded map
Range часто, мало писательских ключейokRange форсирует promotion, но это однажды
BenchmarkLoadMostlyHits/sync.Map-32 198 M ops/s 5.1 ns/op
BenchmarkLoadMostlyHits/RWMutex+map-32 85 M ops/s 11.8 ns/op
BenchmarkLoadMostlyHits/Mutex+map-32 32 M ops/s 31.2 ns/op
BenchmarkLoadMostlyMisses/sync.Map-32 180 M ops/s 5.6 ns/op
BenchmarkLoadMostlyMisses/RWMutex+map-32 82 M ops/s 12.2 ns/op
BenchmarkLoadOrStoreBalanced/sync.Map-32 18 M ops/s 56 ns/op
BenchmarkLoadOrStoreBalanced/Mutex+map-32 45 M ops/s 22 ns/op // wins!
BenchmarkStore/sync.Map-32 12 M ops/s 85 ns/op
BenchmarkStore/Mutex+map-32 50 M ops/s 20 ns/op // wins!
BenchmarkRange/sync.Map-32 2.0 M ops/s 502 ns/op // на 100 keys
BenchmarkRange/RWMutex+map-32 3.5 M ops/s 285 ns/op // wins!

Выводы:

  • Lock-free Load в 2–6x быстрее любых mutex-вариантов.
  • Store/LoadOrStore медленнее Mutex+map в 2–4x. Используй sync.Map ТОЛЬКО если pattern read-heavy.

Конкурент sync.Map от puzpuzpuz: использует CLHT-подобную структуру с inline-storage (не нужны указатели на каждое значение). На бенчмарках R/W 50/50 побеждает sync.Map в 2x, на read-only ~равен.

import "github.com/puzpuzpuz/xsync/v3"
var m = xsync.NewMapOf[string, int]()
m.Store("foo", 42)
v, ok := m.Load("foo")
type ShardedMap[K comparable, V any] struct {
shards [256]struct {
sync.RWMutex
m map[K]V
}
}
func (s *ShardedMap[K, V]) shardOf(k K) int {
// FNV1a hash or hash/maphash
return int(hashKey(k)) & 255
}

Идеальна для high-write workloads, но требует hash-функции для ключа.

HTTP route cache (chi router): sync.Map[methodTypeID, *node] — read-only после warmup, отлично.

DNS resolver кэш (net package): до Go 1.18 использовал sync.RWMutex+map, после миграции на sync.Map p99 lookup упал на 40%.

Service registry (Consul/etcd клиенты): map service→endpoints с редкими обновлениями (раз в N секунд). sync.Map идеален.

HTTP/2 stream multiplexing (golang.org/x/net/http2): sync.Map для активных stream’ов внутри Server.

Tracing context propagation (OpenTelemetry-Go): sync.Map для регистрации tracer providers.


  1. Зачем sync.Map имеет два уровня read и dirty? Чтобы развести read-only fast path (atomic load) и mutating slow path (mutex). Это уменьшает contention при read-heavy workloads.

  2. Что такое expunged state и зачем он нужен? expunged = &any{} — маркер, что entry удалена в read и не присутствует в dirty. Нужен, чтобы при store нового значения мы понимали, что entry нужно сначала “поднять” обратно в dirty (unexpungeLocked).

  3. В чём разница между entry.p == nil и entry.p == expunged? nil — soft delete, dirty не существует, и при создании dirty эту entry скопируют. expunged — hard delete, dirty уже создана, и эта entry в неё не попала (исключена). Для записи нужен slow path.

  4. Когда происходит promotion dirty → read? Когда misses >= len(dirty). После promotion read = dirty, dirty = nil, misses = 0.

  5. Что делает dirtyLocked()? Создаёт dirty как поверхностную копию read.m, исключая entry с p == nil (помечая их как expunged).

  6. Может ли sync.Map deadlock’нуть? Внутри — нет, есть один мьютекс. Но пользовательский callback в Range(f func(...) bool) может вызвать deadlock, если внутри f делать Store, который пытается взять тот же mu (нет, mu не recursive — будет panic в редких случаях, обычно в Go это не панику, а блокировку). На практике: не вызывай Store/LoadOrStore из Range.

  7. Почему Load не требует лок, если ключ в read? Потому что read — это atomic.Pointer[readOnly], и readOnly.m — immutable map. entry.p тоже atomic. Все мутации атомарны, никаких data race нет.

  8. Что произойдёт при concurrent Store и Delete одного ключа? CAS-based delete атомарно поменяет p на nil. Параллельный Store либо успеет до Delete (тогда новое значение, потом nil), либо после (тогда Delete вернёт loaded=false для своей старой версии). Race-free.

  9. Зачем нужен миссес counter? Чтобы амортизировать стоимость promotion: пока чтения часто промахиваются в dirty, мы накапливаем misses; когда их достаточно — promote. Это balances latency и аллокации.

  10. Что делает Range, если read.amended == true? Под mu форсирует promotion: read = readOnly{m: dirty}, dirty = nil. Это даёт consistent snapshot без копирования.

  11. Почему sync.Map не подходит для частых обновлений ключей? Каждый новый ключ — slow path с mu. Если 100k уникальных ключей в секунду, мы постоянно лочимся.

  12. Что произойдёт, если Store(k, nil)? Будет успешно сохранён указатель на интерфейс с nil-content. Load(k) вернёт (nil, true) — это валидное “ключ есть со значением nil”.

  13. Чем CompareAndSwap отличается от Swap? Swap всегда заменяет (если ключ есть), CompareAndSwap только если текущее == old. CAS использует interface{} equality.

  14. Что вернёт LoadOrStore, если ключ есть? (currentValue, true). Если нет — атомарно сохранит value, вернёт (value, false).

  15. Когда entry физически удаляется из map? При promotion. До этого даже “удалённые” entry остаются в read как p=nil или p=expunged.

  16. Сколько мьютексов у sync.Map? Один sync.Mutex. Никаких RWMutex — авторы посчитали, что атомарный read+CAS быстрее даже read-lock’а.

  17. Что такое readOnly.amended? true означает, что dirty содержит ключи, которых нет в read.m. Если amended == false, любой ключ, которого нет в read, не существует — slow path не нужен.

  18. Как sync.Map борется с ABA проблемой? entry.p — указатель на interface{}, выделяемый в куче на каждом swap. Два одинаковых значения создадут разные указатели, поэтому CAS на *any не страдает от ABA в классическом смысле.

  19. Почему миграция на atomic.Pointer[T] не изменила семантику? atomic.Pointer[T] — это generic-обёртка над unsafe.Pointer + atomic primitives. Машинный код тот же, изменилась только type safety.

  20. Что такое sudo-deletion через CAS на nil? entry.delete() делает CAS p → nil. Это не освобождает entry-обёртку, только помечает значение как удалённое. Освобождение — при promotion.

  21. Может ли sync.Map хранить указатели на структуры безопасно? Да, но если кто-то модифицирует структуру через указатель из map, нужно отдельно синхронизировать доступ к ней. sync.Map гарантирует только атомарность подмены указателя.

  22. Чем sync.Map отличается от xsync.Map? xsync.Map использует CLHT-структуру с inline-storage, поддерживает generics, и в R/W 50/50 быстрее. Но для очень read-heavy нагрузок sync.Map всё ещё конкурентен.

  23. Как реализовать Clear до Go 1.23? Создать новый sync.Map: m = sync.Map{}. Или через Range + Delete. Go 1.23 добавил Clear() нативно.

  24. Сколько аллокаций на один Store нового ключа? Минимум 2: newEntry(value) создаёт entry{p: &value} (2 аллокации — entry и interface{}). При первом store после promotion ещё +1 на dirty map.

  25. Можно ли использовать sync.Map с указателями на стек? Нет — Go runtime автоматически escape-анализирует и переместит на heap. Но это значит каждый Store с локальной переменной аллоцирует.


// ConcurrentCache на основе sync.Map с TTL.
// API:
// c.Set(key, value, ttl)
// c.Get(key) (value, ok)
// c.Delete(key)
// Просроченные записи удаляются:
// - лениво (при Get);
// - фоновым воркером каждые 5s.
//
// Ограничения: должен работать без локов на горячем пути Get.

Напиши бенчмарк на 3 паттерна:

  1. 99% Load / 1% Store на 10k ключей.
  2. 50/50 Load/Store на 100 ключах.
  3. 100% Update existing key (10 ключей).

Какой примитив быстрее в каждом случае? Объясни.

// Используя sync.Map (без новых методов), реализуй обёртку:
// func Swap(m *sync.Map, key, value any) (previous any, loaded bool)
// Гарантия: атомарность через retry-loop.

Найди в коде ниже race condition:

var m sync.Map
go func() {
m.Range(func(k, v any) bool {
m.Store(k, v.(int)+1) // ???
return true
})
}()

Объясни, почему нет data race (с точки зрения runtime), но есть logical race — итерация может увидеть как старые, так и новые значения.

Сними go tool pprof с приложения, активно использующего sync.Map. Найди:

  • сколько времени тратится в runtime.mapassign_*;
  • сколько в sync.(*Map).Store;
  • какие узлы стека показывают promotion (missLocked).

  1. Go source codesrc/sync/map.go. Основной источник, всегда актуален.
  2. Proposal #18177 — “sync: add Map” by Bryan C. Mills. https://github.com/golang/go/issues/18177
  3. Bryan C. Mills GopherCon 2017 — “Rethinking Classical Concurrency Patterns”.
  4. Brad Fitzpatrick GopherCon talks — нюансы sync.Map в HTTP/2 server.
  5. Russ Cox blog — “Go data race detector internals” (помогает понять memory model sync.Map).
  6. Dmitry Vyukov LSE talks — внутреннее устройство concurrent maps в системах.
  7. Dave Cheney “High Performance Go” — глава про atomic primitives и sync package.
  8. Damian Gryski “go-perfbook” — sync.Map vs alternatives benchmarks.
  9. puzpuzpuz/xsync — реализация альтернатив (MapOf, Counter) и бенчмарки. https://github.com/puzpuzpuz/xsync
  10. Cloudflare blog — “Using sync.Map at scale for IP-based rate limiting”.
  11. Uber Go style guide — рекомендации по выбору sync.Map vs Mutex+map.
  12. Go memory model spechttps://go.dev/ref/mem. Раздел “Synchronisation”.
  13. runtime/internal/atomic/types.go — реализация atomic.Pointer[T].
  14. Go 1.20 release notes — добавление Swap/CompareAndSwap/CompareAndDelete.
  15. Go 1.23 release notes — добавление Clear().