12. Продвинутая конкурентность

«Параллелизм — это не просто несколько вещей, происходящих одновременно; это координация действий в отсутствие глобального времени».
— Лесли Лэмпорт


Архитектура выбора: sync.Map vs RWMutex + map

В стандартной библиотеке Go существует два базовых инструмента для конкурентного доступа к ассоциативным массивам. Выбор между ними определяется паттерном нагрузки на кэш-линии процессора:

flowchart TD
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    QPattern{"Каков профиль нагрузки на карту?"}:::entry
    QPattern -->|"Частые параллельные чтения (Read-heavy >90%) И стабильный набор ключей"| UseSyncMap["sync.Map (lock-free чтение из readOnly)"]:::success
    QPattern -->|"Множество непересекающихся ключей у горутин"| UseSyncMap
    QPattern -->|"Частые вставки и модификации (Write-heavy >20%)"| UseRWMutex["RWMutex + стандартная map"]:::accent
    QPattern -->|"Строгая типизация без any и type assertions"| UseRWMutex
    QPattern -->|"Сверхвысокая конкуренция на запись (High Concurrency)"| ShardedMap["Шардированная карта (ConcurrentMap / N бакетов с мьютексами)"]:::warning
  

Алгоритм неблокирующей синхронизации (Lock-Free CAS Loop)

Lock-free структуры данных в Go строятся на базе аппаратных инструкций Compare-And-Swap (CMPXCHG на x86-64, LDREX/STREX или CAS на ARM64) пакета sync/atomic:

flowchart TD
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    Start["Старт итерации: чтение текущего значения (atomic.Load)"]:::entry --> Calc["Вычисление нового состояния локально в регистрах"]:::process
    Calc --> CAS{"atomic.CompareAndSwap(ptr, old, new)"}:::accent
    CAS -->|"Success: память не менялась другими потоками"| Done["Успешный коммит без блокировки"]:::success
    CAS -->|"Fail: значение изменено другой горутиной"| Backoff["Сброс или адаптивная пауза (CPU PAUSE)"]:::warning
    Backoff --> Start
  

Внутренний жизненный цикл sync.Pool и связь с GC

Пул объектов sync.Pool снижает нагрузку на сборщик мусора за счет локальных очередей логических процессоров P и двухфазной очистки (Victim Cache):

flowchart TD
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    Req["Вызов pool.Get()"]:::entry --> PLocal{"Есть объект в P.private?"}:::process
    PLocal -->|"Да (без блокировок!)"| RetP["Возврат объекта"]:::success
    PLocal -->|"Нет"| PShared{"Есть объект в P.shared?"}:::process
    PShared -->|"Да (быстрый spinlock)"| RetS["Возврат объекта"]:::success
    PShared -->|"Нет"| Steal{"Кража из других P (work stealing)?"}:::accent
    Steal -->|"Да"| RetSt["Возврат украденного"]:::success
    Steal -->|"Нет"| Victim{"Есть в victim cache (прошлый GC)?"}:::warning
    Victim -->|"Да"| RetV["Возврат из victim"]:::success
    Victim -->|"Нет"| NewObj["Вызов фабрики Pool.New()"]:::process

    subgraph GCAction["Фаза Stop-The-World в начале GC"]
        FlushVictim["Уничтожение старого victim cache"]:::warning --> MoveShared["Перенос всех P.local в victim cache"]:::accent
    end
  

  1. Что такое Mutex?
  2. Как работает Mutex на низком уровне (состояния, семафор)?
  3. Что такое RWMutex?
  4. Когда использовать RWMutex?
  5. Что такое голодание (starvation)?
  6. Что такое взаимная блокировка (deadlock)?
  7. Что такое livelock?
  8. Что такое atomic?
  9. Когда использовать atomic?
  10. Что такое lock-free структуры данных?
  11. Как работает atomic.Value?
  12. Как работает atomic.Pointer (Go 1.19+)?
  13. Что такое sync.Map?
  14. Когда sync.Map лучше, чем map + Mutex?
  15. Как работает sync.Pool?
  16. Как sync.Pool взаимодействует с GC?
  17. Что такое sync.Once?
  18. Как работает sync.Cond?
  19. Как реализовать барьер (barrier) для горутин?
  20. Как реализовать «обед философов» на Go?
  21. Как реализовать producer-consumer с backpressure?
  22. Как работает lock-free стек или очередь на атомиках?
  23. Как реализовать паттерн «издатель-подписчик» (pub-sub)?
  24. Как избежать ложных пробуждений (spurious wakeups)?
  25. Как работает runtime.RWMutex и runtime.Mutex?
  26. Что такое "активная блокировка" (spinlock) и зачем она нужна?
  27. Как runtime.futex используется в Go?
  28. Как работает "семафор" (runtime.sema) внутри Go?
  29. Как реализовать TryLock в Go 1.18+?
  30. Что такое "lock ordering" и как она предотвращает дедлоки?
  31. Как дебажить дедлоки с помощью GODEBUG или трассировки?
  32. Как работает sync.WaitGroup с точки зрения атомарных операций?
  33. В чем разница между atomic.Store и atomic.CompareAndSwap?
  34. Как работает "CAS-цикл"?
  35. Как реализовать «параллельный map-filter-reduce»?
  36. Как управлять параллелизмом (concurrency) с помощью семафора?
  37. Как реализовать «Dining Cryptographers» на Go?
  38. Как работает "семафор с приоритетом" в Go runtime?
  39. Что такое "канал с динамическим буфером" и как его реализовать?
  40. Как работает errgroup.Group и как он упрощает управление горутинами?
  41. Что такое "structured concurrency" и как его достичь в Go?
  42. Как реализовать «двухфазный коммит» с помощью каналов и мьютексов?
  43. Как обрабатывать "back-pressure" в системе с высоким параллелизмом?
  44. Как работает sync/rwmutex.go в части предотвращения starvation писателей?
  45. Как sync.Cond.Wait реализован и почему он требует захваченный мьютекс?
  46. Как реализовать "flow control" с помощью каналов?
  47. Что такое "memory model" с точки зрения атомарных операций?
  48. Как race detector работает с atomic и unsafe?

Что такое Mutex?

Краткий ответ

sync.Mutex — примитив взаимного исключения, гарантирующий, что только одна горутина может войти в критическую секцию кода. Предоставляет методы Lock() и Unlock(). Построен на основе внутреннего состояния и семафора рантайма для блокировки ожидающих горутин.

Развёрнутый ответ

Mutex защищает общие данные от гонок, обеспечивая сериализацию доступа. В Go mutex не рекурсивен: повторный Lock из той же горутины вызывает фатальную ошибку, что предотвращает баги с самоблокировкой. Идиоматически его используют с defer mu.Unlock() сразу после блокировки, чтобы гарантировать освобождение даже при панике.

Модель работы:

  • При вызове Lock(): если мьютекс свободен, горутина захватывает его атомарно (состояние 0 → 1).
  • Если занят, горутина переходит в режим ожидания (spin → yield → семафор).
  • Unlock() атомарно сбрасывает состояние; если есть ожидающие, пробуждает одну из них через семафор.

Мьютекс обеспечивает свойства взаимного исключения и прогресса, но сам по себе не гарантирует справедливость без starvation mode.

Под капотом (internals)

Структура sync.Mutex:

Go
type Mutex struct {
    state int32  // младший бит: locked/unlocked; второй бит: woken; остальные — число ожидающих горутин
    sema  uint32 // семафор для блокировки
}

Алгоритм:

  • Быстрый путь (fast path): CAS state с 0 на 1. Если успех — захвачен.
  • Медленный путь: runtime_SemacquireMutex(&m.sema, ...) приостанавливает горутину.
  • Релиз: CAS с 1 на 0; если state>>1 != 0 (есть ожидающие), runtime_Semrelease(&m.sema) пробуждает одну горутину.
  • В режиме голодания (starvation mode) пробудившаяся горутина сразу захватывает мьютекс, не соревнуясь с новыми претендентами.

Типичные ошибки кандидатов

  • Повторный Lock: mu.Lock(); mu.Lock() → deadlock. sync.Mutex не рекурсивен. Если нужна рекурсия — пересмотрите дизайн или используйте собственный рекурсивный мьютекс с учётом рисков.
  • Отсутствие Unlock при панике: mu.Lock() без defer mu.Unlock() — при панике состояние остаётся locked, останавливая всех конкурентов.
  • Копирование Mutex: передача по значению копирует внутреннее состояние, ломая синхронизацию. Всегда передавайте по указателю (*sync.Mutex).
  • Использование Mutex после Unlock в другой горутине: Unlock незаблокированного мьютекса — паника. Требуется строгое соответствие Lock/Unlock в одной горутине.

Возможные дополнительные вопросы интервьюера

  • Почему sync.Mutex не рекурсивен? Рекурсивные мьютексы усложняют анализ блокировок, скрывают ошибки декомпозиции. Гоферу предлагается явно управлять состоянием, избегая повторного входа.
  • Как работает пробуждение ожидающих горутин? Семафор sema разблокирует одну горутину, но из-за эффекта гонок может произойти «ложное пробуждение» (spurious wakeup), поэтому горутина перепроверяет состояние mutex в цикле.

Как работает Mutex на низком уровне (состояния, семафор)?

Краткий ответ

Внутри sync.Mutex — 32-битное слово состояния и семафор. Младший бит — флаг блокировки, второй — флаг пробуждения, остальные — счётчик ожидающих. Реализация использует комбинацию spin-ожидания и блокировку через семафор рантайма (runtime.sema), а также режим голодания для предотвращения долгого ожидания.

Развёрнутый ответ

Состояние делится на:

  • mutexLocked (бит 0): 1 — захвачен.
  • mutexWoken (бит 1): 1 — горутина пробуждена, чтобы избежать накопления пробуждений.
  • mutexStarving (бит 2): 1 — мьютекс в режиме голодания.
  • mutexWaiterShift (биты 3–31): количество ожидающих горутин.

Алгоритм Lock() (упрощённо):

  1. Быстрое CAS(0, mutexLocked). Если удачно — выход.
  2. В цикле: если мьютекс locked и не в режиме голодания, горутина пытается spin (вращается до 4 циклов на P). Это ускоряет захват при коротких критических секциях.
  3. Если spin не помог, атомарно увеличивает счётчик ожидающих и вызывает runtime_SemacquireMutex(&m.sema), переводя горутину в сон.
  4. При пробуждении: если starvation mode — безусловно захватывает; иначе борется за захват с новыми горутинами.

Unlock():

  1. Снимает флаг блокировки (CAS locked → 0).
  2. Если есть ожидающие, вызывает runtime_Semrelease, который пробуждает голову очереди семафора.

Под капотом (internals)

Семафор runtime.sema — это таблица wait-записей, индексированная адресом sema поля mutex. Используется futex на Linux (или semaphore на macOS). runtime_Semacquire атомарно записывает горутину в очередь ожидания и вызывает gopark. runtime_Semrelease ставит горутину в очередь готовых. При starvation mode счётчик ожидающих проверяется и при превышении 1e6 нс ожидания (по умолчанию) включается флаг mutexStarving.

Исходники: src/sync/mutex.go, src/runtime/sema.go.

Типичные ошибки кандидатов

  • Непонимание режима голодания: кандидаты часто упускают, что starvation mode меняет логику захвата: пробуждённая горутина получает мьютекс без конкуренции, предотвращая вечное ожидание.
  • Ожидание, что spin гарантирует захват: spin — оптимизация, после нескольких попыток горутина всё равно уходит в сон. Нельзя строить логику, зависящую от spin.
  • Предположение о порядке пробуждения: семафор не FIFO в чистом виде; очередность зависит от планировщика и структуры трекеров, хотя starvation mode добавляет приоритет ожидавшим.

