10. Горутины и базовая конкурентность

«Параллельные процессы взаимодействуют исключительно посредством передачи сообщений, синхронизируя свою работу без разделяемого состояния».
— Чарльз Энтони Ричард Хоар


Архитектура легковесных потоков и масштабируемость

Горутины — фундаментальный строительный блок конкурентности в Go. В отличие от тяжеловесных потоков операционной системы (требующих от 1 до 8 МБ виртуальной памяти на стек и дорогостоящего переключения контекста через кольцо ядра), горутина стартует всего с 2 КБ непрерывного стека и обслуживается кооперативным планировщиком Go (GMP) полностью в пространстве пользователя (userspace).

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

    Op["Инструкция go func()"]:::entry --> NewG["runtime.newproc()"]:::process
    NewG --> AllocG["Аллокация / получение структуры g (~376 байт)"]:::process
    AllocG --> InitStack["Выделение непрерывного стека (2 КБ)"]:::accent
    InitStack --> PushQueue["Помещение g в локальную очередь P (runq, макс. 256)"]:::process
    PushQueue -->|"Если runq переполнена"| GlobalQ["Сброс половины задач в sched.runq (глобальная)"]:::warning
    PushQueue -->|"Локальная очередь готова"| Schedule["Планировщик schedule() выбирает g"]:::process
    Schedule --> GOGO["gogo(&g.sched) -> выполнение на M (поток ОС)"]:::success
  

Архитектурный паттерн: Fan-Out / Fan-In

Паттерн Fan-Out / Fan-In позволяет параллельно обрабатывать массив независимых подзадач на пуле воркеров (Fan-Out) с последующей агрегацией результатов в единый выходной поток (Fan-In) без блокировок и взаимных гонок.

flowchart LR
    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

    Input["Входной поток задач (Job Queue)"]:::entry --> Dispatcher["Диспетчер (Fan-Out)"]:::accent
    Dispatcher --> W1["Воркер 1 (Горутина)"]:::process
    Dispatcher --> W2["Воркер 2 (Горутина)"]:::process
    Dispatcher --> W3["Воркер N (Горутина)"]:::process
    W1 --> Aggregator["Сборщик результатов (Fan-In)"]:::accent
    W2 --> Aggregator
    W3 --> Aggregator
    Aggregator --> Output["Единый результирующий канал"]:::success
  

  1. Как создать горутину?
  2. Что такое легковесный поток (lightweight thread)?
  3. Сколько памяти занимает горутина?
  4. Что такое состояние гонки (race condition)?
  5. Что такое гонка данных (data race)?
  6. Как избежать гонки?
  7. Что такое sync.WaitGroup?
  8. Как работает WaitGroup?
  9. Как WaitGroup отслеживает завершение горутин?
  10. Что такое sync.Once?
  11. Для чего нужен sync.Once?
  12. Как реализовать отложенную инициализацию с помощью sync.Once?
  13. Что такое sync.Cond?
  14. Для чего нужен sync.Cond?
  15. Как использовать Broadcast и Signal у sync.Cond?
  16. Что такое утечка горутин (goroutine leak)?
  17. Как предотвратить утечку горутин?
  18. Как корректно завершить горутину?
  19. Что такое context.Context?
  20. Зачем нужен context.Context?
  21. Как отменить горутину с помощью context?
  22. Что такое sync.Mutex?
  23. Как работает sync.Mutex?
  24. Что такое sync.RWMutex?
  25. Когда использовать RWMutex вместо Mutex?
  26. Что такое голодание (starvation) при блокировке?
  27. Что такое взаимная блокировка (deadlock)?
  28. Что такое sync.Map и когда его использовать?
  29. Чем sync.Map отличается от обычной map?
  30. Как работает sync.Pool для переиспользования горутин?
  31. Что такое "background" и "TODO" контексты?
  32. Как передавать данные через контекст?
  33. Какие проблемы могут возникнуть при передаче значений через контекст?
  34. Как работает паттерн "рабочий пул" (worker pool)?
  35. Как ограничить количество одновременно работающих горутин?
  36. Как обрабатывать ошибки в горутинах?
  37. Как реализовать семафор с помощью канала?
  38. Что такое "semaphore" и зачем он нужен?
  39. Что такое "signal channel" для завершения горутины?
  40. Как корректно остановить бесконечный цикл в горутине?
  41. Как WaitGroup.Done() связан с Add()?
  42. Что будет, если Done() вызвать больше раз, чем Add()?
  43. В чем разница между sync.Mutex и каналом с буфером 1?
  44. Как context.WithTimeout и context.WithDeadline взаимодействуют с горутинами?
  45. Как реализовать "честное" распределение задач между воркерами?
  46. Что такое "fan-out" и "fan-in"?
  47. Как pprof помогает найти утечку горутин?
  48. Что такое "rate limiting" и как его реализовать с помощью горутин?
  49. Как реализовать "graceful shutdown" с ожиданием завершения горутин?

Как создать горутину?

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

Горутина создаётся ключевым словом go перед вызовом функции или анонимной функции. Это запускает переданную функцию в новой, легковесной нити исполнения внутри того же адресного пространства. Горутина выполняется асинхронно, а планировщик Go мультиплексирует тысячи горутин на потоки ОС.

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

Создание горутины тривиально:

Go
go myFunc()
go func() {
    fmt.Println("anonymous goroutine")
}()

Выражение go вычисляет функцию (и аргументы, которые захватываются до старта), после чего выполнение переходит в новую горутину. Родительская горутина продолжается немедленно. Порядок выполнения между родителем и ребёнком не определён без синхронизации.

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

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

При вызове go компилятор Go (начиная с 1.14 и позже) генерирует вызов runtime.newproc(fn). Эта функция:

  1. Берёт из локального кэша (p.gfree) или выделяет новую структуру g (goroutine), размером около 376 байт (на 64-битных системах), в стеке которой изначально только g.stackguard0 и небольшой стек (2–8 КБ).
  2. Помещает g в очередь выполнения: либо в локальную очередь текущего P (процессора планировщика), либо в глобальную. При заимствовании потоков (work stealing) пустые P забирают горутины из глобальной очереди или у соседей.
  3. Планировщик (функция schedule) позже запустит её на свободном M (потоке ОС), вызвав gogo(&g.sched), который переключает контекст (регистры, указатель стека) на сохранённый в структуре g.sched.

Таким образом, старт горутины — это выделение g, запись в очередь и асинхронный запуск. Никаких системных вызовов на создание не происходит, в отличие от потоков ОС.

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

  • Захват переменной цикла в замыкании без копирования.
    Если внутри цикла for i := 0; i < n; i++ запускается горутина, использующая i, все горутины могут увидеть одно и то же (последнее) значение. Это классическая ошибка. Причина: замыкание захватывает ссылку на i, а не значение на момент создания. Исправление: передавать i как параметр go func(i int){} (i) или создать локальную копию i := i перед go.

  • Предположение, что горутина немедленно начинает исполнение.
    Кандидаты иногда полагаются на то, что горутина выполнится до основной, что ведёт к недетерминированному поведению. Необходимо явно синхронизировать (WaitGroup, канал, блокировка).

  • Игнорирование возможности паники в горутине.
    Паника в горутине без recover обрушит весь процесс, даже если основная горутина ещё жива. Нужно использовать defer с recover в корневой функции горутины, если требуется изоляция ошибок.

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

Каков минимальный размер стека горутины и как он растёт?
Изначально стек горутины составляет 2 КБ (раньше 8 КБ, потом уменьшен). Стек динамически растёт и сжимается: при переполнении вызывается runtime.morestack, аллоцируется новый сегмент в два раза больше, данные копируются. В актуальных версиях стек непрерывный (contiguous stack), а не сегментированный.

Можно ли запустить миллион горутин? Что лимитирует?
Теоретически да, на практике память: каждая горутина потребляет минимум 2 КБ стека плюс служебные структуры. Миллион горутин ≈ 2 ГБ только на стеки. Операционная система может ограничить число потоков или виртуальную память. Планировщик Go справляется, но утилизация CPU станет проблемой.

Как планировщик Go назначает горутину на поток?
Модель G-M-P. G – горутина, M – поток ОС, P – логический процессор (контекст планирования). Каждый P имеет локальную очередь G. Поток M должен захватить P, чтобы выполнять G. При блокирующих системных вызовах M и G блокируются, а P открепляется для других M и горутин.


Что такое легковесный поток (lightweight thread)?

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

Легковесный поток — это единица выполнения, управляемая в пространстве пользователя, а не ядром ОС. Горутины Go — именно легковесные потоки: они создаются и планируются рантаймом Go, а не операционной системой. Это даёт крайне низкие накладные расходы и возможность дешёвого переключения контекста.

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

В отличие от нативных потоков (pthreads, WinThreads), легковесные потоки не требуют системных вызовов для создания или планирования. Планировщик Go самостоятельно решает, когда и на каком ядре выполнять горутину, кооперируясь с потоками ОС. Ключевые свойства:

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

Легковесная модель позволяет запускать сотни тысяч горутин без деградации, недоступной для потоков ОС, где каждый поток занимает 1–8 МБ стека и весомую структуру ОС.

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

Основная структура — runtime.g. Планировщик управляет g в модели G-M-P, где P представляет виртуальный процессор (обычно GOMAXPROCS). Переключение контекста между горутинами — это сохранение/восстановление указателей стека и регистров через g.sched. В Go переключение занимает десятки-сотни наносекунд (только сохранение регистров), тогда как переключение потоков ОС — микросекунды с переключением в режим ядра и обратно.

Горутины уступают выполнение добровольно в точках park (блокировка на канале, мьютексе, таймере) или при вытеснении на основе сигнала SIGURG, посылаемого потоку-исполнителю, когда горутина занимает процессор дольше 10 мс (в Go 1.14+). Вытеснение асинхронное и безопасно в точках, где не нарушается атомарность.

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

  • Путаница с потоками ОС: ожидание, что горутина всегда выполняется на выделенном потоке.
    На деле горутина мигрирует между потоками; аффинность не гарантирована. Это важно для понимания привязки к тред-локальному состоянию — не следует полагаться на go runtime.LockOSThread() без крайней нужды.

  • Ошибочное предположение, что легковесность означает «бесплатность» при любых обстоятельствах.
    Хотя создание дёшево, каждая горутина всё равно потребляет память (стек + служебные структуры) и нагружает GC. Создание неограниченного количества без контроля приведёт к OOM.

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

Чем отличается горутина от корутины в других языках (Lua, C++20)?
Горутины Go отличаются многоядерным планированием (работа украдкой) и прозрачной интеграцией с сетевыми/блокирующими вызовами: сетевые вызовы epoll/kqueue превращаются в асинхронное ожидание в рантайме, позволяя другим горутинам выполняться на том же M. Классические корутины обычно однопоточны.

Какое максимальное число горутин можно создать на машине с 8 ГБ ОЗУ?
При стеке ~2 КБ и структуре ~0.4 КБ — около 3 млн, если не учитывать другие расходы памяти (куча, код). Реально же приложение упадёт от нехватки виртуальной памяти или ограничений ОС на процессы.


Сколько памяти занимает горутина?

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

Начальный стек горутины занимает 2 КБ (на 64-битной платформе). Плюс служебные структуры — около 376 байт для g и немного для планировщика. Итого минимальный начальный footprint примерно 2.4 КБ. Стек динамически растёт, поэтому реальное потребление зависит от глубины вызовов.

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

В Go 1.22+ начальный размер стека горутины – 2048 байт (раньше 8 КБ, уменьшен для снижения потребления при большом количестве горутин). Структура runtime.g содержит поля: указатель стека, stackguard0, планировочную информацию (g.sched), каналы ожидания, таймеры и т.д. Её размер в памяти около 376 байт (зависит от архитектуры). Также горутина может аллоцировать память в куче (работа с кучей не учитывается как overhead горутины).

Память для стека выделяется виртуально и может не быть задействована физически до использования (copy-on-write), но в учёте RSS она всё равно может фигурировать. Планировщик переиспользует освобождённые горутины через кэш gfree, экономя на выделении.

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

Выделение стека: runtime.stackalloc() запрашивает страницы у ОС (mmap), обычно кратно 8 КБ, но с учётом того, что каждая горутина может начинаться с маленького куска внутри более крупной страницы (стековые пулы). Для нити выделяется stack размером _FixedStack, определённый в runtime/stack.go как 2048 (до Go 1.19 было 4096, раньше 8192). При переполнении вызывается runtime.morestack(), выделяется новый стек двойного размера, копируется фрейм и освобождается старый (или помещается в пул). Сжатие стека происходит при GC (runtime.shrinkstack), если используется меньше четверти.

Структура g размещается на куче, но внутри runtime.mallocgc с флагом needzero, размером unsafe.Sizeof(g{}). В ней хранится ссылка на стек, m, p, контекст сохранения gobuf и т.д. Детальный размер можно посмотреть через отладчик или анализ бинарника.

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

  • Оценка размера горутины только по стеку без учёта структуры g.
    Приводят цифру 2 КБ, забывая о ~0.4 КБ служебных данных. В итоге на миллион горутин недооценивают память на 400 МБ.

  • Предположение, что стек всегда остаётся 2 КБ.
    Рекурсия или большие локальные переменные легко раздувают стек до десятков мегабайт, что надо учитывать при расчётах.

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

