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

Пакет sync: мьютексы, атомики, гонки и дедлоки

Вся синхронизация в Go стоит на двух уровнях. Нижний — аппаратный: атомарные инструкции процессора (LOCK XADD, LOCK CMPXCHG на x86, пара LDAXR/STLXR на arm64) и протокол когерентности кэшей, который делает так, что операция «прочитал–изменил–записал» над одним машинным словом неделима с точки зрения других ядер. Пакет sync/atomic — это тонкая обёртка ровно над этими инструкциями. Верхний уровень — рантайм Go: если захватить ресурс атомарно не удалось, горутину надо не крутить в цикле, а припарковать (gopark) и снять с потока, чтобы планировщик отдал ядро кому-то другому; за это отвечает внутренняя семафорная таблица рантайма (runtime_SemacquireMutex / runtime_Semrelease). Отсюда главный тезис, который ждут на собеседовании: мьютекс = быстрый атомарный путь + медленный путь через планировщик, а атомик — только быстрый путь и только над одним словом.

Вторая опора — модель памяти Go (The Go Memory Model). Она не про «переменные обновляются мгновенно», а про отношение happens-before: если горутина A записала переменную и затем отпустила мьютекс, а горутина B этот мьютекс захватила, то B гарантированно видит запись A. Те же гарантии дают отправка/приём по каналу, sync.Once.Do, WaitGroup.Wait относительно предшествующих Done, и атомарные операции (с Go 1.19 модель памяти явно описывает их как последовательно согласованные, sequentially consistent). Если между двумя обращениями к одной ячейке памяти из разных горутин нет ни одного такого ребра happens-before и хотя бы одно из обращений — запись, это data race, и поведение программы не определено: компилятор вправе кэшировать значение в регистре, переупорядочить доступы, а на не-word-размерных типах вы можете увидеть «порванное» значение.

Отсюда же различие двух терминов, которые часто путают. Data race — свойство кода: неупорядоченный конкурентный доступ к памяти; ловится детектором -race. Race condition — свойство логики: результат зависит от порядка событий (проверил-и-сделал, TOCTOU, «кто первый обновит баланс»). Race condition бывает и без единого data race — например, при работе через полностью потокобезопасное key-value хранилище, если между Get и Put вклинился другой процесс. Детектор гонок такие вещи не находит.

Третья опора — выбор примитива. По убыванию частоты применения: обычный Mutex вокруг небольшой критической секции; RWMutex, когда чтений на порядок больше записей и критическая секция достаточно велика, чтобы окупить более дорогой RLock; atomic для счётчиков, флагов и атомарной подмены указателя на неизменяемый снапшот; WaitGroup для «дождаться N задач»; Once для ленивой инициализации; каналы — когда синхронизация естественно выражается как передача владения данными, а не как защита разделяемого состояния. Отдельно стоит sync.Pool (переиспользование временных объектов ради снижения нагрузки на GC) и sync.Map (специализированный кейс: пишем редко, читаем много, ключи в основном непересекающиеся).

Коротко. Полный дедлок, когда заблокированы вообще все горутины, рантайм ловит сам: программа падает с fatal error: all goroutines are asleep - deadlock! и печатает стеки. Частичный дедлок (часть горутин висит, программа продолжает работать) рантайм не видит — его ищут по goroutine-профилю (net/http/pprof, curl localhost:6060/debug/pprof/goroutine?debug=2), по SIGQUIT/GOTRACEBACK=all, по блок-профилю и go tool trace.

Глубже. Встроенный детектор устроен примитивно: планировщик замечает, что нет ни одной runnable-горутины и при этом нет горутин, заблокированных в системном вызове, в netpoll или ожидающих таймер. Поэтому типичный сервер, у которого висит http.ListenAndServe, никогда не получит эту фатальную ошибку, даже если весь бизнес-код встал намертво. Практические приёмы: держать net/http/pprof на служебном порту и в инциденте снимать goroutine?debug=2 — там видно, сколько горутин и сколько минут висят на sync.Mutex.Lock или chansend; включать блок-профиль (runtime.SetBlockProfileRate) и mutex-профиль (runtime.SetMutexProfileFraction) для поиска долгих ожиданий; на стенде подключать github.com/sasha-s/go-deadlock — это drop-in замена sync.Mutex, которая ругается на нарушение порядка блокировок и на слишком долгое ожидание. Ещё дешёвый приём — оборачивать все блокирующие ожидания в context.WithTimeout: тогда вместо вечного зависания вы получите ошибку с трассировкой.

Коротко. Mutex пускает в критическую секцию ровно одну горутину. RWMutex различает читателей и писателя: одновременно может быть либо сколько угодно читателей (RLock), либо один писатель (Lock). Выигрыш появляется только при доминирующих чтениях, потому что сам RLock дороже Lock.

Глубже. Внутри RWMutex — обычный Mutex (он сериализует писателей между собой), счётчик readerCount, счётчик readerWait и два семафора. RLock делает атомарный инкремент readerCount; если значение стало отрицательным, значит писатель уже объявил о себе (он вычитает rwmutexMaxReaders = 1<<30), и читатель паркуется. Реализация writer-preferring: пришедший писатель блокирует новых читателей, чтобы поток чтений его не заморил голодом. Отсюда два следствия. Первое: рекурсивный RLock внутри уже удерживаемого RLock может привести к дедлоку, если между ними вклинился писатель. Второе: RWMutex не апгрейдится — нельзя «повысить» RLock до Lock, надо отпустить и взять заново, а значит перепроверить состояние.

Коротко. Это счётчик незавершённых задач: Add(n) увеличивает, Done() уменьшает, Wait() блокируется, пока счётчик не станет нулём. Используют, чтобы дождаться завершения группы горутин перед выходом из функции или из main.

Глубже. Правила, за нарушение которых снимают баллы: Add вызывается в родительской горутине до go, иначе Wait может проскочить раньше, чем дочерняя горутина успеет зарегистрироваться; Done всегда в defer, чтобы паника или ранний return не подвесили Wait; WaitGroup передаётся только указателем (копия — отдельный счётчик, go vet ловит это как copylocks); отрицательный счётчик — это паника sync: negative WaitGroup counter. WaitGroup не умеет возвращать ошибки и не умеет отменяться, поэтому в реальном коде чаще берут golang.org/x/sync/errgroup, который делает то же плюс собирает первую ошибку и отменяет общий контекст. В Go 1.25 у WaitGroup появился метод Go(f func()), который сам делает Add(1), запускает горутину и вызывает Done — это убирает самый частый класс ошибок.

var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(u)
}
wg.Wait()

Коротко. Data race — это два конкурентных обращения к одной ячейке памяти из разных горутин, где хотя бы одно обращение — запись, и между ними нет отношения happens-before. Поведение программы при этом не определено. Ищут детектором: go test -race, go run -race, go build -race для стенда.

Глубже. Детектор — это ThreadSanitizer, встроенный в рантайм: на каждое обращение к памяти компилятор вставляет вызов, который сравнивает векторные часы текущей горутины с теневой памятью ячейки. Он не даёт ложных срабатываний: если он сообщил о гонке, гонка есть. Но он находит только те гонки, которые реально произошли на данном прогоне, поэтому его надо гонять по тестам с реальной конкуренцией (-race -count=10, нагрузочные сценарии), а не «один раз на happy path». Цена — примерно 2–20x по времени и 5–10x по памяти, плюс требуется cgo-совместимая платформа, поэтому в прод обычно не ставят (иногда ставят на одну канареечную реплику). Важно помнить границу применимости: гонки логики (race condition без гонки данных) детектор не видит.

В чем разница между sync.Mutex и sync.RWMutex? Когда предпочтительнее использовать второй?

Заголовок раздела «В чем разница между sync.Mutex и sync.RWMutex? Когда предпочтительнее использовать второй?»

Коротко. См. выше про различие. RWMutex предпочтителен, когда чтения существенно преобладают над записями (условно 10:1 и больше) и критическая секция чтения не микроскопическая — например, in-memory справочник/конфиг, который перечитывают тысячи раз в секунду и обновляют раз в минуту.

Глубже. Формально RLock — это как минимум один атомарный инкремент плюс декремент и проверки, то есть работа с той же кэш-линией, что и у всех остальных читателей. При очень коротких секциях (прочитать одно поле) читатели начинают конкурировать за кэш-линию счётчика, и RWMutex проигрывает обычному Mutex, а оба проигрывают atomic.Pointer со снапшотом. Правило простое: RWMutex — гипотеза, которую проверяют бенчмарком go test -bench . -cpu 1,4,8, а не выбор по умолчанию.

Чем sync.Map отличается от обычной map и когда ее следует использовать?

Заголовок раздела «Чем sync.Map отличается от обычной map и когда ее следует использовать?»

Коротко. Обычная map вообще не потокобезопасна (конкурентная запись роняет процесс с fatal error: concurrent map writes) и требует внешней блокировки. sync.Map потокобезопасна сама по себе и оптимизирована под два сценария из документации: (1) ключ пишется один раз, а читается много раз; (2) разные горутины работают с непересекающимися наборами ключей. В остальных случаях map + RWMutex обычно быстрее и удобнее.

Глубже. Исторически sync.Map состояла из двух карт: атомарно читаемой read (для чтения без блокировок) и dirty под мьютексом, с промоушеном dirty в read после накопления промахов. В Go 1.24 реализацию заменили на конкурентный hash-trie, что улучшило масштабируемость на большом числе ядер и убрало «обрыв» производительности при частых записях новых ключей. Минусы остались: API нетипизированный (any ключи и значения → упаковка в интерфейс, лишние аллокации и косвенность), нет len, Range не даёт консистентного снапшота, нельзя атомарно выполнить составную операцию над несколькими ключами. Полезные методы: Load, Store, LoadOrStore, LoadAndDelete, Delete, Swap, CompareAndSwap и CompareAndDelete (с Go 1.20), Clear (с Go 1.23).

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

Глубже. Для взаимоблокировки нужны четыре условия Коффмана: взаимное исключение, удержание с ожиданием, отсутствие принудительного отъёма и циклическое ожидание. Ломать проще всего последнее — ввести глобальный порядок захвата блокировок (например, всегда по возрастанию ID аккаунта при переводе денег) и никогда его не нарушать. Другие приёмы: не вызывать чужой/внешний код и не ходить в сеть и БД под мьютексом; не брать вторую блокировку, удерживая первую; использовать TryLock (с Go 1.18) с откатом там, где порядок невозможно зафиксировать; ставить таймауты через context; уменьшать зернистость — один мьютекс на структуру вместо трёх переплетённых. Отдельная частая ловушка Go — «самодедлок»: sync.Mutex не реентерабельный, повторный Lock в той же горутине (например, публичный метод вызвал другой публичный метод) вешает её навсегда.

func transfer(a, b *Account, sum int64) {
first, second := a, b
if a.id > b.id { // единый порядок захвата
first, second = b, a
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
a.balance -= sum
b.balance += sum
}

Коротко. Это корректное завершение сервиса: перестать принимать новые запросы, дать доработать текущим, закрыть соединения и сбросить буферы, и только потом выйти. Нужен, чтобы деплой и масштабирование не приводили к оборванным запросам, потерянным сообщениям и незакоммиченным транзакциям.

Глубже. Скелет: ловим SIGTERM/SIGINT через signal.NotifyContext, помечаем readiness-пробу как неготовую (чтобы балансировщик увёл трафик — это важно, иначе часть запросов всё равно придёт после начала выключения), вызываем srv.Shutdown(ctx) с дедлайном, параллельно отменяем контекст фоновых воркеров и ждём их WaitGroup/errgroup, затем закрываем пул БД, продюсеров/консьюмеров очереди и флашим трейсы и логи. Порядок важен: сначала останавливаем источники работы, потом потребителей ресурсов, потом сами ресурсы. Дедлайн должен быть меньше terminationGracePeriodSeconds в Kubernetes, иначе процесс добьют SIGKILL.

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}

Allocations and sync.Pool a) Memory allocation for structures and global variables b) Deleting from a map/slice does not free memory c) Reusing variables and sync.Pool

Заголовок раздела «Allocations and sync.Pool a) Memory allocation for structures and global variables b) Deleting from a map/slice does not free memory c) Reusing variables and sync.Pool»

Коротко. (a) Куда попадёт структура — на стек или в кучу — решает escape-анализ компилятора, а не new/&; глобальные переменные живут в статических сегментах данных программы и живы всё время работы. (b) delete из map и укорачивание слайса не отдают память: у map остаётся выделенная таблица, у слайса — весь backing array. (c) sync.Pool позволяет переиспользовать временные объекты и снизить нагрузку на GC.

Глубже. По (a): смотреть решения компилятора нужно через go build -gcflags='-m -m'; типичные причины убегания в кучу — сохранение указателя в структуру, живущую дольше, передача в интерфейс, замыкание, слишком большой или неизвестного во время компиляции размера объект. По (b): удаление ключей из map освобождает память под сами значения-указатели (GC соберёт то, на что они ссылались), но занятые бакеты/группы остаются под будущие вставки — вернуть память можно только пересозданием map (в Go 1.24 map реализована на Swiss Tables, но это свойство сохраняется). Для слайсов: s = s[:0] оставляет весь массив живым, а s = s[2:] держит и «отрезанное» начало; чтобы дать GC собрать элементы-указатели, перед укорачиванием их обнуляют — с Go 1.21 удобно clear(s). По (c): sync.Pool — это кэш временных объектов, обязательно с Reset() перед использованием и проверкой размера перед Put (не возвращайте в пул раздутый на 10 МБ буфер).

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func render(w io.Writer, v any) error {
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
defer func() {
if b.Cap() <= 64<<10 { // не удерживаем гигантские буферы
bufPool.Put(b)
}
}()
if err := json.NewEncoder(b).Encode(v); err != nil {
return err
}
_, err := w.Write(b.Bytes())
return err
}

Коротко. Mutex (mutual exclusion) — примитив взаимного исключения: в каждый момент времени критическую секцию, защищённую мьютексом, выполняет не более одной горутины. В Go это sync.Mutex с методами Lock, Unlock и TryLock.

Глубже. Важные свойства именно Go-шного мьютекса: он не реентерабельный; у него нет «владельца» — разблокировать может любая горутина, но Unlock незалоченного мьютекса — это фатальная ошибка sync: unlock of unlocked mutex, которую нельзя перехватить через recover; нулевое значение уже готово к работе (var mu sync.Mutex); копировать мьютекс после первого использования нельзя. Помимо взаимного исключения он даёт гарантии видимости: всё, что горутина записала до Unlock, видно горутине, которая потом сделала Lock.

Коротко. См. выше — это тот же вопрос по-русски: примитив, обеспечивающий эксклюзивный доступ к разделяемым данным.

Глубже. Единственное, что стоит добавить, если вопрос звучит второй раз: в Go принято класть мьютекс полем рядом с теми данными, которые он защищает, и комментировать, что именно он охраняет — это делает инвариант читаемым и помогает go vet.

type Cache struct {
mu sync.Mutex // защищает items
items map[string]int
}

Коротко. Чтобы родительская горутина (обычно main или обработчик) дождалась завершения всех запущенных дочерних горутин, прежде чем прочитать результат или выйти. Без неё main завершится и убьёт ещё работающие горутины.