Возможные дополнительные вопросы интервьюера

  • Какие преимущества даёт spin-оптимизация? Позволяет избежать дорогих системных вызовов пробуждения/засыпания для коротких критических секций, характерных для Go-программ.
  • Когда мьютекс переходит в starvation mode? Если горутина ожидает больше 1 мс, она взводит флаг и при пробуждении получает мьютекс без борьбы, после чего starvation mode выключается при отсутствии других ожидающих.

Что такое RWMutex?

Краткий ответ

sync.RWMutex — мьютекс с разделяемым чтением и эксклюзивной записью. Несколько горутин могут одновременно держать RLock (читать), но только одна — Lock (писать). Писатель ждёт завершения всех читателей; новые читатели блокируются, если есть ожидающий писатель, чтобы предотвратить starvation писателя.

Развёрнутый ответ

Интерфейс:

  • Lock(), Unlock() — эксклюзивный захват.
  • RLock(), RUnlock() — разделяемый захват.

Политика предпочтения писателя (write-preferring): когда приходит писатель, он останавливает поступление новых читателей, но должен дождаться завершения активных читателей. Это предотвращает бесконечное ожидание писателя при непрерывном потоке читателей (starvation писателя).

Используется когда операции чтения значительно преобладают над записью, например, кеш конфигурации, реестр сервисов.

Под капотом (internals)

Структура:

Go
type RWMutex struct {
    w           Mutex  // защищает состояние
    writerSem   uint32 // семафор ожидания писателя
    readerSem   uint32 // семафор ожидания читателя
    readerCount int32  // количество активных читателей (или отрицательное, если писатель ожидает)
    readerWait  int32  // количество читателей, которых нужно дождаться перед записью
}

RLock() увеличивает readerCount атомарно. Если readerCount < 0 (есть ожидающий писатель), горутина блокируется на readerSem. RUnlock() уменьшает readerCount; если стало 0 и есть ожидающий писатель, сигнализирует writerSem.

Lock(): захватывает w (внутренний mutex), затем атомарно делает readerCount отрицательным (вычитает rwmutexMaxReaders), чтобы блокировать новых читателей, сохраняя исходное значение в readerWait. Далее ждёт на writerSem пока readerWait не станет 0 (все активные читатели завершились). Всё это под защитой w.

Типичные ошибки кандидатов

  • Забывают, что RWMutex не рекурсивен: попытка Lock() после RLock в той же горутине → deadlock (писатель ждёт читателя, который бесконечно ждёт). Решение: никогда не повышать блокировку с чтения до записи без явного сброса.
  • Копирование RWMutex по значению: аналогично Mutex, копия разрушает синхронизацию.
  • Использование RWMutex при коротких критических секциях: оверхед учёта читателей может превысить выгоду от параллелизма, если операции записи часты. Лучше использовать sync.Mutex.
  • Неправильный расчёт readerCount: кандидаты могут ожидать, что readerCount всегда >=0, забывая о маркере ожидающего писателя (становится отрицательным плюс смещение).

Возможные дополнительные вопросы интервьюера

  • Как реализован алгоритм предпочтения писателя? Писатель делает readerCount отрицательным (вычитает большую константу), новые читатели видят отрицательный счётчик и блокируются, а старые продолжают работу до завершения.
  • Можно ли реализовать read-preferring или fair RWMutex? Да, чередуя флаги приоритета, но стандартная библиотека выбрала write-preferring для предотвращения голодания писателя.

Когда использовать RWMutex?

Краткий ответ

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

Развёрнутый ответ

Критерии выбора:

  • Частота и длительность операций: RLock добавляет атомарное инкрементирование/декрементирование и возможные contention на внутреннем w. Если сама операция чтения — несколько наносекунд (например, чтение одного указателя), накладные расходы могут превысить выгоду. Используйте бенчмарки.
  • Наличие блокирующих операций внутри критической секции чтения: нельзя, т.к. это увеличит задержку писателя.
  • Схема доступа: если записи частые (например, каждая 5‑я операция), то постоянное ожидание писателя приведёт к тому, что читатели будут блокироваться, и параллелизм сводится на нет.
  • Мораль: "Measure, don’t guess".

Под капотом (internals)

RWMutex хранит readerCount и readerWait для координации. Из-за внутреннего Mutex w при вызове Lock писатель должен захватить этот мьютекс, что добавляет дополнительную точку contention среди писателей и потенциальных читателей при старте/завершении.

Типичные ошибки кандидатов

  • Применение к простым переменным: защита int64 через RWMutex там, где atomic.Int64 даст лучшую производительность.
  • Игнорирование contention на внутреннем мьютексе: если читателей очень много, они соревнуются за дешёвые атомарные операции, но при старте/стопе может возникнуть contention на w.
  • Использование в коде с частыми краткими записями: тогда параллелизм чтения минимален, а overhead высок.

Возможные дополнительные вопросы интервьюера

  • Как измерить эффективность RWMutex? Написать бенчмарк с типичным распределением операций, сравнить throughput и latency с простым Mutex, проанализировать с помощью go tool trace и профилировщика contention.
  • Есть ли альтернативы RWMutex в стандартной библиотеке? sync.Map для определённых кейсов, атомики для отдельных значений, каналы для передачи владения.

Что такое голодание (starvation)?

Краткий ответ

Голодание (starvation) — ситуация, когда горутина не может захватить мьютекс в течение длительного времени, потому что его непрерывно перехватывают другие горутины. sync.Mutex решает это специальным режимом голодания (starvation mode), в котором пробуждённая горутина получает мьютекс без конкуренции.

Развёрнутый ответ

В обычном режиме после Unlock пробуждённая горутина конкурирует с новыми Lock(). Если приходят новые горутины с быстрым захватом (например, на локальном CPU), пробуждённая может проигрывать гонку бесконечно. Режим голодания прерывает этот цикл: если горутина ждёт больше порога (1 мс), она включает starvation; при разблокировке мьютекс отдаётся непосредственно ожидающей горутине (ручная передача владения).

Starvation mode поддерживается, пока есть ещё ожидающие в очереди, и автоматически отключается, когда последняя ожидающая горутина захватывает mutex и больше никого нет.

Под капотом (internals)

В sync.Mutex поле state содержит бит mutexStarving. При Lock() медленного пути проверяется, не превысило ли время ожидания 1e6 нс. Если да, и текущий mutex locked, горутина взводит бит starvation. При Unlock(), если starvation активен, мьютекс переводится напрямую в locked состояние и передаётся голове очереди без CAS, гарантируя захват.

Типичные ошибки кандидатов

  • Считать, что Go-мьютексы абсолютно справедливы: без starvation mode возможна несправедливость. Кандидат должен знать о специальном режиме.
  • Полагать, что starvation mode включается сразу при любой задержке: есть порог 1 мс, не каждый случай ожидания вызывает starvation.
  • Вручную пытаться компенсировать голодание yield-ом или sleep: на микробенчмарках это иногда помогает, но в продакшене усугубляет ситуацию — доверьтесь рантайму.

Возможные дополнительные вопросы интервьюера

  • Как определить, что в системе происходит голодание? Профилировщик мьютексов (Mutex profile, go tool pprof) показывает время ожидания, или можно анализировать метрики таймингов.
  • Влияет ли GOMAXPROCS на starvation? Да, при большом числе P и высокой конкуренции вероятность голодания растёт, так как больше горутин могут параллельно выполнять быстрый захват.

Что такое взаимная блокировка (deadlock)?

Краткий ответ

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

Развёрнутый ответ

В Go deadlock может возникнуть, например, при неправильном порядке блокировки нескольких мьютексов, при блокировке канала, из которого никто не читает, или при использовании sync.WaitGroup с неправильным Add/Дone. Рантайм Go имеет встроенный детектор deadlock-ов для горутин, но только когда все горутины заблокированы, выводится фатальная ошибка "all goroutines are asleep - deadlock!". В общем случае частичный deadlock (не все горутины заблокированы) не детектируется.

Под капотом (internals)

Когда все горутины находятся в состоянии ожидания (системные вызовы, семафоры, каналы), планировщик проверяет, есть ли возможность прогресса. Если нет, вызывается throw("all goroutines are asleep - deadlock!"). Механизм основан на счётчиках паркующихся горутин, а не на анализе графа ожидания. Поэтому он не ловит deadlock в подсистеме, когда другие горутины продолжают работать.

Типичные ошибки кандидатов

  • Не тот порядок Lock(): горутина A: mu1.Lock(); mu2.Lock(), горутина B: mu2.Lock(); mu1.Lock() → циклическое ожидание. Решение: всегда следовать единому порядку (lock ordering), либо использовать sync.Cond, либо каналы.
  • Забытый Unlock на одном из путей: deadlock при попытке повторного захвата. Решение: defer или структурированный код.
  • Отправка в небуферизированный канал без получателя: горутина навсегда зависает. Требуется либо буферизация, либо select с default/таймаутом.
  • Deadlock с sync.WaitGroup: wg.Add(1) внутри горутины после wg.Wait() → зависание.

Возможные дополнительные вопросы интервьюера

  • Можно ли детектировать deadlock статически? Да, анализаторы типа go vet или golangci-lint с правилами порядка мьютексов могут находить типичные паттерны, но не все.
  • Как избежать deadlock при использовании нескольких каналов? Использовать select для мультиплексирования, избегать вложенных блокировок каналов, устанавливать таймауты.

Что такое livelock?

Краткий ответ

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

Развёрнутый ответ

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

Отличие от deadlock: при deadlock горутины спят, при livelock — потребляют CPU, но безрезультатно. Лечится добавлением случайной задержки (backoff), таймаутами или сменой алгоритма.

Под капотом (internals)

В стандартной библиотеке Go нет встроенного детектора livelock-а. Планировщик видит, что горутины готовы к выполнению, и продолжает их планировать. Это приводит к высокой загрузке CPU без прогресса. Обнаружить можно через runtime/trace и профилирование, наблюдая аномальное количество повторных CAS-операций.

Типичные ошибки кандидатов

  • Неограниченные CAS-циклы без backoff: например, при реализации spinlock без runtime.Gosched() или exponential backoff. Нужно добавлять задержку, чтобы дать шанс другим горутинам завершить работу.
  • Слишком агрессивное освобождение ресурсов при конфликте: алгоритмы типа "захватил — вижу конфликт — отпускаю", могут привести к livelock. Необходимо добавить случайную паузу или приоритет.
  • Использование tryLock в бесконечном цикле: постоянный mu.TryLock() без паузы — прямой путь к livelock.

Возможные дополнительные вопросы интервьюера

  • Как отличить livelock от высокого contention? Livelock не даёт прогресса вообще; contention снижает throughput, но прогресс есть. Анализируют метрики успешных операций: при livelock они нулевые.
  • Какие инструменты помогут диагностировать livelock? Профилировщик CPU (top функций с высоким потреблением, много CAS-провалов), трассировка конкуренции, добавление метрик попыток/успехов.

Что такое atomic?

Краткий ответ

Пакет sync/atomic предоставляет атомарные операции над примитивными типами и указателями, которые выполняются без прерывания другими горутинами. Они гарантируют последовательную согласованность (sequentially consistent) для выбранных операций, что делает их основой для lock-free программирования.

Развёрнутый ответ

Атомарность означает, что операция неделима. В Go atomic предоставляет функции типа AddInt64, LoadInt64, StoreInt64, CompareAndSwapInt64. Начиная с Go 1.19 появились типы-обёртки atomic.Int64, atomic.Bool и т.д., упрощающие использование. Атомики не используют мьютексы и работают на уровне процессорных инструкций (например, LOCK префикс на x86). Они обеспечивают упорядочивание памяти: операция Load имеет acquire-семантику, Store — release, а CAS — полный барьер (sequentially consistent по умолчанию).

Под капотом (internals)

Компилятор Go транслирует вызовы atomic в соответствующие аппаратные инструкции с барьерами памяти. На x86 используется LOCK префикс (например, LOCK XADD). На ARM — LDREX/STREX для CAS-петли. Рантайм не вмешивается. Переменные, используемые с atomic, должны быть выровнены в памяти в соответствии с требованиями архитектуры (для 64-битных значений на 32-битных платформах особенно важно, компилятор и escape analysis помогают).