Как посмотреть текущее использование памяти горутинами в рантайме?
Через runtime.ReadMemStats() (runtime.MemStats.StackSys — память, занятая стеками всех горутин) или с помощью runtime.NumGoroutine(). Профилирование: pprof.Lookup("goroutine") покажет стеки всех горутин.

Когда стек горутины сжимается?
Во время сборки мусора (STW фаза mark termination или в фоне) вызывается shrinkstack, который уменьшает стек, если занято менее 25% текущего размера. Это предотвращает неконтролируемый рост после пиковых нагрузок.


Что такое состояние гонки (race condition)?

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

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

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

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

Go
if !done {
    done = true
    // критическая секция, но условие не атомарно
}

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

Следовательно, состояние гонки — это дефект логики синхронизации. Оно может возникать даже с корректными блокировками, если протокол взаимодействия неверен (например, deadlock как частный случай гонки или потеря сигнала).

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

С точки зрения рантайма, race condition не детектируется -race детектором, потому что он фиксирует только несинхронизированные обращения к одному слову (data race). Логические гонки требуют формальной верификации или тестирования. Рантайм не отслеживает инварианты высокого уровня. Однако детектор гонок (ThreadSanitizer) может помочь найти подозрительные чередования, если вставить атомарные операции и использовать happens-before ребра, но не все race condition ему подвластны.

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

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

  • Полагаться на удачное планирование при тестировании.
    При тестах можно не заметить гонку, если горутины выполняются в одном порядке. Использовать детектор гонок (-race) и стресс-тесты.

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

Можно ли иметь race condition без data race?
Да. Пример: две горутины атомарно проверяют и устанавливают флаг, но из-за отсутствия атомарности «проверил-установил» как целого обе могут войти в критическую секцию. Data race отсутствует, если каждая операция атомарна, но race condition есть.

Как детектор -race помогает в борьбе с race condition?
Он не ловит логические гонки напрямую, но сообщает о data races, которые часто являются симптомами более высокоуровневых гонок. Исследуя эти отчёты, можно выявить и исправить race condition.


Что такое гонка данных (data race)?

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

Data race — это ситуация, когда две горутины одновременно обращаются к одной и той же ячейке памяти, причём хотя бы одно обращение — запись, и отсутствует синхронизация (happens-before) между ними. Это частный случай race condition, нарушающий модель памяти Go и ведущий к неопределённому поведению.

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

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

Go
var x int
go func() { x = 1 }()
fmt.Println(x) // data race

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

Go предоставляет -race детектор (основан на ThreadSanitizer), который инструментирует обращения к памяти и сообщает о гонках во время исполнения.

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

Детектор гонок внедряет для каждого обращения к памяти (на куче, стеке отслеживается отдельно) вызов функций runtime.raceReadRange/raceWriteRange. Рантайм ведёт теневой массив, фиксируя происходящие обращения и проверяя отношение happens-before. Барьеры памяти вставлены для корректности. Внутренне используется алгоритм «векторных часов» на каждом goroutine, чтобы определить, были ли конфликтующие обращения из разных горутин без синхронизации.

Сами гонки могут происходить на регистровых/стековых переменных, если адрес взят или переменная захвачена замыканием. Компилятор при включённом -race добавляет вызовы для всех операций с памятью, что замедляет программу в 5–10 раз и увеличивает потребление памяти.

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

  • Считать, что -race ловит все гонки.
    Детектор работает только на выполненных путях; редкие race в не протестированных ветках могут остаться незамеченными. Нужны интеграционные тесты с -race и нагрузочное тестирование.

  • Использовать sync/atomic без упорядочивания, думая что это решает все проблемы.
    Атомики гарантируют атомарность конкретной операции, но не создают happens-before для остальной памяти, если явно не указать модель памяти. Например, запись указателя через atomic.StorePointer и чтение без синхронизации могут привести к частично инициализированному объекту. Нужно использовать соответствующие барьеры (Acquire/Release семантику) и atomic.Value.

  • Прятать гонку за time.Sleep в тестах.
    Добавление слипов для «устранения» гонки не исправляет проблему, а маскирует её; на быстрых/медленных машинах она всплывёт снова.

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

Какая модель памяти Go?
Модель памяти Go описывает, при каких условиях чтение переменной в одной горутине гарантированно увидит запись в другой. Основные механизмы синхронизации: инициализация пакета, запуск горутины, каналы, мьютексы, sync/atomic, sync.Once. Для атомиков есть acquire/release семантика (atomic.Load/atomic.Store не дают полной упорядоченности без эксплицитных флагов? На самом деле atomic операции упорядочены последовательно, но для relaxed упорядочения нет в стандартной библиотеке). Нужно знать, что каналы обеспечивают happens-before: запись в канал happens-before соответствующего чтения.

Можно ли создать data race на синхронизированном мьютексе?
Если забыть разблокировать или скопировать мьютекс (что приводит к разным состояниям), то можно получить некорректную синхронизацию и гонку. Но в правильно используемом мьютексе гонки данных на защищаемых переменных нет.


Как избежать гонки?

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

Для избежания гонки данных следует использовать синхронизацию: каналы, мьютексы, sync/atomic, sync.WaitGroup, context для координирования доступа. Логические race condition предотвращаются корректным проектированием протоколов обмена и использованием паттернов «разделяй память через коммуникацию», а также минимизацией общей изменяемой памяти.

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

Стратегии:

  1. Не делить память – функциональный стиль: каждая горутина работает с локальными копиями, результат передаётся через канал (процессор каналов).
  2. Каналы – передача владения данными: горутина отправляет структуру по каналу и больше к ней не обращается.
  3. Мьютексы (sync.Mutex, RWMutex) – защита критических секций. Обязательно использовать defer mu.Unlock().
  4. Атомики – для простых счётчиков и флагов без сложных инвариантов.
  5. Иммутабельные данные – если данные не изменяются после создания, конкурентный доступ безопасен.
  6. sync.Once для инициализации.
  7. Высокоуровневые примитивы – sync.Map, sync.Cond.
    Лучшая практика: начинать с каналов; если они неудобны или создают избыточное копирование, использовать мьютексы, тщательно тестируя с -race.

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

Рантайм предоставляет атомарные операции и фьютексы (в Linux) через низкоуровневые вызовы. Мьютексы реализованы с использованием runtime.semacquire/semrelease, которые в свою очередь используют фьютексы или аналоги ОС. Каналы реализованы через структуру hchan, блокирующую горутину при операции; синхронизация встроена в рантайм.

При использовании атомиков рантайм использует инструкции процессора с барьерами (LOCK префикс на x86), чтобы гарантировать атомарность и упорядоченность.

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

  • Чрезмерное применение мьютексов там, где достаточно атомиков или перепроектирования.
    Это ведёт к сложности и вероятным дедлокам. Атомики проще и быстрее для простых счётчиков.

  • Передача указателей на защищённые данные за пределы критической секции.
    После mu.Unlock() указатель, сохранённый ранее, может использоваться другой горутиной без блокировки. Решение: копировать данные или передавать владение.

  • Использование defer mu.Unlock() внутри цикла без осторожности.
    Если цикл долгий, это удерживает блокировку, снижая параллелизм. Иногда нужно разбить на мелкие критические секции.

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

В чём преимущество канала перед мьютексом для избежания гонок?
Каналы встраивают синхронизацию в передачу данных, делая неявным освобождение доступа. С помощью select можно реализовать таймауты и неблокирующие операции. Мьютекс требует явных Lock/Unlock, и легко забыть разблокировать или создать дедлок.

Какие гонки не ловит -race?
Гонки, которые не происходят в тестовых сценариях, а также гонки на уровнях выше (race condition). Детектор не проверяет код на этапе компиляции, а только сообщает о фактах при исполнении.


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

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

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

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

Идиоматический пример:

Go
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        // работа
    }(i)
}
wg.Wait() // блокируется, пока все не завершатся

WaitGroup можно переиспользовать только после того, как Wait вернётся и счётчик снова ноль. Нельзя вызывать Add с положительным дельтой после начала ожидания (это приводит к панике). Лучшая практика: вызывать Add до старта горутины, а не внутри неё, чтобы избежать гонки: если Add внутри, может случиться, что Wait пройдёт до того, как Add выполнится.

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

Реализация в src/sync/waitgroup.go. Структура WaitGroup содержит единственное поле noCopy и внутреннее представление — 32-битный счётчик + 32-битный waiter count, упакованные в одно 64-битное слово (для 64-битных систем) или выровненное (на 32-битных). state хранит: верхние 32 бита — счётчик, нижние 32 бита — число ожидающих (waiters). sema — значение для семафора (uint32), используемого для пробуждения.

Add(delta) атомарно добавляет к счётчику, используя atomic.AddUint64. Если новый счётчик == 0 и есть ожидающие, будит все ожидающие горутины через runtime.semrelease. Wait() атомарно увеличивает waiter count и затем вызывает runtime.semacquire, засыпая до пробуждения.

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

  • Вызов Add внутри горутины, а не перед её запуском.
    Это может привести к тому, что Wait вернётся до фактического завершения, если планировщик быстро выполнил Done до Add. Всегда вызывайте Add до go.

  • Копирование WaitGroup после первого использования.
    WaitGroup содержит состояние и запрещает копирование (noCopy); при копировании получается независимый счётчик, и Wait на копии не будет ждать оригинальные горутины, что нарушит синхронизацию. Передавать только по указателю.

  • Отрицательный счётчик из-за Done() без Add().
    Паника. Следите, чтобы число вызовов Done() точно соответствовало Add.

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

Что будет, если вызвать Wait в нескольких горутинах одновременно?
Это допустимо; все ждущие горутины увеличат waiter count и затем одновременно разблокируются, когда счётчик станет 0. Но повторное использование WaitGroup после этого требует осторожности.

Как WaitGroup связан с go vet?
go vet может обнаружить копирование WaitGroup после первого использования благодаря проверке noCopy с помощью sync.Locker (интерфейс). Также есть проверки на передачу WaitGroup по значению.


Как работает WaitGroup?

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

WaitGroup поддерживает атомарный 64-битный счётчик (state), старшие 32 бита — число активных горутин, младшие — число ждущих. Add атомарно меняет счётчик. Когда он становится 0, все ждущие горутины пробуждаются через внутренний семафор. Wait увеличивает waiter count и засыпает на семафоре до обнуления счётчика.

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

Принцип работы по шагам:

  1. Add(delta) — атомарно добавляет delta к счётчику. Если новый счётчик < 0 — паника. Если == 0 и waiter count > 0, сбрасывает waiter count в 0 и вызывает runtime.semrelease, чтобы разбудить все ожидающие горутины.
  2. Done() — эквивалентен Add(-1).
  3. Wait() — в бесконечном цикле загружает state; если счётчик == 0, сразу возвращается. Если нет, атомарно увеличивает waiter count на 1 (CAS-цикл, чтобы избежать гонок при одновременных Wait и Add). Затем вызывает runtime.semacquire на sema. При пробуждении (после semrelease) проверяется, что waiter count ещё не сброшен; если сброшен — выходит.

Таким образом, WaitGroup эффективно реализует барьер для группы горутин.

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

Код sync/waitgroup.go:

Go
type WaitGroup struct {
    noCopy noCopy
    state1 uint64
    state2 uint32
}

На 64-битных системах state1 содержит и counter (старшие 32), и waiters (младшие 32). state2 – sema. На 32-битных добавляется сложность с выравниванием. Add использует atomic.AddUint64(&wg.state1, delta<<32) (если 64-бит), что напрямую меняет счётчик. Wait использует цикл с atomic.CompareAndSwapUint64 для инкремента waiter count.

Базовый семафор реализован через runtime.semacquire/semrelease, которые работают через фьютексы (Linux) или аналогичные механизмы ОС. Сам семафор — это uint32, адрес которого используется как идентификатор.

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

  • Неправильное использование с переиспользованием WaitGroup.
    Нельзя вызывать Add с положительным дельтой, пока Wait не вернулся. Иначе состояние гонки: Wait может увидеть ненулевой счётчик, увеличить waiters, но до этого новое Add может обнулить счётчик и разбудить, не увидев нового waiters. Паника «sync: WaitGroup is reused before previous Wait has returned».

  • Вызов Done() больше раз, чем нужно — паника.
    Кандидаты могут ошибиться в подсчёте количества запущенных горутин.

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

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

Как WaitGroup обеспечивает безопасную работу с несколькими одновременно вызывающими Wait?
Через CAS-цикл при увеличении waiter count. Каждый Wait атомарно регистрирует себя как ожидающего, и все они будут разбужены при обнулении counter.

Почему WaitGroup не предоставляет метод для получения текущего счётчика?
Потому что это провоцирует race condition: другая горутина может изменить счётчик сразу после чтения, делая информацию недостоверной. Проектировщики решили не вводить ложное чувство контроля.