Глубже. Вопрос обычно задают по конкретному листингу, поэтому отвечать надо, показывая инвариант: «Add(1) перед go фиксирует, что задача зарегистрирована до старта; defer wg.Done() гарантирует декремент даже при панике; wg.Wait() создаёт happens-before между всеми Done и кодом после Wait, поэтому после него можно безопасно читать результаты, записанные горутинами в разные элементы слайса без дополнительной блокировки». Альтернатива для того же кода — канал результатов с известным числом сообщений или errgroup, если нужны ошибки.

почему необходимо использовать мьютекс? А еще можно решить эту проблему?

Заголовок раздела «почему необходимо использовать мьютекс? А еще можно решить эту проблему?»

Коротко. Мьютекс нужен, чтобы конкурентные чтения-записи разделяемого состояния не превратились в data race: даже x++ — это три операции (загрузка, инкремент, сохранение), и без синхронизации инкременты теряются. Решить ту же проблему можно ещё атомиками, каналом, RWMutex, шардированием состояния или вообще устранением разделяемого состояния.

Глубже. Выбор зависит от формы состояния. Один счётчик — atomic.Int64, будет быстрее мьютекса. Неизменяемый снапшот конфигурации — atomic.Pointer[Config] с copy-on-write. Состояние, обновляемое только одной горутиной-владельцем, — канал команд к этой горутине (классическое «не общайтесь разделяемой памятью, делите память общением»). Составная структура с инвариантом между полями — только мьютекс (атомиками нельзя атомарно обновить два поля). Высокая конкуренция за одну карту — шардирование: N мьютексов и N карт по хэшу ключа.

Коротко. В стандартной библиотеке: sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once (и производные OnceFunc/OnceValue/OnceValues), sync.Cond, sync.Pool, sync.Map, интерфейс sync.Locker, плюс пакет sync/atomic. Языковые средства — каналы и select. В golang.org/x/syncerrgroup, semaphore, singleflight.

Глубже. Полезно классифицировать: взаимное исключение (Mutex, RWMutex, семафор с весом 1), координация/ожидание (WaitGroup, каналы, Cond, errgroup), однократное выполнение (Once, singleflight), ограничение параллелизма (буферизированный канал как семафор, x/sync/semaphore), lock-free (sync/atomic), кэширование объектов (sync.Pool). За рамками языка — примитивы ОС (futex, spinlock, condition variable), на которых всё это в конечном счёте строится.

Какие тут примитивы синхронизации можем использовать?

Заголовок раздела «Какие тут примитивы синхронизации можем использовать?»

Коротко. Ответ зависит от листинга, но осмысленный набор выбирается по типу задачи: защитить составное состояние — Mutex/RWMutex; посчитать/переключить флаг — atomic; дождаться N горутин — WaitGroup или errgroup; инициализировать один раз — Once; ограничить параллелизм — буферизированный канал или x/sync/semaphore; передать данные — канал.

Глубже. На собеседовании выигрывает ответ, где вы вслух перебираете варианты и обосновываете выбор: «здесь два поля обновляются вместе, значит атомики не подходят — нужен мьютекс», «здесь только счётчик и он на горячем пути — возьму atomic.Int64», «здесь надо и подождать, и вернуть ошибку — errgroup.Group». Пустое перечисление всех примитивов ценится меньше.

Коротко. Атомики — операции пакета sync/atomic над одним машинным словом (или указателем), которые выполняются неделимо относительно других горутин: никто не может увидеть промежуточное состояние. Это Load, Store, Add, Swap, CompareAndSwap, а с Go 1.23 ещё And/Or.

Глубже. С Go 1.19 есть типизированный API: atomic.Int32/Int64/Uint32/Uint64/Bool/Pointer[T]/Value. Его надо предпочитать функциям вида atomic.AddInt64(&x, 1): типы нельзя случайно прочитать неатомарно, и они сами решают проблему выравнивания 64-битных полей на 32-битных платформах (у функционального API 64-битная переменная должна быть выровнена по 8 байт, иначе паника на arm/386). По модели памяти Go атомарные операции последовательно согласованы, то есть работают и как барьеры памяти, а не только как «неделимый инкремент».

type Counter struct{ n atomic.Int64 }
func (c *Counter) Inc() { c.n.Add(1) }
func (c *Counter) Value() int64 { return c.n.Load() }

Коротко. Если задача сводится к счётчику или флагу — заменить mu.Lock(); x++; mu.Unlock() на x.Add(1). Если надо атомарно обновить структуру — держать её неизменяемой и подменять указатель через atomic.Pointer[T] (copy-on-write), при конкурентных обновлениях — в CAS-цикле.

Глубже. CAS-цикл — базовая техника: читаем текущее значение, вычисляем новое, пытаемся заменить CompareAndSwap; если не удалось, значит кто-то опередил, повторяем с новым значением. Так реализуются lock-free счётчики с нетривиальной логикой (например, «максимум»), стеки и очереди. Ограничения: обновляется ровно одно слово, поэтому нельзя атомарно согласовать два поля; при высокой конкуренции CAS-цикл может долго «промахиваться» и проиграть мьютексу; и есть проблема ABA (значение вернулось к прежнему, CAS проходит, хотя состояние менялось).

var cfg atomic.Pointer[Config]
func Get() *Config { return cfg.Load() } // читатели без блокировок
func Set(c *Config) { cfg.Store(c) } // писатель подменяет снапшот
func addMax(v *atomic.Int64, x int64) { // CAS-цикл
for {
old := v.Load()
if x <= old || v.CompareAndSwap(old, x) {
return
}
}
}

Как сделать так, чтобы явно у нас только одна отработала, без доработки программы?

Заголовок раздела «Как сделать так, чтобы явно у нас только одна отработала, без доработки программы?»

Коротко. Вопрос — обрывок из живого диалога по листингу; наиболее вероятный смысл: «как гарантировать, что действие выполнится ровно один раз, не переписывая логику». Штатный способ в Go — обернуть вызов в sync.Once (или sync.OnceFunc), а для «одна на процесс/кластер» — CAS-флаг, singleflight или внешняя блокировка (advisory lock в БД, ключ в Redis).

Глубже. sync.Once.Do(f) гарантирует, что f выполнится ровно один раз, а все остальные вызовы дождутся её завершения и увидят результаты её работы (happens-before). Если нужно «первая победила, остальные не ждут» — это уже не Once, а atomic.Bool.CompareAndSwap(false, true). Если нужно «параллельные одинаковые запросы схлопнуть в один вызов, но результат отдать всем» — это golang.org/x/sync/singleflight, и он, в отличие от Once, позволяет повторить попытку после ошибки.

Что такое примитивы синхронизации, их виды?

Заголовок раздела «Что такое примитивы синхронизации, их виды?»

Коротко. См. выше про «Примитивы синхронизации». Кратко: механизмы, упорядочивающие конкурентный доступ к общим ресурсам. Виды: блокирующие (мьютекс, RWMutex, семафор, condition variable), координирующие (WaitGroup, барьер, каналы), неблокирующие/lock-free (атомики, CAS), однократные (Once).

Глубже. Отличие от предыдущей формулировки только в акценте на классификацию, поэтому стоит добавить измерение «блокирует ли поток исполнения»: спинлок крутит процессор и оправдан только для очень коротких секций в ядре/рантайме; мьютекс в Go начинает со спина, но затем паркует горутину силами планировщика, не блокируя ОС-поток; семафор ограничивает число одновременных участников числом N.

Коротко. sync.Mutex — это два 32-битных слова: state (бит «залочен», бит «разбужена горутина», бит «режим голодания» и счётчик ждущих) и sema — токен семафора рантайма. Быстрый путь: CompareAndSwap бита locked. Не получилось — короткий спин, затем runtime_SemacquireMutex, который паркует горутину.

Глубже. Спин выполняется всего несколько итераций (инструкции PAUSE) и только если это имеет смысл: многоядерная машина, есть другие работающие P, горутина не в режиме голодания. Дальше горутина встаёт в очередь на семафоре рантайма — это хэш-таблица sudog-очередей внутри рантайма, а не системный вызов; futex/ОС задействуется только глубже, при парковке самого потока M. У мьютекса два режима: нормальный (проснувшаяся горутина конкурирует с новыми «набегающими» — это даёт высокую пропускную способность, но новичок часто выигрывает) и режим голодания, в который мьютекс переходит, если горутина прождала больше 1 мс: тогда владение передаётся напрямую первому в очереди (FIFO-handoff), что убивает голодание ценой пропускной способности. Заметьте: в Go 1.24 переписали именно внутренний мьютекс рантайма (runtime.mutex), а sync.Mutex из стандартной библиотеки сохранил схему state+sema.

Что такое атомик и за счет происходит атомарность операции?

Заголовок раздела «Что такое атомик и за счет происходит атомарность операции?»

Коротко. Атомик — неделимая операция над словом памяти. Атомарность обеспечивает процессор: специальные инструкции (LOCK-префикс и XADD/CMPXCHG на x86-64, пара load-linked/store-conditional LDAXR/STLXR на arm64) вместе с протоколом когерентности кэшей (MESI), который на время операции даёт ядру эксклюзивное владение кэш-линией.

Глубже. Современные x86 не блокируют шину целиком — блокируется кэш-линия (cache locking), поэтому цена атомарной операции — это цена перевода линии в состояние Exclusive/Modified и инвалидации её копий в других ядрах: десятки наносекунд при конкуренции, единицы — без неё. На arm64 атомарность достигается иначе: LDAXR ставит эксклюзивную метку на адрес, STLXR записывает, только если метку никто не сбросил, иначе цикл повторяется; в ARMv8.1 добавлены и однокомандные атомики (LDADD, CASAL). В Go компилятор транслирует функции sync/atomic в эти инструкции напрямую как интринсики — вызова функции в рантайм на большинстве платформ нет. Побочный эффект: эти инструкции служат ещё и барьерами памяти, поэтому атомики упорядочивают и соседние обычные обращения к памяти в рамках модели памяти Go.

Как думаешь почему мьютексы реализованы через атомики?

Заголовок раздела «Как думаешь почему мьютексы реализованы через атомики?»

Коротко. Потому что взаимное исключение требует неделимой операции «проверить и захватить»: без атомарного CAS две горутины могут одновременно увидеть «свободно» и обе войти в секцию. Атомик — это минимальный аппаратный кирпич, на котором вообще возможно построить блокировку.

Глубже. Теоретически мутуальное исключение можно построить и на обычных чтениях/записях (алгоритм Петерсона, Лампорта), но такие алгоритмы требуют явных барьеров памяти, плохо масштабируются на N потоков и тратят процессор на ожидание. Практический аргумент — производительность: в неконкурентном случае (а это подавляющее большинство вызовов) захват мьютекса должен стоить один успешный CAS без единого обращения к планировщику и ядру ОС. Медленный путь (очередь ждущих, парковка) нужен только при реальной конкуренции. Такая двухуровневая схема и называется гибридным мьютексом, и именно она реализована в sync.Mutex.

Коротко. init() — для дешёвой и обязательной инициализации, которая должна быть выполнена до любого использования пакета (регистрация драйверов, сборка таблиц констант). sync.Once (чаще sync.OnceValue) — для дорогой или падающей инициализации, которая может не понадобиться: соединение с БД, чтение конфига, компиляция регулярок при первом использовании.

Глубже. Практические доводы против init в прикладном коде: он выполняется всегда, даже когда пакет импортирован ради одной константы; он не может вернуть ошибку — только panic или log.Fatal, что убивает процесс при старте; он замедляет старт бинаря и тесты; его нельзя подменить в тестах. Ленивый вариант через sync.Once этих проблем лишён, но требует помнить о том, что ошибка первого вызова «залипнет» — Once не повторяет попытку, поэтому для повторяемой инициализации берут singleflight или собственный CAS-протокол. Наиболее чистый вариант в современном коде — вообще явная функция New(...) (*Service, error) и передача зависимостей, без глобального состояния.

var loadConfig = sync.OnceValues(func() (*Config, error) { // Go 1.21+
return parseConfig(os.Getenv("CONFIG_PATH"))
})

Коротко. Race condition возникает, когда корректность результата зависит от относительного порядка или тайминга конкурентных операций и этот порядок ничем не зафиксирован. Классические поводы: check-then-act (проверил, что ключа нет, и вставил), read-modify-write без атомарности, зависимость от порядка запуска горутин.

Глубже. Race condition шире, чем data race: она бывает и при полностью синхронизированных обращениях. Пример: два обработчика делают if !store.Exists(id) { store.Create(id) } — сам store потокобезопасен, гонки данных нет, но создадутся две записи. Лечится тем, что составная операция делается атомарно на уровне владельца данных: LoadOrStore вместо Load+Store, INSERT ... ON CONFLICT DO NOTHING вместо SELECT+INSERT, UPDATE ... WHERE version = $1 (оптимистическая блокировка) вместо чтения и записи. Именно поэтому -race — необходимый, но не достаточный инструмент.

Коротко. Да. Варианты: передавать данные по каналам вместо разделяемой памяти; сделать состояние иммутабельным и подменять снапшот через atomic.Pointer; использовать атомарные счётчики; отдать состояние одной горутине-владельцу и общаться с ней командами; шардировать данные так, чтобы каждая горутина работала со своим куском без пересечений.

Глубже. Важно не превращать это в догму. Канал внутри тоже содержит мьютекс, поэтому «без мьютекса» на уровне рантайма чаще всего означает «мьютекс спрятан». Настоящий lock-free-код (CAS-циклы, очереди Майкла–Скотта) писать сложно и легко ошибиться в ABA и в упорядочивании; в Go его пишут редко, и почти всегда правильный ответ — «взял бы sync.Mutex, а если бенчмарк покажет, что он узкое место, посмотрел бы на шардирование, атомарный снапшот или структуру данных без разделяемого состояния».

Коротко. Атомарная операция — это одна процессорная инструкция без обращения к планировщику: горутина не паркуется, не встаёт в очередь, не будит другую горутину. Мьютекс в неконкурентном случае стоит примерно столько же (тот же CAS), но при конкуренции добавляет спин, парковку и пробуждение — сотни наносекунд и переключения контекста.

Глубже. Оговорка, которую стоит произнести самому: атомики «быстрее» не всегда. При высокой конкуренции за одну и ту же кэш-линию (десятки ядер инкрементируют один счётчик) начинается ping-pong кэш-линий, и пропускная способность падает; иногда шардированный счётчик с суммированием при чтении или даже мьютекс с батчингом обновлений оказывается выгоднее. Плюс атомик защищает одно слово, а мьютекс — произвольный инвариант, так что сравнение честно только для однословных операций.

Коротко. Да — простейший спинлок строится на одном CompareAndSwap. Но полноценный мьютекс требует ещё и механизма ожидания: без него ждущие горутины будут жечь процессор, поэтому реальные реализации добавляют парковку через рантайм/ОС.

Глубже. Учебный спинлок выглядит так, и его допустимо показать на доске; в проде вместо него берут sync.Mutex, потому что он умеет спинить ограниченно, парковать горутину и бороться с голоданием.