Типичные ошибки кандидатов

  • Смешивание атомарных и неатомарных доступов: если одна горутина пишет через atomic.Store, а другая читает обычным присваиванием, возникает data race (гонка данных). Все доступы к переменной должны быть атомарными.
  • Непонимание гарантий памяти: некоторые считают, что атомики автоматически создают "happens-before" для всех переменных. Это верно только для операций с sequentially consistent заказом, и то в рамках цепочки синхронизации.
  • Использование atomic для сложных структур данных без должной подготовки: например, атомарная замена указателя на структуру не делает её внутренности атомарно изменяемыми.
  • Забывают про выравнивание на 32-битных платформах: atomic.LoadInt64 на невыровненном адресе вызывает панику, поэтому нужно использовать int64 как поле структуры с выравниванием или через atomic.Int64 структуру, где выравнивание гарантируется.

Возможные дополнительные вопросы интервьюера

  • Каковы отличия между atomic.Store и обычным присваиванием с мьютексом? atomic.Store не блокирует другие горутины, обеспечивает атомарность и барьеры памяти, но защищает только одну переменную.
  • Какую семантику памяти обеспечивает atomic.CompareAndSwap? Полный sequentially consistent барьер, то есть все операции до CAS видны операциям после, плюс сама атомарная замена.

Когда использовать atomic?

Краткий ответ

Используйте atomic, когда нужно атомарно обновить одну переменную (флаг, счётчик, указатель) без блокировок, и операции очень быстрые. Мьютекс предпочтительнее для защиты композитного состояния или когда критическая секция содержит несколько операций.

Развёрнутый ответ

Сценарии применения:

  • Счётчики запросов, активных соединений.
  • Флаги состояния (завершение работы, пауза) — atomic.Bool.
  • Lock-free списки, стеки (требуют CAS).
  • Кеш с атомарным обновлением указателя на новый объект.
  • Реализация sync.WaitGroup, sync.Once внутри.

Когда НЕ использовать: если нужно изменить два связанных поля атомарно — только мьютекс или lock-free структура посложнее. Также атомики не помогают при долгих операциях (не ждут).

Под капотом (internals)

Процессорные инструкции с атомарностью могут быть дороже обычных из-за блокировки шины/кеша, но всё равно значительно быстрее системных вызовов, которые могут потребоваться при contention на мьютексе. На x86 CAS включает полный барьер памяти, что дороже обычного store.

Типичные ошибки кандидатов

  • Использование атомиков для замены мьютекса в сложной логике: пример — попытка атомарно обновить два поля структуры через два атомика, нарушая инварианты. Ошибка: гонка данных между обновлениями.
  • Недооценка стоимости на ARM: на слабых моделях памяти атомарные операции могут быть значительно дороже.
  • Избыточное применение: программа становится трудночитаемой без веской причины.

Возможные дополнительные вопросы интервьюера

  • Можно ли атомарно увеличить два счётчика одновременно? Прямо нет, только если упаковать их в одно 64-битное слово и использовать CAS с манипуляцией битами, либо мьютекс.
  • Чем atomic лучше sync.Mutex для простого счётчика? Отсутствие блокировок даёт предсказуемую латентность и масштабируемость при высоком параллелизме.

Что такое lock-free структуры данных?

Краткий ответ

Lock-free структуры гарантируют, что в любой момент времени хотя бы одна горутина прогрессирует, без использования блокировок. Реализуются на атомарных CAS-циклах. В Go встречаются реже из-за простоты каналов и мьютексов, но полезны для высоконагруженных систем (стеки, очереди).

Развёрнутый ответ

Lock-free ≠ wait-free. Lock-free означает, что за конечное число шагов система в целом продвигается, но конкретная горутина может откатываться. Wait-free гарантирует прогресс каждой горутины за ограниченное число шагов. Типичные lock-free структуры: стек Трейбера, очередь Майкла-Скотта. Реализуются через атомарные CAS на голове/хвосте. В Go пакет sync включает lock-free-подобные WaitGroup и Once. Писать свои lock-free структуры сложно из-за проблем ABA, управления памятью и слабой модели памяти.

Под капотом (internals)

CAS в Go — это полный барьер. Lock-free структуры опираются на циклические попытки CAS, часто с backoff. В Go для указателей используется unsafe.Pointer с atomic.CompareAndSwapPointer (или atomic.Pointer). Проблема ABA решается через счётчики или tagged pointers. Go GC не перемещает объекты, поэтому указатели стабильны, что упрощает lock-free программирование.

Типичные ошибки кандидатов

  • Некорректное решение проблемы ABA: например, при повторном использовании указателя без версионного счётчика CAS может ложно сработать, нарушая структуру.
  • Неверное управление памятью: после извлечения узла из lock-free стека при конкурентном доступе другой поток может освободить его, вызывая use-after-free.
  • Забытый exponential backoff: может вызвать livelock при высоком contention.
  • Использование lock-free ради lock-free: в большинстве случаев sync.Mutex достаточно и безопаснее.

Возможные дополнительные вопросы интервьюера

  • Как Go решает проблему ABA? Стандартных средств нет, нужно вручную добавлять счётчик к указателю (теневой указатель). atomic.Pointer не защищает от ABA.
  • Почему lock-free структуры не всегда быстрее lock-based? Высокое contention может вызывать постоянные повторные CAS-циклы и degraded-производительность.

Как работает atomic.Value?

Краткий ответ

atomic.Value обеспечивает атомарное хранение и загрузку значения произвольного типа (interface{}). Первое сохранение задаёт конкретный тип; последующие должны быть того же типа. Использует внутри ifaceWords и атомарные операции над указателями данных и таблицы интерфейса.

Развёрнутый ответ

Интерфейс:

  • Store(val any) — атомарно записывает значение.
  • Load() any — возвращает сохранённое значение.

Реализация гарантирует, что после успешного Store все последующие Load увидят это значение. При одновременных Store без синхронизации результат непредсказуем; тип фиксируется после первого Store. Пример: конфигурационный объект.

Go
var config atomic.Value
config.Store(myConfig)
cfg := config.Load().(Config)

Под капотом (internals)

atomic.Value хранит any как пару из (type, data). Внутренняя структура:

Go
type Value struct {
    v any
}

На деле использует efaceWords (адреса полей интерфейса), и атомарно обновляет оба поля с помощью atomic.StorePointer. Атомарность достигается через упорядочивание записи: сначала data, потом typ (или наоборот, в зависимости от реализации). Проверка типа выполняется сравнением typ-указателей при Store.

Типичные ошибки кандидатов

  • Изменение типа после первого Store: приведёт к панике. Необходимо всегда хранить значения одного типа.
  • Сохранение nil первым Store: первый Store(nil) допускается, но тогда тип фиксируется как nil, и последующие Store не-nil значений с конкретным типом вызовут панику.
  • Гонка при конструировании через Load: если загрузили указатель на mutable объект и изменили его, это data race. Нужно хранить неизменяемые объекты или копии.

Возможные дополнительные вопросы интервьюера

  • Можно ли использовать atomic.Value для хранения map? Да, но map — ссылочный тип; при изменении содержимого после сохранения будет гонка. Лучше хранить неизменяемую структуру с map или копию.
  • Чем отличается atomic.Value от any с мьютексом? Не требует блокировки для чтения, что даёт масштабируемость, но тип фиксирован и нет блокировки при обновлении.

Как работает atomic.Pointer (Go 1.19+)?

Краткий ответ

atomic.Pointer[T] — generic-обёртка над указателем, предоставляющая атомарные Load, Store и CompareAndSwap без unsafe-приведений. Она заменяет старые функции atomic.LoadPointer/StorePointer и улучшает типобезопасность.

Развёрнутый ответ

Объявление:

Go
type Pointer[T any] struct {
    _ noCopy
    v unsafe.Pointer
}

Методы: Load() *T, Store(val *T), Swap(new *T) (old *T), CompareAndSwap(old, new *T) bool. Позволяет атомарно обновлять указатель на любой тип. Очень удобна для lock-free структур и кешей.

Пример:

Go
type Config struct { ... }
var curConfig atomic.Pointer[Config]
curConfig.Store(&Config{...})
cfg := curConfig.Load()

Под капотом (internals)

Использует unsafe.Pointer внутри и транслируется в те же атомарные инструкции. Благодаря генерации кода для конкретного типа (мономорфизация) устраняется overhead интерфейсов. На уровне рантайма — те же runtime/internal/atomic функции.

Типичные ошибки кандидатов

  • Изменение данных по указателю после Store: тот же data race, что и с atomic.Value. Решение: хранить иммутабельные объекты или копии.
  • Копирование atomic.Pointer структуры: поле noCopy запрещает копирование, но статический анализ может не сработать. Не копируйте.
  • Использование указателя на локальную переменную: после выхода из функции указатель висячий. Нужно выделять в куче.

Возможные дополнительные вопросы интервьюера

  • Зачем нужен noCopy? Чтобы предотвратить случайное копирование структуры (например, передача по значению), которое нарушило бы атомарность, т.к. каждая копия имела бы своё поле v.
  • Как atomic.Pointer связан с unsafe.Pointer? Является безопасной обёрткой, внутри преобразуется в unsafe.Pointer для вызова атомарных операций.

Что такое sync.Map?

Краткий ответ

sync.Map — конкурентная map, оптимизированная для случаев, когда ключи стабильны (записываются единожды, а читаются многократно) или когда операции записи и чтения слабо пересекаются. Не требует отдельного мьютекса, использует внутреннюю сегментацию: read-only map (амортизированный доступ) и dirty map с мьютексом.

Развёрнутый ответ

Это не просто map+Mutex. Структура содержит:

  • read — atomic.Pointer[readOnly], содержащая read-only map и флаг amended.
  • dirty — map[any]*entry, содержит все элементы, не попавшие в read (создаётся лениво).
  • mu — Mutex для защиты dirty.

Алгоритм:

  • Load сначала ищет в read (без блокировок). Если нет и amended=true, блокируется и проверяет dirty.
  • Store (по новому ключу) блокирует mu, вносит в dirty, и если read сильно отличается, промотирует dirty в read.
  • Delete помечает entry как nil/expunged.

Выигрыш возникает при стабильном read, так как обращения без блокировок.

Под капотом (internals)

entry — указатель на interface{}. Состояние: nil — мягкое удаление, expunged — жёсткое удаление. При Load атомарно читается entry. Промоция dirty → read происходит при промахах в read, когда amended=true и требуется запись или при достижении порога. Механизм описан в src/sync/map.go.

Типичные ошибки кандидатов

  • Использование sync.Map как замены обычной map+Mutex всегда: для большинства сценариев с частой записью это медленнее из-за двойного учёта и промоций.
  • Непонимание, что Range может пропускать ключи: при одновременных изменениях Range выдаёт снимок на момент начала, но допускается недетерминизм.
  • Хранение указателей на mutable структуры: гонки внутри структуры остаются.
  • Использование sync.Map с ключами-указателями, значение которых меняется: без хеширования производительность деградирует.

Возможные дополнительные вопросы интервьюера

  • В чём отличие sync.Map от custom map+Mutex при высокой конкуренции записи? sync.Map показывает хуже производительность из-за частых блокировок и копирования dirty.
  • Как реализовать аналог sync.Map с дженериками? Можно использовать atomic.Pointer на неизменяемую map + copy-on-write, но управление конкуренцией сложное.

Когда sync.Map лучше, чем map + Mutex?

Краткий ответ

sync.Map предпочтительнее, когда большинство операций — чтение существующих ключей, а запись происходит редко или в отдельной фазе; либо когда много горутин читают непересекающиеся наборы ключей, а dirty-промоция амортизируется. В противном случае обычная map+Mutex/RWMutex эффективнее.

Развёрнутый ответ