Как WaitGroup отслеживает завершение горутин?

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

WaitGroup не отслеживает конкретные горутины; он оперирует целочисленным счётчиком. Каждый запуск горутины увеличивает счётчик (Add(1)), а по завершении уменьшает (Done()). Когда счётчик падает до нуля, WaitGroup будит все ожидающие горутины, сигнализируя, что все задачи завершены.

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

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

  • При старте задачи — инкремент.
  • При завершении — декремент.
  • Если счётчик == 0, значит все задачи завершены (или их не было).
    Родительская горутина вызывает Wait(), которая блокируется до нуля. Таким образом, мы получаем барьер.

Для гарантии, что декремент произойдёт даже при панике, используется defer wg.Done() в начале горутины.

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

Реализация счётчика в state1: старшие 32 бита — текущее количество активных горутин. При каждом Done выполняется атомарное вычитание. В момент, когда счётчик становится 0, проверяется waiter count. Если он > 0, происходит вызов runtime.semrelease(&wg.sema) с количеством waiter, пробуждая все ожидающие горутины.

Важно: никакой горутине не присваивается уникальный идентификатор и не ведётся список. Это чрезвычайно легковесно.

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

  • Думать, что WaitGroup знает, какие горутины ещё работают, и можно получить их список.
    Нет, знает только число.

  • Создавать горутину, декрементирующую WaitGroup внутри себя, но вне defer, и при панике счётчик не уменьшится.
    Это приведёт к вечному ожиданию на Wait. Всегда используйте defer wg.Done().

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

Как WaitGroup взаимодействует с планировщиком, когда счётчик обнуляется?
Когда счётчик достигает 0, semrelease помечает семафор как свободный и пробуждает все горутины, заблокированные на semacquire. Планировщик переводит их в состояние Grunnable и ставит в очередь. Никакого специального приоритета нет.

Можно ли повторно использовать WaitGroup после Wait?
Да, после того как Wait вернулся, счётчик 0, waiter count сброшен. Можно снова вызывать Add(n) и запускать новые задачи.


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

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

sync.Once — это примитив, гарантирующий, что переданная функция выполнится ровно один раз, независимо от того, сколько горутин вызовут Do. Используется для отложенной инициализации, singleton-объектов, безопасной установки флагов.

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

Пример:

Go
var once sync.Once
var config Config

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

Все вызовы GetConfig из любых горутин безопасны: loadConfig выполнится только один раз, даже при конкурентных вызовах. Быстрая проверка без блокировки: после завершения инициализации атомарный флаг предотвращает вход в критическую секцию.

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

Структура Once содержит done (uint32, атомарно) и m (мьютекс). Алгоритм:

  1. Быстрая проверка atomic.LoadUint32(&o.done) == 0; если 1 — сразу выход.
  2. Блокировка o.m.Lock().
  3. Повторная проверка o.done == 0 (double-checked locking).
  4. Выполнение функции f() (defer-разблокировка).
  5. atomic.StoreUint32(&o.done, 1).

Таким образом, только одна горутина выполняет функцию, остальные ждут на мьютексе или пролетают после установки done.

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

  • Полагаться, что внутри Do безопасно паниковать.
    Если функция паникует, done не будет установлен, и следующие вызовы снова выполнят функцию. Это может привести к повторным паникам. Нужно перехватывать панику внутри, если надо.

  • Копирование sync.Once после первого использования.
    Как и другие синхропримитивы, Once не должен копироваться; копия будет иметь независимый done.

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

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

Можно ли использовать sync.Once для инициализации, которая может завершиться ошибкой?
Нет, Once не предназначен для повторения при ошибке. В таком случае инициализацию стоит делать в init() или использовать sync.Mutex с флагом и ручным управлением.

Как Once работает с точки зрения модели памяти?
Вызов Do синхронизирован: завершение f happens-before возврата из любого Do. То есть все записи, сделанные f, видны после возврата Do.


Для чего нужен sync.Once?

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

sync.Once нужен для гарантированного однократного выполнения кода, чаще всего для потокобезопасной отложенной инициализации (lazy initialization). Это предотвращает гонки при создании ресурса несколькими горутинами и избавляет от необходимости ручной двойной проверки (double-checked locking).

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

Типичные сценарии:

  • Инициализация глобального конфига из файла при первом обращении.
  • Создание экземпляра клиента базы данных, которое должно произойти один раз.
  • Настройка логгера.
  • Регистрация обработчиков в net/http (хотя обычно происходит в init, но возможно отложенное).

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

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

Аналогично предыдущему вопросу, подчеркнём эффективность: после выполнения done = 1, все последующие вызовы Do сразу возвращаются без блокировок — только атомарное чтение, работающее на уровне кэша очень быстро.

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

  • Передавать разные функции в один и тот же Once.
    Once привязан к экземпляру; если вызывать Do с разными функциями, только первая выполнится, остальные проигнорированы. Это зачастую баг. Обычно один Once — одна функция.

  • Использовать Once для периодических обновлений (например, refresh token) — неверно, нужно использовать atomic или мьютекс с условием.

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

Чем отличается sync.Once от init() функции?
init выполняется до main всегда, при старте программы. sync.Once — ленивая инициализация, запускается при первом обращении, что может уменьшить время запуска и экономить ресурсы, если сервис так и не понадобился.

Можно ли реализовать Once через каналы?
Теоретически можно закрытием канала: первый закрывает канал, остальные читают из него, получая zero value немедленно. Но это не даёт защиты от паник и менее удобно. Стандартный sync.Once предпочтительнее.


Как реализовать отложенную инициализацию с помощью sync.Once?

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

Создать переменную типа sync.Once и в функции-геттере вызывать once.Do(func() { /* инициализация */ }). При первом вызове произойдёт инициализация, последующие вызовы её пропустят. Это обеспечивает потокобезопасную отложенную инициализацию без явных блокировок.

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

Полный паттерн:

Go
type Service struct {
    client *http.Client
    initOnce sync.Once
}

func (s *Service) client() *http.Client {
    s.initOnce.Do(func() {
        s.client = &http.Client{Timeout: 10 * time.Second}
    })
    return s.client
}

Здесь client создаётся только при первом вызове client(), и все дальнейшие вызовы возвращают готовый экземпляр. Это уменьшает время старта и позволяет работать с невалидными конфигурациями, которые могут не понадобиться.

Отложенная инициализация может сочетаться с передачей зависимостей, но нужно следить, чтобы инициализируемые поля были безопасно опубликованы (после Do все записи видны благодаря happens-before).

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

Once.Do создаёт синхронизационное ребро: завершение переданной функции happens-before всех возвратов из Do. С точки зрения модели памяти, после завершения f все горутины, вышедшие из Do, видят все изменения, произведённые f.

В реализации используется мьютекс для предотвращения параллельного выполнения, но после установки done блокировка не захватывается.

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

  • Захват внешней переменной, которая изменяется после инициализации.
    Если Do инициализирует поле, но позже код меняет его без синхронизации — гонка. После инициализации нужно либо считать поле неизменяемым, либо защищать доступ блокировками.

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

  • Попытка сброса Once для повторной инициализации (например, перезагрузка конфига).
    Once не поддерживает сброс. Для этого нужна собственная реализация с мьютексом и флагом, либо создавать новый экземпляр Once (но проще RWMutex).

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

Как гарантировать, что инициализационная функция не будет блокироваться вечно?
Использовать контекст с таймаутом внутри инициализации. Если таймаут истечёт, можно паниковать (и тогда Once не будет считаться выполненным). Более надёжно — инициализировать в init или до старта горутин.

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


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

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

sync.Cond — условная переменная, позволяющая горутинам ожидать наступления некоторого условия. Она связана с sync.Locker (обычно мьютекс). Горутины вызывают Wait() (атомарно разблокирует мьютекс и засыпает), а другая горутина может разбудить одну или все ждущие через Signal или Broadcast после изменения условия.

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

Cond полезна, когда горутине нужно ждать изменения состояния, защищённого мьютексом, но постоянный опрос (spin) неэффективен. Типичный сценарий: очередь задач — потребители ждут, когда появится элемент; производитель добавляет элемент и вызывает Signal(). Пример:

Go
mu.Lock()
for len(queue) == 0 {
    cond.Wait()
}
item := queue[0]
queue = queue[1:]
mu.Unlock()
process(item)

Цикл for обязателен, потому что может произойти ложное пробуждение, или условие может измениться между пробуждением и захватом блокировки.

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

Реализация sync/cond.go:

Go
type Cond struct {
    noCopy noCopy
    L Locker
    notify  notifyList
    checker copyChecker
}

notifyList — список ожидающих горутин (структура из рантайма). Wait атомарно добавляет горутину в notifyList, разблокирует L, вызывает runtime.notifyListWait (которая паркует горутину), а после пробуждения снова захватывает L. Signal пробуждает одну горутину из списка (FIFO), Broadcast пробуждает все.

Список реализован через связный список из sudog-структур, привязанных к горутинам. notifyList содержит поле wait (счётчик пробуждений) и notify (счётчик сигналов). Это позволяет эффективно управлять очередью.

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

  • Пропуск проверки условия в цикле for.
    Если использовать if вместо for, после ложного пробуждения или украденного сигнала горутина может продолжить, не проверив условие, что приведёт к логическим ошибкам. Всегда цикл.

  • Вызов Wait без удержания блокировки L.
    По спецификации нужно удерживать L перед вызовом Wait. Иначе поведение не определено (паника или гонка). Аналогично, Signal/Broadcast можно вызывать без блокировки, но рекомендуется всё же с ней во избежание потерянных пробуждений.

  • Использование Cond вместо каналов там, где каналы уместнее.
    Cond сложнее, и для простых уведомлений «данные готовы» канал проще и безопаснее. Но если нужен широковещательный сигнал (Broadcast) или сложная логика — Cond оправдана.

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

Почему в Go Cond не используется так часто, как в pthread?
Потому что модель каналов и горутин предоставляет более высокоуровневую абстракцию. Многие задачи, решаемые через Cond, в Go решаются каналами или select. Cond остаётся для низкоуровневых случаев, когда нужно оповестить множество ожидающих или работа с блокировками.

Как notifyList гарантирует отсутствие гонок?
Рантайм использует атомарные операции для добавления в список и счётчики пробуждений. Парковка и распарковка горутин осуществляется с блокировкой внутреннего спин-лока рантайма, который защищает эти структуры.


Для чего нужен sync.Cond?

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

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

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

Примеры:

  • Ожидание заполнения буфера до определённого уровня.
  • Координация старта нескольких горутин одновременно: Broadcast() после установки флага готовности.
  • Реализация семафоров с поддержкой ожидания определённого количества ресурсов.
  • Ожидание изменения множества флагов под одним мьютексом.

В Go Cond менее популярен из-за каналов, но незаменим в некоторых библиотеках (например, net/http использует sync.Cond внутри http2 транспорта для управления потоком).

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

Внутреннее устройство как в предыдущем ответе. Cond эффективно использует список ожидания в рантайме и не требует постоянного опроса. При этом Broadcast может разбудить тысячи горутин одной операцией, тогда как с каналами пришлось бы рассылать N сообщений (или закрывать канал, но закрытие канала можно сделать только один раз).

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

  • Предположение, что Broadcast гарантирует порядок пробуждения.
    Порядок не гарантирован; проснувшиеся горутины будут конкурировать за мьютекс в произвольном порядке, определяемом планировщиком.

  • Неосвобождение мьютекса до вызова Wait — deadlock.

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

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

Чем Broadcast отличается от закрытия канала?
Закрытый канал пробуждает всех читателей однократно, после этого читатели получают zero value. Broadcast можно вызывать многократно, и после пробуждения горутины могут снова проверить условие и при необходимости опять ждать.

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


Как использовать Broadcast и Signal у sync.Cond?

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

Signal() пробуждает одну ожидающую горутину (если есть), Broadcast() – все ожидающие. Вызывать их можно как с удержанием, так и без удержания ассоциированного мьютекса (хотя часто с мьютексом, чтобы гарантировать, что изменение условия и пробуждение атомарны). После пробуждения горутина обязана перепроверить условие в цикле.

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

Идиоматичный шаблон:

Go
var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false
// горутина-ожидатель:
mu.Lock()
for !ready {
    cond.Wait()
}
// условие истинно
mu.Unlock()

// горутина-инициатор:
mu.Lock()
ready = true
cond.Broadcast() // или cond.Signal() если ждёт только одна
mu.Unlock()

Signal будит одну горутину (порядок FIFO, но не гарантируется строго). Если ждущих нет, вызов безвреден. Broadcast будит всех. После пробуждения горутина снова захватывает мьютекс и продолжает цикл for, проверяя условие.

Рекомендация: вызывать Signal/Broadcast после изменения условия, удерживая мьютекс, чтобы избежать «потерянного пробуждения», когда другая горутина изменяет условие и вызывает Signal до того, как ожидающая горутина успела войти в Wait.

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

Signal увеличивает внутренний счётчик notify и вызывает runtime.notifyListNotifyOne, который просматривает список ожидающих, находит первую не разбуженную горутину (по указателю) и переводит её в состояние Grunnable, помечая связанный sudog. Broadcast вызывает runtime.notifyListNotifyAll, который проходит весь список и пробуждает всех.