type SpinLock struct{ locked atomic.Bool }
func (s *SpinLock) Lock() {
for !s.locked.CompareAndSwap(false, true) {
runtime.Gosched() // без этого можно занять P целиком
}
}
func (s *SpinLock) Unlock() { s.locked.Store(false) }

Коротко. sync.Pool — кэш временных объектов, снижающий число аллокаций и давление на GC. Применяется на горячих путях, где на каждый запрос создаётся и выбрасывается однотипный объект: буферы bytes.Buffer, срезы байт, объекты энкодеров/декодеров, парсеры.

Глубже. Устройство: у каждого P (процессора планировщика) свой приватный слот и локальная lock-free очередь poolChain, из чужих очередей можно «воровать» — поэтому пул хорошо масштабируется. Объекты не живут вечно: на каждом цикле GC содержимое пула переезжает в victim-кэш, а victim прошлого цикла выбрасывается, то есть объект переживает примерно два цикла GC. Отсюда правила: пул нельзя использовать как пул соединений или как кэш с гарантией наличия (нет Len, нет ограничения размера, Get может вернуть новый объект в любой момент); New должен уметь создать объект; объект обязательно сбрасывать при получении; не класть обратно объекты с чужими ссылками (иначе логическая гонка — двое будут писать в один буфер); класть указатели, а не значения, чтобы Put не аллоцировал при упаковке в any. Хрестоматийный пример использования — fmt и encoding/json внутри стандартной библиотеки.

Коротко. См. выше про отличие sync.Map от map. Это готовая потокобезопасная карта с API Load/Store/LoadOrStore/LoadAndDelete/Delete/Swap/CompareAndSwap/CompareAndDelete/Range/Clear, рассчитанная на «много читаем, редко пишем» и на непересекающиеся ключи.

Глубже. Отличие от предыдущего вопроса — акцент на API: Range принимает func(k, v any) bool и не гарантирует консистентный снапшот (ключи, добавленные во время обхода, могут попасть, а могут нет); LoadOrStore — та самая атомарная операция check-then-act, ради которой её часто и берут; Clear появился в Go 1.23; в Go 1.24 внутренняя реализация заменена на hash-trie.

Коротко. Да: две транзакции держат блокировки на строки и ждут блокировок друг друга. СУБД не ждёт вечно — она обнаруживает цикл в графе ожиданий и убивает одну из транзакций с ошибкой, которую приложение должно поймать и повторить.

Глубже. В PostgreSQL детектор запускается, когда ожидание превысило deadlock_timeout (по умолчанию 1 с), жертва получает SQLSTATE 40P01 (deadlock_detected). В MySQL/InnoDB граф ожиданий проверяется сразу, жертвой выбирается транзакция с меньшим «весом», ошибка 1213 ER_LOCK_WAIT_DEADLOCK. Профилактика ровно та же, что и в коде: единый порядок обращения к строкам (например, SELECT ... FOR UPDATE ORDER BY id), короткие транзакции, никакого «человеческого» ожидания внутри транзакции, батчи в стабильном порядке сортировки, при необходимости — SELECT ... FOR UPDATE NOWAIT/SKIP LOCKED и lock_timeout. На стороне приложения обязателен ретрай с backoff, потому что при уровне изоляции Serializable к дедлокам добавляются ещё и ошибки сериализации (40001).

Коротко. Это ситуация, когда результат работы программы зависит от порядка выполнения конкурентных операций. Частный и самый опасный случай — data race: неупорядоченный доступ к одной памяти, где хотя бы одна операция — запись.

Глубже. В Go к этому добавляются специфические ловушки: конкурентная запись в map ловится рантаймом и роняет процесс (fatal error: concurrent map writes — это не паника, recover не поможет); присваивание интерфейса или слайса — не атомарная операция, поэтому читатель может увидеть «полуобновлённое» значение (тип от нового, данные от старого); до Go 1.22 переменная цикла for i := range ... была одна на весь цикл, и захват её в горутине давал классическую гонку — с Go 1.22 переменная создаётся на каждой итерации и эта ловушка исчезла. Лечится синхронизацией (мьютекс/атомик/канал) или устранением разделяемого состояния.

Коротко. См. выше — циклическое ожидание блокировок между транзакциями; СУБД сама разрывает цикл, откатывая одну транзакцию, приложение обязано её повторить.

Глубже. Стоит добавить, чем это отличается от Go-шного дедлока: в БД есть внешний арбитр (детектор), который гарантированно завершает ожидание, а в Go при частичном дедлоке никакого арбитра нет — горутины будут висеть до перезапуска процесса. Поэтому в коде роль «детектора» играют таймауты контекста.

Что подразумевает под собой гарантия доставки “at most once”?

Заголовок раздела «Что подразумевает под собой гарантия доставки “at most once”?»

Коротко. «Не более одного раза»: сообщение будет доставлено либо один раз, либо ни разу — дубликатов не будет, но возможна потеря. Достигается тем, что отправитель не делает повторов, а получатель подтверждает получение до обработки (или подтверждения нет вовсе).

Глубже. Три классические гарантии: at-most-once (нет ретраев → возможна потеря), at-least-once (есть ретраи и ack после обработки → возможны дубликаты), exactly-once (на практике — at-least-once плюс дедупликация или идемпотентность обработчика, «effectively once»; сквозной exactly-once в распределённой системе недостижим без транзакционной поддержки на обеих сторонах). Выбор диктуется ценой ошибки: метрики и телеметрию можно слать at-most-once, финансовые события — at-least-once с идемпотентным ключом. Локальный аналог этой семантики в Go — sync.Once: гарантия «ровно один раз» внутри одного процесса, но именно внутри процесса и без сетевых отказов.

Коротко. Собрать и прогнать код с детектором: go test -race ./..., go run -race, для стенда — go build -race. Детектор печатает обе конфликтующие операции со стеками и место создания горутин.

Глубже. Практические детали: гоняйте с -race -count=5 и с -cpu=1,4,8, потому что детектор видит только фактически произошедшие конфликты; включайте -race в CI хотя бы на unit- и интеграционных тестах; GORACE="halt_on_error=1" заставляет падать на первой находке, atexit_sleep_ms, log_path помогают в CI. Гонки логики детектор не найдёт — их ловят стресс-тестами с инвариантами, testing/synctest (детерминированные тесты конкурентного кода, экспериментальный в 1.24 и стабилизированный в 1.25) и обычным ревью на предмет check-then-act.

Как избежать гонки в представленной программе?

Заголовок раздела «Как избежать гонки в представленной программе?»

Коротко. Программы в задании нет, поэтому по существу — общий рецепт: убрать разделяемое изменяемое состояние либо защитить его. Наиболее частая правка в таких задачах — накрыть доступ мьютексом, заменить счётчик на atomic.Int64, собирать результаты в разные ячейки слайса (индекс на горутину) или через канал.

Глубже. Порядок действий, который стоит проговорить: (1) назвать конкретную переменную, к которой идёт конкурентный доступ; (2) сказать, какие операции конфликтуют (запись/запись или чтение/запись); (3) предложить минимальную правку и объяснить, какой happens-before она создаёт; (4) проверить, что не появился дедлок (не берём блокировку рекурсивно, не блокируемся внутри секции) и что правка не убила параллелизм (не завели один глобальный мьютекс вокруг всей работы); (5) сказать, что проверите go test -race.

Коротко. См. выше: Mutex — один вход для всех, RWMutex — либо много читателей, либо один писатель. Дополнительно: у RWMutex четыре метода (Lock/Unlock/RLock/RUnlock, плюс TryLock/TryRLock с Go 1.18) и метод RLocker(), отдающий «читательский» sync.Locker.

Глубже. Ещё одно отличие, о котором редко говорят: RWMutex тяжелее по памяти (внутри лежит целый Mutex плюс два семафора и два счётчика) и его нельзя апгрейдить/даунгрейдить. И у него writer-preferring семантика: как только писатель встал в очередь, новые читатели уже не проходят, а ждут.

Почему ты предпочитаешь использовать map+RWMutex, а не sync.Map?

Заголовок раздела «Почему ты предпочитаешь использовать map+RWMutex, а не sync.Map?»

Коротко. Потому что map + RWMutex типизирована (нет упаковки в any и лишних аллокаций), позволяет атомарно выполнить составную операцию над несколькими ключами, даёт len, честную итерацию и просто читается. sync.Map выигрывает только на своих сценариях: почти-только-чтение или непересекающиеся ключи по горутинам при большом числе ядер.

Глубже. Отвечать лучше не догмой, а критериями: если профиль показывает, что мьютекс на карте — узкое место, я сначала попробую шардирование (N мьютексов и N карт по хэшу ключа) — оно сохраняет типизацию и обычно бьёт sync.Map на смешанной нагрузке; sync.Map возьму, если ключей много, они пишутся однажды и читаются постоянно (кэш скомпилированных шаблонов, реестр метрик). Честно упомяните, что в Go 1.24 sync.Map переписали на hash-trie и она стала лучше масштабироваться, так что старые бенчмарки могут вводить в заблуждение — решает измерение на своём профиле нагрузки.

Коротко. sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once (+ OnceFunc/OnceValue/OnceValues), sync.Cond, sync.Pool, sync.Map, sync.Locker; пакет sync/atomic; каналы и select как языковые примитивы; context для отмены; из x/syncerrgroup, semaphore, singleflight.

Глубже. Это самый частый вопрос подтемы (встречается чаще всех остальных), поэтому ответ стоит выучить как список с одной фразой на каждый пункт: Mutex — эксклюзивный доступ; RWMutex — много читателей/один писатель; WaitGroup — дождаться группы; Once — ровно один раз; Cond — ожидание условия с оповещением; Pool — переиспользование объектов; Map — потокобезопасная карта под read-heavy; atomic — неделимые операции над словом; канал — передача данных с happens-before; errgroup — WaitGroup с ошибкой и отменой; semaphore — ограничение параллелизма с весами; singleflight — схлопывание одинаковых конкурентных вызовов.

Какое действие нельзя делать со всеми примитивами?

Заголовок раздела «Какое действие нельзя делать со всеми примитивами?»

Коротко. Их нельзя копировать после начала использования: ни Mutex, ни RWMutex, ни WaitGroup, ни Once, ни Cond, ни Pool, ни sync.Map. Копия уносит с собой часть состояния и ломает инвариант — передавать надо только по указателю.

Глубже. Типичные способы случайно скопировать: метод с value-receiver у структуры, содержащей мьютекс; передача такой структуры в функцию по значению; помещение её в слайс/карту по значению и последующее чтение элемента; замыкание, захватившее копию. Ловится статически: go vet (анализатор copylocks) выдаёт «passes lock by value». Именно поэтому во многих типах стандартной библиотеки лежит поле noCopy. Вторая universally-forbidden вещь, которую тоже принимают как ответ: нельзя отпускать/завершать примитив «не по протоколу» — Unlock незалоченного мьютекса, RUnlock без RLock, отрицательный счётчик WaitGroup — это не паники бизнес-уровня, а фатальные ошибки/паники, которые роняют программу.

Рассказать подробно где и как тут будет происходить гонка?

Заголовок раздела «Рассказать подробно где и как тут будет происходить гонка?»

Коротко. Кода в задании нет, поэтому отвечаю схемой разбора: назвать переменную, назвать две конкурирующие операции, показать чередование, при котором результат неверен, и объяснить, почему между ними нет happens-before.

Глубже. Самые частые места в задачах с собеседований: (1) counter++ в N горутинах — это load/add/store, два инкремента могут прочитать одно и то же значение, часть обновлений теряется; (2) конкурентная запись в общую map — рантайм детектирует и падает с fatal error: concurrent map writes; (3) append в общий слайс — гонка и по полю len, и по backing-массиву, элементы теряются или перезаписываются; (4) wg.Add(1) внутри горутины вместо родителя — Wait может завершиться до старта; (5) чтение результата, записанного другой горутиной, без Wait/мьютекса/канала — даже если запись «уже произошла» по времени, нет гарантии видимости; (6) до Go 1.22 — захват переменной цикла замыканием. По каждому пункту полезно назвать конкретную починку: atomic.Int64, мьютекс вокруг карты, запись по своему индексу в заранее выделенный слайс, Add до go.

Коротко. Паника — это исключительная ситуация в конкретной горутине: раскручивается стек, работают defer, её можно перехватить через recover. Дедлок — это отсутствие какого-либо исполнения: горутины просто вечно ждут. Если заблокированы все горутины, рантайм печатает fatal error: all goroutines are asleep - deadlock! — это фатальная ошибка, а не паника: defer не выполняются, recover невозможен, процесс завершается сразу.

Глубже. Полезно перечислить и другие фатальные (не перехватываемые) ошибки рантайма той же природы: concurrent map writes, sync: unlock of unlocked mutex, out of memory, stack overflow. Практическое следствие: паника локализуема (можно поставить recover в HTTP-мидлваре и продолжить обслуживать остальные запросы), а дедлок — нет: частичный дедлок молча съедает часть функциональности, полный убивает процесс, и лечение — только таймауты, порядок блокировок и мониторинг числа горутин.

Коротко. В Go два типа в стандартной библиотеке: sync.Mutex (эксклюзивный) и sync.RWMutex (разделяемый на чтение). В общем случае мьютексы классифицируют как реентерабельные/нереентерабельные, честные/нечестные, спин-локи против блокирующих, рекурсивные, с таймаутом. Go-шные мьютексы нереентерабельны и гибридны: сначала короткий спин, потом парковка.

Глубже. По честности sync.Mutex — адаптивный: обычный режим нечестный (это даёт пропускную способность), но при ожидании дольше 1 мс включается режим голодания с честной FIFO-передачей владения. Реентерабельного мьютекса в Go нет намеренно: рекурсивная блокировка обычно означает, что инвариант «кто владеет данными» размазан по коду; вместо неё разделяют публичный метод, который берёт блокировку, и приватный doXxxLocked, который её уже требует. Мьютекса с таймаутом тоже нет — есть TryLock (Go 1.18) и семафор x/sync/semaphore, чей Acquire принимает context.

Коротко. См. выше про устройство: атомарные операции над полем state (CAS) для быстрого пути и семафор рантайма (runtime_SemacquireMutex/runtime_Semrelease) с парковкой горутины для медленного, плюс ограниченный активный спин между ними.

Глубже. Если спрашивают ещё глубже: семафор рантайма — это semtable, хэш-таблица деревьев ожидающих sudog, а не системный вызов; на уровень ОС (futex на Linux) уходит уже сам поток M, когда планировщику нечего исполнять. Поэтому блокировка на sync.Mutex в Go «дешевле» pthread-мьютекса при конкуренции: паркуется горутина, а поток остаётся и берёт другую работу.

Коротко. Дешевле на неконкурентном пути и не вовлекает планировщик: нет парковки, пробуждения и очереди ожидающих. Для счётчиков, флагов и подмены указателя атомик обычно в разы быстрее и не может привести к дедлоку.

Глубже. «Лучше» только в своей нише. Атомик работает с одним словом, поэтому им нельзя защитить инвариант из нескольких полей; код на CAS-циклах труднее читать и легко получить ABA; при жёсткой конкуренции за одну кэш-линию атомик деградирует. И атомик не даёт взаимного исключения на произвольный участок кода — он даёт неделимость одной операции. Формулировка для собеседования: «atomic — не замена мьютексу, а более узкий инструмент; там, где хватает одного слова, он быстрее и проще, во всех остальных случаях нужен мьютекс».