Критерии:

  • Ключи преимущественно статичны, запись — один раз, чтение — часто. Например, кеш конфигурации сервиса, загружаемый при старте.
  • Множество горутин читают, а запись происходит только из одной горутины (редко).
  • В остальных случаях — map + sync.RWMutex с write-preferring, или sync.Mutex при частых записях.

Есть неочевидный случай: при большом количестве ядер и редко обновляемых ключах, sync.Map может быть быстрее из-за отсутствия contention даже на RWMutex.RLock (который содержит атомарный инкремент).

Под капотом (internals)

При частом добавлении новых ключей dirty map растёт, блокировки становятся длительными, и sync.Map деградирует. При редком обновлении read map не dirty, и все чтения свободны от блокировки.

Типичные ошибки кандидатов

  • Выбор sync.Map "на всякий случай": приводит к снижению производительности.
  • Ожидание, что Range обойдёт все ключи как в фиксированный момент: конкурентные записи могут быть не видны во время итерации.
  • Использование там, где важен порядок: sync.Map не гарантирует порядок обхода.

Возможные дополнительные вопросы интервьюера

  • Можно ли заставить sync.Map работать быстрее, чем map+Mutex при частых записях? Нет, его алгоритм оптимизирован под чтение.
  • Как промотировать dirty в read? При промахе в read и наличии amended, после блокировки проверяется dirty; при последующих операциях, если dirty превышает read, оно поднимается.

Как работает sync.Pool?

Краткий ответ

sync.Pool — пул временных объектов, позволяющий переиспользовать аллокации и снизить нагрузку на GC. Не гарантирует сохранность объектов между вызовами; может в любой момент очистить пул. Состоит из local-пулов на каждый P (процессор планировщика) и общей очереди.

Развёрнутый ответ

Использование:

  • Get() any — извлекает объект из пула или создаёт новый через New().
  • Put(x any) — возвращает объект в пул.

Важно: после Put нельзя использовать объект, ибо его может извлечь другая горутина. Пул автоматически очищается при каждом GC (до Go 1.13 — полностью, с 1.13 — частично, удерживая один элемент на P для снижения аллокаций). Применяется для буферов (bytes.Buffer), временных структур.

Под капотом (internals)

Структура:

Go
type Pool struct {
    noCopy noCopy
    local     unsafe.Pointer // [P]poolLocal
    localSize uintptr
    victim     unsafe.Pointer // предыдущий local после GC
    victimSize uintptr
    New func() any
}
type poolLocal struct {
    poolLocalInternal
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte // снижение false sharing
}
type poolLocalInternal struct {
    private any       // быстрый путь без синхронизации (только текущий P)
    shared  poolChain // lock-free очередь для общего доступа
}

При Get/Put горутина сначала пытается работать с private. Если private пуст, то берет из головы shared-очереди своего P, затем — хвосты других P. Это lock-free. При GC victim становится предыдущим local, а новый local чист.

Типичные ошибки кандидатов

  • Хранение объектов с состоянием, требующих сброса: Get возвращает объект с неизвестным состоянием, нужно обязательно очищать перед использованием (сбросить поля).
  • Помещение объекта в Pool после закрытия ресурса: если положить закрытый файл/соединение, другой потребитель получит закрытый.
  • Ожидание, что объект в Pool будет жить вечно: GC может очистить пул.
  • Использование для дорогих объектов с низкой частотой переиспользования: накладные расходы на конкуренцию могут перекрыть выгоду.

Возможные дополнительные вопросы интервьюера

  • Как реализовано lock-free извлечение из других P? Через poolChain — lock-free очередь (ring buffer), кража работы (work stealing) аналогично планировщику горутин.
  • Почему добавили victim cache? Чтобы смягчить пиковые аллокации после GC: старый пул не выбрасывается сразу, а живёт ещё один цикл GC, обеспечивая плавное переиспользование.

Как sync.Pool взаимодействует с GC?

Краткий ответ

При каждой сборке мусора sync.Pool очищает пул: все объекты, не защищённые ссылками, становятся недостижимыми и могут быть собраны. Начиная с Go 1.13 добавлен двухуровневый victim-кэш: пул сохраняется на один цикл GC после очистки, чтобы сгладить пики аллокаций.

Развёрнутый ответ

До 1.13 каждый GC полностью очищал все poolLocal, что вызывало всплеск аллокаций. Теперь: при GC вызывается poolCleanup(), перемещая текущий local в victim, а local обнуляется. При следующем GC victim окончательно удаляется. При Get, если local пуст, горутина обращается к victim. Это позволяет пережить один сборщик мусора без потери объектов, если они всё ещё нужны.

Под капотом (internals)

Функция sync_runtime_registerPoolCleanup регистрирует poolCleanup в GC. Во время STW фазы очистки вызывается poolCleanup, которая для каждого P сдвигает poolChain из local в victim. Исходники: src/sync/pool.go.

Типичные ошибки кандидатов

  • Создание объекта через New при каждом Get из-за того, что GC почистил: нужно быть готовым к тому, что Get вернёт nil и придётся аллоцировать.
  • Неправильное размещение sync.Pool: если пул глобальный, но объекты долго используются вне Put, они не возвращаются до GC, увеличивая расход памяти.
  • Расчёт на сохранение объектов между GC без victim-кэша: код, написанный до 1.13, мог рассчитывать на сохранение, а после — поведение изменилось.

Возможные дополнительные вопросы интервьюера

  • Как часто вызывается poolCleanup? Срабатывает в начале каждого цикла GC (Sweep termination) и работает кратковременно.
  • Можно ли вручную запретить GC очищать pool? Нет, это противоречит концепции; для постоянного кеша используйте map.

Что такое sync.Once?

Краткий ответ

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

Развёрнутый ответ

Go
var once sync.Once
once.Do(func() { fmt.Println("init") })

Вызовы Do блокируются до завершения первого вызова, после чего все последующие вызовы сразу возвращаются без выполнения функции. Это атомарный флаг с мьютексом внутри.

Под капотом (internals)

Структура:

Go
type Once struct {
    done uint32
    m    Mutex
}

Алгоритм:

  1. Атомарно загрузить done. Если ==1, сразу выход.
  2. Захватить m.Lock().
  3. Дважды проверить done (double-checked locking).
  4. Выполнить f.
  5. Атомарно установить done = 1.
  6. m.Unlock().

Типичные ошибки кандидатов

  • Копирование sync.Once: копия сбрасывает done, что ведёт к повторному выполнению.
  • Использование Once как общего флага завершения: если нужно повторное использование — лучше atomic.Bool.
  • Паника внутри f: Once считает, что функция выполнена? Нет, done останется 0. Следующий Do вызовет f снова. Это может быть нежелательно; решается обёрткой с defer recover.

Возможные дополнительные вопросы интервьюера

  • Почему используют двойную проверку? Чтобы избежать блокировки в быстром пути после инициализации; без второй проверки возможна ситуация, когда две горутины одновременно видят done==0, одна захватывает мьютекс, а другая после пробуждения тоже захватывает и повторно запускает f.
  • Можно ли сбросить Once? Нет, стандартного способа нет; при необходимости можно создать кастомный тип с atomic.

Как работает sync.Cond?

Краткий ответ

sync.Cond — условная переменная, позволяющая горутинам ожидать наступления некоторого условия. Используется с sync.Mutex (или RWMutex): горутина атомарно освобождает мьютекс и засыпает; другая горутина может изменить условие и сигнализировать Signal() или Broadcast() для пробуждения.

Развёрнутый ответ

Сценарий: producer-consumer с условным ожиданием непустого буфера.

Go
cond := sync.NewCond(&sync.Mutex{})
cond.L.Lock()
for !condition() {
    cond.Wait()
}
// работа
cond.L.Unlock()

Wait() атомарно разблокирует L, приостанавливает горутину, при пробуждении снова захватывает L. Поэтому Wait обязательно вызывать в цикле, проверяя условие, из-за возможных ложных пробуждений.

Signal() пробуждает одну горутину, Broadcast() — все ожидающие.

Под капотом (internals)

sync.Cond содержит L Locker и список ожидающих горутин (notifyList). Wait() вызывает runtime_notifyListAdd и runtime_notifyListWait. Это взаимодействует с планировщиком, переводя горутину в ожидание. Реализация notifyList в src/runtime/sema.go.

Типичные ошибки кандидатов

  • Проверка условия без цикла for: ложное пробуждение или изменение условия до захвата мьютекса может привести к пропуску условия.
  • Забытый Signal: потребители могут навсегда зависнуть.
  • Использование Signal там, где нужен Broadcast, когда условие изменилось для всех ожидающих.
  • Передача RWMutex как Locker: Wait освобождает мьютекс, но RWMutex в режиме RLock не может быть освобождён таким образом (нельзя передать read locker).

Возможные дополнительные вопросы интервьюера

  • В чём отличие sync.Cond от channel? Каналы передают данные, Cond — сигнал; Cond более гибкое для сложных условий, но каналы легче и идиоматичнее для многих случаев.
  • Можно ли использовать Cond без мьютекса? Нет, NewCond требует Locker, и Wait опирается на него для защиты условия.

Как реализовать барьер (barrier) для горутин?

Краткий ответ

Барьер синхронизации заставляет группу горутин дождаться друг друга на определённой точке перед продолжением. В Go можно реализовать через sync.WaitGroup с дополнительной синхронизацией повторного использования, sync.Cond или канал с широким вещанием (broadcast).

Развёрнутый ответ

Пример с WaitGroup для однократного барьера:

Go
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
    go func() {
        // фаза 1
        wg.Done()
        wg.Wait() // здесь все дождались
        // фаза 2
    }()
}

Но это работает, только если мы уверены, что wg.Wait() вызывается после всех Done(). Для циклического барьера нужен самодельный Brrier со счётчиком и sync.Cond:

Go
type Barrier struct {
    n, count int
    mu       sync.Mutex
    cond     *sync.Cond
}

Фаза внутри горутины увеличивает count и, если count < n, вызывает cond.Wait(), иначе сбрасывает count и cond.Broadcast().

Под капотом (internals)

При cond.Wait горутина паркуется на внутреннем notifyList. Пробуждение через Broadcast переводит все ожидающие в готовность, но они будут соревноваться за mu.

Типичные ошибки кандидатов

  • Гонка при повторном использовании: если барьер используется в цикле, нужно правильно сбросить count. Часто забывают.
  • Deadlock при неравных вызовах: если одна горутина не дошла до барьера, остальные зависают.
  • Использование wg.Wait() до wg.Add(): порядок важен.

Возможные дополнительные вопросы интервьюера

  • Как реализовать reusable barrier с каналами? Можно использовать канал с буфером N и after: каждая горутина пишет в канал, закрыть канал не получится, но можно применить широковещательный канал замены.
  • В чём разница между барьером и WaitGroup? WaitGroup — счётчик завершения работ, одноразовый; барьер — точка синхронизации в середине выполнения, может быть многоразовым.

Как реализовать «обед философов» на Go?

Краткий ответ

Классическая задача о пяти философах и вилках решается в Go введением иерархии вилок (lock ordering), разрешением брать вилки только в определённом порядке, или использованием каналов как токенов, или ограничением числа одновременно обедающих (семафор). Это предотвращает deadlock.

Развёрнутый ответ

Пример с мьютексами и упорядочиванием (брать сначала вилку с меньшим индексом):

Go
type Philosopher struct {
    left, right *sync.Mutex
}
func (p *Philosopher) eat() {
    if p.left < p.right { // упорядочивание по адресам
        p.left.Lock(); p.right.Lock()
    } else {
        p.right.Lock(); p.left.Lock()
    }
    // ест
    p.right.Unlock(); p.left.Unlock()
}

Альтернатива — использовать семафор (канал с буфером) на N-1 мест, гарантируя, что не все сядут одновременно.

Под капотом (internals)

С мьютексами порядок устраняет цикл. С канальным семафором философ отправляет в канал перед взятием вилок; если канал полон, блокируется. Это эквивалентно ограничению параллелизма.