Необходимость цикла for обусловлена тем, что runtime не гарантирует, что при пробуждении условие всё ещё истинно (другая горутина могла изменить его в промежутке), а также возможны ложные пробуждения.

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

  • Использовать if ready вместо for !ready.
    Чревато ошибками при ложных пробуждениях или множественных ожидающих.

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

  • Вызов Signal без мьютекса и последующее изменение условия — возможен пропуск уведомления.
    Другая горутина может войти в Wait после вызова Signal, но до изменения условия, и не получить уведомление. Держите мьютекс.

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

Как notifyList решает проблему ложных пробуждений?
Он не решает, он позволяет им случаться (например, из-за обработки сигналов ОС или внутренней реализации). Поэтому спецификация Cond требует цикл for.

Можно ли повторно использовать sync.Cond после Broadcast?
Да, Cond многоразовый. После пробуждения горутин, список ожидающих пуст, и новые Wait будут ждать следующего сигнала.


Что такое утечка горутин (goroutine leak)?

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

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

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

Типичные причины утечек:

  • Горутина заблокирована на канале, из которого никто не читает или в который никто не пишет.
  • Ожидание мьютекса, который никогда не освободят.
  • Ожидание sync.WaitGroup, где не вызывают Done().
  • Бесконечный цикл без механизма выхода.
  • context не отменяется, и горутина висит на связанном <-ctx.Done().

Утечки особенно опасны в долгоживущих серверах, где число горутин может достигнуть сотен тысяч, вызывая OOM.

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

Рантайм не производит автоматического обнаружения утечек горутин. Каждая «зависшая» горутина занимает запись g и стек. Они учитываются в runtime.NumGoroutine() и видны в профиле горутин (pprof). Парковка происходит через вызовы runtime.gopark, которые помещают горутину в wait-очередь канала, мьютекса или сети. Без внешнего воздействия они не проснутся.

Сборщик мусора не может очистить живую горутину, так как она всё ещё достижима со стека и считается активной.

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

  • Забывают предусмотреть выход при ошибке или завершении контекста.
    Горутина запускается с циклом, читающим из канала, но при закрытии канала не выходит, так как канал не закрыт, или используют range только по одному каналу без select.

  • Блокировка на отправке в небуферизованный канал без получателя.
    Если горутина-отправитель никем не читается, она зависает навсегда. Нужно использовать select с default или context.Done().

  • Создание горутин без контроля их жизненного цикла.
    Каждая горутина должна иметь понятный способ завершения: по закрытию канала, по контексту, по таймауту.

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

Как профилировать и находить утечки горутин?
Использовать pprof.Lookup("goroutine") или HTTP-профилировщик debug/pprof/goroutine?debug=1/2. Анализировать стеки горутин, искать подозрительно много висящих на одной точке.

Как предотвратить утечку при использовании channel между горутинами?
Явно закрывать канал, когда отправитель завершил работу; использовать select с Done() каналом контекста; применять паттерн «or-channel» для объединения отмен.


Как предотвратить утечку горутин?

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

Для предотвращения утечек необходимо для каждой горутины определить явный механизм завершения: передача контекста с отменой, закрытие канала, таймауты. Использовать структурные паттерны (worker pool, fan-in/fan-out) с гарантированным выходом. Избегать блокировок навсегда — везде, где возможно, применять select с несколькими каналами, включая сигнальный.

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

Основные техники:

  • Контекст: пробрасывать ctx в горутину и проверять ctx.Done().
  • Канал-сигнала: специальный chan struct{} для остановки.
  • WaitGroup + отмена: родитель отслеживает завершение дочерних горутин и при необходимости форсирует отмену через контекст.
  • Ограничение числа горутин через семафор, чтобы избежать взрывного роста.
  • Временные лимиты: time.After или context.WithTimeout для операций, которые могут зависнуть.
    Пример безопасной горутины:
Go
func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        case msg := <-ch:
            process(msg)
        }
    }
}

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

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

Контекст с отменой (WithCancel) при вызове cancel закрывает внутренний канал ctx.Done(). Все горутины, ожидающие на этом канале, получат событие и смогут выйти. Рантайм закрывает канал и переводит ожидающие горутины в runnable, они выходят из select и могут корректно завершиться.

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

  • Создание горутины без привязки к жизненному циклу родителя.
    Если родитель завершился или потерял интерес, горутина продолжает висеть. Всегда связывайте через контекст.

  • Использование time.Sleep как способа «отменить» горутину.
    Непредсказуемо и ненадёжно. Только явные сигнальные механизмы.

  • Забыть вызвать cancel у контекста с таймаутом, что приводит к утечке таймеров и горутин.
    Всегда defer cancel().

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

Есть ли в Go автоматическая сборка «мусорных» горутин?
Нет, рантайм не определяет, что горутина больше не нужна, и не убивает её. Это задача разработчика.

Как runtime.Goexit влияет на утечки?
Goexit завершает текущую горутину, но не выполняет отложенные вызовы других горутин. Сама по себе не вызывает утечку, но при неправильном использовании может нарушить инварианты WaitGroup и привести к зависанию.


Как корректно завершить горутину?

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

Корректное завершение — добровольное, по сигналу. Используются: закрытие канала, отмена контекста, таймаут, флаг с атомарной проверкой, sync.WaitGroup для ожидания. Нельзя убить горутину извне; она должна сама проверить условие и выйти (обычно через return).

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

Шаги корректного завершения:

  1. Предоставить горутине способ получения сигнала остановки (канал done <- struct{}, ctx.Done()).
  2. Внутри горутины в основном цикле или в критических точках проверять этот сигнал с помощью select или неблокирующего чтения.
  3. После получения сигнала горутина завершает работу: закрывает ресурсы, уведомляет о завершении (WaitGroup.Done) и выходит.
  4. Внешний код, отправив сигнал, должен дождаться фактического выхода (через WaitGroup или канал-подтверждение).

Пример завершения через закрытие канала:

Go
done := make(chan struct{})
go func() {
    defer wg.Done()
    for {
        select {
        case <-done:
            return
        case v := <-ch:
            handle(v)
        }
    }
}()
// остановка:
close(done)
wg.Wait()

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

Закрытие канала (close(done)) немедленно делает операции чтения из него успешными (возвращая zero value, false). Все горутины, ожидающие в select на этом канале, получат событие case <-done и выполнят ветку. Планировщик переводит их в runnable.

При использовании context, cancel() закрывает внутренний канал ctx.Done(), что аналогично.

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

  • Попытка принудительно завершить горутину через runtime.Goexit() или os.Exit – грубо и небезопасно.
    Goexit может быть использован, но он не дает возможности корректно освободить внешние ресурсы, а os.Exit убивает весь процесс.

  • Отправка сигнала остановки, но отсутствие ожидания фактического завершения.
    Родительская горутина может продолжить работу и, например, закрыть ресурсы, которые ещё используются завершающейся горутиной. Нужен механизм ожидания (WaitGroup, канал).

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

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

Как остановить горутину, выполняющую долгую CPU-bound операцию без точек ожидания?
Можно периодически проверять флаг (атомарный) или ctx.Done() внутри цикла. Если алгоритм не делится на итерации, сложно. Вытеснение Go (с 1.14) может прервать её только для планирования, но не для завершения по воле программиста. Разработчику нужно вставлять проверки.

Безопасно ли паниковать в горутине для её завершения?
Паника без recover обрушит процесс. Если использовать recover, можно завершить горутину, но это не считается нормальным способом управления потоком; сигнал лучше.


Что такое context.Context?

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

context.Context — это интерфейс в стандартной библиотеке Go для передачи значений, сигналов отмены и дедлайнов через границы горутин и API. Он является стандартным способом управления жизненным циклом операций, распространения таймаутов и отмен, а также передачи метаданных запроса (но не для опциональных параметров).

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

Контекст содержит:

  • Deadline() — время, когда работа должна быть завершена.
  • Done() — канал, закрываемый при отмене контекста или истечении дедлайна.
  • Err() — причина отмены (ContextCanceled / DeadlineExceeded).
  • Value(key) — ассоциированные значения (использовать с осторожностью).

Основные производные: WithCancel, WithTimeout, WithDeadline, WithValue. Типичный паттерн: родительская операция создаёт дочерний контекст с таймаутом и передаёт в вызовы ниже по стеку; при отмене все операции, слушающие Done, могут корректно завершиться, освобождая ресурсы.

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

Базовые реализации:

  • emptyCtx (для Background() и TODO()) — просто целое число, реализующее интерфейс.
  • cancelCtx — содержит done channel (lazy инициализация) и массив дочерних контекстов. При cancel() закрывает канал и рекурсивно отменяет дочерние.
  • timerCtx — обёртка над cancelCtx с таймером. При истечении таймера вызывает cancel.
  • valueCtx — содержит ключ-значение и родительский контекст.

Канал done создаётся один раз (лениво) и представляет собой chan struct{} нулевого размера; закрытие широковещательно пробуждает все ожидающие горутины.

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

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

  • Использование context.Background() в глубоком коде, когда доступен контекст.
    Это разрывает цепочку отмены, и операция не сможет быть отменена. Всегда передавайте контекст.

  • Передача изменяемых значений через Value.
    Значения должны быть иммутабельны или безопасны для конкурентного использования; изменение переданного значения может привести к гонкам.

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

Можно ли использовать контекст для передачи опциональных параметров функции?
Нет, это антипаттерн. Для опциональных параметров используйте структуры опций (functional options). Контекст должен содержать только данные, относящиеся к запросу (trace ID, deadlines), а не бизнес-логику.

Почему интерфейс Context не имеет метода Cancel()?
Потому что только создатель контекста должен иметь возможность его отменить. Функция отмены cancel возвращается отдельно и передаётся контролирующему коду.


Зачем нужен context.Context?

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

context.Context решает проблему распространения отмены, дедлайнов и метаданных в синхронных и асинхронных цепочках вызовов. Он является стандартным механизмом для graceful завершения операций, ограничения времени их выполнения и изоляции значений в рамках одного запроса.

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

Контекст стал ответом на хаос из ручных каналов отмены и таймеров. Без контекста каждая библиотека изобретала свой способ отмены. Стандартизация позволила:

  • Строить иерархии: сервер HTTP создаёт контекст с таймаутом на весь запрос; база данных, кеш, внешние API получают производный контекст и могут автоматически отменяться при таймауте запроса.
  • Легко добавлять распределённую трассировку (trace ID через Value).
  • Безопасно передавать значения, специфичные для запроса, не загрязняя глобальное состояние.

Таким образом, context обеспечивает сквозную функциональность (cross-cutting concerns).

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

При отмене родительского контекста все дочерние cancelCtx рекурсивно отменяются, и их Done каналы закрываются. Это выполняется синхронно в рамках вызова cancel. Тайм-аут реализован через time.AfterFunc, которая по истечении дёргает cancel. Использование стандартного таймера рантайма с минимальным оверхедом.

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

  • Создание контекстов без отмены (context.Background()) и передача их в долгие операции.
    Тогда операция не может быть прервана снаружи, что приводит к зависанию и утечкам ресурсов.

  • Не проверять ctx.Err() в длительных циклах.
    Даже если контекст отменён, горутина может продолжать бесполезно работать, если не смотреть Done.

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

Что произойдёт, если не вызвать cancel у контекста, созданного с WithTimeout?
До истечения таймаута будет утечка ресурсов: таймер не остановится раньше времени, и связанные горутины продолжат существовать. После истечения таймаута контекст всё равно отменится, но таймер в пуле так и останется до истечения, а если таймаут большой, это опасно. cancel немедленно освобождает таймер.

Обязан ли я передавать контекст во все функции?
Нет, но если функция выполняет I/O, блокировки, долгие операции, она должна принимать контекст и уважать его отмену. Чистые вычислительные функции контекст не требуют.


Как отменить горутину с помощью context?

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

Создать контекст с WithCancel (или WithTimeout), передать горутине, а внутри горутины в select или в цикле проверять ctx.Done(). При вызове cancel() извне канал Done закрывается, горутина просыпается и должна корректно завершиться.

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

Go
ctx, cancel := context.WithCancel(context.Background())
go func() {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("отмена:", ctx.Err())
            return
        default:
            // работа
        }
        // также можно if ctx.Err() != nil { return }
    }
}()
// отменить:
cancel()

Можно также привязать таймаут:

Go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Горутина получит отмену через 5 секунд автоматически. Обязательно defer cancel() для освобождения ресурсов.

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

При вызове cancel() функция context.cancelCtx.cancel закрывает внутренний канал done (однократно). Это вызывает пробуждение всех горутин, зарегистрированных на чтение из этого канала. Планировщик перемещает их в runnable.

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

  • Игнорирование ctx.Done() и использование time.Sleep для ожидания отмены.
    Это неатомарно и ненадёжно. Только select.

  • Закрытие внешнего канала вместо отмены контекста.
    Тогда горутина, слушающая только ctx.Done(), не узнает об отмене. Нужно согласованно использовать контекст.

  • Передача cancel в саму горутину.
    Горутина не должна самостоятельно отменять контекст, если она не владелец. Это может привести к непредсказуемой отмене других горутин.

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