Какие основные техники используются при работе с атомиками?

Заголовок раздела «Какие основные техники используются при работе с атомиками?»

Коротко. CAS-цикл (retry-loop), атомарные счётчики, атомарная подмена неизменяемого снапшота через atomic.Pointer[T] (copy-on-write), флаги состояния и переключение конечного автомата через CAS, двойная проверка (double-checked locking) и защита от false sharing выравниванием по кэш-линии.

Глубже. Что стоит упомянуть отдельно: (1) типизированный API atomic.Int64/atomic.Pointer[T] вместо функций — он снимает вопрос выравнивания на 32-битных платформах и мешает случайно прочитать переменную неатомарно; (2) atomic.Value для значений произвольного типа с требованием постоянства конкретного типа между Store; (3) проблема ABA в lock-free структурах и её обход через счётчик поколений, упакованный вместе с указателем; (4) шардирование счётчиков (по одному на P, суммируем при чтении), когда одна кэш-линия становится узким местом; (5) And/Or из Go 1.23 для битовых масок. Главное правило: смешивать атомарные и обычные обращения к одной переменной нельзя — либо все обращения атомарные, либо никакие.

Какие примитивы синхронизации из стандартного пакета sync вы применяли на практике, помимо sync.Map?

Заголовок раздела «Какие примитивы синхронизации из стандартного пакета sync вы применяли на практике, помимо sync.Map?»

Коротко. Практически всегда — Mutex и RWMutex (защита кэшей и внутреннего состояния сервисов), WaitGroup (ожидание пула воркеров и фоновых задач при graceful shutdown), Once/OnceValue (ленивая инициализация клиентов и конфигов), sync.Pool (буферы на горячем пути сериализации). Реже — sync.Cond.

Глубже. Это вопрос про опыт, поэтому его ждут с конкретикой: назовите задачу, примитив и почему именно он. Хороший каркас ответа — «проблема → что попробовал → чем измерил → к чему пришёл». Например: «in-memory кэш справочника, читают все обработчики, обновляет один фоновый рефрешер; начинали с RWMutex, потом заменили на atomic.Pointer на неизменяемую карту, потому что профиль показал конкуренцию на RLock». Плохо звучит перечисление без контекста и упоминание sync.Cond, если вы не сможете рассказать протокол for !condition { c.Wait() }.

Коротко. Там, где на каждый запрос выделяется однотипный временный объект и профилирование показывает, что аллокации заметны: буферы для сериализации JSON/protobuf, срезы байт для чтения из сети, объекты gzip.Writer, промежуточные структуры в парсерах.

Глубже. Ответ выигрывает от дисциплины измерений: «увидели в pprof -alloc_objects, что 40% аллокаций — это bytes.Buffer в мидлваре логирования; завели пул, Reset перед использованием, не возвращаем буферы больше 64 КБ; аллокации на запрос упали, GC pause сократились». И обязательно оговорка о том, чем sync.Pool не является: это не пул соединений (нет контроля числа объектов и времени жизни — используйте database/sql или x/sync/semaphore), не кэш данных (GC очистит), и он опасен, если объект после Put продолжает использоваться — это тихая порча данных, которую -race может и не поймать.

Какой метод не является частью пакета sync.WaitGroup?

Заголовок раздела «Какой метод не является частью пакета sync.WaitGroup?»

Коротко. Remove(). У WaitGroup есть Add(delta int), Done() и Wait() (а с Go 1.25 ещё и Go(f func())); метода Remove не существует.

Глубже. Логика вопроса в том, что «убрать задачу из группы» выражается через Add с отрицательной дельтой — собственно, Done() это ровно Add(-1). Если счётчик уйдёт ниже нуля, будет паника sync: negative WaitGroup counter.

Коротко. Вариант ответа к вопросу выше — и он правильный: такого метода у sync.WaitGroup нет.

Коротко. Вариант ответа к вопросу выше. Метод существует: блокирует вызывающую горутину, пока счётчик не станет нулём.

Коротко. Вариант ответа к вопросу выше. Метод существует: уменьшает счётчик на единицу, эквивалент Add(-1).

Коротко. Вариант ответа к вопросу выше. Метод существует: Add(delta int) увеличивает (или уменьшает при отрицательной дельте) счётчик незавершённых задач.

Какой метод используется для блокировки мьютекса в Go?

Заголовок раздела «Какой метод используется для блокировки мьютекса в Go?»

Коротко. Lock() — он захватывает мьютекс, блокируя горутину до освобождения; парный ему Unlock(). Есть ещё неблокирующий TryLock() bool (Go 1.18) и у RWMutexRLock()/RUnlock() для чтения.

Глубже. В приведённом ниже списке вариантов правильного ответа нет — ни Reserve, ни Seize, ни Save, ни Read, ни Create, ни Load к sync.Mutex отношения не имеют (Load существует, но у sync/atomic и sync.Map). Канонический шаблон использования: mu.Lock(); defer mu.Unlock().

Коротко. Вариант ответа к вопросу выше — неверный: метода Reserve у sync.Mutex нет.

Коротко. Вариант ответа к вопросу выше — неверный: метода Seize в пакете sync не существует.

Коротко. Вариант ответа к вопросу выше — неверный: Save к мьютексам отношения не имеет.

Коротко. Вариант ответа к вопросу выше — неверный. Блокировка на чтение называется RLock() у sync.RWMutex, метода Read нет.

Коротко. Вариант ответа к вопросу выше — неверный: мьютекс не нужно создавать, нулевое значение sync.Mutex уже готово к использованию.

Коротко. Вариант ответа к вопросу выше — неверный для мьютекса. Load — это метод атомарных типов (atomic.Int64.Load) и sync.Map.Load.

callService медленный (до 5 минут), надо отдавать свежий результат вызова callService (время жизни — полчаса). Можно сделать background job через таймер, который вызывает callService и засовывает в структуру с мьютексом, а хэндлер отдает клиенту.

Заголовок раздела «callService медленный (до 5 минут), надо отдавать свежий результат вызова callService (время жизни — полчаса). Можно сделать background job через таймер, который вызывает callService и засовывает в структуру с мьютексом, а хэндлер отдает клиенту.»

Коротко. Да, это правильный базовый дизайн: фоновая горутина с time.Ticker (интервал заметно меньше TTL, например 10–15 минут) обновляет значение, хэндлер только читает готовый снапшот под RWMutex или через atomic.Pointer. Главное — прогреть кэш на старте, не отдавать пустое значение и при ошибке обновления оставлять предыдущее с пометкой «устарело».

Глубже. Что стоит проговорить сверх исходной идеи: (1) хранить не только значение, но и время обновления и последнюю ошибку — тогда хэндлер может решать, отдавать ли протухшие данные (stale-while-revalidate) или 503; (2) вызов должен идти с context и таймаутом больше 5 минут, а тикер не должен запускать второй вызов поверх незавершённого (флаг через CompareAndSwap или обновление в одном цикле, а не по go); (3) atomic.Pointer[Snapshot] предпочтительнее мьютекса: читатели вообще не блокируются, а писатель один; (4) если несколько реплик — учитывать, что каждая греет свой кэш, иногда лучше общий кэш (Redis) плюс singleflight от «стада» одновременных промахов; (5) корректное завершение: фоновая горутина слушает ctx.Done() и учтена в WaitGroup.

type snapshot struct {
data Result
updatedAt time.Time
err error
}
type Cache struct {
v atomic.Pointer[snapshot]
ttl time.Duration
}
func (c *Cache) Run(ctx context.Context) {
c.refresh(ctx) // прогрев на старте
t := time.NewTicker(c.ttl / 3)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.refresh(ctx)
}
}
}
func (c *Cache) refresh(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 6*time.Minute)
defer cancel()
res, err := callService(ctx)
if err != nil {
if old := c.v.Load(); old != nil {
c.v.Store(&snapshot{data: old.data, updatedAt: old.updatedAt, err: err})
}
return
}
c.v.Store(&snapshot{data: res, updatedAt: time.Now()})
}
func (c *Cache) Get() (Result, bool) {
s := c.v.Load()
if s == nil {
return Result{}, false
}
return s.data, time.Since(s.updatedAt) < c.ttl
}

Коротко. См. выше: Mutex даёт эксклюзивный доступ всегда, RWMutex разрешает параллельные чтения и эксклюзивную запись.

Глубже. Если вопрос задают в третий раз, добавьте практический критерий: RWMutex окупается, только когда доля чтений очень велика и критическая секция достаточно длинная; при коротких секциях его дополнительная бухгалтерия делает его медленнее обычного Mutex.

Если кол-во операций на чтение и запись одинаково, то есть ли смысл использовать RWMutex и почему?

Заголовок раздела «Если кол-во операций на чтение и запись одинаково, то есть ли смысл использовать RWMutex и почему?»

Коротко. Нет. При равном соотношении чтений и записей RWMutex только добавляет накладные расходы: писатели всё равно сериализуются между собой, а каждый RLock дороже обычного Lock и при этом читатели постоянно вытесняются пришедшими писателями. Обычный Mutex будет проще и, как правило, быстрее.

Глубже. Механика: пришедший писатель через Lock вычитает rwmutexMaxReaders из readerCount, блокируя новых читателей, и ждёт ухода текущих (readerWait). При частых записях читатели почти никогда не идут параллельно, зато каждый платит за атомарные операции над общими счётчиками — тот самый cache-line ping-pong. Формулировка для ответа: «RWMutex выигрывает не от факта чтения, а от параллелизма читателей; если писатели постоянно рвут это окно, параллелизма нет и выигрыша нет — проверяется бенчмарком».

Коротко. См. выше про атомики: пакет sync/atomic, неделимые операции над одним словом или указателем, реализуются процессорными инструкциями, не вовлекают планировщик. Ниша — счётчики, флаги, атомарная подмена указателя; составное состояние ими не защитить.

Глубже. Что добавить, если спрашивают «расскажи всё, что знаешь»: типизированный API с Go 1.19 (atomic.Int64, atomic.Pointer[T], atomic.Bool, atomic.Value), правило выравнивания для старого функционального API на 32-битных платформах, последовательная согласованность атомиков в модели памяти Go (с Go 1.19 это зафиксировано в спецификации памяти), CAS-циклы и ABA, And/Or с Go 1.23, и то, что смешивать атомарный и неатомарный доступ к одной переменной нельзя.

Коротко. Кроме Mutex, RWMutex и WaitGroupOnce, OnceFunc/OnceValue/OnceValues (Go 1.21), Cond, Pool, Map и интерфейс Locker. Пакет sync/atomic формально отдельный.

Глубже. sync.Cond — condition variable: горутина под удерживаемой блокировкой ждёт c.Wait() (он отпускает L на время ожидания), а другая сторона вызывает Signal/Broadcast. Обязательный шаблон — проверка условия в цикле for !ready { c.Wait() }, потому что пробуждение не гарантирует истинность условия. На практике Cond в Go применяют редко: он не дружит с context (нельзя отменить ожидание по таймауту), и почти всегда чище решить задачу каналом. Стоит упомянуть и golang.org/x/sync: errgroup (группа с ошибкой, отменой и SetLimit), semaphore.Weighted, singleflight.Group.

Коротко. См. выше про race condition и data race. Гонка — это зависимость результата от порядка конкурентных операций; её частный случай, гонка данных, — неупорядоченный доступ к общей памяти с хотя бы одной записью.

Глубже. Если интервьюер просит «на пальцах»: два кассира одновременно смотрят на остаток товара, оба видят «1 штука», оба продают — покупателей двое, товар один. В коде это if balance >= sum { balance -= sum } из двух горутин. Починка: сделать проверку и изменение одной атомарной операцией — под мьютексом, через CAS или через условный UPDATE в БД.

Коротко. В sync/atomic: Load, Store, Add, Swap, CompareAndSwap, а также And и Or (появились в Go 1.23). Для каждого поддерживаемого типа — Int32/Int64/Uint32/Uint64/Uintptr/Pointer, плюс atomic.Value со Store/Load/Swap/CompareAndSwap.

Глубже. Полезно отделять «атомарные операции пакета» от «атомарности на уровне языка». В Go неатомарны почти все привычные операции: x++, присваивание интерфейса, слайса или строки (это несколько слов), запись в map. Гарантированно атомарны только операции из sync/atomic. На уровне процессора им соответствуют LOCK XADD (Add), LOCK CMPXCHG (CompareAndSwap), XCHG (Swap), обычные выровненные MOV с барьерами (Load/Store) на x86-64.

Что из пакета sync вы использовали в работе?

Заголовок раздела «Что из пакета sync вы использовали в работе?»

Коротко. См. выше про практический опыт: чаще всего Mutex/RWMutex, WaitGroup, Once, реже Pool и Map; из смежного — atomic, errgroup, singleflight.

Глубже. Отличие от похожего вопроса выше — здесь нет ограничения «кроме sync.Map», поэтому уместно рассказать и про неё: где пробовали, почему оставили или отказались. Структура ответа та же: задача → выбранный примитив → как проверили, что он уместен (бенчмарк, pprof, -race) → чем закончилось.

Коротко. Add(delta int), Done(), Wait(). В Go 1.25 добавился Go(f func()), который сам делает Add(1), запускает горутину и вызывает Done в defer.

Глубже. Внутри WaitGroup — атомарное 64-битное состояние (в старших 32 битах счётчик задач, в младших — число ожидающих в Wait) и семафор рантайма для пробуждения. Done — это Add(-1); уход счётчика в минус даёт панику; повторное использование WaitGroup допустимо, но только после того, как все предыдущие Wait вернулись, иначе — паника WaitGroup is reused before previous Wait has returned.

Коротко. См. выше — пакет sync/atomic и предоставляемые им неделимые операции над одним машинным словом; выполняются одной инструкцией процессора, не блокируют горутину и не обращаются к планировщику.

Глубже. Если вопрос повторяется, добавьте одну вещь, которую любят проверять: атомарность не равна видимости «по умолчанию» в других языках, но в Go атомики последовательно согласованы, то есть заодно упорядочивают окружающие обращения к памяти. Поэтому пара «писатель: data = x; ready.Store(true)» и «читатель: if ready.Load() { use(data) }» корректна в Go, хотя аналогичный код на C++ с relaxed-порядком был бы сломан.

Коротко. Затем, что для однословных операций атомик даёт тот же результат существенно дешевле: нет очереди ожидания, парковки горутины и пробуждения, невозможен дедлок. Мьютекс остаётся для случаев, когда надо согласованно изменить несколько полей или выполнить произвольный участок кода эксклюзивно.

Глубже. Есть и обратная зависимость: сами мьютексы построены на атомиках, так что вопрос «зачем атомики, если есть мьютексы» частично отвечает сам на себя — без атомиков не было бы мьютексов. Практический критерий выбора: если состояние помещается в одно слово или в один указатель на неизменяемый снапшот — атомик; если инвариант связывает несколько полей или критическая секция содержит вызовы — мьютекс. И не забывайте третий вариант: иногда правильный ответ — вообще не делить состояние (канал, шардирование, копия на горутину).