Типичные ошибки кандидатов

  • Симметричный захват left/right без порядка: deadlock неизбежен.
  • Захват с предварительным резервированием, но без таймаута: livelock при неудаче.
  • Использование глобального мьютекса: решает проблему, но убирает параллелизм.

Возможные дополнительные вопросы интервьюера

  • Как реализовать решение без иерархии, но с монитором? Можно завести официанта (горутину-арбитра), который получает запросы на вилки через каналы.
  • Можно ли решить с помощью sync.Cond? Да, когда философ ожидает доступности двух вилок, используя условную переменную, но это сложнее.

Как реализовать producer-consumer с backpressure?

Краткий ответ

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

Развёрнутый ответ

Простейший вариант:

Go
ch := make(chan Item, bufferSize)
// producer: пока ch <- item { ... } // блокируется, если буфер заполнен

Более тонкий вариант — consumer посылает acknowledgements или управляет размером окна:

Go
tokenCh := make(chan struct{}, maxInflight)
for _, item := range items {
    tokenCh <- struct{}{} // backpressure: ждём слот
    go func(item Item) {
        defer func() { <-tokenCh }()
        process(item)
    }(item)
}

Здесь горутины-обработчики уменьшают семафор, ограничивая параллелизм и создавая противодавление на создателя горутин.

Под капотом (internals)

Блокировка на канале использует семафор рантайма; планировщик вытесняет продюсера, снижая нагрузку.

Типичные ошибки кандидатов

  • Использование небуферизированного канала для быстрых продюсеров: приводит к постоянной блокировке и переключениям контекста, снижая пропускную способность.
  • Бесконечный рост очереди через make(chan T, huge): скрывает проблему, приводит к OOM.
  • Игнорирование завершения: контекст отмены не передаётся, горутины зависают.

Возможные дополнительные вопросы интервьюера

  • Как адаптировать backpressure в распределённой системе? Использовать протоколы типа Reactive Streams, credit-based flow control, или gRPC flow control.
  • Как мониторить backpressure? Через метрики длины канала (len(ch)), latency, частоту блокировок (tracer).

Как работает lock-free стек или очередь на атомиках?

Краткий ответ

Lock-free стек (стек Трейбера) реализуется через атомарный CAS на вершине. Lock-free очередь (очередь Майкла-Скотта) — на двух указателях (head, tail) с атомарным добавлением в tail и извлечением из head, с обработкой отставшего tail. В Go используются atomic.Pointer или unsafe.Pointer с CAS.

Развёрнутый ответ

Стек Трейбера:

Go
type node[T any] struct { value T; next *node[T] }
type Stack[T any] struct { top atomic.Pointer[node[T]] }
func (s *Stack[T]) Push(v T) {
    n := &node[T]{value: v}
    for {
        old := s.top.Load()
        n.next = old
        if s.top.CompareAndSwap(old, n) { break }
    }
}

Pop аналогично. Очередь Майкла-Скотта требует двух указателей и дополнительного состояния для корректного обновления tail на отстающих.

Под капотом (internals)

CAS в Go — полный барьер. Lock-free структуры полагаются на то, что failed CAS — не конец света, цикл повторяется. Из-за GC Go не требуется ручное освобождение памяти узлов, что упрощает, но проблема ABA остаётся.

Типичные ошибки кандидатов

  • Забывают про ABA: если указатель повторно используется, CAS может успешно сработать при кажущемся неизменном top, но next уже указывает на мусор. Решение — tagged pointer.
  • Игнорирование retry loop с backoff: при высоком contention CAS-цикл может крутиться бесконечно, потребляя CPU. Нужно добавить exponential backoff или runtime.Gosched().
  • Неправильное обновление next до CAS: n.next = old должен происходить перед CAS, иначе данные потеряются.

Возможные дополнительные вопросы интервьюера

  • Как реализовать lock-free очередь с поддержкой нескольких продюсеров и потребителей? Очередь Майкла-Скотта как раз MP/ MC. Код нужно аккуратно управлять tail с отставанием.
  • Может ли Go GC нарушить lock-free гарантии? Нет, но STW может задержать прогресс, это не блокировка, но временная остановка.

Как реализовать паттерн «издатель-подписчик» (pub-sub)?

Краткий ответ

Pub-sub в Go делается через каналы: издатель хранит список подписчиков (каналов) под мьютексом, при публикации рассылает сообщение в каждый канал неблокирующим способом или с таймаутом. На высоких нагрузках используют sync.Map для подписок и небуферизированные каналы с дропом сообщений.

Развёрнутый ответ

Базовая структура:

Go
type Broker struct {
    mu  sync.RWMutex
    subs map[chan string]struct{}
}
func (b *Broker) Subscribe() chan string {
    ch := make(chan string, 10)
    b.mu.Lock()
    b.subs[ch] = struct{}{}
    b.mu.Unlock()
    return ch
}
func (b *Broker) Publish(msg string) {
    b.mu.RLock()
    for ch := range b.subs {
        select {
        case ch <- msg:
        default: // skip slow consumer
        }
    }
    b.mu.RUnlock()
}

Для гарантированной доставки можно заменить default на блокировку, но риск блокировки издателя.

Под капотом (internals)

При большом числе подписчиков копия слайса под RLock может быть дорогой. Можно использовать sync.Map и итерироваться с Range.

Типичные ошибки кандидатов

  • Блокировка издателя медленным подписчиком: без select/default вся система встаёт. Нужна политика: дроп, backpressure или отключение подписчика.
  • Утечка горутин у подписчиков: забывают закрыть канал и удалить подписку → утечка ресурсов.
  • Гонка при удалении подписки во время Publish: при неправильной синхронизации может быть запись в закрытый канал. Решение — исключить канал из map под мьютексом перед закрытием.

Возможные дополнительные вопросы интервьюера

  • Как масштабировать pub-sub на множество ядер? Сегментировать подписчиков по темам, использовать lock-free структуры, либо fan-out каналы.
  • Как обеспечить сохранение сообщений при падении подписчика? Добавить подтверждение (Ack/Nack) или использовать буфер с перезапросами.

Как избежать ложных пробуждений (spurious wakeups)?

Краткий ответ

Ложное пробуждение — это ситуация, когда горутина просыпается из Wait (например, sync.Cond.Wait) без явного Signal. Защита всегда используется в цикле: горутина после пробуждения перепроверяет условие, и если оно ложно, снова вызывает Wait. Этот паттерн обязателен.

Развёрнутый ответ

Пример правильного использования sync.Cond:

Go
cond.L.Lock()
for !condition() {
    cond.Wait()
}
// условие истинно
cond.L.Unlock()

Причина в том, что рантайм или ОС могут разбудить горутину без Signal (spurious wakeup), или условие могло измениться между пробуждением и захватом мьютекса. Цикл for гарантирует корректность.

Под капотом (internals)

В Linux futex может вернуть управление из-за сигнала или прерывания. Рантайм Go не маскирует все сигналы. Реализация sync.Cond.Wait использует рантаймовский notifyList и планировщик, где таких проблем меньше, но документация Go настаивает на цикле.

Типичные ошибки кандидатов

  • Использование if вместо for: если условие не выполнилось, горутина пойдёт работать с неконсистентным состоянием.
  • Забывают захватить мьютекс перед проверкой условия после Wait: Wait сам захватывает мьютекс перед возвратом, но некоторые ошибочно отпускают его снова без проверки.
  • Не синхронизируют изменение условия: condition variable без мьютекса бессмысленна, изменение условия должно быть под тем же мьютексом.

Возможные дополнительные вопросы интервьюера

  • Бывают ли ложные пробуждения в каналах? Нет, чтение из канала блокируется строго до появления значения или закрытия, spurious wakeups исключены архитектурой.
  • Как отличить ложное пробуждение от потерянного сигнала? Не нужно — достаточно цикла.

Как работает runtime.RWMutex и runtime.Mutex?

Краткий ответ

Это низкоуровневые блокировки рантайма, используемые внутри самой среды выполнения (планировщик, аллокатор памяти, GC). runtime.Mutex — простая спин-блокировка с возможностью парковки, runtime.RWMutex — аналогично sync.RWMutex, но без starvation mode, без профилирования. Они не экспортируются.

Развёрнутый ответ

runtime.Mutex (в коде runtime.mutex) — структура с ключевым словом uintptr для состояния и sema. Алгоритм: быстрый захват через xchg, потом спин, затем futexsleep на системном семафоре. Он не экспортируется, используется для защиты внутренних структур (mheap, mcentral, stackpool). runtime.RWMutex (в runtime/rwmutex.go) также не экспортируется, аналогичен логике sync.RWMutex, но проще.

Под капотом (internals)

runtime.mutex:

Go
type mutex struct {
    key uintptr // состояние: 0 свободен, 1 захвачен, mutex_sleeping и т.д.
}

Используются OS-специфичные функции futexsleep / futexwakeup или semasleep / semawakeup. Рантайм не использует профилировщик и не переключается в starvation mode, предполагая, что блокировки короткие.

Типичные ошибки кандидатов

  • Попытка использовать runtime.mutex в пользовательском коде: не экспортируется; нужно применять sync.Mutex.
  • Предположение, что sync.Mutex и runtime.mutex одинаковы: последний примитивнее, не имеет режима голодания, не подходит для долгих ожиданий.
  • Смотреть на исходники рантайма как на лучшие практики: они оптимизированы под внутренние инварианты, не всегда подходят для прикладного кода.

Возможные дополнительные вопросы интервьюера

  • Почему рантайм не использует sync.Mutex? Избегает зависимостей от пакета sync (циклические импорты) и профилирования, нужна минимальная задержка.
  • Как рантайм гарантирует прогресс без starvation mode? Критические секции всегда очень короткие, и конкуренция ограничена (несколько потоков ОС).

Что такое "активная блокировка" (spinlock) и зачем она нужна?

Краткий ответ

Spinlock — блокировка, при которой горутина не паркуется, а циклически проверяет доступность в цикле ("крутится"), ожидая освобождения. Используется для очень коротких критических секций, где накладные расходы на парковку/пробуждение превышают время ожидания.

Развёрнутый ответ

В пользовательском Go редко реализуется в явном виде, поскольку планировщик может переключить горутину, но есть sync.Mutex со встроенным spin. Чистый spinlock потребляет CPU. Реализуется через атомарный CAS:

Go
type Spinlock uint32
func (s *Spinlock) Lock() {
    for !atomic.CompareAndSwapUint32((*uint32)(s), 0, 1) {
        runtime.Gosched() // уступаем, чтобы не забивать CPU
    }
}

Но в рантайме spinlock реализован через procyield (PAUSE на x86) без Gosched, поскольку отключены прерывания.

Под капотом (internals)

В Go рантайме функция procyield — цикл с инструкцией PAUSE, снижающей энергопотребление. sync.Mutex делает spin до 4 раз с активным вращением, но при high contention отключает его. На пользовательском уровне spinlock редко оправдан из-за вытесняющей природы планировщика Go.

Типичные ошибки кандидатов

  • Долгое удержание spinlock: если критическая секция длинная, все ядра заняты ожиданием, производительность падает катастрофически.
  • Реализация без runtime.Gosched(): горутина не уступает, другие горутины на том же P не могут выполниться, возможен livelock.
  • Использование там, где подошёл бы sync.Mutex: Mutex уже содержит оптимизации spin и starvation, эффективнее.

Возможные дополнительные вопросы интервьюера

  • Когда spinlock быстрее mutex? На выделенных ядрах с очень короткими секциями, когда гарантировано время меньше двух квантов планировщика.
  • Есть ли spinlock в стандартной библиотеке? sync.Mutex включает spin; отдельного нет, но его можно скопировать из рантайма (не рекомендуется).

Как runtime.futex используется в Go?

Краткий ответ

futex (fast userspace mutex) — системный вызов Linux, позволяющий атомарно проверять значение в памяти и засыпать или пробуждать поток. Рантайм Go использует futex для реализации runtime.mutex и семафоров (runtime.sema) — блокировка потоков ОС при ожидании горутин.

Развёрнутый ответ