Как отменить несколько горутин одновременно?
Дать им общий контекст. Вызов cancel() отменит его, и все горутины, слушающие Done, получат сигнал.

Что если горутина уже завершилась, а cancel вызывается позже?
Ничего страшного, cancel идемпотентен; повторный вызов безопасен, просто no-op.


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

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

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

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

Мьютекс имеет два состояния: разблокирован и заблокирован. Lock() блокирует, если мьютекс занят; Unlock() освобождает и будит ожидающие горутины. Идиоматически используется с defer:

Go
mu.Lock()
defer mu.Unlock()
// критическая секция

Мьютекс в Go справедливый только частично: в нормальном режиме горутины ждут в очереди FIFO приблизительно, но новый прибывший может перехватить блокировку (забегание), что повышает производительность. Однако при голодании включается режим голодания (starvation mode), где владение передаётся строго ожидающей.

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

Структура sync.Mutex включает два поля: state (int32) и sema (uint32). state кодирует: бит блокировки (locked), бит пробуждения (woken), бит голодания (starving), и количество ожидающих (waiters) в остальных битах. sema — семафор для парковки/распарковки.

Алгоритм комбинирует спин-блокировку (до ограниченного числа циклов, если mutex занят недолго) и обращение к семафору (runtime.semacquire). При Unlock, если есть ожидающие, вызывается runtime.semrelease для пробуждения одной горутины.

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

  • Забывают разблокировать — deadlock. defer mu.Unlock() решает, но иногда defer в цикле удерживает мьютекс слишком долго.

  • Копирование мьютекса (передача по значению). Копия получает то же внутреннее состояние, что и оригинал, и последующие блокировки работают некорректно. go vet предупреждает.

  • Рекурсивная блокировка. В Go mutex не подсчитывает владельца. Двойной Lock из одной горутины заблокирует её навсегда. Нужно использовать sync.RWMutex или перепроектировать.

  • Публикация защищённых данных через указатель вне критической секции. Данные могут быть прочитаны без блокировки после Unlock.

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

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

Как spin lock помогает производительности?
При коротких критических секциях выгоднее покрутиться в цикле, чем парковать горутину (переключение контекста). Рантайм ограничивает количество циклов, чтобы не тратить CPU впустую.


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

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

Mutex реализован через атомарное состояние (int32) и семафор для ожидания. Lock пытается атомарно установить locked-бит; если не удалось, горутина входит в медленный путь с возможным спином и затем засыпает на семафоре. Unlock сбрасывает locked-бит и, если есть ожидающие, будит одну из них через семафор.

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

Подробный алгоритм:

  • Нормальный режим: ожидающие становятся в очередь FIFO, но просыпающаяся горутина не получает мьютекс автоматически — она конкурирует с новыми вызывающими Lock. Новый забежавший может выиграть (это ускоряет, но может привести к голоданию).
  • Режим голодания: если просыпающаяся горутина не может захватить мьютекс более 1 мс, мьютекс переходит в режим голодания. В этом режиме владение передаётся непосредственно пробуждённой горутине, новые горутины не перехватывают, а встают в очередь.
  • Spin: в медленном пути мутекс делает ограниченное количество spin-циклов (активное ожидание), если текущий P свободен и нет других горутин в очереди; это уменьшает задержку.

Код медленного пути: runtime.semacquire1 для парковки и runtime.semrelease1 для пробуждения используют низкоуровневые механизмы ОС (futex в Linux, waitsleep в Darwin и т.д.). Это ядро рантайма для синхронизации.

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

Детали state:

  • 0 бит: locked.
  • 1 бит: woken (горутина уже будится, чтобы избежать лишних пробуждений).
  • 2 бит: starving.
  • Биты 3-31: количество waiters (минус возможные смещения).
    Манипуляции с state выполняются атомарными CAS. Например, Lock в быстром пути: atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked). Медленный путь вызывает lockSlow, в котором реализована очередь ожидания и spin.

Структура очереди ожидающих не явная, а встроена в рантайм через планировщик: каждая горутина, которая засыпает на семафоре, связывается с адресом &m.sema. При semrelease рантайм находит одну ждущую горутину и пробуждает её.

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

  • Неправильное использование Unlock без Lock — паника «sync: unlock of unlocked mutex».
  • Двойной Unlock — паника.
  • Долгие блокировки в критической секции — снижение производительности из-за голодания других горутин. Надо минимизировать работу под мьютексом.

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

Почему Mutex не экспортирует методы для проверки состояния?
Чтобы не поощрять race condition на проверке состояния «заблокирован ли?» между моментом проверки и действием. Such checks are meaningless.

Как Mutex взаимодействует с defer?
defer mu.Unlock() гарантирует, что разблокировка произойдёт при любом выходе из функции, включая панику. Это стандартная практика.


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

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

RWMutex — это читательско-писательский мьютекс: поддерживает одновременное удержание несколькими «читателями» (RLock/RUnlock) и эксклюзивное удержание одним «писателем» (Lock/Unlock). Писатель имеет приоритет, чтобы предотвратить голодание. Используется, когда данные чаще читаются, чем пишутся.

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

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

Сигнатура:

  • RLock(), RUnlock()
  • Lock(), Unlock()
    Ограничение: нельзя повысить RLock до Lock без риска дедлока.

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

Структура sync.RWMutex содержит:

  • w — внутренний Mutex для писателей.
  • writerSem, readerSem — семафоры для ожидания.
  • readerCount (int32) — количество активных читателей (или отрицательное, если писатель ожидает/активен).
  • readerWait — число читателей, которых писатель должен дождаться перед входом.

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

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

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

  • Неосвобождение RLock (забыли RUnlock) — приводит к вечной блокировке писателя.
  • Использование RWMutex при коротких критических секциях и редких чтениях. Накладные расходы на RWMutex выше, чем у Mutex из-за дополнительной логики. Если чтения не часты, обычный Mutex быстрее.
  • Рекурсивное взятие RLock (та же горутина пытается взять RLock дважды) — допустимо, если нет промежуточного Lock, но чревато дедлоком при попытке повышения до Lock. Лучше избегать.
  • Копирование RWMutex.

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

Гарантирует ли RWMutex полную справедливость?
Нет, он предотвращает голодание писателей, блокируя новых читателей при ожидающем писателе. Но если писатели приходят непрерывно, читатели могут голодать. Зависит от конкретного конкурентного доступа.

Как RWMutex обрабатывает попытку Lock при уже активных читателях?
Писатель блокируется до завершения всех активных читателей (ждущих на writerSem), новые читатели после запроса писателя блокируются.


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

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

Использовать RWMutex, когда данные значительно чаще читаются, чем пишутся, и критическая секция чтения достаточно длинна, чтобы выигрыш от параллельных чтений превысил накладные расходы на управление читателями/писателями. В сценариях с редкими записями и множеством конкурентных чтений RWMutex даёт существенный выигрыш в throughput.

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

Эвристика:

  • Профилируйте. Если блокировка мьютекса является узким местом из-за множества читателей, переходите на RWMutex.
  • Важно, чтобы секция чтения была не тривиальной (не просто возврат поля), иначе оверхед RWMutex может сделать его медленнее обычного Mutex.
  • Если записи часты, или чтение и запись чередуются, Mutex может быть лучше, так как читательско-писательская блокировка добавляет задержку на синхронизацию счётчиков.

Пример: кэш в веб-сервере, где 99% операций — чтение из map, а обновление происходит раз в минуту, идеально для RWMutex.

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

RWMutex использует атомарные операции над readerCount, что добавляет затраты на каждый RLock/RUnlock (атомарный инкремент/декремент). При высоком конкурентном доступе к кэш-линии счётчика может возникнуть contention на уровне процессора. Mutex же проще и в незагруженном состоянии может захватываться одной атомарной CAS. Поэтому в low-contention Mutex может быть быстрее.

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

  • Применение RWMutex без измерения реальной нагрузки — может ухудшить производительность.
  • Смешивание защиты map через RWMutex и итерации. При итерации по map без блокировки записи возможен race (детектор скажет). Нужно либо держать RLock на всё время итерации, либо скопировать map.
  • Попытка использовать RWMutex для конкурентных append в слайс — чтение может быть параллельным с модификацией, если не синхронизировано.

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

В чём разница накладных расходов RWMutex и Mutex на уровне процессора?
RWMutex требует как минимум двух атомарных операций (readerCount) при каждом RLock/RUnlock, что вызывает трафик между кэшами ядер. Mutex обходится одной CAS в быстром пути. На высоконагруженных системах разница может быть значительна.

Можно ли использовать RWMutex для защиты atomic полей?
Нет смысла, для атомарных операций синхронизация не нужна.


Что такое голодание (starvation) при блокировке?

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

Голодание — это ситуация, когда одна или несколько горутин не могут получить доступ к разделяемому ресурсу (мьютексу) в течение длительного времени из-за того, что другие горутины постоянно его захватывают. В контексте sync.Mutex это может случиться в нормальном режиме, когда новые горутины «забегают» и перехватывают мьютекс у пробуждающихся ожидающих.

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

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

Голодание также может происходить на уровне приложения: например, горутина-читатель постоянно удерживает RWMutex, не давая писателям шанса (но стандартный RWMutex решает это приоритетом писателя).

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

В sync.Mutex.lockSlow есть счётчик ожидания. Если горутина просыпается и видит, что снова не может захватить мьютекс, она увеличивает счётчик попыток. Когда время ожидания превышает порог (1 мс), она устанавливает флаг mutexStarving в state через CAS. После этого все новые Lock видят starving и не пытаются захватить мьютекс, а паркуются. Когда мьютекс освобождается и есть ожидающие, владение передаётся непосредственно пробуждённой горутине. После получения блокировки starving горутина, если она последняя в очереди, снимает флаг голодания.

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

  • Путают голодание с deadlock.
    При голодании программа прогрессирует, но конкретная горутина долго не может получить ресурс. Deadlock — все остановились.

  • Игнорирование возможности голодания при проектировании собственных спин-блокировок.
    Свои примитивы без стратегии избегания голодания могут привести к зависанию низкоприоритетных горутин. Лучше использовать стандартные примитивы.

  • Долгое удержание мьютекса в критической секции — усугубляет голодание, увеличивая время ожидания. Критические секции надо минимизировать.

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

Может ли горутина голодать на sync.RWMutex?
Да, хотя писатели имеют приоритет перед новыми читателями, если писатели приходят непрерывно, читатели могут голодать. Реализация RWMutex не даёт абсолютных гарантий справедливости.

Как sync.Mutex обнаруживает голодание?
Через измерение времени ожидания: когда горутина вызывала Lock и не смогла захватить, она запускает таймер или подсчитывает время в очереди. Если время > 1 мс, взводится флаг starving.


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

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

Deadlock (взаимная блокировка) — это ситуация, при которой две или более горутин ожидают друг друга, и ни одна не может продолжить выполнение. В Go классический deadlock — когда горутина A ждёт мьютекс, удерживаемый B, а B ждёт мьютекс, удерживаемый A. Также deadlock возникает при блокировке всех горутин (например, main завершается, а горутины висят).

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

Условия deadlock (Коффман):

  1. Взаимное исключение (ресурсы не могут быть разделены).
  2. Удержание и ожидание (горутина удерживает один ресурс и ждёт другой).
  3. Отсутствие принудительного изъятия ресурсов.
  4. Циклическое ожидание (A→B→…→A).

В Go детектор deadlock в рантайме определяет, когда все горутины спят (parked), и сообщает «fatal error: all goroutines are asleep — deadlock!». Однако локальный дедлок (например, две горутины из сотни застряли) не детектируется, программа продолжит работу, но эти горутины утекли.

Пример:

Go
var mu1, mu2 sync.Mutex
go func() {
    mu1.Lock(); mu2.Lock()  // порядок 1,2
}()
mu2.Lock(); mu1.Lock()      // обратный порядок

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

Рантайм при каждом semacquire может проверять, не является ли ситуация «все горутины ждут» (в случае однопоточной программы или когда main завершается). Для этого ведётся счётчик mcount и sched.nmidle и т.д. При панике выводится стек всех горутин.

Никаких алгоритмов банкира или обнаружения циклов в рантайме нет; задача избежания deadlock возлагается на разработчика.

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

  • Захват мьютексов в разном порядке.
    Решение: всегда захватывать в одном порядке, использовать иерархию блокировок.

  • Блокировка на канале, который никто не обслуживает.
    Горутина пишет в небуферизованный канал, никто не читает — deadlock. Нужен select с default или гарантировать получателя.

  • Ожидание WaitGroup без вызова Done.
    Горутина могла забыть Done из-за паники или условного перехода; тогда Wait никогда не завершится.

  • Использование sync.Cond без широковещательного пробуждения.
    Ждущие могут не получить сигнал и навсегда остаться заблокированными.

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