Коротко. Дедлок — ситуация, когда группа горутин навсегда заблокирована, потому что каждая ждёт ресурс, удерживаемый другой из этой же группы. В Go он бывает не только на мьютексах, но и на каналах: чтение из канала, в который никто никогда не запишет, или запись в канал без читателя. Если все горутины программы заблокированы, рантайм это обнаруживает и падает с fatal error: all goroutines are asleep - deadlock!.

Глубже. Важно понимать границы детектора: рантайм не анализирует граф ожиданий, он просто замечает, что не осталось ни одной runnable-горутины и нет активных таймеров, системных вызовов и сетевого поллера. Поэтому «частичный» дедлок — две горутины взаимно заблокировались, а main продолжает обслуживать HTTP — не обнаруживается вообще: программа просто медленно течёт горутинами. То же самое, если хоть одна горутина висит в net.Read или в time.Sleep — рантайм считает, что прогресс возможен.

func main() {
var mu sync.Mutex
mu.Lock()
mu.Lock() // fatal error: all goroutines are asleep - deadlock!
}

sync.Mutex не рекурсивный: повторный Lock в той же горутине — гарантированный дедлок. У мьютекса нет понятия «владелец», поэтому и Unlock может сделать другая горутина (это легально, но почти всегда признак плохого дизайна).

Коротко. sync — стандартный набор низкоуровневых примитивов синхронизации: Mutex, RWMutex, WaitGroup, Once, Cond, Pool, Map, плюс подпакет sync/atomic. Все они не копируемы после первого использования (нулевое значение готово к работе, копирование ломает инвариант — за этим следит go vet -copylocks).

Глубже. Практический срез по каждому: Mutex/RWMutex — взаимное исключение; WaitGroup — дождаться N задач; OnceOnceFunc/OnceValue/OnceValues с Go 1.21) — ленивая однократная инициализация с гарантией happens-before; Cond — ожидание изменения условия, почти всегда лучше заменяется каналом, потому что Cond.Wait нельзя положить в select и он не умеет отменяться по контексту; Pool — переиспользование временных объектов для снижения нагрузки на GC; Map — конкурентная мапа под специфические профили нагрузки. Отдельно стоит golang.org/x/sync: errgroup, semaphore, singleflight — де-факто стандарт, но формально это не stdlib. Документация пакета прямо советует: для большинства задач канал или errgroup читаются лучше, чем ручной Cond или голый atomic.

Коротко. WaitGroup умеет только считать: дождаться, пока N горутин завершатся. errgroup.Group дополнительно собирает первую ненулевую ошибку, умеет отменять общий контекст при первой ошибке (errgroup.WithContext) и ограничивать степень параллелизма (SetLimit). По сути это WaitGroup + sync.Once для ошибки + context.CancelFunc.

Глубже. Разница в эргономике на реальном коде: с WaitGroup вы обязаны сами заводить канал ошибок или слайс с мьютексом и сами прокидывать отмену. С errgroup это встроено.

import (
"context"
"golang.org/x/sync/errgroup"
)
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // не более 8 одновременных запросов
results := make([]string, len(urls))
for i, u := range urls {
i, u := i, u // с Go 1.22 не обязательно, но безвредно
g.Go(func() error {
body, err := fetch(ctx, u)
if err != nil {
return err
}
results[i] = body
return nil
})
}
return g.Wait()
}

Обратите внимание: запись в results[i] по разным индексам — не гонка, потому что каждая горутина пишет в свой элемент слайса, и Wait даёт happens-before для последующего чтения. g.Wait() возвращает первую ошибку, остальные теряются; если нужны все — собирайте их сами через errors.Join.

Коротко. Ключевая особенность — errgroup.WithContext: возвращённый контекст отменяется автоматически, как только первая горутина вернула ошибку (а также после возврата из Wait). Это даёт паттерн «fail fast»: остальные задачи, если они уважают контекст, сворачиваются немедленно.

Глубже. Из этого вытекают два подводных камня. Первый: контекст отменяется и при успешном завершении Wait, поэтому нельзя утекать этот ctx наружу и использовать его после Wait. Второй: отмена работает только если ваши задачи реально проверяют ctx.Done() или передают ctx в сетевые вызовы; горутина с чистым CPU-циклом продолжит крутиться, и Wait дождётся её. Второстепенная, но полезная особенность — SetLimit(n)TryGo), которая превращает группу в пул воркеров без ручного семафора; вызывать SetLimit нужно до первого Go и нельзя менять, пока есть активные задачи.

Почему часто используют обычную мапу с мьютексом, а не мапу из пакета sync?

Заголовок раздела «Почему часто используют обычную мапу с мьютексом, а не мапу из пакета sync?»

Коротко. Потому что map + sync.RWMutex типобезопасна, предсказуема и в большинстве профилей нагрузки быстрее. sync.Map работает с any, а значит боксит ключи и значения (аллокации, косвенность, type assertion на каждом чтении) и оптимизирована лишь под два узких сценария.

Глубже. Документация sync.Map прямо перечисляет её сценарии: (1) ключ пишется один раз и потом много раз читается — кеш, растущий только вверх; (2) несколько горутин работают с непересекающимися наборами ключей. Всё остальное — частые перезаписи одних и тех же ключей, смешанные read/write — лучше ложится на RWMutex + обычная мапа или на шардирование (N мапов со своим мьютексом, шард по хешу ключа). Плюс sync.Map не даёт len, не даёт консистентного снимка (Range не атомарен) и не позволяет удобно делать составные операции «прочитал–посчитал–записал» атомарно.

Отдельно про версии: в Go 1.24 внутренняя реализация sync.Map была заменена на структуру на основе hash-trie, что заметно улучшило масштабирование записей по сравнению со старой схемой read/dirty (там при промахе по read-части накапливались misses и происходило полное копирование dirty-мапы). Это уменьшило разрыв, но не отменило проблему интерфейсного боксинга и отсутствия дженериков.

Коротко. Нет. Атомик почти всегда быстрее при низкой конкуренции и для одной короткой операции. При высокой конкуренции CAS-цикл начинает бесконечно ретраиться и жечь CPU, тогда как мьютекс паркует горутину и освобождает ядро; а если критическая секция состоит из нескольких атомарных операций, один мьютекс окажется и быстрее, и корректнее.

Глубже. Три конкретные ситуации, где мьютекс выигрывает. Первая: составное обновление. x.Add(1) дёшево, но «прочитать три поля и согласованно обновить их» через набор атомиков невозможно без CAS-цикла на всю структуру, а такой цикл под нагрузкой будет ретраиться десятки раз. Вторая: сильная конкуренция на одну кеш-линию — атомики деградируют квадратично по числу ядер, мьютекс в режиме голодания хотя бы гарантирует прогресс и не сжигает CPU спином. Третья: сам sync.Mutex на неконкурентном пути — это ровно один успешный CAS, то есть по стоимости практически равен атомику; разница появляется только под contention.

Важно и то, что атомик не заменяет мьютекс семантически: он даёт атомарность одной операции, а не защиту инварианта из нескольких переменных. Мерить надо конкретный код: go test -bench с -cpu=1,4,8,16 и b.RunParallel.

Коротко. Классическая формулировка: запустить N горутин, каждая инкрементирует общий счётчик, объяснить, почему результат меньше N, и починить тремя способами — мьютексом, атомиком или каналом/владельцем данных.

Глубже. Багованный вариант и его разбор:

package main
import (
"fmt"
"sync"
"sync/atomic"
)
func broken() int {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // гонка: read-modify-write не атомарен
}()
}
wg.Wait()
return counter // обычно < 1000
}

counter++ — это три операции (load, add, store), между которыми другая горутина успевает вклиниться. Починка мьютексом:

func withMutex() int {
var mu sync.Mutex
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
return counter
}

Починка атомиком (предпочтительно для простого счётчика, Go 1.19+ типизированные атомики):

func withAtomic() int64 {
var counter atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Add(1)
}()
}
wg.Wait()
return counter.Load()
}
func main() { fmt.Println(broken(), withMutex(), withAtomic()) }

Что ещё спрашивают в этой задаче: (а) почему wg.Add(1) обязан быть до go, а не внутри горутины — иначе Wait может увидеть нулевой счётчик и вернуться раньше; (б) почему нельзя передавать wg по значению — копия WaitGroup считает свои Done; (в) как раньше приходилось делать i := i из-за переиспользования переменной цикла — с Go 1.22 переменная цикла создаётся на каждой итерации, и эта ловушка исчезла; (г) как проверить — go run -race.

Как Go решает проблему в RWMutex, если бесконечно висит lock на чтение и когда произойдет lock на запись?

Заголовок раздела «Как Go решает проблему в RWMutex, если бесконечно висит lock на чтение и когда произойдет lock на запись?»

Коротко. sync.RWMutex защищён от голодания писателя: как только писатель вызвал Lock, все новые RLock блокируются, даже если сейчас активны другие читатели. Писатель дожидается только уже вошедших читателей и получает лок сразу после того, как последний из них сделает RUnlock. Бесконечного откладывания записи из-за постоянного потока читателей не происходит.

Глубже. Механика в src/sync/rwmutex.go: писатель сначала берёт внутренний w sync.Mutex (отсекая других писателей), затем атомарно вычитает rwmutexMaxReaders (= 1<<30) из readerCount. Отрицательный readerCount — сигнал «есть ожидающий писатель», и RLock на быстром пути видит отрицательное значение и паркуется на семафоре readerSem. Писатель считает оставшихся активных читателей в readerWait и засыпает на writerSem; последний уходящий читатель его будит.

Прямое следствие, которое любят спрашивать: рекурсивный RLock (взять read-лок, а внутри — ещё раз) может привести к дедлоку, если между ними успел встать писатель — внешний читатель держит лок, внутренний заблокирован писателем, писатель ждёт внешнего. Это документированное ограничение, RWMutex не реентерабельный. Настоящее голодание в RWMutex возможно только для читателей при потоке писателей, и там гарантий по порядку нет.

Мапа потокобезопасна? А как сделать безопасной? В чем отличие RW от Mutex

Заголовок раздела «Мапа потокобезопасна? А как сделать безопасной? В чем отличие RW от Mutex»

Коротко. Нет, встроенная map не потокобезопасна: конкурентная запись обнаруживается рантаймом и валит процесс с fatal error: concurrent map writes (это не паника, её нельзя перехватить recover). Сделать безопасной: обернуть в структуру с sync.Mutex/sync.RWMutex, использовать sync.Map, шардировать или отдать мапу во владение одной горутине и общаться через канал. Mutex даёт эксклюзивный доступ всем, RWMutex разрешает много одновременных читателей, но писателя пускает только в одиночку.

Глубже. Типовая безопасная обёртка:

type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func NewSafeMap() *SafeMap { return &SafeMap{m: make(map[string]int)} }
func (s *SafeMap) Get(k string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *SafeMap) Set(k string, v int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}

Нюанс: рантайм ловит только конкурентную запись и «запись во время итерации», причём эвристически (флаг hashWriting в заголовке мапы). Конкурентные чтение+запись он может и не заметить — это по-прежнему UB, и ловится оно только -race. Ещё одна деталь для Go 1.24: обычные мапы перешли на реализацию Swiss Tables — это изменило производительность и порядок обхода в деталях, но никак не сделало их потокобезопасными.

Что такое graceful-shutdown? Рeaлизовывал ли/Как работает?

Заголовок раздела «Что такое graceful-shutdown? Рeaлизовывал ли/Как работает?»

Коротко. Graceful shutdown — корректное завершение сервиса: перестать принимать новые запросы, дождаться завершения уже принятых в разумный срок, закрыть внешние ресурсы (БД, брокеры, файлы), сбросить буферы и только потом выйти. В Go это signal.NotifyContext + http.Server.Shutdown(ctx) с таймаутом.

Глубже. Каркас ответа «реализовывал ли» — расскажите порядок шагов и почему он именно такой:

package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
stop() // вернуть обработку сигнала по умолчанию: второй Ctrl+C убьёт сразу
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
_ = srv.Close() // жёстко рвём оставшиеся соединения
}
// затем: закрыть consumer'ы брокера, дождаться воркеров через WaitGroup/errgroup,
// закрыть пул БД, сбросить логи и метрики.
}

Что важно проговорить дополнительно. Shutdown закрывает слушающие сокеты и ждёт завершения активных запросов и простаивающих keep-alive соединений; hijacked-соединения и WebSocket он не закрывает — их надо гасить самому через отдельный контекст. Порядок остановки должен быть обратным порядку запуска: сначала входящий трафик, потом фоновые воркеры, в конце — соединения с БД (иначе висящий запрос упадёт на закрытом пуле). В Kubernetes к этому добавляется: сначала readiness-проба должна начать отвечать «не готов» и надо переждать несколько секунд, пока endpoints разъедутся по нодам (иначе балансировщик продолжит слать трафик в уже закрытый листенер), и terminationGracePeriodSeconds должен быть больше вашего внутреннего таймаута, иначе прилетит SIGKILL посреди дренажа.

Коротко. Чтобы не сериализовать читателей. Если 95% операций — чтения и они не мгновенные, RWMutex позволяет им идти параллельно на разных ядрах, а Mutex пропускал бы их по одному.

Глубже. Выигрыш не бесплатный: RLock/RUnlock дороже, чем Lock/Unlock (больше атомарных операций и веток), и все читатели дёргают один и тот же счётчик readerCount — то есть одну кеш-линию, которая начинает пинг-понгить между ядрами. Поэтому на очень коротких критических секциях (прочитать одно поле) RWMutex часто медленнее обычного Mutex, а на десятках ядер — заметно медленнее. Практическое правило: RWMutex окупается, когда чтений сильно больше записей и критическая секция чтения нетривиальна (обход структуры, сериализация, поиск). Для «read-mostly» конфигов лучше вообще atomic.Pointer[Config] с copy-on-write — там читатели вообще не конкурируют за запись.

Коротко. Graceful degradation — способность системы под нагрузкой или при отказе зависимостей продолжать работать с урезанной функциональностью вместо полного отказа: отдать данные из кеша, отключить необязательные блоки ответа, вернуть приблизительный результат. Это про устойчивость в рантайме, а не про завершение процесса (это graceful shutdown).

Глубже. Конкретные механизмы, которые стоит назвать: circuit breaker вокруг зависимости (после серии ошибок перестаём её дёргать и сразу отдаём fallback), таймауты и распространение дедлайнов через context, load shedding (отбрасывание запросов на входе, когда очередь превысила порог — лучше быстро вернуть 503, чем накопить очередь и умереть по памяти), rate limiting и семафоры на конкурентность, feature-флаги для отключения тяжёлых фич, отдача устаревших данных из кеша (stale-while-revalidate). В Go всё это опирается ровно на обсуждаемые примитивы: semaphore.Weighted или буферизованный канал для ограничения параллелизма, singleflight против лавины одинаковых запросов к упавшей зависимости, context.WithTimeout для дедлайнов.

Работал ли с мьютексами? Какие они бывают и чем отличаются?

Заголовок раздела «Работал ли с мьютексами? Какие они бывают и чем отличаются?»

Коротко. В stdlib два: sync.Mutex (эксклюзивный) и sync.RWMutex (разделяемый на чтение / эксклюзивный на запись). Оба не рекурсивные, оба имеют нулевое значение, готовое к использованию, оба не копируемы. С Go 1.18 у обоих есть TryLockTryRLock у RWMutex).