Рантайм оборачивает futex в функции futexsleep и futexwake. При захвате мьютекса, если быстрый CAS не удался, вызывается futexsleep(addr, val) — поток переходит в сон, если значение по адресу addr всё ещё равно val. При Unlock с ожидающими вызывается futexwakeup(addr) для пробуждения одного или всех потоков.

На macOS используется аналог — semaphore_wait и semaphore_signal.

Под капотом (internals)

Файл src/runtime/os_linux.go содержит futexsleep и futexwake. Они используют syscall.Syscall6 с SYS_futex. Параметр val используется для избежания гонок: если перед вызовом futexsleep кто-то изменил слово, поток не засыпает. Это — краеугольный камень реализации блокировок.

Типичные ошибки кандидатов

  • Путать runtime.mutex с sync.Mutex: sync.Mutex тоже использует рантайм семафор, но через уровень runtime_Semacquire, а не напрямую futex.
  • Считать, что futex доступен везде: на Windows используются другие примитивы.
  • Детали реализации не нужны для прикладного кода: не следует полагаться на конкретный механизм засыпания.

Возможные дополнительные вопросы интервьюера

  • Почему futex предпочтительнее pthread mutex? Он быстрее в неконкурентном случае, так как избегает системных вызовов при свободном мьютексе.
  • Как работает пробуждение нескольких потоков через futex? FUTEX_WAKE с параметром >1 или FUTEX_REQUEUE.

Как работает "семафор" (runtime.sema) внутри Go?

Краткий ответ

runtime.sema — внутренняя реализация семафора, используемая sync.Mutex, каналами и другими примитивами. Это асинхронная очередь ожидающих горутин, сопоставленных с произвольным адресом памяти. Операции semacquire и semrelease обеспечивают блокировку и пробуждение в режиме FIFO.

Развёрнутый ответ

Структура семафора не статична; она хранится в хеш-таблице semtable, индексированной по адресу (uint32). При semacquire(addr) происходит атомарное добавление горутины в очередь ожидания для этого адреса, и горутина паркуется. При semrelease(addr), если есть ожидающие, пробуждается одна горутина (голова очереди). Есть версии semacquire с профилированием мьютекса, которые сохраняют время ожидания.

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

Под капотом (internals)

Код в src/runtime/sema.go. Использует sudog для представления ожидающей горутины. Очередь — lock-free список с атомарными операциями. Адрес семафора произволен, но часто это адрес поля sema структуры мьютекса.

Типичные ошибки кандидатов

  • Предположение, что семафор видим пользователю: экспортируется только через sync.Mutex.
  • Неправильное использование sync.Mutex как семафора: мьютекс должен быть захвачен и освобождён одной горутиной; семафор может использоваться разными.
  • Самодельные семафоры на каналах без учёта краевых случаев: канал с буфером 1 — это двоичный семафор, но надо аккуратно с закрытием.

Возможные дополнительные вопросы интервьюера

  • Как runtime.sema связан с semaphore из golang.org/x/sync/semaphore? Пакет semaphore использует каналы или WaitGroup, это надстройка над примитивами.
  • Можно ли использовать один семафор для разных целей? В рантайме да, через разные адреса, но для пользователя это скрыто.

Как реализовать TryLock в Go 1.18+?

Краткий ответ

С Go 1.18 sync.Mutex получил метод TryLock() bool, который пытается захватить мьютекс без блокировки, возвращая true в случае успеха. Ранее требовалась ручная реализация через atomic.CompareAndSwap на поле состояния мьютекса, что не рекомендовалось из-за нарушения идиомы.

Развёрнутый ответ

Теперь:

Go
if mu.TryLock() {
    defer mu.Unlock()
    // работа
} else {
    // fallback
}

Ручная реализация (для старых версий) использовала unsafe и state поле:

Go
// unsafe, не рекомендуется
if atomic.CompareAndSwapInt32(&mu.state, 0, mutexLocked) { /* ... */ }

Но это ломало инкапсуляцию. TryLock поощряется для случаев, когда не хочется блокироваться (например, при проверке состояния или сборе метрик).

Под капотом (internals)

Внутри TryLock делает CAS на state с 0 на mutexLocked. Он не использует spin, не пробуждает ожидающих, не переводит в starvation mode. Это просто быстрый атомарный захват. Если CAS не удался, возвращается false.

Типичные ошибки кандидатов

  • Использование TryLock в цикле без backoff: создаёт busy-wait и высокую нагрузку. При необходимости добавить sleep или sync.Cond.
  • Применение TryLock как основного метода синхронизации: Lock предпочтительнее для блокирующихся операций, TryLock — для оптимистичного захвата.
  • Проверка TryLock на разблокированном мьютексе: нет смысла — если мьютекс свободен, TryLock захватит, иначе нет.

Возможные дополнительные вопросы интервьюера

  • Безопасно ли одновременно использовать Lock и TryLock? Да, они конкурируют на одном состоянии, TryLock либо захватывает, либо нет.
  • Как TryLock взаимодействует с режимом голодания? Никак, starvation mode не учитывается, TryLock просто CAS.

Что такое "lock ordering" и как она предотвращает дедлоки?

Краткий ответ

Lock ordering — это правило, требующее всегда захватывать мьютексы в одном и том же порядке во всех горутинах. Это разрушает циклическое ожидание — одно из четырёх условий deadlock, делая deadlock невозможным.

Развёрнутый ответ

Если функции A и B используют мьютексы mu1 и mu2:

  • A: mu1 затем mu2
  • B: тоже mu1 затем mu2 (не наоборот). Тогда deadlock невозможен, даже если они пересекаются.

На практике можно упорядочивать по адресам (&mu1 < &mu2), по идентификаторам, либо вводить иерархию. Сложные системы вводят лок-менеджер, проверяющий порядок в рантайме. В Go нет встроенных механических проверок, это соглашение разработчика.

Под капотом (internals)

На уровне рантайма lock ordering проверяется внутри самого Go (например, стек памяти блокирует mheap.lock до mcentral.lock). Нарушение вызывает летальную ошибку (throw).

Типичные ошибки кандидатов

  • Слепое упорядочивание по адресам: если мьютексы создаются динамически, адреса непредсказуемы, нужно сортировать логические идентификаторы.
  • Игнорирование вложенных блокировок через вызовы: вызываемая функция может захватывать другие мьютексы, не зная порядка. Решение: документировать контракт.
  • Применение trylock для обхода: может приводить к livelock.

Возможные дополнительные вопросы интервьюера

  • Как реализовать динамическую проверку lock ordering? В рантайме есть отладочные функции, в пользовательском коде можно использовать обёртку с трекингом захваченных мьютексов и проверкой порядка.
  • Есть ли инструменты статического анализа? Да, go vet и анализаторы типа copylocks, но не всегда покрывают cross-goroutine паттерны.

Как дебажить дедлоки с помощью GODEBUG или трассировки?

Краткий ответ

Go предоставляет GODEBUG=asyncpreemptoff, GODEBUG=schedtrace=1000 и трассировщик (go tool trace). Для дедлоков используют профиль горутин (pprof goroutine), GODEBUG=gotraceback=crash, а в рантайме при полном дедлоке программа падает с полным дампом стеков.

Развёрнутый ответ

  • Отправка SIGQUIT или kill -ABRT заставит рантайм вывести стек всех горутин, где видны ожидающие блокировки.
  • GODEBUG=schedtrace=1000 печатает состояние планировщика, можно увидеть, какие горутины спят на мьютексе.
  • Трассировка (runtime/trace) показывает блокировки, синхронизацию. Визуализация помогает понять циклы.
  • debug.Stack() в критических точках.

Под капотом (internals)

При GOTRACEBACK=system детальнее показывает C-стеки. runtime.Stack() собирает стеки всех горутин. Внутренний механизм deadlock detection опирается на allgs и количество паркованных горутин.

Типичные ошибки кандидатов

  • Не включают трассировку блокировок: runtime.SetBlockProfileRate(1) необходим для профиля contention.
  • Игнорируют GOMAXPROCS: на одном потоке deadlock детектится сразу, на многих — не всегда.
  • Не проверяют логи после падения: core dump с GOTRACEBACK=crash даёт полную картину.

Возможные дополнительные вопросы интервьюера

  • Как профилировать contention на мьютексах? Включить блок-профиль: runtime.SetBlockProfileRate(1), затем go tool pprof /debug/pprof/block.
  • Как go trace показывает взаимные блокировки? Показывает события блокировки и разблокировки, можно увидеть цикл ожидания.

Как работает sync.WaitGroup с точки зрения атомарных операций?

Краткий ответ

sync.WaitGroup использует атомарные операции над внутренним счётчиком и семафор для ожидающих. Add атомарно изменяет счётчик, Done декрементирует. Wait ждёт, пока счётчик не станет 0, используя runtime_Semacquire/Semrelease.

Развёрнутый ответ

Структура:

Go
type WaitGroup struct {
    noCopy noCopy
    state atomic.Uint64 // high 32 bits: counter, low 32 bits: waiter count, + sema?
}

На самом деле WaitGroup содержит state (64 бита: старший — счётчик, младший — число ожидающих) и sema uint32. Add(delta) атомарно прибавляет delta к старшим битам. Если delta отрицательна и счётчик стал 0, то пробуждаются ожидающие (через runtime_Semrelease). Wait() увеличивает счётчик ожидающих, загружает счётчик, и если счётчик > 0, вызывает runtime_Semacquire. Атомарные Add и Load гарантируют корректность без мьютекса.

Под капотом (internals)

Исходники src/sync/waitgroup.go. state — uint64. Функция wg.Add вызывает state.Add(delta), затем проверяет counter. Если counter == 0, вызывается runtime_Semrelease. Все атомарные операции имеют полную memory order.

Типичные ошибки кандидатов

  • Вызов Add после Wait: приводит к панике, т.к. счётчик должен быть стабилизирован до Wait.
  • Отрицательный счётчик: Add может привести к панике.
  • Несоответствие Add и Done: забытый Done оставляет Wait висеть.
  • Использование WaitGroup для повторного использования: нужно дождаться Wait, затем снова устанавливать. Проще создавать новую.

Возможные дополнительные вопросы интервьюера

  • Как WaitGroup реализует повторное использование? Никак, Add после достижения нуля снова можно, но между Wait нужно быть осторожным, обычно создают новую.
  • Можно ли реализовать WaitGroup только на каналах? Да, используя канал с буфером, но будет менее эффективно.

В чем разница между atomic.Store и atomic.CompareAndSwap?

Краткий ответ

Store атомарно записывает значение без проверки предыдущего; CompareAndSwap атомарно сравнивает текущее значение с ожидаемым и, если они совпадают, записывает новое, возвращая успех. Store — безусловная запись, CAS — условное обновление, основа lock-free программирования.

Развёрнутый ответ

Store — просто атомарное присваивание. CAS(addr, old, new) возвращает bool: поменял или нет. Часто используется в циклах: считать, вычислить новое, CAS; если не удалось — повторить. Store быстрее (инструкция MOV с барьером), чем CAS (LOCK CMPXCHG с полным барьером). Но Store не проверяет одновременно изменения.

Под капотом (internals)

На x86 Store для int32 реализуется как XCHG или MOV с Lock префиксом не всегда (зависит от Go, но использует XCHG для Store, чтобы гарантировать release-семантику). CAS — LOCK CMPXCHG. Барьер памяти полный.

Типичные ошибки кандидатов

  • Ожидание от Store условной записи: Store перезаписывает всегда, можно потерять обновления другого потока.
  • Забывают, что CAS требует передачу указателя: в atomic.CompareAndSwapInt64(&val, old, new).
  • Сравнение указателей напрямую для структур: может быть ложно из-за равенства адресов, но содержимое разное.

Возможные дополнительные вопросы интервьюера

  • Когда CAS бывает неуспешен, хотя значение не менялось? Проблема ABA: значение изменилось, потом вернулось, CAS не заметит. Нужен version tag.
  • Как реализовать atomic increment через CAS? В цикле: читаем, прибавляем, CAS. Но есть atomic.Add, которая эффективнее.