Обнаружит ли Go deadlock, если хотя бы одна горутина не спит (например, выполняет CPU-bound)?
Нет, детектор сработает только когда все горутины находятся в состоянии ожидания (parked). Если есть активная горутина, deadlock не детектируется, что может привести к утечке заблокированных горутин.

Как предотвратить deadlock с каналами?
Проектировать поток данных так, чтобы не было циклических зависимостей. Использовать буферизацию, select с таймаутами, контекст. Парное закрытие каналов только отправителем.


Что такое sync.Map и когда его использовать?

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

sync.Map — это конкурентная map, оптимизированная для случаев, когда ключи стабильны (записываются один раз, а читаются много) или когда несколько горутин одновременно работают с непересекающимися наборами ключей. Она не требует ручной блокировки, в отличие от обычной map с Mutex. Использовать, когда обычная map с sync.RWMutex показывает contention.

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

Типичные сценарии:

  • Кэш, который только растёт (редкие записи).
  • Когда много горутин читают и изредка записывают, но ключи уникальны для горутин.
  • Если профилирование указывает на высокую нагрузку на RWMutex.

Обычная map с Mutex или RWMutex обычно быстрее для общего случая с умеренным конкурентным доступом. sync.Map выигрывает при определённых шаблонах использования. Официальная документация советует сначала использовать обычную map с блокировкой и только при доказанной необходимости переходить на sync.Map.

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

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

  • Чтение (Load) сначала ищет в read (атомарно, без блокировки). Если находит — быстро. Если нет (мисс), захватывает блокировку, ищет в dirty, и если найдено, увеличивает счётчик промахов. При большом количестве промахов dirty продвигается в read (копируется без незаписанных ключей).
  • Запись (Store) всегда идёт в dirty под блокировкой, а также обновляет read, если ключ там уже есть.
  • Удаление (Delete) помечает ключ в read как удалённый (expunged), а в dirty удаляет физически.

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

  • Использовать sync.Map повсеместно, считая «быстрой map для всего».
    Она медленнее обычной map+RWMutex для большинства паттернов. Только бенчмарки показывают, где выигрыш.

  • Игнорирование того, что Range может увидеть непоследовательное состояние (ключи, удалённые параллельно, могут быть пропущены или продублированы). Гарантий моментального снимка нет.

  • Использование sync.Map с частыми записями и удалениями — производительность падает из-за постоянного копирования dirty в read.

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

Как sync.Map обеспечивает lock-free чтения?
Через атомарный доступ к read map; эта map является неизменяемой (при продвижении создаётся новая map). Чтение не требует блокировки, но может промахнуться, тогда захватывается мьютекс для доступа к dirty.

Почему sync.Map не реализует интерфейс map?
Потому что это не параметризованный тип (до Go 1.18+), но даже сейчас sync.Map остаётся для обратной совместимости и использует interface{}. В новых проектах можно использовать generic map с мьютексом.


Чем sync.Map отличается от обычной map?

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

Обычная map не безопасна для конкурентного использования. sync.Map предоставляет конкурентный доступ без явных блокировок, но с ограничениями. В обычной map при конкурентных чтении/записи возникает фатальная ошибка рантайма. sync.Map предотвращает гонки данных, ценой более сложной внутренней структуры и накладных расходов.

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

Характеристика Обычная map sync.Map
Конкурентность Небезопасна, детектируется рантаймом Безопасна, не требует внешнего Mutex
Производительность при низкой конкуренции Выше (простая хеш-таблица) Ниже (двойная структура)
Типизация до 1.18 map[K]V interface{} (медленнее, нет типобезопасности)
Итерация Стабильный snapshot (без изменений во время итерации) Range не гарантирует консистентность
Применение Все случаи, когда горутина одна, либо с Mutex/RWMutex Когда профилирование показывает contention на Mutex

Для generic кода (Go 1.18+) можно сделать свою безопасную map, основанную на map[K]V + Mutex, которая будет более производительна и типобезопасна, чем sync.Map.

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

Обычная map реализована как хеш-таблица (runtime.hmap) без какой-либо синхронизации. Конкурентный доступ детектится флагом hashWriting, что вызывает фатальную ошибку. sync.Map использует разделение на read/dirty и атомики для минимизации блокировок.

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

  • Предполагать, что sync.Map автоматически быстрее везде.
    Это не так; бенчмарки почти всегда показывают преимущество map+Mutex при общем использовании.

  • Использование sync.Map с частыми обновлениями одних и тех же ключей — много работы по продвижению dirty.

  • Забывать, что значения в sync.Map — interface{}, что приводит к аллокациям при упаковке и распаковке (до 1.18). Сейчас всё ещё так, но можно обойти generic-обёрткой.

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

Как sync.Map справляется с ростом количества удалённых ключей?
Удалённые ключи помечаются в read и не копируются при продвижении dirty в read. Это может привести к тому, что удалённые записи занимают память до следующей очистки (когда dirty перестраивается). GC не освобождает их немедленно.

Почему бы просто не сделать встроенную map конкурентной?
Это замедлило бы все однопоточные случаи использования map, которые составляют большинство. Легковесность — ключевое свойство Go.


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

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

sync.Pool не предназначен для переиспользования горутин. Он служит для временного кэширования и переиспользования объектов (структур, буферов), чтобы снизить нагрузку на GC. Для переиспользования горутин применяется паттерн worker pool с фиксированным количеством горутин, которые берут задачи из канала или семафора. sync.Pool может использоваться внутри воркера для переиспользования промежуточных объектов.

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

Worker pool для горутин:

Go
tasks := make(chan Task, 100)
for i := 0; i < 10; i++ {
    go worker(tasks)
}
// горутины живут вечно и переиспользуются для разных задач.

Здесь горутины создаются один раз и обрабатывают задачи из канала. Это экономит накладные расходы на создание горутин.

sync.Pool здесь может помочь переиспользовать Task структуры или буферы:

Go
pool := sync.Pool{New: func() interface{} { return new(bytes.Buffer) }}
// внутри воркера:
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
// использование
pool.Put(buf)

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

sync.Pool устроен как набор локальных кэшей (poolLocal) для каждого P, чтобы избежать блокировок. Локальный кэш — это приватный слот + очередь (разделяемая). При GC пул очищается (object harvest), предотвращая удержание большого количества памяти.

Горутины же управляются планировщиком: при завершении горутины её структура g может быть кэширована в p.gfree для быстрого переиспользования при запуске следующей горутины. То есть рантайм сам переиспользует горутины через пул свободных g, но это не sync.Pool. Разработчик не контролирует этот процесс.

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

  • Путаница: утверждение, что sync.Pool нужен для горутин.
    Неверно. Это распространённый миф. Нужно чётко разделять: sync.Pool — для объектов, worker pool — для горутин.

  • Использование sync.Pool для долгоживущих объектов или как кэш без учёта очистки GC.
    Пул может быть очищен в любой сборке мусора, поэтому не подходит для критичных данных, которые должны сохраняться.

  • Забывают вызвать Reset у полученного объекта.
    Объект может содержать старые данные, что приведёт к багам.

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

Почему бы не использовать sync.Pool для управления жизненным циклом горутин?
Пул не предоставляет механизма запуска/остановки выполнения; он просто хранит объекты. Горутины же — это контексты выполнения, их нельзя «положить» в пул без запуска. Для управления горутинами нужны другие примитивы.

Как рантайм переиспользует горутины?
При старте новой горутины (newproc) сначала проверяется кэш свободных g у текущего P (p.gfree), и если там есть, берётся оттуда без выделения. При выходе из горутины (exit) её g возвращается в этот кэш. Это прозрачно для программиста.


Что такое "background" и "TODO" контексты?

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

context.Background() возвращает пустой контекст, который никогда не отменяется и не имеет дедлайна/значений. Это корневой контекст, используемый в main, инициализации и тестах. context.TODO() — это Background() с семантикой «я должен был использовать настоящий контекст, но пока не понял какой», используется как заглушка при рефакторинге или в неясных местах.

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

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

Оба реализованы через emptyCtx (простые int, никогда не отменяются). Их нельзя отменить, и они не имеют значения.

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

  • Background — main(), инициализация, тесты, когда контекст создаётся на верхнем уровне.
  • TODO — в коде, который пока не принимает контекст, но должен.

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

Тип emptyCtx представлен как type emptyCtx int. Он возвращает nil для Done(), Err(), Deadline(), и nil для Value(key). Это самый простой контекст, без каких-либо затрат.

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

  • Использование TODO в production коде без последующего исправления.
    Это скрывает потенциальные утечки горутин из-за отсутствия отмены. Нужно заменить на реальный контекст.

  • Передача Background в библиотечные функции, когда доступен контекст запроса.
    Это разрывает цепочку отмены. Если функция ожидает контекст, передавайте правильный.

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

Можно ли отменить Background()?
Нет, и это сделано намеренно: корневой контекст не должен зависеть от какой-либо операции.

Когда использовать context.WithValue(Background(), ...)?
Для глобального процесса, например, для передачи конфигурации в весь сервис. Но вообще лучше передавать явно, контекст не для этого.


Как передавать данные через контекст?

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

Используя context.WithValue(parent, key, value). Ключом должен быть неэкспортируемый тип (чтобы избежать коллизий), а значение — неизменяемое или безопасное для конкурентного доступа. Получение: ctx.Value(key). Это следует использовать только для передачи сквозных данных (request-scoped), таких как идентификатор запроса, трассировочный ID, а не бизнес-объектов.

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

Идиоматический пример:

Go
type contextKey string // неэкспортируемый
const requestIDKey contextKey = "requestID"
ctx := context.WithValue(parent, requestIDKey, "abc-123")
...
id, ok := ctx.Value(requestIDKey).(string)

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

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

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

WithValue возвращает valueCtx, который встраивает родительский контекст и содержит key, val. При поиске Value(key) вызывается цепочка: сначала сравнивается ключ текущего контекста, если нет — рекурсивно у родителя. Это линейный поиск по цепочке вложенности. Производительность O(N) по глубине контекста.

Важно: ключи сравниваются через ==, так что интерфейсы с одинаковыми динамическими значениями и типами равны.

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

  • Использование строкового ключа из экспортируемого пакета — коллизия с ключами другого пакета.
  • Хранение изменяемых объектов (слайсов, мап) приводит к гонкам, если несколько горутин читают/пишут.
  • Чрезмерное использование Value вместо явной передачи параметров — код становится непрозрачным, сложным для тестирования и анализа. Контекст не замена DI.

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

Как гарантировать уникальность ключа?
Создать собственный тип (пустая структура): type myKey struct{}. Такой ключ не пересекается с другими пакетами.

Можно ли изменить значение, хранящееся в контексте?
Нет, контекст неизменяем. Только создать новый WithValue с обновлённым значением. Это обеспечивает безопасность.


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

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

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

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

Детально:

  1. Небезопасность типов: Value возвращает interface{}, требует приведения типа, что может привести к панике во время выполнения.
  2. Коллизии ключей: при экспортируемых ключах другой пакет может вставить значение с тем же ключом, переопределив данные.
  3. Неявная связность: функции не объявляют, какие именно данные они ожидают из контекста, что усложняет понимание, поддержку и рефакторинг.
  4. Производительность: поиск значения линейно зависит от глубины контекста. При большой вложенности может быть затратно.
  5. Изменяемые значения: если положить указатель на структуру или map, несколько горутин могут начать менять её одновременно без синхронизации.
  6. Злоупотребление: контекст часто используют как «глобальную переменную», передавая через него то, что должно быть параметрами функции.

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

Цепочка valueCtx хранит key/value и указатель на родительский контекст. При каждом Value происходит проход вверх до корня. При глубокой иерархии (например, каждый middleware добавляет значение) это может добавить заметные накладные расходы.

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

  • Передача через контекст больших объектов, которые нужны лишь паре функций.
    Засоряет контекст и замедляет поиск.

  • Использование контекста как хранилища состояния сессии — для этого есть явные структуры.

  • Игнорирование ok приведения типа — паника.

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

Как избежать коллизий ключей?
Создать уникальный тип ключа: type ctxKey int с iota в пакете, но не экспортировать сам тип, только константы для ключей.

Какие альтернативы передаче данных через контекст?
Явные параметры функций, структуры запроса, middleware-обёртки, имплементация интерфейсов.


Как работает паттерн "рабочий пул" (worker pool)?

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

Worker pool — это фиксированное количество горутин, которые непрерывно читают задачи из общего канала и обрабатывают их. Позволяет контролировать параллелизм, избегать избыточной конкуренции и переиспользовать горутины. Обычно состоит из канала задач, набора воркеров и механизма ожидания завершения (WaitGroup). Иногда воркеры перезапускаются при ошибках.

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

Простая реализация:

Go
func worker(id int, tasks <-chan Task, wg *sync.WaitGroup) {
    defer wg.Done()
    for task := range tasks {
        process(task)
    }
}
func main() {
    tasks := make(chan Task, 100)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go worker(i, tasks, &wg)
    }
    // отправка задач
    close(tasks)
    wg.Wait()
}

Паттерн обеспечивает:

  • Ограничение количества одновременно обрабатываемых задач.
  • Предотвращение создания/уничтожения горутин на каждую задачу.
  • Упорядочение нагрузки на ресурсы (например, соединения с БД).

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