Глубже. Если вопрос про классификацию вообще (не только Go), полезно разложить по осям: спинлок против блокирующего (Go-мьютекс гибридный — сначала спин, потом парковка); рекурсивный/нерекурсивный (в Go только нерекурсивный, и это осознанное решение — рекурсивный лок скрывает нарушение инвариантов); справедливый/несправедливый (Go-мьютекс несправедлив в нормальном режиме ради throughput и становится справедливым в режиме голодания); межпроцессный против внутрипроцессного (в Go только внутрипроцессный, для межпроцессного — файловые блокировки/flock). Про личный опыт отвечайте конкретикой: какую структуру защищали, почему выбрали Mutex, а не RWMutex или канал, как ловили contention (mutex-профиль) и как уменьшали критическую секцию.

Коротко. В Go: каналы и select, sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once, sync.Cond, sync.Pool, sync.Map, пакет sync/atomic, context для отмены/дедлайнов, а из golang.org/x/syncerrgroup, semaphore.Weighted, singleflight.

Глубже. Полезно сгруппировать их по решаемой задаче, а не перечислять списком: взаимное исключение — Mutex, RWMutex, атомики, семафор с весом 1; ожидание завершения — WaitGroup, errgroup, закрытие канала; однократное действие — Once, OnceValue; ограничение параллелизма — буферизованный канал, semaphore.Weighted, errgroup.SetLimit; передача данных — каналы; сигнализация об изменении состояния — Cond, закрытый канал как broadcast, context.Done(); дедупликация работы — singleflight; переиспользование памяти — Pool. На собеседовании ценится именно такое сопоставление «примитив → задача».

Коротко. Mutex — один владелец в любой момент. RWMutex — либо произвольное число читателей, либо один писатель. RWMutex выигрывает при read-heavy нагрузке с нетривиальными чтениями, проигрывает на коротких секциях и при большом числе ядер из-за дороже стоящих RLock/RUnlock и конкуренции за общий счётчик читателей.

Глубже. См. выше «Зачем RWMutex если у нас есть обычный Mutex» — там подробности про механику и правило выбора. Добавлю одно: RWMutex не даёт апгрейда лока (нельзя «повысить» RLock до Lock) — попытка сделать это через RUnlock+Lock создаёт окно, в котором состояние могло измениться, и требует перепроверки условия после захвата.

Какая команда помогает проверить есть ли гонки или дедлоки?

Заголовок раздела «Какая команда помогает проверить есть ли гонки или дедлоки?»

Коротко. Гонки данных — go test -race ./... (или go run -race, go build -race). Дедлок «всех горутин» рантайм ловит сам; частичные дедлоки ищут дампом стеков: go test -timeout 30s (по таймауту печатает все горутины), kill -QUIT <pid> с GOTRACEBACK=all, либо /debug/pprof/goroutine?debug=2. Ещё go vet находит статические ошибки вроде копирования мьютекса.

Глубже. -race — это ThreadSanitizer: он инструментирует каждое обращение к памяти и ведёт векторные часы, поэтому даёт примерно 2–20x замедление и 5–10x рост памяти; включать его в проде обычно нельзя, а в CI — нужно. Принципиальное ограничение: детектор находит только те гонки, которые реально произошли при этом прогоне, ложных срабатываний он практически не даёт, но пропуски — обычное дело. Для дедлоков в stdlib детектора нет; косвенно помогают mutex/block-профили (runtime.SetMutexProfileFraction, runtime.SetBlockProfileRate + go tool pprof) и go tool trace, где видно, что горутина навсегда ушла в блокировку. В экосистеме есть go.uber.org/goleak для поиска утёкших (в том числе намертво заблокированных) горутин в тестах.

Коротко. Data race — конкретное техническое явление: два потока обращаются к одной ячейке памяти без синхронизации, и хотя бы одно обращение — запись. Race condition — логическая ошибка: результат зависит от порядка/времени событий. Одно не влечёт другого: можно иметь race condition без единой гонки данных (например, TOCTOU через полностью синхронизированные вызовы) и наоборот — гонка данных, которая на практике не меняет наблюдаемый результат, но всё равно UB.

Глубже. Пример race condition без data race: if !m.Has(k) { m.Set(k, v) }, где Has и Set каждый по отдельности под мьютексом. Гонки данных нет — детектор молчит, — но два потока могут оба увидеть «нет ключа» и второй перетрёт первого. Лечится расширением критической секции до всей составной операции или атомарной операцией вроде LoadOrStore. И наоборот: любая гонка данных в Go — неопределённое поведение, даже «безобидная» на bool-флаге: компилятор вправе закешировать значение в регистре, и цикл for !done {} может не завершиться никогда.

Коротко. Готовая конкурентная мапа без ручных мьютексов, с атомарными составными операциями (LoadOrStore, LoadAndDelete, CompareAndSwap, CompareAndDelete), и хорошим масштабированием на read-mostly нагрузке и на непересекающихся наборах ключей у разных горутин — там читатели практически не конкурируют между собой.

Глубже. Исторически преимущество достигалось двухуровневой схемой: неизменяемая read-часть, читаемая атомарно вообще без блокировки, и dirty-часть под мьютексом для новых ключей. В Go 1.24 реализация заменена на hash-trie, что убрало главный минус старой схемы — периодическое полное копирование dirty-мапы и деградацию на смешанной нагрузке. Минусы остались прежними: any вместо дженериков (аллокации и type assertions), нет Len, Range не даёт консистентного снимка, память под удалённые ключи освобождается не сразу.

Коротко. Чтобы обеспечить взаимное исключение: гарантировать, что критическую секцию в любой момент выполняет не более одной горутины, и тем самым сохранить инвариант разделяемых данных и установить happens-before между операциями разных горутин.

Глубже. Второй, менее очевидный смысл — именно барьер памяти. Даже если бы counter++ был атомарным, без синхронизации другая горутина могла бы никогда не увидеть новое значение: модель памяти не обязывает делать записи видимыми без цепочки happens-before. Unlock публикует все записи, сделанные под локом, а последующий Lock того же мьютекса их видит. Практические правила: держать лок как можно короче, не делать под ним I/O и вызовов пользовательских колбэков, всегда defer mu.Unlock() (кроме горячих путей, где важна каждая наносекунда и секция тривиальна), не возвращать наружу указатели на защищённые данные.

Коротко. Состояние гонки (race condition) — ситуация, когда корректность результата зависит от относительного порядка выполнения конкурентных операций, который ничем не зафиксирован. Программа «иногда работает», и поведение меняется от нагрузки, числа ядер и даже от компилятора.

Глубже. См. предыдущий разбор «Data race и Race condition» про отличие от гонки данных. Классические подвиды: check-then-act (TOCTOU) — проверили условие и действуем, а состояние между этим изменилось; read-modify-write без атомарности; гонка инициализации — используем объект, который другая горутина ещё дописывает; гонка на завершение — читаем результат, не дождавшись Wait. Диагностический признак на собеседовании: если вы можете сформулировать «а что если планировщик остановит горутину ровно здесь» и получить неверный результат — это гонка.

Коротко. sync.WaitGroup — счётчик незавершённых задач: Add(n) увеличивает, Done() уменьшает, Wait() блокируется, пока счётчик не станет нулём. Типовой способ дождаться группы горутин без канала.

Глубже. Правила, на которых валятся: Add вызывается до запуска горутины (иначе Wait может проскочить); WaitGroup передаётся указателем (копия — своя, независимая; go vet ругается); уход счётчика в отрицательное значение — паника sync: negative WaitGroup counter; повторное использование одной группы допустимо, но новый Add нельзя вызывать одновременно с ещё не вернувшимся Wait. Wait даёт happens-before: все записи, сделанные в горутинах до Done, видны после Wait. В Go 1.25 добавлен метод wg.Go(f), который объединяет Add(1) + go + defer Done() — это устраняет самый частый источник ошибок.

Коротко. Четыре базовых: (1) не делить данные вообще — владение одной горутиной, общение через каналы; (2) сделать данные неизменяемыми после публикации (copy-on-write, atomic.Pointer на новый снимок); (3) защитить каждый доступ одним и тем же мьютексом; (4) использовать атомарные операции для простых значений. Плюс инструментальная страховка — -race в CI.

Глубже. Дизайнерский порядок предпочтений именно такой, как перечислен: отсутствие разделяемого состояния всегда дешевле в поддержке, чем корректная блокировка. Инкапсуляция критична: мьютекс и защищаемые им поля должны лежать в одной структуре, а доступ идти только через методы — иначе никто не удержит инвариант «этот срез читают только под mu». Полезно документировать в коде: // guarded by mu. Также важно не «протекать» ссылками: если метод под локом возвращает []string или map наружу, вызывающий будет читать её уже без лока — надо возвращать копию.

Коротко. Сначала обнаружить (go test -race, стресс-тест с -count, -cpu), затем локализовать разделяемое состояние по отчёту детектора (он печатает оба стека — читающий и пишущий), затем выбрать протокол доступа: мьютекс, атомик, канал или устранение разделения.

Глубже. См. также «Способы предотвращения гонки данных» — там про профилактику, здесь про реакцию. Практика починки: не «навесить мьютекс на место падения», а найти границу инварианта. Если детектор указывает на поле структуры, вопрос «какие ещё поля меняются согласованно с ним» обычно расширяет критическую секцию. Отчёт -race содержит и место аллокации объекта, и стек создания горутины — это часто быстрее приводит к причине, чем сами два стека доступа. Для гонок, которые в CI не воспроизводятся, помогает прогон под -race с искусственным разнообразием планировщика: GOMAXPROCS больше 1, -count=100, задержки в тестах, runtime.Gosched() в подозрительных местах.

С какой проблемой можно столкнуться решая проблемы сихронизации?

Заголовок раздела «С какой проблемой можно столкнуться решая проблемы сихронизации?»

Коротко. Главные — дедлок (взаимная блокировка), livelock (горутины активны, но прогресса нет), голодание (starvation) отдельных горутин, contention (лок становится узким местом и сводит на нет параллелизм) и просто ошибочная гранулярность: слишком широкий лок убивает производительность, слишком узкий ломает инвариант.

Глубже. Ещё несколько ловушек, которые ценят на собеседовании: конвой-эффект — одна медленная операция под локом выстраивает за собой всех и удлиняет хвост латентности; false sharing — разные переменные в одной 64-байтной кеш-линии, из-за чего независимые счётчики тормозят друг друга; ABA-проблема в CAS-циклах; утечки горутин, заблокированных навсегда на канале или локе (растёт память, runtime.NumGoroutine() только вверх); и невоспроизводимость — гонки проявляются на другой машине, под другой нагрузкой, после смены версии Go. Отдельная категория — «синхронизация, которая выглядит корректной»: копирование структуры с мьютексом, defer не в том месте, Unlock в ветке с ранним return.

Коротко. sync.Map — потокобезопасная мапа из stdlib с ключами и значениями типа any, спроектированная под read-mostly нагрузку. API: Load, Store, LoadOrStore, LoadAndDelete, Delete, Swap, CompareAndSwap, CompareAndDelete, Range, Clear (с Go 1.23).

Глубже. См. выше «Какие основные преимущества sync.Map» и «Почему часто используют обычную мапу с мьютексом». Кратко про устройство: до Go 1.24 — пара read/dirty (read читается атомарно без лока, промахи считаются, при накоплении промахов dirty продвигается в read), с Go 1.24 — реализация на hash-trie, лучше масштабирующаяся при записях. Нулевое значение готово к использованию, копировать после первого использования нельзя.

Коротко. Ровно два: sync.Mutex и sync.RWMutex. Рекурсивных, таймаутных и именованных мьютексов в stdlib нет. Ближайшая замена «мьютекса с попыткой» — TryLock/TryRLock (Go 1.18+), «мьютекса с таймаутом» — семафор через буферизованный канал с select+time.After.

Глубже. Оба типа реализуют интерфейс sync.Locker (Lock/Unlock), и RWMutex.RLocker() возвращает Locker, у которого Lock — это RLock; это удобно, когда API принимает Locker. Внутри RWMutex содержит обычный Mutex для сериализации писателей. Документация TryLock прямо предупреждает: корректное использование встречается редко, и наличие TryLock в коде — часто признак проблемы в дизайне (кроме случаев вроде «пропустить фоновую задачу, если предыдущая ещё идёт»).

Коротко. Формально — ничего конкретного: мьютекс защищает критическую секцию, то есть код, а не память. Связь «этот мьютекс охраняет эти поля» существует только как соглашение разработчиков; компилятор её не проверяет. Практически мьютекс обеспечивает две вещи: взаимное исключение и барьер памяти (happens-before между Unlock и следующим Lock).

Глубже. Из этого следуют практические выводы. Если хоть один участок кода трогает данные без лока — защиты нет вообще. Если два разных мьютекса защищают одни данные — защиты нет. Если под локом вы возвращаете наружу указатель/слайс/мапу на защищённые данные — защита кончается на выходе из метода. Именно поэтому в Go принято класть mu в ту же структуру, что и защищаемые поля, сразу над ними, и писать комментарий вида // mu guards items. Дисциплина «lock is a convention» — единственное, что работает; статически это частично проверяют линтеры, но не компилятор.

сколько R и W локов можно взять от sync.RWMutex одновременно?

Заголовок раздела «сколько R и W локов можно взять от sync.RWMutex одновременно?»

Коротко. Писателей — ровно один и только при полном отсутствии активных читателей. Читателей — сколько угодно одновременно, до внутреннего лимита rwmutexMaxReaders = 1<<30 (около миллиарда); превышение приводит к фатальной ошибке рантайма. Комбинация «читатель + писатель одновременно» невозможна.

Глубже. Лимит 1<<30 — не про «реальный предел», а про кодирование состояния: писатель вычитает это число из readerCount, чтобы сделать его отрицательным и тем самым пометить «есть ожидающий писатель». Отсюда и практический предел на число одновременных читателей. На собеседовании достаточно ответить «1 писатель, N читателей, взаимно исключающие», а упоминание 1<<30 и способа кодирования — приятный бонус, показывающий, что вы читали исходники.

Коротко. Чтобы выполнять простые операции над одной переменной (загрузка, запись, инкремент, обмен, CAS) неделимо и с гарантией видимости между горутинами, не платя за захват мьютекса и без парковки горутины. Типичное применение: счётчики метрик, флаги состояния, lock-free публикация указателя на конфиг.

Глубже. С Go 1.19 предпочтительны типизированные обёртки: atomic.Int32/Int64/Uint32/Uint64/Bool/Pointer[T]/Value. Они дают два бонуса поверх старых функций atomic.AddInt64(&x, 1): невозможно случайно смешать атомарный и неатомарный доступ к одной переменной, и обеспечено выравнивание на 64 бита (со старыми функциями на 32-битных платформах 64-битное поле нужно было вручную класть первым в структуру, иначе паника при невыровненном доступе). Паттерн copy-on-write для read-mostly конфигов:

type Config struct{ Timeout time.Duration }
var cfg atomic.Pointer[Config]
func Load() *Config { return cfg.Load() } // читатели вообще не блокируются
func Store(c *Config) { cfg.Store(c) } // писатель публикует новый снимок целиком