Как работает "CAS-цикл"?

Краткий ответ

CAS-цикл — шаблон, в котором:

  1. Атомарно читается текущее значение.
  2. Вычисляется новое значение на основе прочитанного.
  3. Вызывается CAS для замены старого на новое.
  4. Если CAS не удался (кто-то изменил), повторяется с шага 1. Так достигается атомарное обновление без блокировок.

Развёрнутый ответ

Пример атомарного умножения на 2:

Go
for {
    old := atomic.LoadInt64(&x)
    new := old * 2
    if atomic.CompareAndSwapInt64(&x, old, new) {
        break
    }
}

Паттерн используется в lock-free структурах. Может страдать от starvation и ABA. При высоком contention в цикл добавляют backoff.

Под капотом (internals)

CAS-цикл генерирует поток атомарных инструкций. На x86 каждая итерация — полный барьер, нагрузка на шину. Рантайм Go использует CAS-циклы для sync.Mutex lock path (увеличивает waiter count) и в sync.Pool.

Типичные ошибки кандидатов

  • Дорогие вычисления внутри цикла: если new calc медленный, под CAS старое значение может устареть, и цикл станет долгим.
  • Отсутствие ограничения на количество итераций: может привести к livelock; рекомендуется ограничивать и переходить к fallback.
  • Игнорирование ABA: для указателей нужен инкремент версии.

Возможные дополнительные вопросы интервьюера

  • Можно ли реализовать lock-free стек без CAS-цикла? Нет, CAS обязателен для обновления головы.
  • Как отлаживать высокий contention в CAS-циклах? Счётчики повторных попыток, профилировщик CPU.

Как реализовать «параллельный map-filter-reduce»?

Краткий ответ

Разбиваем входной слайс на батчи, параллельно применяем map и filter (в одной горутине на батч). Результаты собираем в каналы или с помощью sync.Mutex в общий слайс. Reduce выполняется после сбора всех результатов или параллельно с аккумулированием через атомарные операции.

Развёрнутый ответ

С использованием errgroup:

Go
g, ctx := errgroup.WithContext(ctx)
results := make(chan int, numBatches)
for _, batch := range batches {
    batch := batch
    g.Go(func() error {
        for _, v := range batch {
            if v%2 == 0 { // filter
                results <- v * v // map
            }
        }
    })
}
go func() { g.Wait(); close(results) }()
var sum int
for r := range results { sum += r } // reduce

Можно использовать sync.Mutex для добавления в слайс, или каналы слияния.

Под капотом (internals)

Используется планировщик горутин, каналы для потокобезопасной передачи. При большом количестве результатов канал может стать узким местом, поэтому буферизированный нужен.

Типичные ошибки кандидатов

  • Дорогой reduce в однопоточном режиме: можно сделать reduce локально в каждой горутине, затем смержить.
  • Утечка горутин при ошибке: errgroup с контекстом отменяет остальные, но канал нужно закрывать аккуратно.
  • Неверный выбор размера батча: слишком маленький — overhead, большой — дисбаланс.

Возможные дополнительные вопросы интервьюера

  • Как добиться балансировки нагрузки? Использовать воркер-пул с общей очередью задач.
  • Можно ли использовать только каналы без mutex? Да, fan-in/fan-out паттерны.

Как управлять параллелизмом (concurrency) с помощью семафора?

Краткий ответ

Семафор ограничивает количество одновременно работающих горутин. В Go реализуется буферизированным каналом: захват слота — отправка в канал, освобождение — чтение. golang.org/x/sync/semaphore предоставляет взвешенный семафор.

Развёрнутый ответ

Простой семафор:

Go
sem := make(chan struct{}, maxConcurrency)
for _, task := range tasks {
    sem <- struct{}{} // захват
    go func(t Task) {
        defer func() { <-sem }() // освобождение
        process(t)
    }(task)
}

Взвешенный семафор semaphore.NewWeighted(limit) позволяет захватывать несколько единиц:

Go
sem := semaphore.NewWeighted(100)
sem.Acquire(ctx, 10)
defer sem.Release(10)

Под капотом (internals)

Канальный семафор использует внутренние блокировки канала. semaphore.Weighted использует sync.Mutex и список ожидающих с приоритетами, ближе к классическому семафору.

Типичные ошибки кандидатов

  • Забывают освободить семафор при ошибке: нужен defer или обработка паники.
  • Использование tryAcquire без проверки ошибок: может пропустить задачу.
  • Блокировка всей программы при нулевом лимите: канал нулевого буфера не работает как семафор.

Возможные дополнительные вопросы интервьюера

  • Чем отличается семафор от sync.WaitGroup? WaitGroup — ожидание завершения фиксированного числа горутин, семафор ограничивает параллелизм, не зная заранее число горутин.
  • Как реализовать семафор с приоритетом? Использовать semaphore.Weighted с разными Acquire или кастомную очередь.

Как реализовать «Dining Cryptographers» на Go?

Краткий ответ

Задача криптографов: несколько участников сидят за столом, каждый подбрасывает монету, общую с соседом; анонимно вычисляется XOR всех, чтобы определить, кто заплатил за обед. На Go моделируется горутинами, обменивающимися битами через каналы, и сбором результата в общий канал.

Развёрнутый ответ

Каждый криптограф знает свою пару монет (левая/правая). Получив монеты от соседей, вычисляет XOR (свой секретный бит, заплатил ли). Все передают результат в сборщик, который суммирует XOR и получает итог.

Go
func cryptographer(id int, leftCoin, rightCoin chan bool, result chan bool, paid bool) {
    left <- paid
    right <- paid
    l := <-leftCoin
    r := <-rightCoin
    result <- l != r != paid // XOR
}

Координация через каналы обеспечивает синхронизацию.

Под капотом (internals)

Используются небуферизированные каналы для синхронного обмена монетами, что моделирует реальное бросание. Можно реализовать на sync.Cond и мьютексах.

Типичные ошибки кандидатов

  • Deadlock при неправильном порядке отправки/приёма: если все начнут ждать приёма, никто не отправит. Нужно сначала отправить, потом принимать.
  • Путаница с XOR: неправильная логика.
  • Гонка за сбор результата: аккуратно собирать через канал.

Возможные дополнительные вопросы интервьюера

  • Как обобщить на асинхронную модель? Использовать буферизированные каналы и передавать токены без блокировки.
  • Можно ли решить с помощью atomic битовых полей? Да, атомарный XOR в общем слове, но нужно координировать передачу «монет».

Как работает "семафор с приоритетом" в Go runtime?

Краткий ответ

В рантайме семафор с приоритетом используется для runtime.netpoll и внутренних нужд: горутины с более высоким приоритетом (например, сетевой поллер) могут быть разбужены раньше. Реализация базируется на runtime.sema, но с дополнительной логикой упорядочивания по типу ожидания.

Развёрнутый ответ

В runtime.sema нет явного приоритета, но используется semacquire с параметром profile (mutex profile) и semrelease с ручным пробуждением. Для управления приоритетами в планировщике применяются глобальные очереди и очереди на P. Семафор с приоритетом в общедоступном смысле — это скорее semaphore.Weighted с Acquire и контекстом, который учитывает вес.

Под капотом (internals)

В src/runtime/sema.go существует semroot хеш-таблица, каждая корзина содержит очередь sudog. При пробуждении ищется sudog с флагом semaProfile, если таковой есть. Приоритетов как таковых нет, но есть разные очереди (например, sema для mutex и для каналов). В runtime.netpoll используются специальные флаги для немедленного пробуждения.

Типичные ошибки кандидатов

  • Ожидание гарантированного порядка пробуждения: документация не гарантирует FIFO или приоритет.
  • Попытка реализовать приоритет через свой семафор на каналах: канал не поддерживает приоритет.

Возможные дополнительные вопросы интервьюера

  • Как можно реализовать приоритетное ожидание на пользовательском уровне? Использовать несколько каналов с select, но это не честно; можно хранить очереди разных приоритетов.
  • Есть ли в стандартной библиотеке приоритетный семафор? x/sync/semaphore не поддерживает приоритеты, только взвешивание.

Что такое "канал с динамическим буфером" и как его реализовать?

Краткий ответ

Канал с динамическим буфером расширяет или сжимает ёмкость динамически в зависимости от нагрузки, в отличие от статического make(chan T, cap). Реализуется через связку: горутина-диспетчер, работающая с небуферизированным каналом и внутренним буфером (slice), переключение новых потребителей на прямой приём.

Развёрнутый ответ

Идея: создать внутренний буфер, общий для продюсеров и консьюмеров. Можно сделать обёртку над обычным каналом, где горутина-менеджер принимает из входного канала и пытается отправить в выходной; если консьюмеры не успевают, буфер растёт (в памяти). Либо использовать sync.Cond и container/list для неограниченной очереди. Но следует опасаться неконтролируемого роста.

Пример на основе канала и слайса под мьютексом: продюсер под мьютексом добавляет в slice и сигналит cond; потребители забирают из slice.

Под капотом (internals)

Не является стандартной конструкцией. Стандартные каналы имеют фиксированный размер, выделенный при создании. Динамический буфер реализуется поверх.

Типичные ошибки кандидатов

  • Бесконечный рост буфера: без backpressure память утекает.
  • Сложность с select: стандартный select не работает с динамическим буфером напрямую.
  • Плохая производительность из-за постоянного копирования slice: нужно кольцевой буфер.

Возможные дополнительные вопросы интервьюера

  • Как реализовать блокирующую запись при превышении лимита? Ввести условие на максимальный размер буфера.
  • Можно ли использовать container/ring? Да, для кольцевого буфера, потребуется синхронизация.

Как работает errgroup.Group и как он упрощает управление горутинами?

Краткий ответ

errgroup.Group из golang.org/x/sync/errgroup управляет группой горутин, собирая первую ошибку (или все) и обеспечивая ожидание завершения всех через Wait(). Может работать с контекстом: при возникновении первой ошибки контекст отменяется, сигнализируя остальным горутинам остановиться.

Развёрнутый ответ

Go
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return doWork(ctx) })
g.Go(func() error { return doOther(ctx) })
if err := g.Wait(); err != nil { ... }

Group основана на sync.WaitGroup и канале ошибок. При возникновении ошибки она отменяет контекст (если используется WithContext), и другие горутины могут завершиться. Альтернатива — ручное управление waitgroup и каналами, что чревато ошибками.

Под капотом (internals)

Реализация проста: Group хранит wg sync.WaitGroup, errOnce sync.Once, err error, и канал cancel (функция контекста). Go оборачивает функцию, увеличивает wg, при ошибке через errOnce.Do сохраняет ошибку и отменяет контекст. Wait ожидает wg и возвращает ошибку.

Типичные ошибки кандидатов

  • Не проверяют контекст внутри функции: несмотря на отмену, горутина может продолжать работать, если не слушает ctx.Done().
  • Игнорирование отмены из-за паники: errgroup не обрабатывает панику, горутина падает, Wait не дождётся.
  • Создание Group с context.Background() вместо WithContext: тогда нет распространения отмены.

Возможные дополнительные вопросы интервьюера

  • Как отменить все горутины при ошибке без errgroup? Вручную через канал отмены, закрывая его или через контекст.
  • Можно ли с помощью errgroup дождаться завершения без ошибок? Да, просто Group и Wait.

Что такое "structured concurrency" и как его достичь в Go?

Краткий ответ

Structured concurrency — парадигма, при которой время жизни горутин жёстко ограничено областью видимости (функцией/блоком), и все порождённые горутины завершаются до выхода из этой области. В Go достигается через errgroup, контексты с отменой, sync.WaitGroup в рамках функции, и правило «каждая горутина должна быть отслежена».

Развёрнутый ответ

Пример:

Go
func process() error {
    g, ctx := errgroup.WithContext(context.Background())
    g.Go(func() error { return task1(ctx) })
    g.Go(func() error { return task2(ctx) })
    return g.Wait() // гарантирует завершение обеих горутин
}