Горутины-воркеры блокируются на чтении из канала (runtime.chanrecv). Когда задача поступает, планировщик пробуждает одну из них. Канал с буфером может сглаживать пиковые нагрузки, не блокируя отправителя до заполнения буфера.

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

  • Не закрывать канал задач, из-за чего воркеры висят вечно.
    После отправки всех задач нужно close(tasks), чтобы range завершился.
  • Динамическое изменение числа воркеров без синхронизации может привести к гонкам. Лучше фиксированное число или использовать семафор.
  • Паника в worker без recover убивает воркер, и задачи могут не обработаться. Лучше defer recover или перезапуск.

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

Как масштабировать количество воркеров в зависимости от нагрузки?
Можно использовать семафор (канал с ёмкостью) вместо фиксированного числа горутин: на каждую задачу запускается горутина, но семафор ограничивает параллелизм. Либо динамический пул с отслеживанием загрузки, но сложно.

Чем worker pool отличается от семафорной модели?
В worker pool горутины живут постоянно, в семафорной — создаются и завершаются, ограничиваясь только семафором. Второе проще, но создание горутин на каждую задачу может быть оверхедом.


Как ограничить количество одновременно работающих горутин?

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

Использовать буферизованный канал в качестве семафора: канал ёмкостью N, перед запуском горутины отправляем в канал, после завершения читаем. Или использовать паттерн worker pool с фиксированным числом воркеров. Также можно применять golang.org/x/sync/semaphore.

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

Семафор через канал:

Go
sem := make(chan struct{}, 10) // максимум 10 горутин
for _, item := range items {
    sem <- struct{}{} // блокирует, если 10 уже работают
    go func(item Item) {
        defer func() { <-sem }()
        process(item)
    }(item)
}

Это ограничивает параллелизм, но запускает горутину на каждую задачу. При большом количестве задач может быть много горутин, ожидающих на входе в семафор, что всё равно тратит память. Worker pool эффективнее по памяти, так как горутин фиксированное число.

Альтернатива с semaphore.Weighted:

Go
sem := semaphore.NewWeighted(10)
ctx := context.Background()
for _, item := range items {
    sem.Acquire(ctx, 1)
    go func() {
        defer sem.Release(1)
        process(item)
    }()
}

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

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

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

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

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

Как предотвратить неограниченную очередь ожидающих горутин с семафором?
Можно использовать select с default для пропуска задачи или с context.Done() для отмены. Или использовать TryAcquire.

Чем отличается семафор от лимитированного количества воркеров?
Воркеры постоянно потребляют память стека; семафор даёт возможность быстрее реагировать на всплески без постоянных горутин, но создаёт горутину на каждую задачу.


Как обрабатывать ошибки в горутинах?

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

Ошибки в горутинах не всплывают автоматически в родительскую горутину. Их нужно явно передавать через канал ошибок, WaitGroup с атомарным флагом ошибки, либо через errgroup из golang.org/x/sync. Внутри горутины панику следует перехватывать recover, чтобы не обрушить весь процесс.

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

Распространённый подход — канал ошибок:

Go
errs := make(chan error, goroutineCount)
for i := 0; i < cap(errs); i++ {
    go func() {
        errs <- doWork()
    }()
}
for i := 0; i < cap(errs); i++ {
    if err := <-errs; err != nil {
        // обработка первой ошибки
    }
}

Более продвинуто — errgroup:

Go
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
    item := item
    g.Go(func() error {
        return process(ctx, item)
    })
}
if err := g.Wait(); err != nil { ... }

errgroup автоматически собирает первую ошибку и отменяет контекст.

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

Go
defer func() {
    if r := recover(); r != nil {
        log.Printf("panic in goroutine: %v", r)
    }
}()

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

errgroup использует sync.WaitGroup и sync.Once для фиксации первой ошибки. При возникновении ошибки вызывается cancel контекста, что может привести к отмене всех остальных горутин, слушающих контекст.

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

  • Игнорирование ошибок из горутин, что приводит к пропуску критических сбоев.
  • Блокировка на небуферизованном канале ошибок, если не все горутины отправят ошибку — deadlock. Нужно обеспечить, чтобы канал был прочитан столько раз, сколько горутин пишет, или использовать буфер нужного размера.
  • Паника без recover — внезапный краш приложения.

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

Как errgroup обрабатывает паники?
До версии 1.20 errgroup не обрабатывал паники; нужно было оборачивать Go с recover. В более новых версиях появилась возможность добавлять свои обёртки. В любом случае, важно самому перехватывать панику.

Как передать несколько ошибок?
Использовать канал ошибок с буфером, либо multierror из hashicorp/go-multierror; собирать все ошибки с помощью мьютекса.


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

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

Создать буферизованный канал make(chan struct{}, N), где N — максимальное количество одновременных захватов. Для захвата — отправить в канал (блокируется при полном буфере), для освобождения — прочитать из канала. Это простой семафор для контроля параллелизма.

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

Go
sem := make(chan struct{}, 10)
// захват:
sem <- struct{}{}
// освобождение:
<-sem

Использование в горутине:

Go
go func() {
    sem <- struct{}{}
    defer func() { <-sem }()
    // работа
}()

Более выразительная обёртка может включать контекст:

Go
select {
case sem <- struct{}{}:
    defer func() { <-sem }()
case <-ctx.Done():
    return ctx.Err()
}

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

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

Отправка в канал — это runtime.chansend, которая добавляет значение в буфер или паркует горутину при заполнении. Получение из канала — runtime.chanrecv, извлекает из буфера или будит отправителя. Блокировка реализована через sudog и очереди ожидания.

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

  • Использовать небуферизованный канал — ограничение в 0, канал блокируется сразу, не давая горутине начать работу.
  • Забыть <-sem (освобождение) — семафор рано или поздно заполнится, и программа зависнет.
  • Захват семафора внутри горутины после её старта — горутина запустилась, но ещё не захватила семафор, что не ограничивает её выполнение до захвата. Нужно захватывать до старта.

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

Как с помощью канала реализовать семафор с ожиданием нескольких разрешений?
Можно отправлять несколько раз в канал, если нужно несколько единиц. Но это усложняет логику. Лучше использовать semaphore.Weighted.

Что даёт semaphore.Weighted по сравнению с каналом?
Методы Acquire с контекстом, TryAcquire, Release с весом, что удобнее. Но для простых случаев канал проще.


Что такое "semaphore" и зачем он нужен?

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

Семафор — это примитив синхронизации, ограничивающий количество горутин, одновременно выполняющих некоторую работу или получающих доступ к ресурсу. Он решает задачу ограничения параллелизма (rate limiting на уровне горутин), предотвращая перегрузку системы (например, слишком много соединений к БД).

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

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

Типичный пример: веб-сервер, обрабатывающий запросы; каждая обработка требует тяжёлого запроса к внешнему сервису. Вместо запуска горутины на каждый запрос (что может перегрузить сервис), используется семафор, чтобы одновременно было не более K запросов.

В Go семафор можно реализовать через буферизованный канал или использовать golang.org/x/sync/semaphore.Weighted.

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

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

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

  • Путаница с мьютексом: мьютекс допускает только одну горутину, семафор — N.
  • Неправильная установка N — слишком маленькое число замедляет обработку, слишком большое — не предохраняет от перегрузки.
  • Утечка разрешений — не освободили семафор, в итоге дедлок.

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

Как реализовать семафор с динамическим изменением лимита?
С помощью канала сложно; Weighted не поддерживает изменение лимита. Обычно пересоздают пул или используют более сложные конструкции.

Какая связь семафора и worker pool?
Worker pool — это частный случай семафора, где горутины постоянны, а семафором может быть канал задач (количество воркеров фиксировано).


Что такое "signal channel" для завершения горутины?

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

Signal channel — это специальный канал chan struct{}, используемый для уведомления горутины о необходимости завершить работу. Обычно он закрывается отправителем, что действует как широковещательный сигнал всем горутинам, слушающим его в select. Так горутина может корректно завершиться.

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

Идиома:

Go
stop := make(chan struct{})
go func() {
    for {
        select {
        case <-stop:
            return
        default:
            // работа
        }
    }
}()
close(stop) // сигнал остановки

Преимущества:

  • Не требует внешней синхронизации, достаточно закрыть канал.
  • Все горутины, подписанные на <-stop, немедленно получают zero value (и могут выйти).
  • Можно комбинировать с таймаутом и контекстом.

Сигнальный канал обычно не буферизуется (но может), используется только для сигнала, без передачи данных.

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

Закрытие канала (close) помечает его как закрытый и пробуждает все горутины, ожидающие на чтение из него. Рантайм проходит по очереди ожидающих и переводит их в runnable, возвращая false как второе значение. Планировщик позже запустит их, и они выйдут из select.

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

  • Отправка значения в канал вместо закрытия — тогда только одна горутина получит сигнал (если буфер 0, и то может блокировать отправителя). Для широковещательного сигнала — только close.
  • Попытка закрыть канал несколько раз — паника. Обычно закрывает владелец канала, а получатели только читают.
  • Использование chan bool вместо chan struct{} — работает, но struct{} экономит память.

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

Можно ли использовать сигнальный канал для остановки нескольких горутин, не закрывая его?
Технически да, можно посылать N значений в буферизованный канал, но это сложно и не атомарно; закрытие проще и надёжнее.

Как совместить сигнальный канал и контекст?
Обычно контекст заменяет сигнальный канал: ctx.Done() уже предоставляет такой же механизм. Если у вас уже есть контекст, дополнительный stop канал избыточен.


Как корректно остановить бесконечный цикл в горутине?

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

Включить в цикл select с каналом <-ctx.Done() или <посигнальным каналом>; при срабатывании сделать return. Также можно периодически проверять if ctx.Err() != nil { return }. Бесконечный цикл без точек ожидания (CPU-bound) требует периодической проверки атомарного флага или ctx.Done().

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

Для цикла, обрабатывающего канал:

Go
for {
    select {
    case <-ctx.Done():
        return
    case v, ok := <-ch:
        if !ok { return }
        process(v)
    }
}

Для цикла без каналов:

Go
for {
    select {
    case <-ctx.Done():
        return
    default:
    }
    // часть работы
}

Для долгих вычислений — разбить на итерации и проверять флаг на каждой итерации.

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

Канал ctx.Done() при отмене закрывается, что делает операцию чтения из него всегда готовой, возвращая zero value и false. select сразу выбирает эту ветку. При проверках ctx.Err() важно, чтобы компилятор не оптимизировал проверку (она атомарная через atomic внутри, так что безопасно).

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

  • Пустой for {} без проверок — никогда не завершится. Даже вытеснение не остановит горутину, она продолжит занимать CPU.
  • Проверка флага без использования атомиков — потенциальная гонка данных, если флаг устанавливается другой горутиной.
  • Засыпание на долгий time.Sleep без возможности прерывания — горутина не может завершиться немедленно. Лучше использовать time.After с select.

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

Как быть, если тело цикла не содержит вызовов, которые можно прервать?
Внедрить искусственные точки проверки: select { case <-ctx.Done(): return; default: } перед тяжёлым шагом. Если алгоритм не позволяет, придётся мириться с задержкой завершения.

Что лучше: select или атомарный флаг?
select на канале даёт немедленное уведомление без опроса, предпочтительнее. Атомарный флаг требует периодической проверки, но может быть быстрее, если проверка очень частая. Обычно канал/контекст — лучший выбор.


Как WaitGroup.Done() связан с Add()?

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

Done() эквивалентен Add(-1). Он уменьшает внутренний счётчик WaitGroup на 1. Add(positive) перед запуском горутин увеличивает счётчик, Done уменьшает. Когда счётчик достигает 0, Wait разблокируется.

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

Контракт: каждый Add(1) должен быть сбалансирован одним Done(). При старте горутины делается wg.Add(1), а внутри горутины при завершении — wg.Done() (обычно через defer wg.Done()). Done просто Add(-1).

Если Add вызывается несколько раз до запуска горутин, счётчик увеличивается, и Wait будет ждать, пока все Done не уменьшат до нуля.

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

Done() реализован как Add(-1). Add с отрицательным дельтой выполняет атомарное вычитание и, если результат == 0, пробуждает ожидающих.

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

  • Вызов Done без предварительного Add — паника «negative WaitGroup counter».
  • Несоответствие количества Add и Done — либо Wait зависнет, либо паника.
  • Динамический Add внутри горутин после начала Wait — запрещено, паника.

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

Можно ли использовать Add(-1) напрямую вместо Done?
Да, но Done семантически яснее. Использование Add(-1) может запутать код.

Как WaitGroup обрабатывает случай, когда Add(0)?
Ничего не делает, но это разрешено. Может использоваться для проверки состояния (не паникует).


Что будет, если Done() вызвать больше раз, чем Add()?

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

Произойдёт паника sync: negative WaitGroup counter и программа аварийно завершится. WaitGroup не допускает отрицательного счётчика, так как это нарушает инвариант: количество завершённых горутин не может превышать запущенных.

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