Модель памяти Go определяет атомарные операции как последовательно согласованные, поэтому такой Store корректно публикует и содержимое структуры, на которую указывает указатель, — при условии, что после публикации её никто не мутирует.

Почему интенсивная конкурентная модификация atomic приводит к заметному снижению производительности на многопроцессорных системах?

Заголовок раздела «Почему интенсивная конкурентная модификация atomic приводит к заметному снижению производительности на многопроцессорных системах?»

Коротко. Из-за когерентности кешей. Атомарная запись требует эксклюзивного владения кеш-линией (состояние Modified/Exclusive в MESI), поэтому линия постоянно «переезжает» между ядрами, инвалидируясь у остальных. Чем больше ядер бьётся в одну переменную, тем больше межъядерного трафика и тем дольше каждая операция — масштабирование становится отрицательным.

Глубже. Механика: на x86 atomic.Add компилируется в LOCK XADD, CAS — в LOCK CMPXCHG; на ARM64 это цикл LDAXR/STLXR, который при неудаче повторяется. Каждая такая операция должна получить линию в состояние Exclusive, а это RFO-запрос (read-for-ownership) по межъядерной шине, стоящий десятки-сотни наносекунд при передаче между сокетами NUMA. Плюс CAS-циклы при конкуренции ретраятся, то есть работа выполняется несколько раз впустую.

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

type shardedCounter struct {
shards [64]struct {
v atomic.Int64
_ [56]byte // паддинг до 64 байт против false sharing
}
}
func (c *shardedCounter) Add(shard int, d int64) { c.shards[shard&63].v.Add(d) }
func (c *shardedCounter) Sum() int64 {
var t int64
for i := range c.shards {
t += c.shards[i].v.Load()
}
return t
}

Какие еще примитивы синхронизации знаете?

Заголовок раздела «Какие еще примитивы синхронизации знаете?»

Коротко. Помимо Mutex/RWMutex/WaitGroup/атомиков: sync.Once и её функциональные варианты OnceFunc/OnceValue/OnceValues (Go 1.21), sync.Cond, sync.Pool, sync.Map, каналы как семафор и как broadcast (закрытие канала будит всех), context для отмены, а из golang.org/x/syncerrgroup, semaphore.Weighted, singleflight.

Глубже. См. также «Какие бывают примитивы синхронизации». Пара деталей по редко используемым. sync.Once внутри — атомарный флаг done плюс мьютекс на медленном пути; гарантия сильнее, чем «функция вызвана один раз»: Do не вернётся, пока f не завершилась, и результат её работы виден всем последующим вызывающим. Паника внутри f считается завершением: повторный Do функцию не вызовет. sync.Cond нужен, когда надо будить ожидающих по изменению произвольного условия; его Wait всегда используется в цикле for !condition { c.Wait() }, потому что пробуждение не гарантирует истинность условия. Главный недостаток Cond — несовместимость с select и context, поэтому в Go его почти всегда заменяют каналом.

Что такое дедлок? Как бороться со случившимся дедлоком? Как не допустить дедлок?

Заголовок раздела «Что такое дедлок? Как бороться со случившимся дедлоком? Как не допустить дедлок?»

Коротко. Дедлок — взаимная блокировка группы горутин, каждая ждёт ресурс, удерживаемый другой. Со случившимся: снять дамп всех горутин (SIGQUIT с GOTRACEBACK=all, /debug/pprof/goroutine?debug=2, таймаут в тесте), найти горутины в состоянии semacquire/chan receive и восстановить цикл ожидания. Не допустить: глобальный порядок захвата локов, минимальные критические секции, никаких вызовов чужого кода под локом, таймауты/context вместо бесконечных ожиданий.

Глубже. Классические четыре условия Коффмана (взаимное исключение, удержание и ожидание, отсутствие вытеснения, циклическое ожидание) — ломать проще всего последнее: если все точки кода берут A перед B, цикла не возникнет. Go-специфика: дедлоки чаще возникают на каналах, чем на мьютексах — небуферизованный канал без читателя, for range по каналу, который никто не закрывает, WaitGroup.Wait при потерянном Done, взаимная отправка двух горутин друг другу. Отдельная категория — «дедлок с самим собой»: повторный Lock, RLock внутри RLock при ожидающем писателе, вызов метода той же структуры, который снова берёт лок.

В дампе горутин ищите: goroutine N [semacquire, 5 minutes] (мьютекс), [chan send]/[chan receive], [sync.WaitGroup.Wait]. Указание «X minutes» — прямой признак зависшей навсегда горутины. В проде полезно постоянно снимать runtime.NumGoroutine() в метрики: монотонный рост почти всегда означает утечку из-за блокировки.

Коротко. Речь о горутинах: sync.WaitGroup (Add до запуска, defer wg.Done() внутри, wg.Wait() снаружи), либо errgroup.Group.Wait(), если нужны ошибки и отмена, либо канал (собрать N значений или дождаться закрытия done).

Глубже. Если нужен таймаут на ожидание, WaitGroup его не даёт — оборачивают в горутину с каналом:

func waitTimeout(wg *sync.WaitGroup, d time.Duration) bool {
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return true
case <-time.After(d):
return false // горутины всё ещё работают
}
}

Важно: такой таймаут не отменяет сами горутины, он лишь перестаёт их ждать — отмена делается через context. И ещё: никогда не вызывайте wg.Wait() из той же горутины, которая должна сделать последний Done — это гарантированный дедлок.

Коротко. sync.Pool снижает давление на аллокатор и GC за счёт переиспользования короткоживущих объектов одинакового типа — типично буферов (bytes.Buffer, []byte) в горячих обработчиках. Пул шардирован по P, поэтому в типичном случае Get/Put не требуют блокировки вообще.

Глубже. Польза измеряется в снижении числа аллокаций (-benchmem, метрика allocs/op) и, как следствие, в снижении частоты и стоимости циклов GC. Пул не даёт гарантий: объект, положенный в Put, может быть выброшен в любой момент, а Get может вернуть свежий объект от New или nil, если New не задан. Поэтому пул нельзя использовать как кеш ценных объектов или как пул соединений — для последнего есть database/sql и специализированные библиотеки, где важна именно гарантия удержания ресурса.

Как работает пакет sync ? С какими примитивами из него вы работали?

Заголовок раздела «Как работает пакет sync ? С какими примитивами из него вы работали?»

Коротко. sync — тонкая обёртка над примитивами планировщика рантайма: быстрые пути реализованы атомарными операциями в пользовательском коде, а блокирование — через runtime_Semacquire/runtime_Semrelease, которые паркуют горутину (gopark), а не поток ОС. Именно поэтому заблокированный на мьютексе горутина стоит дёшево: поток M освобождается и берёт другую работу.

Глубже. Разбор по слоям для ответа: Mutex — поле state (биты locked/woken/starving и счётчик ожидающих) плюс sema; быстрый путь — один CompareAndSwap; при неудаче — до 4 итераций активного спина (только если есть свободные P и мы на многоядерной машине), затем парковка; переход в режим голодания при ожидании >1 мс. RWMutexreaderCount, readerWait, два семафора и внутренний Mutex для писателей. WaitGroup — одно 64-битное состояние (счётчик + число ожидающих) плюс семафор. Once — атомарный done + Mutex. Pool — per-P локальные структуры (private-слот + двусторонняя очередь shared, из которой другие P воруют работу) плюс victim cache. Про личный опыт отвечайте конкретными кейсами: RWMutex над кешем конфигов, errgroup для параллельных вызовов апстримов, Once для ленивой инициализации клиента, Pool для буферов сериализации, атомики для метрик.

В чем суть sync.Pool ? Как и когда его правильно использовать?

Заголовок раздела «В чем суть sync.Pool ? Как и когда его правильно использовать?»

Коротко. Суть — свободный список временных объектов, шардированный по процессорам и автоматически очищаемый сборщиком мусора. Правильное применение: объекты одного типа, короткоживущие, часто создаваемые в горячем пути, дорогие в аллокации; обязательно сбрасывать состояние перед повторным использованием и не хранить в пуле большие объекты неограниченного размера.

Глубже. Канонический пример:

var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func render(w io.Writer, data any) error {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // ключевой шаг: состояние из прошлой жизни
defer func() {
if buf.Cap() > 1<<20 { // не возвращаем разросшиеся буферы
return
}
bufPool.Put(buf)
}()
if err := json.NewEncoder(buf).Encode(data); err != nil {
return err
}
_, err := w.Write(buf.Bytes())
return err
}

Три частые ошибки. Первая — забыть Reset: приедут чужие данные, в худшем случае — утечка данных между пользователями. Вторая — вернуть в пул объект, ссылка на который ещё где-то живёт (buf.Bytes() отдан наружу) — это гонка и порча данных. Третья — класть в пул объекты сильно разного размера: пул начнёт удерживать самые большие, и потребление памяти вырастет вместо того, чтобы упасть (в stdlib с этим борются, например, ограничивая размер возвращаемых буферов в fmt и net/http). И общее правило: sync.Pool — оптимизация, её надо подтверждать бенчмарком с -benchmem; на редких вызовах она только усложняет код.

Как garbage collector взаимодействует с объектами в sync.Pool ?

Заголовок раздела «Как garbage collector взаимодействует с объектами в sync.Pool ?»

Коротко. Объекты в пуле — «слабые» с точки зрения GC: в начале каждого цикла сборки рантайм вызывает зарегистрированный poolCleanup, который очищает пулы. С Go 1.13 очистка двухступенчатая (victim cache): содержимое пула сначала переезжает в victim-область и удаляется только на следующем GC. То есть объект переживает максимум примерно два цикла сборки.

Глубже. Зачем victim cache: до Go 1.13 пулы полностью обнулялись на каждом GC, из-за чего после сборки шёл всплеск аллокаций и латентности («холодный» пул). Теперь Get при промахе по локальным очередям сначала заглядывает в victim, и нагрузка сглаживается. Практические следствия: (1) в пуле нельзя держать ничего, что обязано жить — соединения, файловые дескрипторы, состояние; (2) пул не удерживает объекты от сборки, поэтому утечки памяти он не создаёт, но может задерживать освобождение до двух циклов GC; (3) при высокой частоте GC (маленький GOGC, много мусора) эффективность пула падает — иногда правильнее уменьшить аллокации, чем компенсировать их пулом. Очистка пулов происходит в STW-фазе начала цикла, поэтому очень большие пулы теоретически влияют и на паузы.

Как бы вы реализовали функцию tryLock для семафора в Go?

Заголовок раздела «Как бы вы реализовали функцию tryLock для семафора в Go?»

Коротко. Через буферизованный канал ёмкостью N и select с default: успешная неблокирующая отправка = семафор захвачен, попадание в default = занят. Для взвешенного семафора в golang.org/x/sync/semaphore уже есть TryAcquire(n), для мьютекса — sync.Mutex.TryLock (Go 1.18+).

Глубже. Реализация с полным набором операций:

package sem
import (
"context"
"time"
)
type Semaphore struct{ c chan struct{} }
func New(n int) *Semaphore { return &Semaphore{c: make(chan struct{}, n)} }
// Acquire блокируется до получения слота.
func (s *Semaphore) Acquire() { s.c <- struct{}{} }
// TryAcquire не блокируется: true, если слот получен.
func (s *Semaphore) TryAcquire() bool {
select {
case s.c <- struct{}{}:
return true
default:
return false
}
}
// AcquireCtx уважает отмену и дедлайн.
func (s *Semaphore) AcquireCtx(ctx context.Context) error {
select {
case s.c <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// AcquireTimeout — вариант с таймаутом.
func (s *Semaphore) AcquireTimeout(d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case s.c <- struct{}{}:
return true
case <-t.C:
return false
}
}
func (s *Semaphore) Release() { <-s.c }

На что обратят внимание: Release без парного Acquire заблокируется на пустом канале (можно защититься select+default и паникой — лучше падать громко, чем тихо ломать инвариант); реализация справедлива в том смысле, что рантайм обслуживает ожидающих на канале в порядке FIFO; для «весов» больше единицы канальная схема не работает — там нужен semaphore.Weighted с мьютексом и списком ожидающих. Если просят именно TryLock для мьютекса вручную: func (m *Mutex) TryLock() bool { return atomic.CompareAndSwapInt32(&m.state, 0, 1) } — то есть один CAS без ретраев.

Почему возникает log contention ? Как его диагностировать и избежать?

Заголовок раздела «Почему возникает log contention ? Как его диагностировать и избежать?»

Коротко. Потому что логгер — это разделяемый ресурс с единственным мьютексом и, как правило, синхронной записью в один файловый дескриптор: log.Logger сериализует все вызовы под своим mu, а os.Stdout пишется без буфера, то есть каждый вызов — системный вызов. Под нагрузкой сотни горутин выстраиваются в очередь на этом локе, и латентность обработчиков растёт. Диагностика — mutex/block-профили и трассировка; лечение — меньше логов, асинхронная/буферизованная запись, сэмплирование, структурные логгеры с нулевыми аллокациями.

Глубже. Как измерить:

import (
"runtime"
_ "net/http/pprof" // регистрирует /debug/pprof/* на DefaultServeMux
)
func enableContentionProfiling() {
runtime.SetMutexProfileFraction(5) // профилировать 1 из 5 событий блокировки на мьютексе
runtime.SetBlockProfileRate(10000) // порог в наносекундах для block-профиля
}
// затем: go tool pprof http://host/debug/pprof/mutex (и .../block)

В pprof mutex вы увидите сумму времени ожидания по стекам — если верхушка ведёт в log.Output или в writer вашего логгера, диагноз подтверждён. В go tool trace это видно как длинные интервалы в состоянии «блокировка на синхронизации» у множества горутин одновременно.

Как избегать: снизить объём логов на горячем пути (уровни, сэмплирование вида «логировать 1 из 100 однотипных событий»); использовать логгер, который не аллоцирует и умеет писать пакетно (log/slog с собственным Handler, zap, zerolog); писать в bufio.Writer с периодическим флашем (помня, что при аварийном завершении хвост теряется); вынести запись в отдельную горутину, читающую из буферизованного канала, и явно решить, что делать при переполнении — блокироваться или дропать (для телеметрии почти всегда правильнее дропать). Отдельно: не форматировать сообщение до проверки уровня — slog для этого имеет Enabled, а аргументы стоит передавать лениво. Замечу, что этот же анализ дословно применим к любому глобальному локу: метрики, кеш, пул — «log contention» здесь просто самый частый частный случай lock contention.

В чем разница между Mutex и RWMutex ? Приведите пример, где один предпочтительнее другого.

Заголовок раздела «В чем разница между Mutex и RWMutex ? Приведите пример, где один предпочтительнее другого.»

Коротко. См. выше: Mutex — эксклюзивный доступ, RWMutex — много читателей либо один писатель. Mutex предпочтительнее для коротких критических секций и при сопоставимом числе чтений и записей; RWMutex — когда чтения доминируют и они не мгновенные.

Глубже. Конкретика. Mutex лучше: счётчик или флаг под локом (c.n++), очередь задач, где каждый обращающийся всё равно её меняет, или структура, у которой на каждое чтение приходится хотя бы одна запись — накладные расходы RLock тут не окупаются. RWMutex лучше: справочник маршрутов/фич-флагов/тарифов, который перезагружается раз в минуту, а читается тысячи раз в секунду и требует обхода нескольких полей; или in-memory индекс, по которому идёт поиск. Крайний случай read-heavy — конфиг, читаемый на каждом запросе целиком: там ни один из мьютексов не нужен, лучше atomic.Pointer[Config] с публикацией нового снимка. Проверять выбор надо бенчмарком с b.RunParallel и разными -cpu, потому что на 4 ядрах и на 64 ответ может отличаться.

Что такое singleflight из пакета golang.org/x/sync ? Какую проблему решает?

Заголовок раздела «Что такое singleflight из пакета golang.org/x/sync ? Какую проблему решает?»

Коротко. singleflight.Group схлопывает одновременные одинаковые вызовы в один: если 100 горутин просят один и тот же ключ, реально выполнится одна функция, а остальные 99 получат её результат. Решает проблему cache stampede (thundering herd) — лавины идентичных запросов к БД или апстриму в момент промаха кеша.

Глубже. Использование:

import "golang.org/x/sync/singleflight"
var g singleflight.Group
func GetUser(ctx context.Context, id string) (*User, error) {
v, err, _ := g.Do(id, func() (any, error) {
return fetchUserFromDB(ctx, id)
})
if err != nil {
return nil, err
}
return v.(*User), nil
}

Третье возвращаемое значение — shared bool, признак того, что результат был разделён с другими вызывающими. Подводные камни, которые надо назвать: (1) дедупликация действует только на одновременные вызовы — это не кеш, после возврата результата следующий вызов снова выполнит функцию (обычно singleflight ставят перед кешем, а не вместо); (2) ошибка тоже разделяется — один сбойный запрос вернёт ошибку всем ждущим, поэтому для «не кешировать неудачу» есть Forget(key); (3) один медленный вызов задерживает всех, и контекст первого вызывающего управляет судьбой общей работы — если он отменится, остальные получат отменённый результат; лечится DoChan вместе с select по собственному контексту; (4) паника в функции распространяется на всех ожидающих. Ключ должен точно идентифицировать запрос, иначе разные запросы схлопнутся в один неверный ответ.

Представим ситуацию: CI-пайплайн зеленый, все тесты проходят успешно, но в продакшене периодически возникают проблемы, вызванные состоянием гонки. Как можно обнаружить гонки данных на этапе тестирования в CI? Что делать, если стоит задача постоянно отслеживать, есть ли в коде гонка?

Заголовок раздела «Представим ситуацию: CI-пайплайн зеленый, все тесты проходят успешно, но в продакшене периодически возникают проблемы, вызванные состоянием гонки. Как можно обнаружить гонки данных на этапе тестирования в CI? Что делать, если стоит задача постоянно отслеживать, есть ли в коде гонка?»

Коротко. Обязательный шаг — отдельная джоба go test -race ./... (компиляции без -race недостаточно: детектор находит только гонки, случившиеся при исполнении). Дальше — повышать вероятность их случиться: -count=N, разные GOMAXPROCS, нагрузочные и fuzz-тесты под -race, тесты параллельными подтестами (t.Parallel()). Для постоянного мониторинга — держать под -race staging-инстанс или канареечный под, куда льётся доля реального трафика, и собирать отчёты детектора.

Глубже. Практический чек-лист для CI:

  • Отдельный шаг go test -race -count=1 ./... — не смешивать с обычным прогоном, потому что -race меняет тайминги и заметно медленнее; кеш тестов для race-сборки отдельный.
  • GORACE="halt_on_error=1 history_size=7" — падать на первом же отчёте и увеличить глубину истории; по умолчанию детектор продолжает выполнение и отчёт легко утонуть в логах.
  • Стресс: go test -race -count=20 -cpu=1,2,4,8 ./pkg/... на подозрительных пакетах; ночной прогон более длинный, чем PR-прогон.
  • Интеграционные и e2e-тесты тоже собирать с -race — большинство продовых гонок живёт в склейке компонентов (кеш + фоновой рефрешер, воркер + shutdown), а не в юнитах.
  • go vet ./... (проверки copylocks, loopclosure) плюс линтеры вроде go.uber.org/goleak в TestMain — утёкшая горутина часто и есть та, что потом гоняется с данными.
  • Fuzz-тесты (go test -fuzz ) для конкурентных структур: генерировать случайные последовательности операций и гонять их из нескольких горутин под -race.

Для «постоянного отслеживания» в продакшене: полноценный -race в проде обычно неприемлем (2–20x CPU, 5–10x память), но допустим на одном канареечном инстансе с малой долей трафика или на shadow-трафике — это единственный способ поймать гонки, зависящие от реальных данных и нагрузки. Отчёты детектора пишутся в stderr; их надо собирать логами и алертить по подстроке WARNING: DATA RACE. Дополнительно помогает наблюдаемость: рост runtime.NumGoroutine(), mutex/block-профили, непрерывный профайлинг. И организационно: гонка, найденная в проде, должна закрываться регресс-тестом, воспроизводящим её под -race с -count.

Коротко. Deadlock — вечная взаимная блокировка: горутины ждут друг друга по кругу и не двигаются. Race condition — зависимость результата от порядка выполнения конкурентных операций, из-за чего программа работает верно не всегда.

Глубже. См. подробные разборы выше («Что такое дедлок в Go?», «Data race и Race condition»). Разница в симптомах и инструментах: дедлок — программа висит, CPU нулевой, число горутин не падает; ищется дампом стеков. Гонка — программа работает, но данные иногда неверны, CPU нормальный; ищется -race. Смежные понятия, которые полезно назвать в этом же ответе: livelock (горутины активно что-то делают, но прогресса нет — например, вечно откатывают и повторяют транзакцию), starvation (кто-то никогда не получает ресурс) и contention (прогресс есть, но параллелизм съеден борьбой за лок).

Какие примитивы синхронизации существуют?

Заголовок раздела «Какие примитивы синхронизации существуют?»

Коротко. См. выше «Какие бывают примитивы синхронизации». Кратко: каналы и select, мьютексы (Mutex, RWMutex), WaitGroup, Once, Cond, Pool, Map, атомики, context, семафоры, барьеры/группы (errgroup), дедупликация (singleflight).

Глубже. Если вопрос задан в общетеоретическом ключе, а не про Go, стоит перечислить классику и сопоставить с Go: мьютекс — sync.Mutex; семафор — буферизованный канал или semaphore.Weighted; условная переменная — sync.Cond; барьер — WaitGroup; монитор — структура с мьютексом и методами; read-write lock — RWMutex; спинлок — цикл на CAS (в Go нежелателен: горутина не может «занять» ядро, планировщик кооперативный); event/latch — закрытие канала; message passing — каналы. Отдельная категория — lock-free/wait-free структуры на CAS, которых в stdlib почти нет (кроме внутренностей рантайма и sync.Map).

Коротко. sync.Mutex — примитив взаимного исключения: Lock/Unlock, одновременно внутри критической секции одна горутина. sync.RWMutex — то же плюс режим разделяемого чтения: RLock/RUnlock пропускают произвольное число читателей, Lock/Unlock даёт эксклюзив писателю. Оба нерекурсивные, некопируемые, нулевое значение готово к работе.

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

Что такое атомики и за счет чего они работают?

Заголовок раздела «Что такое атомики и за счет чего они работают?»

Коротко. Атомики — операции над одной ячейкой памяти, которые выполняются неделимо относительно других процессоров: загрузка, запись, Add, Swap, CompareAndSwap. Работают за счёт инструкций процессора: на x86-64 — префикс LOCK (LOCK XADD, LOCK CMPXCHG), на ARM64 — пара LDAXR/STLXR (load-exclusive / store-exclusive с ретраем) или атомарные инструкции LSE, плюс протокол когерентности кешей, который делает эффект видимым остальным ядрам.

Глубже. Два аспекта, которые надо разделять в ответе. Атомарность — операция не может быть наблюдаема наполовину; это обеспечивается захватом эксклюзивного владения кеш-линией. Упорядоченность (memory ordering) — какие ещё записи становятся видимыми вместе с этой; модель памяти Go говорит, что атомарные операции из sync/atomic ведут себя как sequentially consistent, то есть существует единый глобальный порядок всех атомарных операций, согласованный с порядком в каждой горутине. Более слабых порядков (acquire/release/relaxed), как в C++, в Go намеренно нет — язык предпочитает простую и безопасную семантику.

Практические ограничения: атомарны только машинные слова и указатели, никаких атомарных структур (для этого atomic.Value, которая хранит any и требует, чтобы все сохраняемые значения были одного конкретного типа, иначе паника). Компилятор не превратит x++ в атомарную операцию сам — атомарность возникает только от явного вызова. Смешивать атомарный и обычный доступ к одной переменной нельзя; типизированные типы Go 1.19+ (atomic.Int64 и т.д.) делают такую ошибку невозможной, потому что поле недоступно напрямую.

Что такое wait group и для чего он используется?

Заголовок раздела «Что такое wait group и для чего он используется?»

Коротко. См. выше «Что такое wait group?». sync.WaitGroup — счётчик активных задач; используется, чтобы дождаться завершения группы горутин перед тем, как продолжить или выйти.

Глубже. Отличие акцента в этом варианте вопроса — «для чего»: типичные сценарии — fan-out/fan-in (разослали работу по N воркерам, собрали результаты после Wait), корректный shutdown (все фоновые задачи должны завершиться до закрытия ресурсов), тесты (дождаться, что все горутины отработали, прежде чем проверять результат — иначе тест сам будет гонкой). Если помимо ожидания нужны ошибки, отмена по первой ошибке или лимит параллелизма — переходите на errgroup.

Коротко. errgroup.Group из golang.org/x/sync — «WaitGroup с ошибками»: запускает задачи через Go(func() error), дожидается всех через Wait() error, возвращает первую ненулевую ошибку, а в варианте WithContext отменяет общий контекст при первой ошибке. SetLimit(n) ограничивает число одновременно работающих задач.

Глубже. См. развёрнутые ответы выше («Чем отличается errgroup от WaitGroup», «Какая ключевая особенность есть в errgroup») с примером кода и подводными камнями. Здесь достаточно добавить: это не stdlib, а официально поддерживаемый модуль golang.org/x/sync, который в большинстве продакшн-проектов уже есть в зависимостях; ставить его ради двух горутин смысла нет, но для любого fan-out с ошибками он на порядок лучше самописного канала ошибок.

  • Путают data race и race condition: говорят «-race найдёт все гонки». Детектор находит только гонки данных и только на реально исполненном пути; логические гонки (check-then-act) он не видит.
  • Утверждают, что RWMutex всегда быстрее Mutex. На коротких секциях и при заметной доле записей он медленнее; нужен профиль чтений сильно больше записей и бенчмарк.
  • Считают sync.Mutex реентерабельным и «принадлежащим горутине». Он ни то, ни другое: повторный Lock в той же горутине — дедлок, Unlock может сделать другая горутина, а Unlock незалоченного — фатальная ошибка.
  • Называют падение с all goroutines are asleep - deadlock! паникой и обещают перехватить его recover. Это фатальная ошибка рантайма, defer не выполнятся.
  • Предлагают sync.Map как «просто быструю потокобезопасную map». Она оптимизирована под read-mostly и непересекающиеся ключи; на смешанной нагрузке типизированная map под RWMutex (или шардированная) обычно лучше.
  • Используют sync.Pool как пул соединений или кэш с гарантией наличия и забывают Reset() при получении объекта — получают либо утечку смысла (GC чистит пул), либо порчу данных.
  • Вызывают wg.Add(1) внутри запускаемой горутины, копируют sync.Mutex/WaitGroup по значению и забывают defer wg.Done().
  • Говорят «атомики всегда быстрее» без оговорки про конкуренцию за кэш-линию и про то, что атомик защищает одно слово, а не инвариант.
  • Говорить «мьютекс защищает переменную». Мьютекс защищает критическую секцию по соглашению; компилятор ничего не связывает, и один доступ мимо лока обнуляет всю защиту.
  • Утверждать, что -race доказывает отсутствие гонок. Детектор находит только те гонки, которые фактически произошли в этом прогоне; зелёный CI ничего не гарантирует.
  • Путать data race и race condition, а также deadlock и livelock/starvation — это разные явления с разными инструментами диагностики.
  • Считать RWMutex строго быстрее Mutex. На коротких секциях и многих ядрах он медленнее; выбор подтверждается бенчмарком, а не интуицией.
  • Говорить, что sync.Map — «просто быстрая потокобезопасная мапа». Она оптимизирована под два узких профиля, боксит значения в any и в общем случае проигрывает map + RWMutex или шардированию.
  • Считать, что атомик всегда быстрее мьютекса. При высокой конкуренции CAS-цикл жжёт CPU и деградирует с ростом числа ядер, а мьютекс паркует горутину.
  • Обещать, что sync.Pool сохранит объекты. GC очищает пулы (с Go 1.13 через victim cache, то есть объект живёт максимум ~2 цикла), поэтому пул не годится для соединений и любого ценного состояния; и Reset перед повторным использованием обязателен.
  • Вызывать wg.Add(1) внутри запускаемой горутины или передавать WaitGroup по значению — классические источники «Wait вернулся слишком рано» и вечного зависания.
  • Утверждать, что рантайм ловит любой дедлок. Он падает только когда спят все горутины; частичный дедлок нужно искать по дампу стеков и mutex-профилям.
  • Забывать про приоритет писателя в RWMutex и заявлять, что поток читателей может «заморить» писателя, — Go это специально предотвращает, а вот рекурсивный RLock действительно может привести к дедлоку.
  • The Go Memory Model — https://go.dev/ref/mem (happens-before, гарантии sync и атомиков, семантика с Go 1.19).
  • Документация пакетов sync и sync/atomichttps://pkg.go.dev/sync и https://pkg.go.dev/sync/atomic.
  • Исходники: src/sync/mutex.go, src/sync/rwmutex.go, src/sync/pool.go, src/sync/map.go — комментарии в этих файлах подробно описывают режим голодания, victim-кэш и структуру карты.
  • Data Race Detector — https://go.dev/doc/articles/race_detector (как включать, что ловит и чего стоит).
  • Go 1.24 Release Notes — https://go.dev/doc/go1.24 (новая реализация sync.Map на hash-trie, Swiss Tables для map, обновлённый внутренний мьютекс рантайма).
  • Модель памяти Go: https://go.dev/ref/mem — формальные гарантии happens-before для мьютексов, каналов, Once и атомиков.
  • Документация пакета sync и sync/atomic: https://pkg.go.dev/sync и https://pkg.go.dev/sync/atomic
  • Исходники: src/sync/mutex.go, src/sync/rwmutex.go, src/sync/pool.go — режим голодания, приоритет писателя, victim cache в комментариях.
  • Data Race Detector: https://go.dev/doc/articles/race_detector — как работает TSan, опции GORACE, ограничения.
  • golang.org/x/sync: https://pkg.go.dev/golang.org/x/syncerrgroup, semaphore, singleflight.
  • Diagnostics: https://go.dev/doc/diagnostics — mutex/block-профили, go tool trace, GOTRACEBACK.

Список исходных вопросов с привязкой к компаниям: ../questions/sync.md