Ни одна горутина не «утекает» за пределы вызова process. В отличие от «fire-and-forget», structured concurrency улучшает понимание и отладку. Контекст с таймаутом или дедлайном усиливает контроль.

Под капотом (internals)

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

Типичные ошибки кандидатов

  • Забытая горутина, запущенная без отслеживания: например, go func() внутри цикла без wg.Add. Утечка.
  • Использование глобального контекста без отмены: при завершении родительской операции дочерние горутины не останавливаются.
  • Смешивание синхронизации: часть через errgroup, часть через ручной WaitGroup — трудно отследить.

Возможные дополнительные вопросы интервьюера

  • Чем отличается от trivially go func? Structured concurrency даёт гарантии завершения и распространение ошибок.
  • Какие библиотеки реализуют structured concurrency? errgroup, x/sync/singleflight, некоторые фреймворки типа oklog/run.

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

Краткий ответ

Распределённый двухфазный коммит (2PC) моделируется с координатором и участниками. Координатор посылает prepare запросы (через каналы), собирает голоса, затем посылает commit/abort. Мьютексы защищают состояние каждого участника (prepared, committed). В Go это делается горутинами-участниками, ожидающими команд через каналы.

Развёрнутый ответ

Координатор:

Go
func coordinator(participants []chan Command) {
    for _, p := range participants { p <- Prepare{} }
    // собрать ответы
    decision := Commit
    for _, p := range participants {
        if (<-p).(Vote) != Yes { decision = Abort }
    }
    for _, p := range participants { p <- decision }
}

Участник:

Go
func participant(in chan Command, out chan Vote) {
    mu.Lock()
    state := Prepared
    mu.Unlock()
    out <- Yes
    cmd := <-in
    if cmd == Commit { mu.Lock(); state = Committed; mu.Unlock() }
}

Мьютексы нужны для атомарного изменения состояния и согласованного ответа.

Под капотом (internals)

Используются каналы для передачи сообщений, мьютексы для гарантии изоляции. Таймауты и контексты добавляют отказоустойчивость: select с time.After.

Типичные ошибки кандидатов

  • Забывают обрабатывать таймауты: если участник не ответил, координатор зависает.
  • Использование одного мьютекса для всех без разграничения: состояние может быть изменено при получении commit.
  • Отправка ответа без подготовки: голос должен приходить после реальной фиксации prepare.

Возможные дополнительные вопросы интервьюера

  • Как обрабатывать сбои координатора? Участники должны иметь таймауты и состояние неопределённости; нужен протокол восстановления (3PC).
  • Можно ли использовать только каналы без мьютексов? Да, если состояние передавать в канале, но сложнее управлять доступом.

Как обрабатывать "back-pressure" в системе с высоким параллелизмом?

Краткий ответ

Back-pressure реализуется через ограниченные очереди (каналы), семафоры (каналы-токены), мониторинг длины очередей, отбрасывание запросов с HTTP 429, адаптивное уменьшение параллелизма, использование кредитных систем (рейт-лимитеры). Цель — не допустить перегрузку и каскадные отказы.

Развёрнутый ответ

  • Внутренний сервис: буферизированный канал фиксированного размера; продюсер блокируется → вызов select с default для отбрасывания или таймаута.
  • Внешний API: установка rate.Limiter из golang.org/x/time/rate, Client с ограничением concurrency.
  • Динамическое управление: горутина-контроллер смотрит на длину очереди и уменьшает количество воркеров.
  • Применение golang.org/x/sync/semaphore.Weighted с отказом при TryAcquire false.

Под капотом (internals)

Используются стандартные примитивы. Важно не блокировать горутину навсегда без контроля.

Типичные ошибки кандидатов

  • Игнорирование backpressure полностью: приводит к OOM и крашу.
  • Постоянная блокировка без таймаутов: система становится недоступной при всплеске.
  • Отбрасывание без логирования метрик: невозможно мониторить перегрузку.

Возможные дополнительные вопросы интервьюера

  • Как реализовать адаптивный лимит? Использовать алгоритмы типа AIMD, изменяя размер семафора на основе latency и err rate.
  • Как сочетать backpressure с graceful shutdown? Перестать принимать новые задачи, дождаться обработки существующих.

Как работает sync/rwmutex.go в части предотвращения starvation писателей?

Краткий ответ

В стандартном sync.RWMutex starvation писателей предотвращается write-preferring политикой: как только появляется ожидающий писатель, новые читатели блокируются. Писатель дожидается только тех читателей, которые уже вошли. Плюс используется starvation mode внутреннего мьютекса для ожидающего на writerSem.

Развёрнутый ответ

Атомарное уменьшение readerCount на rwmutexMaxReaders делает его отрицательным. Это сигнал для RLock: если readerCount < 0, читатель должен ждать на readerSem. Тем самым новые читатели не могут обогнать писателя. Писатель ждёт на writerSem, пока readerWait не станет 0 (оставшиеся активные читатели). Внутренний w Mutex может войти в starvation mode при долгом ожидании, что тоже помогает.

Под капотом (internals)

sync.RWMutex.Lock() вызывает w.Lock() (блокирует других писателей), затем атомарно преобразует readerCount в отрицательное значение, запоминает количество активных читателей в readerWait, и вызывает runtime_SemacquireMutex(&rw.writerSem, ...). Когда последний читатель делает RUnlock, он уменьшает readerCount; если readerWait достигает 0 и есть писатель, то runtime_Semrelease пробуждает писателя.

Типичные ошибки кандидатов

  • Ожидание, что писатель получит доступ сразу после Lock: Нет, он ждёт завершения активных читателей.
  • Думать, что RLock всегда не блокирует: если есть писатель, блокирует.
  • Обход блокировки читателя через TryRLock (Go 1.18+): TryRLock вернёт false при ожидании писателя, но это не отменяет starvation.

Возможные дополнительные вопросы интервьюера

  • Может ли писатель голодать в sync.RWMutex? Нет, благодаря описанному механизму.
  • Есть ли read-preferring mutex в стандартной библиотеке? Нет.

Как sync.Cond.Wait реализован и почему он требует захваченный мьютекс?

Краткий ответ

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

Развёрнутый ответ

Реализация:

  1. Добавляет горутину в notifyList.
  2. Вызывает L.Unlock().
  3. Вызывает runtime_notifyListWait — приостанавливает горутину.
  4. При пробуждении (после Signal/Broadcast) L.Lock().
  5. Возвращается.

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

Под капотом (internals)

notifyList хранит список ожидающих sudog. Wait добавляет sudog в список, затем gopark. Signal находит первый sudog и вызывает goready. Реализация в src/runtime/sema.go.

Типичные ошибки кандидатов

  • Вызов Wait без предварительного Lock: паника или нарушение инвариантов.
  • Изменение условия без захвата того же мьютекса: Signal должен вызываться под Lock, иначе пробуждённая горутина может увидеть несогласованное состояние.
  • Использование разных мьютексов для условия и Wait: логическая ошибка, условие не защищено должным образом.

Возможные дополнительные вопросы интервьюера

  • Как избежать ложных пробуждений? Только цикл for.
  • Можно ли передать sync.RWMutex.RLock в качестве Locker? Нет, Cond ожидает Locker интерфейс, у RWMutex метод RLocker() возвращает Locker, но Wait должен отпускать write lock, что не совместимо с read lock. Лучше использовать write lock.

Как реализовать "flow control" с помощью каналов?

Краткий ответ

Flow control — управление темпом передачи данных, чтобы потребитель успевал обрабатывать. В Go каналы естественно реализуют контроль потока: блокирующая отправка в буферизированный канал при заполнении создаёт противодавление. Явный flow control — через канал обратной связи (ack), семафоры или механизм credit.

Развёрнутый ответ

Пример с окном подтверждений:

Go
const window = 5
ackCh := make(chan struct{}, window)
for i := 0; i < total; i++ {
    ackCh <- struct{}{} // ждёт, пока появится окно
    go func() {
        defer func() { <-ackCh }()
        send()
    }()
}

Или потребитель посылает разрешение:

Go
producer <- token

Под капотом (internals)

Канальный буфер и планировщик управляют блокировками. Когда буфер заполнен, продюсер блокируется, потребление CPU снижается.

Типичные ошибки кандидатов

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

Возможные дополнительные вопросы интервьюера

  • Как реализовать flow control с динамическим окном? Адаптировать размер окна на основе RTT и скорости обработки.
  • Как совместить flow control с graceful shutdown? Закрыть канал с токенами и дождаться обработки.

Что такое "memory model" с точки зрения атомарных операций?

Краткий ответ

Модель памяти определяет, как операции с памятью упорядочиваются между горутинами. Атомарные операции в Go гарантируют sequential consistency (по умолчанию) — они ведут себя как если бы все атомарные операции выполнялись в некотором глобальном порядке, с барьерами acquire/release. Это обеспечивает синхронизацию без мьютексов.

Развёрнутый ответ

Спецификация Go говорит, что атомарные операции обеспечивают синхронизацию, аналогичную sync.Mutex. atomic.Store имеет release-семантику: все предыдущие записи в обычную память становятся видимыми до store. atomic.Load имеет acquire-семантику: последующие чтения/записи не переупорядочатся до load. CAS — полный sequentially consistent барьер.

Это позволяет строить happens-before отношения: если горутина A делает Store(x, 1), а горутина B Load(&x) == 1, то все записи A до Store видны B после Load.

Под капотом (internals)

Компилятор вставляет соответствующие барьеры. На x86 атомарные операции с LOCK префиксом имеют порядок. На ARM требуется явный DMB.

Типичные ошибки кандидатов

  • Смешивание атомарных и обычных записей: нет гарантий, что обычная запись будет видна атомарному чтению. Все доступы должны быть атомарными.
  • Полагаться на sequential consistency для неатомарных операций: это ошибка.
  • Игнорирование атомарности при чтении указателей: нужно использовать atomic.LoadPointer.

Возможные дополнительные вопросы интервьюера

  • Какая модель памяти у map без синхронизации? Неопределённая, data race, UB.
  • Что такое happens-before? Отношение, определяющее, что одна операция гарантированно видна другой.

Как race detector работает с atomic и unsafe?

Краткий ответ

Race detector отслеживает обращения к памяти во времени и предупреждает о гонках данных. Атомарные операции sync/atomic распознаются детектором и не вызывают ложных срабатываний, если весь доступ атомарный. Однако unsafe.Pointer без атомарных операций детектор считает потенциально опасным и может сообщить о гонке, если идёт одновременный доступ.

Развёрнутый ответ

Race detector в Go основан на ThreadSanitizer (TSan). Он инструментирует все чтения/записи памяти. Функции atomic сообщают детектору, что доступ синхронизирован. Если переменная изменяется частично через atomic, а частично нет — детектор закричит. Использование unsafe для обхода типов и прямой манипуляции памятью часто приводит к гонкам, детектор их ловит, если они происходят.

Под капотом (internals)

При компиляции с -race добавляются вызовы runtime.race* функций. atomic перехватывается и вызывает raceacquire/racerelease. unsafe.Pointer операции с приведением типов не добавляют особых аннотаций, поэтому гонки детектируются стандартным образом.

Типичные ошибки кандидатов

  • Полагают, что race detector проверяет атомарность саму по себе: он проверяет гонки, атомарные операции расцениваются как синхронизация, а не отсутствие гонок. Если две горутины делают atomic.Store одновременно — гонки нет (последовательная консистентность).
  • Использование unsafe без синхронизации: детектор справедливо жалуется.
  • Забывают запускать с -race: без него не ловится.

Возможные дополнительные вопросы интервьюера

  • Может ли race detector пропустить гонку с атомиками? Нет, если вся работа с переменной атомарна, гонки нет. Если часть неатомарна — детектор предупредит.
  • Как детектор относится к sync.Map? Внутри sync.Map уже атомарный доступ, детектор не жалуется на корректное использование.