Это может случиться при неправильном учёте количества запущенных горутин или при вызове Done() в условном коде, который выполняется чаще, чем Add. Рантайм паникует при попытке Add с результатом < 0.

Обнаружение происходит в Add:

Go
if v < 0 {
    panic("sync: negative WaitGroup counter")
}

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

Функция Add(delta) после атомарного обновления проверяет, если результат v < 0, вызывает panic. Если v == 0, пробуждает ожидающих и сбрасывает waiter count.

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

  • Вызов Done в обработчике ошибок, который может сработать несколько раз для одной горутины — нужно вызывать Done только один раз, например, через defer.
  • Использование общего WaitGroup для вложенных горутин без учёта иерархии — легко запутаться.
  • Копирование WaitGroup — копия имеет независимый счётчик, что может привести к рассинхронизации.

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

Как диагностировать панику «negative WaitGroup counter»?
Включить отладку, посмотреть стек вызовов, определить, где вызван лишний Done. Анализ логов и порядок запуска горутин.

Предохраняет ли WaitGroup от паники при перехвате через recover?
Паника может быть перехвачена, но лучше не доводить до такого. Используйте defer wg.Done() и избегайте повторных вызовов.


В чем разница между sync.Mutex и каналом с буфером 1?

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

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

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

Канал как мьютекс:

Go
mutex := make(chan struct{}, 1)
mutex <- struct{}{} // Lock
<-mutex // Unlock

Это работает, но:

  • Mutex в быстром пути через CAS, канал же требует взаимодействия с планировщиком (отправка/получение даже с буфером 1 может дороже).
  • Mutex имеет режимы спиновой блокировки и starvation-предотвращения.
  • Канал не предупреждает, если разблокировала не та горутина, которая блокировала, хотя семантика может быть нарушена.
  • Чтение из канала используется для Unlock, что менее очевидно.

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

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

Сравнение быстродействия: Mutex lock в uncontended — одна CAS, unlock — одна CAS + возможный пробуждающий вызов. Канал make(chan struct{}, 1): отправка в пустой буфер — размещение элемента и проверка, блокировок нет; получение — извлечение. В uncontended случае сопоставимо, но при contention mutex эффективнее, так как использует сложные стратегии пробуждения/спина.

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

  • Использовать канал как мьютекс ради единообразия — это идиоматически неверно, код станет сложнее читаемым.
  • Попытка реализовать RWMutex через каналы — практически невозможно без серьёзных ухищрений и потери производительности.
  • Забыть прочитать из канала-мьютекса (Unlock) — deadlock.

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

Может ли канал с буфером 1 заменить sync.Mutex во всех случаях?
Нет, например, sync.Cond требует sync.Locker, который не реализуется каналом. Также Mutex быстрее и предоставляет модель владения.

Где уместно использовать канал как мьютекс?
Только если нужно совмещать блокировку с выборкой из каналов через select, что невозможно с Mutex. Но даже тогда лучше пересмотреть дизайн.


Как context.WithTimeout и context.WithDeadline взаимодействуют с горутинами?

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

Они создают контекст, который автоматически отменяется по истечении заданного времени или при наступлении дедлайна. Горутины, использующие такой контекст, могут слушать ctx.Done() и при отмене завершить работу. Важно всегда вызывать функцию cancel (отложенно), чтобы освободить таймерные ресурсы, даже если контекст отменился по таймауту.

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

WithTimeout — сокращение для WithDeadline(time.Now().Add(timeout)). Они создают timerCtx, внутри которого запускается таймер на время дедлайна. При истечении таймера вызывается cancel(), закрывающая канал Done. Горутина должна выбрать этот канал в select или проверять Err().

Пример:

Go
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            // работа
        }
    }
}()

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

timerCtx встраивает cancelCtx и имеет поле timer *time.Timer. При создании таймер запускается, и по его срабатыванию вызывается cancel(). Если cancel() вызывается явно до истечения таймера, таймер останавливается, освобождая ресурсы (поэтому defer cancel() обязателен). Иначе таймер может остаться в куче до истечения, занимая память.

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

  • Пропуск cancel — утечка таймера и горутин (таймер может остаться висеть, если не остановлен).
  • Передача такого контекста в долгоживущие структуры без продления дедлайна — после таймаута контекст становится отменённым, и все последующие операции будут немедленно получать ошибку.
  • Использование WithTimeout в цикле для каждой итерации без пересоздания — может породить множество таймеров.

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

Как продлить дедлайн контекста?
Никак, контексты неизменяемы. Нужно создать новый WithTimeout от родительского контекста.

Чем отличается поведение WithTimeout и WithDeadline при cancel?
Ничем, оба возвращают cancel функцию. Разница только в способе задания момента отмены.


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

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

Для равномерного распределения задач между воркерами используют один общий FIFO-канал, из которого все воркеры читают. Планировщик Go автоматически пробуждает ожидающие горутины в порядке очереди (приблизительно), что обеспечивает честность. Также можно использовать sync.Cond с ручной раздачей или счётчиками, но общий канал — простейший и достаточно справедливый способ.

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

Простая архитектура:

Go
ch := make(chan Task)
for i := 0; i < N; i++ {
    go worker(ch)
}
// distribute tasks into ch

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

Однако при сильной конкуренции возможны «кражи» пробуждений другими горутинами, но на практике распределение близко к равномерному.

Для более строгой честности (например, round-robin) можно использовать кольцевую очередь с явным перебором воркеров через atomic инкремент, но это редко требуется.

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

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

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

  • Использование нескольких каналов с балансировкой через select с default — может привести к неравномерному распределению.
  • Попытка реализовать сложную логику приоритетов, ломая справедливость без бенчмарков.
  • Ручное распределение через мьютекс и список — добавляет накладные расходы, если не нужно.

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

Можно ли гарантировать строгую очерёдность обработки?
Да, используя один воркер или явную очередь с последовательной обработкой. Для параллельной обработки строгая очерёдность требует дополнительной синхронизации.

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


Что такое "fan-out" и "fan-in"?

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

Fan-out — это паттерн, когда одна горутина (продюсер) распределяет задачи множеству горутин-обработчиков. Fan-in — обратный паттерн: несколько горутин (продюсеров) передают результаты в один канал, откуда их читает одна горутина-потребитель. Они часто комбинируются для построения конвейеров.

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

Fan-out для параллельной обработки:

Go
func fanOut(in <-chan Task, workers int) []<-chan Result {
    outs := make([]<-chan Result, workers)
    for i := 0; i < workers; i++ {
        ch := make(chan Result)
        go func() {
            for task := range in {
                ch <- process(task)
            }
            close(ch)
        }()
        outs[i] = ch
    }
    return outs
}

Fan-in для сбора:

Go
func fanIn(channels ...<-chan Result) <-chan Result {
    out := make(chan Result)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan Result) {
            for v := range c {
                out <- v
            }
            wg.Done()
        }(ch)
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

Используется в пайплайнах для масштабирования CPU-bound операций и буферизации обработки.

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

Горутины и каналы — базовые примитивы. Никакой специальной поддержки в рантайме нет. Важно управлять закрытием каналов, чтобы читатели вышли из range.

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

  • Досрочное закрытие канала fan-out, пока обработчики ещё пишут — паника.
  • Утечка горутин fan-in, если не закрыть результирующий канал после завершения всех источников.
  • Несбалансированное количество воркеров — слишком много или мало.

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

Как отменить fan-out пайплайн при ошибке?
Использовать контекст: передать ctx обработчикам, при ошибке закрыть канал с ошибкой и ожидать завершения через errgroup.

Как объединить fan-out и fan-in с сохранением порядка?
Для сохранения порядка нужно передавать индекс и сортировать после fan-in, так как конкурентная обработка нарушает порядок. Или использовать один воркер.


Как pprof помогает найти утечку горутин?

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

pprof профилировщик может показать текущие стеки всех горутин и их количество. Сравнивая снимки во времени или анализируя профиль «goroutine», можно увидеть аномальный рост числа горутин, висящих на определённых точках (например, ожидание канала, мьютекса, select). Это указывает на потенциальную утечку.

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

Как получить:

  • HTTP эндпоинт: import _ "net/http/pprof", затем GET /debug/pprof/goroutine?debug=1 (текстовый список стеков) или ?debug=2 (детальный), или получить профиль для анализа: curl .../debug/pprof/goroutine > goroutine.pb.gz.
  • Анализ: go tool pprof goroutine.pb.gz, команда top показывает функции, где больше всего горутин. list <func> показывает точное место. Ищем горутины, застрявшие в chan receive, select, sync.Mutex.Lock.
  • Визуализация: web в pprof строит граф; можно увидеть, сколько горутин порождено определённой функцией.

Интерпретация: если число горутин растёт неограниченно, а сервис работает стабильно, скорее всего, есть утечка.

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

Профилировщик горутин обходит все текущие g, собирает их стеки (через runtime.Stack), агрегирует и экспортирует. Это безопасно и не останавливает мир (только слегка замедляет). В рантайме есть переменная allglen, хранящая число горутин.

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

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

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

Какие ещё профили помогают диагностировать проблемы горутин?
Профиль mutex (включён флагом runtime.SetMutexProfileFraction) показывает contention на мьютексах. Профиль block — задержки на каналах и мьютексах. allocs и heap могут показать утечки памяти, связанные с горутинами.

Как автоматически обнаружить утечку горутин в тестах?
Использовать leaktest (например, goleak.VerifyTestMain): после теста проверяет, что количество горутин не увеличилось. Или runtime.NumGoroutine() до и после.


Что такое "rate limiting" и как его реализовать с помощью горутин?

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

Rate limiting — контроль частоты обработки операций (запросов в секунду). С помощью горутин и времени можно реализовать через time.Ticker или time.Tick, ограничивающий темп запуска горутин, либо через семафор с тайм-аутом, либо через специальные библиотеки (golang.org/x/time/rate). Простейший способ: лимитер на основе канала и тикера.

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

Пример с Tick (не рекомендуется для высоконагруженных систем, но просто):

Go
limiter := time.Tick(100 * time.Millisecond) // 10 операций в секунду
for req := range requests {
    <-limiter // ждём тик
    go process(req)
}

Более точный — rate.Limiter:

Go
limiter := rate.NewLimiter(rate.Limit(10), 2) // 10 rps, burst 2
for req := range requests {
    limiter.Wait(ctx) // блокирует, пока не разрешено
    go process(req)
}

Можно также совмещать с семафором для ограничения параллелизма и ставки.

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

rate.Limiter реализован через алгоритм token bucket: ведро с максимальным количеством токенов, пополняемое с заданной скоростью. Wait пополняет ведро и забирает токены; если недостаточно, вычисляет время ожидания и запускает таймер, который пробуждает горутину через планировщик. Использует мьютекс для синхронизации.

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

  • Использование time.Tick внутри горутины, забывая остановить тикер, что ведёт к утечке горутин тикера.
  • Путаница rate limiting и ограничения параллелизма — это разные вещи: rate про частоту, параллелизм про одновременность. Часто нужны оба.
  • Реализация ведра без burst, что делает обработку неэластичной при кратковременных пиках.

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

Как rate.Limiter обрабатывает контекст отмены?
Метод Wait(ctx) возвращает ошибку, если контекст отменён до выделения токена, что позволяет корректно завершить ожидание.

Можно ли динамически изменять лимит?
Да, SetLimit и SetBurst позволяют менять на лету.


Как реализовать "graceful shutdown" с ожиданием завершения горутин?

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

При получении сигнала ОС (SIGINT, SIGTERM) нужно:

  1. Оповестить все горутины о необходимости завершения (через отмену общего контекста или закрытие stop канала).
  2. Дождаться завершения всех горутин с помощью sync.WaitGroup.
  3. Освободить ресурсы (закрыть файлы, соединения) и выйти с определённым кодом.

Типичный паттерн: главная горутина ожидает сигнал, затем вызывает cancel() и wg.Wait().

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

Пример:

Go
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
// запуск воркеров с ctx и wg
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs // ждём сигнал
cancel() // отменяем контекст
wg.Wait() // ждём завершения всех горутин
// cleanup

Важно, чтобы горутины слушали ctx.Done() и вызывали wg.Done() при выходе. Все операции с контекстом должны быть уважительными к отмене.

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

signal.Notify регистрирует канал в рантайме для получения сигналов. При получении сигнала ОС рантайм пробуждает горутину, читающую из канала, что позволяет начать процесс завершения.

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

  • Бесконечное ожидание wg.Wait(), если какая-то горутина проигнорировала отмену контекста или не вызвала Done.
  • Принудительный выход через os.Exit без завершения горутин — небезопасно, могут быть потеряны данные, нарушены инварианты.
  • Отмена контекста без передачи в горутины — старые горутины не получат сигнал.

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

Как ограничить время graceful shutdown?
Добавить таймаут:

Go
cancel()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
    // ок
case <-time.After(10 * time.Second):
    // force exit
    os.Exit(1)
}

Как корректно останавливать HTTP-сервер?
Вызвать server.Shutdown(ctx) с контекстом; он ожидает завершения активных запросов и закрытия листенера. Не использовать Close.