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

Конкурентность, горутины и планировщик (часть 1)

Горутина — это не поток ОС, а структура runtime.g в куче рантайма плюс отдельный растущий стек (стартовый размер 2 КиБ, константа stackMin в runtime/stack.go). Всё управление горутинами — создание, блокировка, пробуждение, переключение — происходит в пользовательском пространстве, без обращения к ядру. Поэтому переключение стоит десятки-сотни наносекунд вместо микросекунд, а миллион горутин в одном процессе — нормальная ситуация, тогда как миллион потоков ОС не поместится ни по памяти, ни по времени планирования.

Модель, которую нужно держать в голове, называется GMP. G — горутина (задача), M — machine, поток ОС, который реально исполняет код, P — processor, логический контекст исполнения, владеющий локальной очередью готовых горутин и кэшем аллокатора (mcache). Число P равно GOMAXPROCS и по умолчанию равно числу доступных ядер. Чтобы выполнять Go-код, поток M обязан держать P; отсюда следует главное правило: одновременно выполняется ровно столько горутин, сколько есть P, а потоков в процессе может быть намного больше — они висят в блокирующих системных вызовах без P.

Планировщик работает по циклу: M берёт G из runnext своего P, затем из локальной очереди (кольцевой буфер на 256 элементов), затем — раз в 61 такт и при опустошении локальной — из глобальной очереди под мьютексом, затем опрашивает сетевой поллер (netpoll) и в конце пытается украсть половину очереди у случайного другого P (work stealing). Блокировки на каналах, мьютексах и сети не блокируют поток: горутина уходит в состояние _Gwaiting через gopark, а M берёт следующую работу. Блокирующим для потока остаётся только настоящий системный вызов, и там срабатывает handoff: фоновой поток sysmon через ~20 мкс отбирает P у застрявшего в syscall M и отдаёт другому потоку.

Многозадачность в Go до версии 1.14 была чисто кооперативной: переключение случалось только в точках, где рантайм вставлял проверку — в прологе функции (проверка stackguard0), на операциях с каналами, при аллокации, при runtime.Gosched(). Горутина с плотным арифметическим циклом без вызовов могла «залипнуть» и заблокировать сборку мусора. С Go 1.14 добавлена асинхронная вытесняющая многозадачность: sysmon шлёт потоку сигнал SIGURG, обработчик сигнала останавливает горутину в безопасной точке (состояние _Gpreempted). Практический порог вытеснения — примерно 10 мс непрерывного исполнения.

Из этой модели естественно выводятся ответы про параллелизм и конкурентность (конкурентность — структура программы, параллелизм — физическое одновременное исполнение, ограниченное GOMAXPROCS и ядрами), про утечки горутин (горутина, навсегда заблокированная на канале или в цикле без выхода, никогда не собирается GC), про гонки данных (у Go слабая модель памяти, любой неупорядоченный конкурентный доступ с хотя бы одной записью — UB, ловится -race) и про пул воркеров (ограничение параллелизма фиксированным числом потребителей одного канала задач).

Коротко. Горутина — это легковесный поток исполнения, управляемый рантаймом Go, а не ядром ОС: структура runtime.g плюс собственный растущий стек от 2 КиБ. Запускается словом go перед вызовом функции, мультиплексируется рантаймом на небольшое число потоков ОС.

Глубже. Физически go f(x) компилируется в вызов runtime.newproc: рантайм берёт свободную g из кэша P (gFree) или аллоцирует новую, копирует аргументы на её стек, ставит состояние _Grunnable и кладёт в runnext текущего P. Никакого системного вызова при этом нет. У горутины нет идентификатора в публичном API (сознательное решение авторов языка, чтобы не появлялись goroutine-local storage), нет приоритетов и нет способа убить её извне — завершиться она может только сама, вернувшись из функции или запаниковав.

Почему можно запустить 1000+ горутин при лимите ОС в 4-8 потоков?

Заголовок раздела «Почему можно запустить 1000+ горутин при лимите ОС в 4-8 потоков?»

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

Глубже. Ограничение «4–8» — это не лимит ОС, а типичное число ядер и значение GOMAXPROCS по умолчанию; жёсткий лимит потоков в Go-процессе — 10000 (runtime.SetMaxThreads, по умолчанию maxmcount = 10000). Ключ в том, что блокировка на канале или сетевом чтении реализована как gopark: горутина снимается с P, M немедленно берёт следующую готовую G. Сетевые операции уходят в netpoller на epoll/kqueue, так что 10 000 соединений обслуживаются несколькими потоками. Новый поток создаётся только когда M действительно застревает в блокирующем syscall (файловый ввод-вывод, cgo) и рантайм передаёт его P другому потоку.

Коротко. Операции с каналами (send/recv/select без готовых веток), примитивы sync (Mutex, WaitGroup.Wait, Cond.Wait, Once), сетевой и файловый ввод-вывод, time.Sleep, системные вызовы, ожидание сборщика мусора, а также запись/чтение из nil-канала — блокировка навсегда.

Глубже. Полезно различать два вида блокировки. Первый — «мягкая»: рантайм знает о ней (каналы, мьютексы, netpoll, таймеры), горутина уходит в _Gwaiting через gopark с причиной (waitReasonChanReceive, waitReasonSelect, …), P и M освобождаются, и именно эти причины вы видите в goroutine-профиле. Второй — блокировка потока: _Gsyscall, когда исполняется настоящий syscall или cgo-вызов; здесь M реально стоит в ядре, а P через ~20 мкс отбирается sysmon. Ещё одна категория — тихая блокировка на пустом select{} или на nil-канале: это вечное ожидание, из которого горутина не выйдет никогда, что и даёт классическую утечку.

Чем отличается конкурентное выполнение от параллельного?

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

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

Глубже. Формулировка Роба Пайка: «Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once». В Go конкурентность выражается горутинами и каналами, а параллелизм включается значением GOMAXPROCS (при GOMAXPROCS=1 конкурентность есть, параллелизма нет). Практический вывод для собеседования: конкурентность полезна для I/O-bound нагрузки даже на одном ядре, ускорение CPU-bound задачи даёт только параллелизм и только до числа ядер.

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

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

Коротко. Три способа: защитить доступ мьютексом (sync.Mutex/RWMutex), использовать атомарные операции (sync/atomic, типы atomic.Int64, atomic.Pointer[T] с Go 1.19), либо вообще не разделять память — передать владение через канал по принципу «share memory by communicating».

Глубже. Выбор диктуется характером доступа. Для счётчика достаточно atomic.Int64.Add — он на порядок дешевле мьютекса. Для составной структуры или инварианта из нескольких полей нужен мьютекс: атомики защищают одну ячейку, а не инвариант. RWMutex выгоден только при явном перекосе в сторону чтений и долгих критических секциях, иначе он проигрывает обычному Mutex из-за более дорогого захвата. Для map-подобной нагрузки с непересекающимися ключами есть sync.Map (в Go 1.24 переписан на HashTrieMap, что убрало прежнюю деградацию на записях). Проверять корректность нужно go test -race / go run -race: детектор гонок ловит только те гонки, которые реально произошли на данном прогоне, поэтому его гоняют на нагрузочных и интеграционных тестах.

package counters
import (
"sync"
"sync/atomic"
)
type Safe struct {
mu sync.Mutex
data map[string]int
}
func (s *Safe) Inc(k string) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[k]++
}
var hits atomic.Int64 // без мьютекса: одна ячейка
func Hit() { hits.Add(1) }

Коротко. Канал задач, фиксированное число горутин-воркеров, читающих из него в for range, канал (или срез с индексами) для результатов и sync.WaitGroup для ожидания завершения. Продюсер закрывает канал задач — воркеры выходят из range сами.

Глубже. Важные детали, которые проверяют на собесе: закрывает канал задач только отправитель; канал результатов закрывает отдельная горутина после wg.Wait(), иначе получите либо панику двойного закрытия, либо дедлок; в каждый воркер прокидывается context.Context, чтобы пул останавливался по отмене; размер пула выбирается по типу нагрузки — для CPU-bound это runtime.GOMAXPROCS(0), для I/O-bound может быть в разы больше. Начиная с Go 1.22 переменная цикла создаётся заново на каждой итерации, поэтому классическая ошибка с захватом i в замыкании больше не воспроизводится (но в коде на старых версиях её всё ещё спрашивают).

package pool
import (
"context"
"sync"
)
func Run(ctx context.Context, workers int, jobs <-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
select {
case out <- j * j:
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}

Коротко. G — горутина, M — поток ОС, P — логический процессор (контекст планирования) с локальной очередью готовых горутин; их количество равно GOMAXPROCS. Чтобы исполнять Go-код, M должен захватить P; связка M+P исполняет по одной G в момент времени.

Глубже. P появился в Go 1.1 (проект Дмитрия Вьюкова «Scalable Go Scheduler»): до него была единственная глобальная очередь под общим мьютексом, что не масштабировалось. P держит локальную очередь на 256 слотов плюс слот runnext (только что созданная/разбуженная горутина исполняется следующей — оптимизация для паттерна «пинг-понг»), кэш аллокатора mcache, кэш свободных g и буфер записи GC. Соотношение чисел: G — сколько угодно, P — ровно GOMAXPROCS, M — динамически, обычно ≥ числу P, растёт при блокирующих syscall (потолок maxmcount = 10000).

Что такое утечка горутин и как ее обнаружить?

Заголовок раздела «Что такое утечка горутин и как ее обнаружить?»

Коротко. Утечка — горутина, которая никогда не завершится: висит на канале, которого никто не закроет, или крутит цикл без условия выхода. Она удерживает свой стек и всё, на что ссылается, а GC её не собирает — горутина является корнем достижимости.

Глубже. Обнаружение: runtime.NumGoroutine() в метриках (монотонный рост под ровной нагрузкой — красный флаг); goroutine-профиль net/http/pprofcurl 'localhost:6060/debug/pprof/goroutine?debug=2' покажет полные стеки и время ожидания каждой горутины, а debug=1 — агрегацию по стекам; в тестах — go.uber.org/goleak (defer goleak.VerifyNone(t)); при разборе зависаний помогают GODEBUG=schedtrace=1000 и SIGQUIT (дамп всех стеков). Полный дедлок всей программы рантайм ловит сам («fatal error: all goroutines are asleep - deadlock!»), но утечка нескольких горутин при живом main — нет.

Коротко. Основные: _Grunnable (готова, в очереди), _Grunning (исполняется на M), _Gwaiting (заблокирована на канале/мьютексе/сети, снята с P), _Gsyscall (в системном вызове), _Gdead (завершилась или лежит в кэше свободных), _Gidle (только создана). Есть также служебные _Gcopystack и _Gpreempted (появилось в Go 1.14 с асинхронным вытеснением).

Глубже. Переходы стоит уметь назвать словами: newproc создаёт G как _Grunnable; execute переводит в _Grunning; gopark — в _Gwaiting с указанием причины (waitReason), goready возвращает в _Grunnable; вход в syscall — _Gsyscall, выход — попытка вернуть свой P, иначе _Grunnable в глобальную очередь; goexit_Gdead, после чего g кладётся в gFree для переиспользования (поэтому создание горутины часто вообще не требует аллокации). Константы лежат в runtime/runtime2.go.

Что такое local run queue и global run queue в планировщике?

Заголовок раздела «Что такое local run queue и global run queue в планировщике?»

Коротко. Локальная очередь принадлежит конкретному P, это кольцевой буфер на 256 горутин плюс слот runnext; работа с ней идёт почти без блокировок. Глобальная очередь одна на весь рантайм, защищена мьютексом планировщика и служит для переполнения, для горутин из syscall и как страховка от голодания.

Глубже. При newproc горутина попадает в runnext текущего P. Если локальная очередь заполнена, половина её (128 штук) вместе с новой горутиной за один раз перекладывается в глобальную — это runqputslow. При выборе следующей горутины schedule() каждые 61 такт заглядывает в глобальную очередь (иначе горутины оттуда могли бы не исполняться никогда), затем берёт из локальной, потом из глобальной (сразу пачкой, чтобы амортизировать блокировку), потом дергает netpoll, и только потом крадёт половину очереди у случайного P. Такая иерархия даёт локальность кэша и почти нулевую конкуренцию за общий мьютекс.

Что такое «голодание» в планировщике и как его избежать?

Заголовок раздела «Что такое «голодание» в планировщике и как его избежать?»

Коротко. Голодание — ситуация, когда готовая горутина долго не получает процессорное время: например, её оттеснили горутины из локальных очередей или её вытеснила «жадная» горутина без точек переключения. Рантайм борется с этим проверкой глобальной очереди каждые 61 такт, асинхронным вытеснением через sysmon (~10 мс) и work stealing.

Глубже. Отдельно существует голодание на sync.Mutex: с Go 1.9 у мьютекса есть starvation mode — если горутина ждёт блокировку дольше 1 мс, мьютекс переключается в режим честной FIFO-передачи владения, чтобы вновь приходящие («barging») не обгоняли очередь. В прикладном коде голодание чаще всего создают сами: бесконечный цикл без вызовов функций (до 1.14 вешал GC, сейчас вытесняется, но всё равно жжёт ядро), одна горутина, монопольно удерживающая RWMutex.Lock, либо select, в котором один канал всегда готов — select выбирает случайно среди готовых, но если готова только одна ветка, остальные не исполняются. Лечится ограничением длительности критических секций, разбиением работы на порции и явным runtime.Gosched() в исключительных случаях.

Memory allocation. Stack and heap a) Each goroutine allocates an expandable stack b) Pointers and the structures they point to escape to the heap c) Garbage collector: mark & sweep algorithm

Заголовок раздела «Memory allocation. Stack and heap a) Each goroutine allocates an expandable stack b) Pointers and the structures they point to escape to the heap c) Garbage collector: mark & sweep algorithm»

Коротко. У каждой горутины свой стек, начинающийся с 2 КиБ и растущий копированием (примерно до 1 ГБ на 64-битных платформах). Куда попадёт объект — на стек или в кучу — решает компилятор escape-анализом, а не наличие указателя само по себе. Куча освобождается конкурентным трёхцветным mark-and-sweep сборщиком без компактизации.

Глубже. Рост стека: в прологе каждой функции компилятор вставляет сравнение SP со stackguard0; при нехватке вызывается morestack, аллоцируется вдвое больший сегмент, старый стек копируется целиком, а все указатели на него корректируются (это возможно, потому что рантайм точно знает карту указателей). Уменьшение стека происходит во время GC, если занято меньше четверти. Escape-анализ (go build -gcflags='-m') отправляет значение в кучу, если компилятор не может доказать, что оно не переживёт кадр: возврат указателя наружу, сохранение в интерфейс с динамическим вызовом, захват замыканием, которое уходит в go, слишком большой или неизвестного размера объект. Формулировка «указатели всегда уезжают в кучу» неверна: указатель на локальную переменную, переданный в функцию, которую компилятор проинлайнил или чей параметр не убегает, остаётся на стеке. Сборщик — конкурентный трёхцветный mark & sweep с write barrier, целевая пауза меньше миллисекунды, порог запуска задаётся GOGC, верхняя граница — GOMEMLIMIT (Go 1.19+). Компактизации нет, поэтому адреса объектов стабильны, а вот стек горутины перемещается — вот почему в Go нельзя долговременно хранить адрес стековой переменной вне рантайма (в C-коде через cgo).

Channels. Purpose. Types of channels a) What is a channel under the hood? Usage patterns i) Buffered - works asynchronously ii) Unbuffered iii) Passing values between goroutines b) What happens if you write to a closed channel? i) Panic c) What happens if you read from a closed channel? i) Default value d) Non-blocking write/read to a channel? i) select case ii) How to check if a channel is closed? iii) if v, ok := <-channel; ok e) What does len(chan) of a unidirectional channel return? i) The number of elements in the channel

Заголовок раздела «Channels. Purpose. Types of channels a) What is a channel under the hood? Usage patterns i) Buffered - works asynchronously ii) Unbuffered iii) Passing values between goroutines b) What happens if you write to a closed channel? i) Panic c) What happens if you read from a closed channel? i) Default value d) Non-blocking write/read to a channel? i) select case ii) How to check if a channel is closed? iii) if v, ok := <-channel; ok e) What does len(chan) of a unidirectional channel return? i) The number of elements in the channel»

Коротко. Канал — это указатель на структуру runtime.hchan: кольцевой буфер, счётчики qcount/dataqsiz, очереди ожидающих sendq/recvq и обычный sync.Mutex. Запись в закрытый канал и повторное закрытие — паника; чтение из закрытого возвращает нулевое значение с ok == false; неблокирующие операции делаются через select с default; len(ch) — число элементов в буфере, cap(ch) — его размер.

Глубже. Небуферизированный канал (dataqsiz == 0) — это рандеву: отправитель блокируется, пока приёмник не заберёт значение, и значение копируется напрямую из стека отправителя в стек получателя, минуя буфер (sendsendDirect). Буферизированный работает асинхронно, пока в буфере есть место. Проверить «закрыт ли канал» отдельной операцией нельзя — только попыткой чтения v, ok := <-ch (для незакрытого канала это заблокирует) или select с default. Закрытие — единственный корректный способ разбудить сразу всех читателей: closechan проходит по recvq и делает goready каждому. Правило владения: закрывает всегда отправитель, и только один; если отправителей несколько, нужен отдельный сигнальный канал done или context. len и cap на однонаправленном канале работают так же, как на двунаправленном, — направление ограничивает только <-, но не встроенные функции. Отдельно помните про nil-канал: и чтение, и запись блокируются навсегда, а close(nil) паникует; это используется как приём — обнулением поля структуры ветку select можно «выключить».

Concurrency a) What are goroutines? How are they structured? How many goroutines can there be? Ratio of goroutines to the number of processors i) A goroutine is a function executed in parallel with others ii) Created via go + function call iii) Lightweight (~2 KB of memory) iv) Preemptive multitasking b) The runtime package i) Documentation: pkg.go.dev/runtime ii) runtime.NumGoroutine() - returns the number of goroutines iii) StartTrace() and StopTrace() - execution tracing c) Communication between goroutines i) Using context.Context for lifecycle management d) Ways to control goroutine execution (WaitGroup, ErrorGroup) i) sync.WaitGroup - waiting for a group of goroutines to finish

Заголовок раздела «Concurrency a) What are goroutines? How are they structured? How many goroutines can there be? Ratio of goroutines to the number of processors i) A goroutine is a function executed in parallel with others ii) Created via go + function call iii) Lightweight (~2 KB of memory) iv) Preemptive multitasking b) The runtime package i) Documentation: pkg.go.dev/runtime ii) runtime.NumGoroutine() - returns the number of goroutines iii) StartTrace() and StopTrace() - execution tracing c) Communication between goroutines i) Using context.Context for lifecycle management d) Ways to control goroutine execution (WaitGroup, ErrorGroup) i) sync.WaitGroup - waiting for a group of goroutines to finish»

Коротко. Горутина — функция, запущенная через go, со своим стеком от 2 КиБ; их число ограничено только памятью (сотни тысяч и миллионы реальны), а одновременно исполняется не больше GOMAXPROCS. Наблюдать за ними помогает пакет runtime (NumGoroutine, GOMAXPROCS, трассировка через runtime/trace), управлять жизненным циклом — context.Context, ждать завершения — sync.WaitGroup или golang.org/x/sync/errgroup.

Глубже. Уточнения к тезисам из вопроса. «Выполняется параллельно» — точнее «конкурентно»: параллелизм появляется только при GOMAXPROCS > 1 и наличии свободных ядер. «~2 КБ» — стартовый размер стека (константа stackMin), а не потолок: под нагрузкой стек растёт, и реальная стоимость горутины, обрабатывающей HTTP-запрос, обычно измеряется килобайтами-десятками килобайт. «Preemptive multitasking» — верно с Go 1.14 (асинхронное вытеснение сигналом SIGURG); до этого была кооперативная модель. Для трассировки в прикладном коде используют не runtime.StartTrace, а обёртку runtime/trace: trace.Start(w) / trace.Stop() либо эндпоинт /debug/pprof/trace, результат смотрят через go tool trace. errgroup.Group удобнее WaitGroup там, где нужна первая ошибка и общая отмена: g, ctx := errgroup.WithContext(ctx), а g.SetLimit(n) заодно ограничивает число одновременных горутин.

package fetch
import (
"context"
"net/http"
"golang.org/x/sync/errgroup"
)
func FetchAll(ctx context.Context, urls []string, limit int) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
for _, u := range urls {
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
return resp.Body.Close()
})
}
return g.Wait()
}

Goroutine leaks. How to avoid them? a) Goroutines run independently of main b) Infinite loops without exit conditions c) Explicit termination via channels or context d) Using sync.WaitGroup

Заголовок раздела «Goroutine leaks. How to avoid them? a) Goroutines run independently of main b) Infinite loops without exit conditions c) Explicit termination via channels or context d) Using sync.WaitGroup»

Коротко. Правило одно: у каждой запущенной горутины должен быть гарантированный путь завершения, и тот, кто её запустил, должен уметь дождаться этого завершения. Практически это context.Context в каждой долгоживущей горутине, select с <-ctx.Done() вместо голых операций с каналами и WaitGroup у владельца.

Глубже. Типовые источники утечек: отправка в небуферизированный канал, читателя которого уже нет (классика — горутина пишет результат, а вызывающий вышел по таймауту; лечится буфером на 1 элемент или select с ctx.Done()); for range ch по каналу, который никто не закроет; time.Tick без остановки (в отличие от time.NewTicker его нельзя остановить); незакрытый resp.Body, из-за которого висит горутина транспорта; подписка на события без отписки. Отдельно: WaitGroup сам по себе от утечки не спасает — если горутина зависла, Wait() зависнет вместе с ней; он лишь делает утечку видимой. В Go 1.25 у sync.WaitGroup появился метод Go, который объединяет Add(1)/go/defer Done(), но семантика та же.

В чем разница между кооперативной и вытесняющей многозадачностью?

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

Коротко. При кооперативной задача сама отдаёт управление в заранее известных точках (вызов функции, операция с каналом, Gosched), при вытесняющей планировщик может прервать её в произвольный момент. Go с версии 1.14 использует гибрид: обычно кооперативно, но sysmon принудительно вытесняет горутину, работающую дольше ~10 мс.

Глубже. Кооперативная модель дешевле (нет сохранения полного контекста, переключение — просто смена нескольких регистров в gogo) и проще для GC: все точки переключения безопасны. Её беда — жадная задача. До Go 1.14 цикл for {} без вызовов не имел ни одной точки переключения, и попытка GC остановить мир (stopTheWorld) приводила к зависанию всей программы. Асинхронное вытеснение решает это так: sysmon шлёт потоку SIGURG, обработчик сигнала проверяет, что регистры находятся в безопасном для GC состоянии, и «дописывает» в поток вызов asyncPreempt, который сохраняет регистры и паркует горутину в состоянии _Gpreempted.

Вопросы: стандартные вопросы про горутины, каналы, пакет sync и рантайм Go.

Заголовок раздела «Вопросы: стандартные вопросы про горутины, каналы, пакет sync и рантайм Go.»

Коротко. Это не вопрос, а пометка о блоке собеседования. Готовиться нужно к четырём кластерам: горутины и планировщик GMP, каналы (hchan, закрытие, nil, select), пакет sync (Mutex/RWMutex, WaitGroup, Once, Pool, Cond, atomic) и рантайм (стеки, escape-анализ, GC, GOMAXPROCS, GODEBUG).

Глубже. Практический каркас подготовки: уметь на доске нарисовать GMP и объяснить, что происходит при блокировке на канале и при syscall; знать поведение всех четырёх комбинаций «канал nil/закрыт × чтение/запись»; объяснить, почему sync.Mutex нельзя копировать (и что об этом скажет go vet); знать, что sync.Pool очищается на каждом GC и не является пулом соединений; уметь показать гонку и объяснить, что найдёт -race. Ответы на все эти пункты — в соответствующих подтемах конспекта.

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

Заголовок раздела «как при этом будет работать P с горутинами в этой задаче?»

Коротко. Обрывок диалога — исходная задача не восстанавливается. Общий ответ: P держит локальную очередь готовых горутин и отдаёт их своему M по одной; горутина, ушедшая в ожидание, освобождает P немедленно, а горутина, ушедшая в syscall, уносит M, и тогда P через ~20 мкс перехватывается другим потоком.

Глубже. Если вопрос звучит на собеседовании как продолжение задачи (например, «20 горутин при GOMAXPROCS=1»), правильная схема ответа такая: сказать, сколько P в системе (GOMAXPROCS), значит столько горутин исполняется одновременно; описать, куда попадают остальные (runnext, локальная очередь на 256, при переполнении — половина в глобальную); перечислить события, по которым горутина сходит с P (блокировка, syscall, вытеснение по 10 мс, Gosched, вызов, требующий роста стека); и упомянуть work stealing, если P больше одного.

Почему в Go используются горутины, а не потоки?

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

Коротко. Горутины дешевле по памяти (2 КиБ против мегабайтов стека потока), дешевле по переключению (userspace, десятки-сотни наносекунд против ~1 мкс с переходом в ядро) и позволяют писать блокирующий по виду код, который рантайм под капотом превращает в асинхронный на epoll. Это даёт модель «одна горутина на запрос/соединение» без C10K-проблем.

Глубже. Горутины не заменяют потоки, а лежат поверх них: рантайм всё равно создаёт M, просто немного. Выигрыш в трёх местах. Память: стек потока в Linux по умолчанию резервирует 8 МиБ виртуального адресного пространства, плюс ядро держит task_struct и ядерный стек; горутина — это g (несколько сотен байт) плюс 2 КиБ стека в куче Go. Переключение: смена горутины — это сохранение нескольких регистров и SP в g.sched и переход gogo, без смены таблиц страниц и без сброса TLB. Планирование: рантайм знает семантику блокировок и может мгновенно поставить на освободившийся M другую работу, а ядро о логике приложения ничего не знает.

Что такое параллельность и конкурентность в Go?

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

Коротко. Конкурентность — способ структурировать программу как набор независимо продвигающихся горутин; параллелизм — фактическое одновременное исполнение на нескольких ядрах, доступное при GOMAXPROCS > 1. Go даёт конкурентность на уровне языка (go, каналы, select), а параллелизм — как следствие настройки рантайма.

Глубже. См. также вопрос «Чем отличается конкурентное выполнение от параллельного». В Go-специфике важны два следствия. Первое: конкурентная программа обязана быть корректной при любом чередовании, поэтому синхронизация нужна и при GOMAXPROCS=1 — вытеснение может произойти между чтением и записью переменной. Второе: GOMAXPROCS по умолчанию равен числу логических CPU, видимых процессу; до Go 1.25 он не учитывал cgroup-лимиты контейнера, из-за чего в Kubernetes с лимитом в 1 CPU процесс мог считать, что у него 64 ядра, и страдать от лишних переключений — типовое решение той эпохи — automaxprocs или явная установка переменной окружения GOMAXPROCS.

Коротко. Поток создаёт и планирует ядро, стек фиксирован (обычно 8 МиБ виртуальных), переключение требует перехода в ядро. Горутина создаётся рантаймом Go, стек начинается с 2 КиБ и растёт копированием, переключение — в пользовательском пространстве; горутины мультиплексируются на потоки как M:N.

Глубже. Ещё несколько отличий, которые ценят на собеседовании: у горутины нет приоритета, нет affinity к ядру и нет идентификатора; горутину нельзя убить извне, поток можно (хотя и не нужно) отменить; поток видим для ОС (top -H, perf), горутина — только для рантайма и pprof; сигналы доставляются процессу и обрабатываются выделенным потоком рантайма, а не «горутиной»; локальные данные потока (TLS) в Go недоступны, вместо них передают context. Единственный способ привязать горутину к потоку — runtime.LockOSThread, он нужен для GUI-фреймворков и cgo-библиотек с thread-affinity.

Типы многозадачности. Вытесняющая многозадачность: зачем добавили?

Заголовок раздела «Типы многозадачности. Вытесняющая многозадачность: зачем добавили?»

Коротко. Кооперативная (задача сама отдаёт управление) и вытесняющая (планировщик прерывает принудительно). В Go асинхронное вытеснение добавили в 1.14, чтобы горутина с длинным циклом без вызовов функций не блокировала сборку мусора, stopTheWorld и остальные горутины на том же P.

Глубже. До 1.14 существовало только «кооперативное вытеснение»: рантайм выставлял stackguard0 = stackPreempt, и горутина замечала флаг в прологе следующей функции. Если вызовов нет — флаг не проверяется. Симптом был характерный: программа с GOMAXPROCS=1 и циклом for i := 0; i < 1e10; i++ {} в горутине не давала другим горутинам исполниться и подвешивала GC на неопределённое время. Асинхронное вытеснение реализовано через сигнал (SIGURG выбран как редко используемый), обработчик подменяет точку возврата на asyncPreempt; отключается для отладки через GODEBUG=asyncpreemptoff=1.

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

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

Коротко. Отмена контекста ничего не делает с горутинами принудительно: она закрывает канал ctx.Done() и выставляет ctx.Err(). Горутина обязана сама проверять это — читать <-ctx.Done() в select или вызывать ctx.Err() в цикле, — и корректно завершиться.

Глубже. Отмена распространяется вниз по дереву: WithCancel/WithTimeout/WithDeadline создают дочерний контекст, и отмена родителя закрывает Done всех потомков; cancel идемпотентен и обязателен к вызову через defer, иначе течёт таймер и запись в родителе (go vet ловит потерянный cancel). ctx.Err() вернёт context.Canceled или context.DeadlineExceeded; с Go 1.20 есть context.WithCancelCause и context.Cause(ctx) для передачи причины, с Go 1.21 — context.WithoutCancel и context.AfterFunc. Стандартная библиотека уважает контекст в net/http, database/sql, os/exec; но time.Sleep, тяжёлый CPU-цикл и чужой код без ctx отменой не прервать.

package worker
import (
"context"
"time"
)
func Loop(ctx context.Context, work func()) error {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled / context.DeadlineExceeded
case <-t.C:
work()
}
}
}

Как доработать код, чтобы вывело оба числа?

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

Коротко. Точный листинг задачи не приведён, но это классика: main запускает горутины и завершается раньше, чем они успевают напечатать. Решение — дождаться их: sync.WaitGroup (правильный способ) или чтение из канала; time.Sleep — не решение, а маскировка.

Глубже. Второй частый вариант той же задачи — небуферизированный канал, в который пишут два значения, а читают одно: тогда «оба числа» появятся, если читать в цикле по числу отправок либо закрыть канал и пройти for range. Третий вариант — до Go 1.22: захват переменной цикла в замыкании приводил к печати одного и того же значения дважды, и правкой было i := i или передача аргументом; начиная с Go 1.22 (при go 1.22 в go.mod) переменная цикла создаётся заново на каждой итерации, и код работает как ожидается.

package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for _, n := range []int{1, 2} {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(n)
}
wg.Wait()
}

Как работает планировщик, как устроены очереди?

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

Коротко. Планировщик — M:N: GOMAXPROCS контекстов P, у каждого своя локальная очередь на 256 горутин плюс слот runnext, есть общая глобальная очередь под мьютексом и сетевой поллер. M, захвативший P, в цикле schedule() выбирает следующую горутину: runnext → локальная очередь → каждый 61-й такт глобальная → netpoll → кража половины очереди у случайного P.

Глубже. Дополнительные механизмы: runqputslow переливает половину переполненной локальной очереди в глобальную; handoffp отдаёт P другому M, когда текущий уходит в блокирующий syscall; sysmon — поток без P, который каждые 20 мкс…10 мс просыпается, отбирает P у долгих syscall, вытесняет горутины, работающие дольше 10 мс, и вызывает netpoll, если его давно никто не звал; при отсутствии работы M паркуется (stopm) и хранится в списке простаивающих, чтобы не создавать поток заново. Наблюдать за всем этим можно через GODEBUG=schedtrace=1000,scheddetail=1.

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

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

Коротко. Это M:N-планировщик рантайма: он мультиплексирует горутины (G) на потоки ОС (M) через логические процессоры (P), число которых равно GOMAXPROCS. Работает кооперативно с асинхронным вытеснением, использует локальные очереди, work stealing и сетевой поллер.

Глубже. См. ответ «Как работает планировщик, как устроены очереди». Что стоит добавить: планировщик не вытесняющий в смысле квантов времени — он не даёт гарантий справедливости и не имеет приоритетов; порядок исполнения горутин недетерминирован, и опираться на него в коде нельзя. Исходники — runtime/proc.go, ключевые функции schedule, findRunnable, execute, park_m, sysmon.

Коротко. Меньший старт (2 КиБ стека против 8 МиБ виртуальных на поток, плюс отсутствие структур ядра), дешевле создание (нет clone, часто вообще без аллокации — g берётся из кэша) и дешевле переключение (сохраняются несколько регистров в userspace, без перехода в ядро и без работы с TLB).

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

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

Заголовок раздела «Что будет когда Go программа запущена в системе с одним ядром и в ней запускается 20 горутин, и одна из горутин начала выполнять тяжелую задачу?»

Коротко. При GOMAXPROCS=1 исполняется одна горутина за раз, остальные 19 ждут в очереди P. Тяжёлая горутина будет вытеснена: либо кооперативно в ближайшей точке переключения, либо принудительно сигналом от sysmon примерно через 10 мс. Программа не зависнет, но пропускная способность упадёт — суммарное время делится между всеми.

Глубже. Нюанс, который любят проверять: до Go 1.14 тот же сценарий с циклом без вызовов функций реально подвешивал программу — не было точек вытеснения, GC не мог остановить мир. Сейчас asyncPreempt это чинит, но latency остальных горутин всё равно ухудшается на величину кванта. Если тяжёлая задача уходит в syscall (например, читает файл), sysmon отберёт P и отдаст его новому M, так что остальные горутины продолжат работать, пока первая стоит в ядре. Практическое лечение: разбить тяжёлую работу на порции, вынести её в отдельный процесс/пул с ограничением или хотя бы вызывать runtime.Gosched() между порциями.

В Go реализована вытесняющая или кооперативная многопоточность?

Заголовок раздела «В Go реализована вытесняющая или кооперативная многопоточность?»

Коротко. Гибрид. Основной режим кооперативный — переключение в точках, вставленных компилятором и рантаймом; поверх него с Go 1.14 работает асинхронное вытеснение через сигнал, которое прерывает горутину, занимающую P дольше ~10 мс.

Глубже. См. подробности в вопросах про типы многозадачности. Формулировка для ответа вслух: «кооперативная по умолчанию, вытесняющая как страховка; вытеснение реализовано сигналом SIGURG из sysmon, отключается GODEBUG=asyncpreemptoff=1».

Коротко. Проектировать так, чтобы у каждой горутины был владелец, отменяющий её (context), и гарантированный выход: любые блокирующие операции — только в select с <-ctx.Done(), каналы закрывает отправитель, а вызывающий дожидается завершения через WaitGroup/errgroup.

Глубже. Практический чек-лист: канал результата делать буферизованным на 1, если получатель может уйти по таймауту; не использовать time.Tick в долгоживущем коде (только NewTicker + defer Stop()); всегда закрывать resp.Body и вычитывать его до конца; в тестах ставить defer goleak.VerifyNone(t); экспортировать runtime.NumGoroutine() в метрики и вешать алерт на монотонный рост; при подозрении смотреть /debug/pprof/goroutine?debug=2 — там видно, сколько минут горутина висит и на какой строке.

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

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

Коротко. Ограничить параллелизм: пул из N воркеров, читающих один канал задач, или семафор — буферизованный канал ёмкостью N, либо errgroup.SetLimit(N) / semaphore.Weighted. Тогда в памяти живёт N горутин, а не 50 000, а очередь задач хранится дёшево (слайс, канал, внешняя очередь).

Глубже. 50 000 горутин сами по себе не катастрофа (~100+ МиБ стеков в лучшем случае), проблема обычно не в них, а в том, что каждая держит соединение, буфер или строку на мегабайты — тогда пик памяти убивает процесс. Второй аргумент в пользу пула — контроль нагрузки на внешние системы: 50 000 одновременных запросов к БД положат БД, а не ускорят обработку. Размер лимита подбирают по узкому месту: для CPU-bound — GOMAXPROCS, для БД — размер пула соединений, для HTTP — MaxIdleConnsPerHost и лимиты сервера.

package limit
import (
"context"
"sync"
)
func Each(ctx context.Context, items []string, n int, do func(context.Context, string)) {
sem := make(chan struct{}, n) // семафор на n одновременных
var wg sync.WaitGroup
for _, it := range items {
select {
case <-ctx.Done():
wg.Wait()
return
case sem <- struct{}{}:
}
wg.Add(1)
go func(it string) {
defer wg.Done()
defer func() { <-sem }()
do(ctx, it)
}(it)
}
wg.Wait()
}

Сколько ресурсов потребляет управление одной горутиной? (около 0.15% cpu на 100 горутин)

Заголовок раздела «Сколько ресурсов потребляет управление одной горутиной? (около 0.15% cpu на 100 горутин)»

Коротко. Память: структура g порядка нескольких сотен байт плюс стек от 2 КиБ, который растёт под нагрузкой. CPU: создание — порядка сотен наносекунд, переключение — десятки-сотни наносекунд. Цифра «0.15% CPU на 100 горутин» не является константой рантайма: накладные расходы зависят от того, как часто горутины блокируются и переключаются, а не от их количества.

Глубже. Правильная модель стоимости: простаивающая горутина стоит только память — планировщик её не опрашивает, она лежит в _Gwaiting и не потребляет CPU вообще. Расходы появляются на переключениях, поэтому «100 горутин, каждая делает send в канал миллион раз в секунду» дороже, чем «100 000 горутин, ждущих сокет». Замерять надо своим бенчмарком (testing.B, -benchmem) и смотреть pprof: строки runtime.schedule, runtime.chansend, runtime.morestack. Косвенный расход, о котором часто забывают, — GC: больше живых горутин, значит больше стеков для сканирования на каждом цикле (хотя стеки сканируются конкурентно).

Безопасна ли стандартная map для конкурентного использования из нескольких горутин без дополнительной синхронизации?

Заголовок раздела «Безопасна ли стандартная map для конкурентного использования из нескольких горутин без дополнительной синхронизации?»

Коротко. Нет. Одновременные чтения безопасны, но любая конкурентная запись вместе с чтением или другой записью — гонка данных. Рантайм специально детектирует такое и падает с fatal error: concurrent map writes / concurrent map read and map write; это фатальная ошибка, её нельзя перехватить recover.

Глубже. Проверка встроена в рантайм через флаг hashWriting в заголовке map и работает без -race, но она best-effort: не каждая гонка будет поймана, поэтому отсутствие падения ничего не доказывает. Варианты синхронизации: sync.RWMutex вокруг обычной map (обычно лучший выбор), sync.Map для сценариев «запись один раз, читают многие» или «непересекающиеся ключи по горутинам», шардирование по хешу ключа при высокой конкуренции. В Go 1.24 внутренняя реализация map переведена на Swiss Tables, а sync.Map переписан на HashTrieMap — производительность изменилась, но правила конкурентного доступа остались прежними.

Коротко. go f() создаёт структуру g со своим стеком и ставит её в очередь P. Поток M с этим P в цикле schedule() выбирает горутину и передаёт ей управление (gogo, восстановление SP/PC из g.sched). При блокировке горутина сохраняет контекст и паркуется, при пробуждении снова попадает в очередь готовых.

Глубже. Стек горутины начинается с 2 КиБ; при нехватке пролог функции ловит выход за stackguard0, вызывает morestack, и рантайм копирует стек на вдвое больший участок, поправляя все внутренние указатели. Завершение — возврат из функции, что приводит к goexit: стек может быть уменьшен, g попадает в кэш свободных для переиспользования. Важные следствия для собеседования: возвращаемое значение функции, запущенной через go, теряется; паника в горутине не перехватывается в другой горутине; адрес локальной переменной горутины может «переехать» вместе со стеком, поэтому передавать его в C-код напрямую нельзя.

Вопрос про case condition. Что будет, если 2 горутины пытаются получить доступ к одной переменной?

Заголовок раздела «Вопрос про case condition. Что будет, если 2 горутины пытаются получить доступ к одной переменной?»

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

Глубже. «Не определено» здесь не фигура речи: модель памяти Go (обновлена и формализована в 2022 году) объявляет программы с гонками некорректными, кроме гарантии, что чтение вернёт какое-то из записанных значений или ноль для типов размером не больше машинного слова. На практике часто наблюдают потерянные инкременты (x++ — это read-modify-write, не атомарная операция) и «зависшие» флаги (горутина крутит for !done {} и никогда не видит done = true, потому что компилятор поднял чтение из цикла). Лечение — sync.Mutex, sync/atomic или передача через канал; диагностика — go test -race.

Коротко. Data race — конкретное техническое явление: два конкурентных доступа к одной ячейке памяти, минимум один из них запись, без отношения happens-before. Race condition — более широкое понятие: некорректность из-за зависимости результата от порядка событий; она возможна и в полностью синхронизированном коде.

Глубже. Пример гонки состояний без гонки данных: if !m.Has(k) { m.Set(k, v) }, где каждая операция сама по себе под мьютексом. Данные защищены, -race промолчит, но между проверкой и записью другая горутина успеет вставить значение (классический TOCTOU). Лечится расширением критической секции (одна операция вроде LoadOrStore, транзакция, SETNX в Redis). Обратное тоже верно: data race — почти всегда баг, даже если «на практике работает». Детектор -race (на базе ThreadSanitizer) ловит только data race и только на выполненных путях, замедляя программу примерно в 2–20 раз и увеличивая память в 5–10 раз.

Коротко. Тремя семействами средств: каналы (передача владения данными, сигнал завершения, семафор), пакет sync (Mutex, RWMutex, WaitGroup, Once, Cond, Pool) и sync/atomic для отдельных ячеек. Плюс context для отмены и errgroup как удобная обёртка над WaitGroup с ошибкой.

Глубже. Ориентир при выборе: «дождаться группы» — WaitGroup/errgroup; «защитить общее состояние» — Mutex; «счётчик или флаг» — atomic; «передать данные и владение» — канал; «однократная инициализация» — sync.Once (или sync.OnceValue/OnceFunc с Go 1.21); «разбудить всех по условию» — close(chan) или sync.Cond.Broadcast; «ограничить параллелизм» — буферизованный канал-семафор или errgroup.SetLimit. Общее правило Go: каналы — для передачи данных и координации потока управления, мьютексы — для защиты состояния; попытка сделать мьютекс из канала работает, но дороже и хуже читается.

Реализовать worker pool для параллельной обработки запросов к различным ресурсам

Заголовок раздела «Реализовать worker pool для параллельной обработки запросов к различным ресурсам»

Коротко. См. выше «Как бы вы реализовали worker pool на Go». Специфика запросов к внешним ресурсам: обязателен context с таймаутом на каждый запрос, результаты нужно собирать вместе с ошибками, а лимит параллелизма выбирается отдельно для каждого ресурса (у БД он один, у HTTP-API — другой).

Глубже. Практичный вариант — errgroup с лимитом и записью результатов в предварительно выделенный слайс по индексу: тогда не нужен канал результатов и не нужна дополнительная синхронизация, потому что каждая горутина пишет в свою ячейку. Если ресурсы разные и лимиты у них тоже разные, заводят по семафору на ресурс. Не забыть про частичный успех: errgroup.Wait вернёт первую ошибку и отменит контекст, а если нужно собрать все ошибки — используйте свой слайс и errors.Join (Go 1.20+).

package fanout
import (
"context"
"golang.org/x/sync/errgroup"
)
func Map[T, R any](ctx context.Context, in []T, limit int, f func(context.Context, T) (R, error)) ([]R, error) {
out := make([]R, len(in))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
for i, v := range in {
g.Go(func() error {
r, err := f(ctx, v)
if err != nil {
return err
}
out[i] = r // своя ячейка на горутину — синхронизация не нужна
return nil
})
}
return out, g.Wait()
}

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

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

Коротко. См. выше «Что такое утечка горутин и как ее обнаружить» и «Как предупредить утечку горутин». Кратко: горутина, заблокированная навсегда, не собирается GC и удерживает память; предотвращается контекстом, закрытием каналов владельцем и ожиданием завершения.

Глубже. Отличие от предыдущих формулировок — акцент на дизайне API. Хорошее правило: функция, запускающая горутину, должна либо блокироваться до её завершения, либо возвращать способ дождаться/остановить (канал, Close() error, context). Функция, которая «просто запускает горутину в фон» и ничего не возвращает, — источник утечек и невозможности graceful shutdown.

Что такое горутина и чем она отличается от потока?

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

Коротко. См. выше «Поток, горутина: отличия». Горутина — единица планирования рантайма Go со стеком от 2 КиБ, поток — единица планирования ядра с фиксированным крупным стеком; горутины мультиплексируются на потоки M:N.

Глубже. Если нужен один аргумент, называйте цену переключения: смена горутины — сохранение SP, PC и нескольких регистров в структуре g без перехода в ядро; смена потока — системный вызов, сохранение всего регистрового контекста, возможная смена адресного пространства и промахи кэша. Отсюда и разница на два-три порядка в стоимости конкурентности.

Для создания горутины в Go требуется около 2 КБ памяти, тогда как для потока - несколько мегабайт. Из чего складывается эта разница в потреблении памяти?

Заголовок раздела «Для создания горутины в Go требуется около 2 КБ памяти, тогда как для потока - несколько мегабайт. Из чего складывается эта разница в потреблении памяти?»

Коротко. У потока стек выделяется ядром сразу целым диапазоном (в Linux по умолчанию 8 МиБ виртуальной памяти плюс страница-guard), к нему добавляются структуры ядра — task_struct и ядерный стек. У горутины же 2 КиБ — это обычный кусок кучи Go, выделенный рантаймом, плюс сама структура g в несколько сотен байт; ядро о ней ничего не знает.

Глубже. Уточнение, которое отличает хороший ответ: 8 МиБ у потока — виртуальные, физические страницы выделяются по мере касания, так что «несколько мегабайт RSS на поток» — преувеличение. Настоящая разница в другом: (1) виртуальное адресное пространство всё равно расходуется и ограничивает число потоков; (2) стек потока нельзя вырастить, поэтому его приходится резервировать с запасом, а стек горутины растёт по факту с 2 КиБ; (3) на поток приходятся неустранимые ядерные структуры и стоимость планирования в ядре. Плюс горутина может отдать память обратно: во время GC слишком просторный стек ужимается.

Может ли одновременно работать больше горутин, чем имеется ядер процессора?

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

Коротко. Готовых к исполнению — сколько угодно, но реально одновременно исполняется не больше min(GOMAXPROCS, число ядер). Остальные ждут в очередях. Исключение — горутины в блокирующих системных вызовах: они «выполняются» в ядре и не занимают P, поэтому потоков в состоянии syscall может быть больше числа ядер.

Глубже. GOMAXPROCS можно поставить больше числа ядер (runtime.GOMAXPROCS(n) или переменная окружения) — тогда рантайм создаст больше P, но параллелизм ограничит планировщик ОС, а накладные расходы на переключения вырастут. Обратная ситуация полезнее: в контейнере с CPU-лимитом стоит опустить GOMAXPROCS до лимита, иначе рантайм увидит все ядра хоста. С Go 1.25 рантайм научился учитывать cgroup-лимит по умолчанию, до этого использовали automaxprocs или явную настройку.

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

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

Коротко. Отправка заблокируется — небуферизированный канал требует рандеву с получателем. Если это единственная горутина (main) и других готовых нет, рантайм обнаружит, что все горутины спят, и завершит программу с fatal error: all goroutines are asleep - deadlock!.

Глубже. Важные оговорки. Во-первых, дедлок детектируется только когда спят вообще все горутины; если где-то работает фоновый воркер или таймер, программа просто зависнет без сообщения. Во-вторых, это fatal error, а не panic: recover его не перехватывает. В-третьих, ситуация чинится либо запуском читателя до отправки, либо буфером (make(chan int, 1) даёт отправку без получателя ровно один раз), либо неблокирующей отправкой через select с default.

package main
func main() {
ch := make(chan int)
ch <- 1 // fatal error: all goroutines are asleep - deadlock!
}

Что такое горутина и как работает планировщик в Go?

Заголовок раздела «Что такое горутина и как работает планировщик в Go?»

Коротко. Комбинация двух предыдущих вопросов: горутина — легковесная единица исполнения рантайма со стеком от 2 КиБ; планировщик — M:N-механизм GMP, который раскладывает горутины по GOMAXPROCS контекстам P и потокам M, используя локальные очереди, глобальную очередь, netpoll, work stealing и асинхронное вытеснение.

Глубже. Если просят одну связную историю, рассказывайте по жизненному циклу: go f()newprocrunnext/локальная очередь → schedule выбирает и execute запускает → горутина блокируется на канале → gopark, P свободен, M берёт следующую → партнёр по каналу вызывает goready → горутина снова _Grunnable, обычно в runnext того P, который её разбудил → завершение через goexit, g в кэш свободных.

Коротко. Переключение происходит в пользовательском пространстве: рантайм сохраняет SP, PC и несколько регистров в поле g.sched, переходит на системный стек g0 и через gogo восстанавливает контекст другой горутины. Инициируется это блокировкой (gopark), добровольной уступкой (runtime.Gosched), ростом стека (morestack) или асинхронным вытеснением по сигналу.

Глубже. Ключевая деталь — стек g0: у каждого M есть служебная горутина g0 с большим стеком, на котором исполняется код планировщика, GC и обработка сигналов. Смена контекста — это mcall/systemstack (переход на g0), выбор следующей G в schedule() и gogo (переход на её стек). Полного сохранения регистрового файла, как при переключении потоков, не требуется: точки переключения — это, по сути, вызовы функций, и вызывающая конвенция уже определяет, какие регистры caller-saved. Исключение — асинхронное вытеснение: там asyncPreempt сохраняет весь регистровый контекст, потому что прерывание пришло в произвольной точке.

Почему горутины потребляют очень мало ресурсов относительно тредов ос?

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

Коротко. См. выше «Почему горутины легче и дешевле потоков». Три причины: маленький растущий стек вместо крупного фиксированного, отсутствие структур и учёта в ядре, переключение без системного вызова.

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

Коротко. Нет. 2 КиБ — только стартовый размер (stackMin). Стек растёт удвоением по мере необходимости — примерно до 1 ГБ на 64-битных платформах (maxstacksize = 1e9 байт; 250 МБ на 32-битных); лимит меняется через runtime/debug.SetMaxStack. Во время GC слишком просторный стек может быть уменьшен.

Глубже. Механизм роста: компилятор вставляет в пролог проверку SP против g.stackguard0; при её срабатывании вызывается morestacknewstack, выделяется вдвое больший сегмент, старое содержимое копируется, а все указатели внутрь стека (в том числе в структурах g, defer-записях и других стеках) корректируются. Отсюда два практических следствия: адрес стековой переменной нестабилен между вызовами, растящими стек (важно для cgo), и глубокая рекурсия упирается не в 2 КиБ, а в maxstacksize, где падает с fatal error: stack overflow. Функции с большими локальными массивами могут провоцировать частый morestack — это видно в профиле CPU.

What ’s the point of forking the process if Goroutine is much more lightweight?

Заголовок раздела «What ’s the point of forking the process if Goroutine is much more lightweight?»

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

Глубже. В Go классического fork() нет — рантайм многопоточный, а fork в многопоточном процессе копирует только вызывающий поток и оставляет чужие мьютексы в неопределённом состоянии, что ломает рантайм. Вместо этого используется os/exec (fork+exec атомарно, через syscall.StartProcess). Причины запускать процесс, а не горутину: исполнение внешней программы; жёсткая изоляция ненадёжного кода (плагин, парсер недоверенного формата) — паника или OOM в горутине убивает весь сервис, в дочернем процессе — нет; отдельный GOMEMLIMIT/cgroup; обход GIL-подобных ограничений чужих библиотек; graceful restart, когда старый и новый бинарник живут одновременно.

When Goroutine wakes up after it was blocked, does it migrate to another thread?

Заголовок раздела «When Goroutine wakes up after it was blocked, does it migrate to another thread?»

Коротко. Да, может. Никакой привязки горутины к конкретному M нет: при пробуждении goready кладёт её в runnext/локальную очередь того P, который её разбудил, и дальше она исполнится на любом потоке, владеющем этим P, или будет украдена другим P. Единственное исключение — горутина, вызвавшая runtime.LockOSThread.

Глубже. Практические следствия: нельзя полагаться на thread-local состояние ОС (например, на состояние, привязанное к потоку в C-библиотеке через cgo, или на setns/uid-манипуляции в Linux, которые действуют на поток). Если такое состояние нужно, вызывайте runtime.LockOSThread() в начале горутины и UnlockOSThread() при выходе — тогда рантайм даст горутине эксклюзивный M, а при завершении горутины без разблокировки поток будет уничтожен. main-горутина при старте программы заблокирована на главном потоке, это важно для GUI-библиотек на macOS.

Standard questions about Go: slices, maps, goroutines, channels, escape analysis;

Заголовок раздела «Standard questions about Go: slices, maps, goroutines, channels, escape analysis;»

Коротко. Это пометка о наборе тем, а не вопрос. Минимум, который нужно уметь: внутреннее устройство слайса (ptr/len/cap, рост, aliasing после append), map (хеш-таблица, в Go 1.24 на Swiss Tables, отсутствие потокобезопасности, случайный порядок обхода), горутины и GMP, каналы (hchan, закрытие, nil, select) и escape-анализ (go build -gcflags='-m').

Глубже. По конкурентной части этого набора ответы даны выше; по слайсам, map и escape-анализу — в подтемах slices-arrays, maps и memory-gc. Единственное, что связывает эти темы в одном вопросе: и слайс, и map, и канал — это заголовочные структуры с указателем внутрь кучи, поэтому копирование значения не копирует данные, и все они одинаково подвержены гонкам при конкурентной модификации.

Что такое context в Go и как он связан с отменой горутин?

Заголовок раздела «Что такое context в Go и как он связан с отменой горутин?»

Коротко. context.Context — стандартный интерфейс для передачи сигнала отмены, дедлайна и request-scoped значений по дереву вызовов. Отмена — это закрытие канала ctx.Done(); горутина завершается сама, увидев его в select, принудительно её никто не остановит.

Глубже. См. также «Контекст: как отмена влияет на горутины? Как проверить контекст?». Дополнительно к сказанному: контекст всегда передаётся первым аргументом и не хранится в структурах (кроме редких обоснованных случаев); context.Background() — корень в main, context.TODO() — заглушка при рефакторинге; значения (WithValue) предназначены для request-scoped метаданных (trace id), а не для передачи параметров функции. Отмена по цепочке работает мгновенно и один раз: повторный cancel безопасен, но Done уже закрыт.

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

Заголовок раздела «Если в одном потоке был открыт файл, то можно передать хендлер в другой поток?»

Коротко. Да. Файловый дескриптор принадлежит процессу, а не потоку, поэтому *os.File спокойно передаётся между горутинами и потоками. Более того, горутина сама может мигрировать между потоками, так что иначе Go просто не работал бы.

Глубже. Ограничение не в передаче, а в конкурентном использовании: у *os.File внутри internal/poll.FD есть мьютекс, который сериализует операции и не даёт закрыть дескриптор во время чтения — крэша не будет, но смещение в файле общее, поэтому два конкурентных Read вычитают разные куски в непредсказуемом порядке. Если нужен параллельный доступ, используйте ReadAt/WriteAt с явными смещениями (они не трогают текущую позицию) либо открывайте файл несколько раз. Отдельная тонкость: File.Fd() переводит дескриптор в блокирующий режим и выводит его из-под netpoller, поэтому передавать «сырой» fd наружу лучше через SyscallConn.

Что такое горутина в Go и как она соотносится с тредами ОС?

Заголовок раздела «Что такое горутина в Go и как она соотносится с тредами ОС?»

Коротко. Горутина — единица планирования рантайма Go; она не соответствует потоку один к одному, а мультиплексируется на пул потоков по модели M:N через контексты P. Одна и та же горутина в разные моменты может исполняться на разных потоках, а один поток последовательно исполняет много горутин.

Глубже. Число потоков в процессе обычно немного больше GOMAXPROCS: по потоку на каждый P, плюс sysmon, плюс потоки, застрявшие в блокирующих syscall и cgo-вызовах, плюс потоки для обработки сигналов и таймеров. Проверить можно GODEBUG=schedtrace=1000 (поле threads) или top -H. Жёсткий потолок — 10 000 (runtime.SetMaxThreads); упереться в него реально, если тысячи горутин одновременно делают долгие блокирующие syscall или cgo-вызовы.

Что такое горутина? Для чего используется?

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

Коротко. См. выше определение. Используется, чтобы выполнять независимые задачи конкурентно: обслуживать соединения и запросы, параллелить CPU-работу по ядрам, делать фоновые задачи (таймеры, воркеры, потребители очередей), реализовывать таймауты и fan-in/fan-out через каналы.

Глубже. Отличие Go от языков с async/await в том, что здесь нет раскраски функций: любая функция может блокироваться, и её можно запустить через go — рантайм сам превратит блокирующий код в асинхронный на уровне netpoller. Обратная сторона — горутину нельзя отменить извне, поэтому дисциплина «контекст + владелец, который дожидается завершения» становится обязательной.

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

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

Коротко. См. выше «как синхронизировать горутины». Честный ответ про личную практику: чаще всего — context для отмены плюс errgroup/WaitGroup для ожидания и sync.Mutex для защиты состояния; каналы — там, где действительно нужен поток данных, а не просто взаимное исключение.

Глубже. Это отчасти вопрос про опыт, и интервьюер слушает обоснование выбора, а не название примитива. Хороший ответ строится так: назвать типовую задачу из своего проекта («fan-out по N внешним API с лимитом и общим дедлайном»), назвать выбранный инструмент (errgroup.WithContext + SetLimit), объяснить, почему не альтернатива («канал результатов здесь лишний — пишем по индексу в предвыделенный слайс»), и упомянуть, как проверяли корректность (-race в CI, goleak в тестах). Типичная ошибка — отвечать «я всегда использую каналы, это же Go-way»: авторы языка сами пишут, что для защиты состояния мьютекс уместнее.

Коротко. Только внутри самой горутины: defer func() { if r := recover(); r != nil { ... } }() первым делом в её теле. recover в вызывающей горутине не поможет — паника не пересекает границу горутины и роняет весь процесс.

Глубже. Практика: заводят обёртку go safeGo(func(){...}), которая ставит recover, логирует стек (debug.Stack()) и, при необходимости, передаёт ошибку в канал или в errgroup. Важные оговорки: recover работает только в функции, вызванной напрямую через defer; после восстановления горутина завершается на месте паники, так что нужно решить, перезапускать ли работу; фатальные ошибки рантайма (concurrent map writes, all goroutines are asleep, out of memory, stack overflow) не паники — их перехватить нельзя. errgroup.Group панику не ловит и пробрасывает дальше, так что обёртка нужна и там.

package safe
import (
"fmt"
"runtime/debug"
)
func Go(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("panic in goroutine: %v\n%s", r, debug.Stack())
}
}()
fn()
}()
}

Коротко. См. выше «Как бы вы реализовали worker pool на Go» и вариант с errgroup. Схема: канал задач → N горутин-воркеров в for range → канал (или слайс) результатов → WaitGroup и закрытие канала результатов после Wait.

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

Коротко. См. выше «Поток, горутина: отличия» и «Что такое горутина и чем она отличается от потока». Кратко: планирует рантайм, а не ядро; стек 2 КиБ растущий против фиксированного мегабайтного; переключение в userspace; модель M:N.

Глубже. Стоит уметь назвать и то, чего у горутин нет по сравнению с потоками: приоритетов, идентификаторов, привязки к ядру, принудительного завершения, thread-local storage. Это не недостатки, а сознательные упрощения, которые делают планировщик дешёвым.

Коротко. Напрямую — очень ограниченно: runtime.GOMAXPROCS(n) меняет число P, runtime.Gosched() добровольно уступает процессор, runtime.LockOSThread() привязывает горутину к потоку, runtime.SetMaxThreads ограничивает число потоков. Приоритетов, affinity и порядка исполнения горутин задать нельзя.

Глубже. Косвенно поведение настраивается через GODEBUG: schedtrace/scheddetail для наблюдения, asyncpreemptoff=1 для отключения асинхронного вытеснения (только для отладки). Уровнем выше «влияние на планировщик» — это архитектурные решения: пул воркеров вместо неограниченного порождения горутин, разбиение длинных вычислений на порции, вынос блокирующего кода из горячих путей, правильный GOMAXPROCS в контейнере. Попытки «оптимизировать» через Gosched() в цикле почти всегда вредны: планировщик и так вытеснит горутину, а лишняя уступка ломает локальность.

Коротко. Компилятор превращает go f(a, b) в вызов runtime.newproc с указателем на функцию и упакованными аргументами. Рантайм берёт свободную g из кэша P (или создаёт новую со стеком 2 КиБ), копирует туда аргументы, ставит точку возврата в goexit, помечает _Grunnable и кладёт в runnext текущего P. Вызывающая горутина продолжает работу немедленно.

Глубже. Полезные следствия, которые проверяют вопросами-ловушками: аргументы вычисляются в момент выполнения оператора go, а не в момент старта горутины (go f(i) фиксирует текущее i, а замыкание без параметра — нет); возвращаемое значение и паника новой горутины вызывающему недоступны; порядок старта не гарантирован — новая горутина может начать исполняться и до, и после следующей строки вызывающей. Если runnext уже занят, прежний обитатель слота вытесняется в обычную локальную очередь; при её переполнении половина уезжает в глобальную.

There are three goroutines where run is blocked. How to make the steady function simultaneously unblock all three runs?

Заголовок раздела «There are three goroutines where run is blocked. How to make the steady function simultaneously unblock all three runs?»

Коротко. Использовать широковещательный сигнал: все три горутины ждут <-done, а «steady/ready» вызывает close(done) — закрытие канала будит сразу всех получателей. Альтернатива — sync.Cond с Broadcast(), а для варианта «дождаться, пока все соберутся» — sync.WaitGroup или барьер.

Глубже. Почему именно close, а не отправка: чтобы разбудить троих отправкой, нужно послать три значения и заранее знать их количество; закрытие же переводит канал в состояние «всегда готов к чтению», и closechan проходит по всей очереди recvq, вызывая goready для каждого ожидающего. Канал типа chan struct{} не занимает памяти под элементы. Если сигнал должен быть повторяемым, канал не подходит (закрыть дважды нельзя) — тогда sync.Cond с Broadcast или новый канал на каждое поколение, как в context.

package barrier
import "sync"
type Gate struct {
once sync.Once
done chan struct{}
}
func NewGate() *Gate { return &Gate{done: make(chan struct{})} }
func (g *Gate) Run() { <-g.done } // блокируется, пока ворота закрыты
func (g *Gate) Steady() { g.once.Do(func() { close(g.done) }) } // будит всех сразу

Complete the code so that the task runs asynchronously with a limit on the number of simultaneous requests

Заголовок раздела «Complete the code so that the task runs asynchronously with a limit on the number of simultaneous requests»

Коротко. Ограничитель — буферизованный канал-семафор ёмкостью N: перед запуском задачи пишем в него, по завершении читаем. Готовая альтернатива — errgroup.Group с SetLimit(n) или golang.org/x/sync/semaphore. Полный код см. в ответе «Как оптимизировать чтобы не запускать все 50 тысяч горутин» и в примере с errgroup выше.

Глубже. На что смотрит интервьюер в такой задаче: захват семафора до go, а не внутри горутины (иначе 50 000 горутин всё-таки создаются и просто ждут); освобождение через defer, чтобы паника не «съела» слот; ожидание всех задач через WaitGroup/Wait; учёт контекста при захвате семафора, чтобы отмена не зависала на полном буфере; сбор ошибок и результатов без гонок. Аккуратный вариант — errgroup с SetLimit: он делает всё это за вас и дополнительно отменяет контекст при первой ошибке.

Как отдаются значения с одной горутины в другую?

Заголовок раздела «Как отдаются значения с одной горутины в другую?»

Коротко. Каноничный способ — канал: ch <- v в одной горутине, v := <-ch в другой; при передаче значение копируется. Альтернативы — общая память под мьютексом или атомиком (atomic.Pointer[T]) и запись результата в заранее выделенную ячейку слайса, которую читают после wg.Wait().

Глубже. Канал даёт не только передачу данных, но и отношение happens-before: всё, что горутина записала до отправки, гарантированно видно получателю после приёма — поэтому передавать через канал указатель безопасно, если отправитель больше не трогает объект. В небуферизированном канале значение копируется напрямую со стека отправителя на стек получателя (sendDirect), минуя буфер. Копируется именно значение: если это структура с внутренним слайсом или указателем, разделяемые данные останутся общими, и гонка возможна. wg.Wait() тоже устанавливает happens-before, поэтому «каждая горутина пишет в свой индекс, потом читаем после Wait» — корректный и самый дешёвый способ собрать результаты.

Коротко. Стартовый — 2 КиБ, дальше растёт удвоением по мере необходимости, максимум около 1 ГБ на 64-битных платформах (debug.SetMaxStack меняет лимит). См. подробности в ответе «Стек горутины всегда занимает 2кб?».

Глубже. Исторический контекст, который иногда спрашивают: в Go 1.0 стартовый стек был 4 КиБ и рост реализовывался сегментами («hot split» — переход между сегментами в цикле давал резкое падение производительности); в Go 1.3 перешли на непрерывный стек с копированием, а в Go 1.4 стартовый размер уменьшили до 2 КиБ.

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

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

Коротко. Нет, будет заметно медленнее. math.Sqrt компилируется в одну процессорную инструкцию (SQRTSD на amd64), а создание горутины, планирование и синхронизация результата стоят на порядки дороже самой работы. Параллелизм окупается, когда порция работы существенно больше накладных расходов на её распределение.

Глубже. Правильная стратегия для по-настоящему больших объёмов — не «горутина на элемент», а «горутина на диапазон»: разбить массив на GOMAXPROCS кусков и обработать каждый в своей горутине; тогда накладные расходы амортизируются миллионами операций. Дополнительные ограничители в таком счёте: ложное разделение (false sharing), когда соседние горутины пишут в соседние ячейки одной кэш-линии, и пропускная способность памяти — арифметика простая, узкое место обычно не в CPU. Проверять всегда бенчмарком: go test -bench . -cpu 1,2,4,8.

Коротко. См. выше «Что такое горутина? Для чего используется?». Дубль вопроса: горутина — легковесная единица конкурентного исполнения рантайма Go, используется для обслуживания запросов, фоновых задач, параллельных вычислений и координации через каналы.

Глубже. Одно уточнение к формулировке: горутина — это не «поток», не «зелёный поток» в смысле 1:1 и не «async task». Это функция, для которой рантайм создал контекст исполнения со своим растущим стеком и которую он умеет парковать и возобновлять без участия ядра.

Что будет, если вызвать несколько gorutine в main и не ждать их завершения?

Заголовок раздела «Что будет, если вызвать несколько gorutine в main и не ждать их завершения?»

Коротко. Когда main вернётся, процесс завершится немедленно, и все незавершённые горутины будут уничтожены без выполнения defer и без какого-либо сообщения. Результат недетерминирован: часть горутин может успеть отработать, часть — нет.

Глубже. Формально: «когда функция main возвращается, программа завершается; она не ждёт завершения других горутин» (спецификация языка). Правильные способы дождаться — sync.WaitGroup, errgroup.Wait(), чтение из канала результатов; time.Sleep — антипаттерн, который в тестах даёт флаки, а в проде — потерянные записи. Отдельная практическая тема — graceful shutdown: перехват SIGTERM через signal.NotifyContext, отмена корневого контекста, ожидание воркеров с таймаутом, http.Server.Shutdown(ctx).

При обработке гигабайт данных в горутине, где хранятся эти данные?

Заголовок раздела «При обработке гигабайт данных в горутине, где хранятся эти данные?»

Коротко. В куче. Стек горутины хоть и растёт, но предназначен для локальных переменных и кадров вызова; всё, что аллоцируется через make/new, имеет неизвестный размер или убегает по escape-анализу, живёт в куче и управляется GC — и, что важнее, куча общая для всего процесса, а не «своя у горутины».

Глубже. Компилятор отправляет объект в кучу и по чисто размерным критериям: локальные переменные крупнее 128 КиБ (ir.MaxStackVarSize) и неявные объекты вроде make крупнее 64 КиБ или неконстантного размера (ir.MaxImplicitStackVarSize) сразу аллоцируются на куче, даже если не убегают. Проверяется это через go build -gcflags='-m -m'. Практический вывод для гигабайтных объёмов: не держать всё в памяти целиком, а обрабатывать потоком (io.Reader, bufio.Scanner, чанки) и переиспользовать буферы (sync.Pool, явный []byte в структуре воркера) — иначе GOGC по умолчанию удвоит пик памяти. Ограничивать пик помогает GOMEMLIMIT (Go 1.19+).

В чем разница между конкурентностью и параллелизмом?

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

Коротко. См. выше «Чем отличается конкурентное выполнение от параллельного» и «Что такое параллельность и конкурентность в Go». Конкурентность — про структуру программы (много задач, продвигающихся с чередованием), параллелизм — про физическое одновременное исполнение на нескольких ядрах.

Глубже. Формулировка на один вдох, которую стоит заготовить: «Конкурентность — это способ организовать программу так, чтобы задачи не мешали друг другу ждать; параллелизм — это способ исполнить их одновременно. Go даёт первое языком (go, каналы, select), а второе — рантаймом через GOMAXPROCS. Конкурентная программа на одном ядре корректна, но не быстрее; параллельная требует и ядер, и отсутствия общего узкого места».

Коротко. Горутина — это функция, выполняемая конкурентно, которой управляет не ядро, а рантайм Go. В памяти это структура g со своим растущим стеком (старт 2 КБ) и сохранённым контекстом регистров; рантайм мультиплексирует множество горутин на небольшое число потоков ОС по модели GMP.

Глубже. go f() вызывает runtime.newproc: берётся g из свободного списка P (gfget) или аллоцируется новая, ей выделяется стек, в gobuf записывается точка входа, и она кладётся в runnext текущего P — то есть новая горутина имеет шанс выполниться следующей. Стек — непрерывный (contiguous stacks с Go 1.4): при нехватке места пролог функции ловит недостаток по stackguard0, вызывается morestack, стек копируется в вдвое больший блок, указатели корректируются. Верхний предел — 1 ГБ на 64-битных платформах (maxstacksize), меняется через debug.SetMaxStack. Сжимается стек во время GC. Из-за этого копирования у горутины нет фиксированного адреса стека, а сама горутина не имеет идентификатора в публичном API — это осознанное решение авторов, чтобы не появлялись goroutine-local storage-костыли.

Какие способы синхронизации горутин знаешь?

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

Коротко. Каналы и select; пакет syncMutex, RWMutex, WaitGroup, Once, Cond, Pool, Map; sync/atomic; context для отмены; поверх — golang.org/x/sync: errgroup, semaphore, singleflight.

Глубже. Выбор диктуется задачей: канал — это передача владения данными плюс синхронизация («share memory by communicating»), мьютекс — защита разделяемого состояния на месте. WaitGroup — барьер «дождаться N завершений», errgroup — то же самое, но с первой ошибкой и отменой общего контекста, semaphore.Weighted/буферизованный канал — ограничение параллелизма, singleflight — схлопывание одинаковых конкурентных запросов. Атомики — для счётчиков и флагов, где нет составных инвариантов. sync.Cond в проде нужен редко и почти всегда заменяется каналом. Важно помнить, что «синхронизация» здесь означает не только взаимное исключение, но и happens-before: гарантии описаны в The Go Memory Model, и без явного примитива компилятор и процессор вправе переупорядочить доступы.

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

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

Коротко. Горутина — легковесный поток исполнения, управляемый планировщиком рантайма Go (user-space, не ядром). Ядро видит только потоки M, на которые рантайм раскладывает горутины.

Глубже. См. выше про устройство. Формально управление распределено: newproc создаёт, schedule/findRunnable выбирают, gopark/goready усыпляют и будят, sysmon вытесняет зависших, GC приостанавливает всех через suspendG. Планировщик Go — не отдельная горутина и не отдельный поток: это код рантайма, который выполняется на том же M в момент, когда текущая горутина отдала управление.

Почему wg.Add(1) нельзя делать прямо в начале ожидаемой горутины?

Заголовок раздела «Почему wg.Add(1) нельзя делать прямо в начале ожидаемой горутины?»

Коротко. Потому что нет гарантии, что горутина успеет стартовать до wg.Wait(): планировщик может не запустить её вовсе, Wait() увидит нулевой счётчик и вернётся немедленно. Add должен вызываться в той же горутине, которая потом делает Wait, — до go.

Глубже. Документация sync.WaitGroup формулирует это как правило: вызовы Add с положительным дельта должны happen-before Wait. Иначе, кроме раннего выхода, возможна и паника sync: WaitGroup is reused before previous Wait has returned при повторном использовании. Детектор гонок такой код часто ловит, но не обязан — это логическая ошибка, а не всегда data race.

// ПЛОХО
for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // может выполниться уже после wg.Wait()
defer wg.Done()
}()
}
wg.Wait() // вернётся мгновенно, счётчик ещё 0
// ХОРОШО
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
}()
}
wg.Wait()

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

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

Коротко. Порядок не определён: спецификация Go не даёт никаких гарантий на порядок выполнения горутин. Единственно верный ответ на собеседовании — «недетерминированно, если порядок нужен, его надо задать явно каналами или WaitGroup-цепочкой».

Глубже. Кода в задании нет, но типовой вариант — цикл for i := 0; i < N; i++ { go func(){ fmt.Println(i) }() } плюс wg.Wait(). Наблюдаемое поведение часто выглядит «почти LIFO» для первых элементов: newproc кладёт новую горутину в runnext текущего P, вытесняя оттуда предыдущую в локальную очередь, — но опираться на это нельзя, при GOMAXPROCS > 1 работает work stealing, а при 61-м тике подмешивается глобальная очередь. Отдельно стоит упомянуть Go 1.22: переменная цикла теперь создаётся заново на каждой итерации, поэтому классический баг «все горутины печатают N» исчез — печатаются все значения, но в произвольном порядке. Если строгий порядок нужен, его строят явно: цепочка каналов, где i-я горутина ждёт сигнала от (i−1)-й.

Какие проблемы могут быть, если в представленной программе в urls будет 100k урлов?

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

Коротко. Неограниченный fan-out: 100 000 одновременных горутин и соединений — исчерпание файловых дескрипторов и эфемерных портов, всплеск памяти (стеки + буферы net/http по десятки КБ на запрос), таймауты и 5xx от целевых хостов, деградация планировщика и GC. Плюс типовые спутники: гонка при записи результатов в общую map и отсутствие таймаута/отмены.

Глубже. Считаем: 100k горутин — это минимум ~200 МБ только под стеки, но реально каждый http.Get тянет буферы чтения/записи bufio (по 4 КБ), TLS-сессию (десятки КБ), структуры Response — легко несколько гигабайт. http.DefaultTransport ограничивает MaxIdleConnsPerHost = 2, но не число одновременных соединений, поэтому все 100k полезут в сеть сразу; DNS-резолвер захлебнётся. ulimit -n (обычно 1024–65535) даст socket: too many open files. Со стороны рантайма: локальные очереди P переполнятся, всё пойдёт через глобальную очередь под общим sched.lock — растёт contention; GC будет сканировать 100k стеков. И, наконец, если результаты пишутся в общий map без блокировки — fatal error: concurrent map writes, которую не перехватить через recover.

Коротко. Ограничить параллелизм: воркер-пул фиксированного размера или семафор на буферизованном канале; добавить context с таймаутом, настроенный http.Client с переиспользованием соединений; результаты собирать через канал или защищённую структуру; при необходимости — ретраи с backoff и rate limiter.

Глубже. Разумный размер пула для сетевых задач — сотни, а не единицы (это I/O-bound работа, GOMAXPROCS тут не потолок), подбирается замером. Обязательно http.NewRequestWithContext, Timeout у клиента, MaxIdleConnsPerHost/MaxConnsPerHost в Transport, и всегда resp.Body.Close() (а лучше ещё и io.Copy(io.Discard, resp.Body) перед закрытием, иначе соединение не вернётся в пул).

func FetchAll(ctx context.Context, urls []string, workers int) []Result {
jobs := make(chan string)
out := make(chan Result, workers)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for u := range jobs {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
out <- Result{URL: u, Err: err}
continue
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
out <- Result{URL: u, Err: err}
continue
}
resp.Body.Close()
out <- Result{URL: u, Code: resp.StatusCode}
}
}()
}
go func() {
defer close(jobs)
for _, u := range urls {
select {
case jobs <- u:
case <-ctx.Done():
return
}
}
}()
go func() { wg.Wait(); close(out) }()
res := make([]Result, 0, len(urls))
for r := range out {
res = append(res, r)
}
return res
}

Альтернатива, если хочется «горутина на задачу»: errgroup.Group с SetLimit(n) или semaphore.Weighted.

Коротко. Планировщик Go — user-space, кооперативно-вытесняющий, построен на модели GMP: G мультиплексируются на M через P, число которых равно GOMAXPROCS. У каждого P — локальная очередь на 256 горутин и слот runnext, есть глобальная очередь, work stealing и интеграция с сетевым поллером.

Глубже. Цикл выглядит так: schedule()findRunnable()execute(). findRunnable смотрит (упрощённо) в таком порядке: каждый 61-й тик — глобальная очередь ради честности; runnext; локальная очередь; глобальная очередь; netpoll без блокировки; кража половины очереди у случайного P; если ничего нет — P отдаётся в pidle, а M уходит спать на notesleep, предварительно «покрутившись» (spinning M), чтобы не платить за парковку/пробуждение на коротких паузах. Ключевые исторические вехи: Go 1.1 — work-stealing планировщик Дмитрия Вьюкова (тогда же появился P), Go 1.5 — GOMAXPROCS по умолчанию равен числу ядер, Go 1.14 — асинхронное вытеснение сигналами. Полезная деталь для ответа: у горутин нет приоритетов, и планировщик не гарантирует справедливости в строгом смысле — только отсутствие полного голодания.

В какой момент происходит переключение горутин?

Заголовок раздела «В какой момент происходит переключение горутин?»

Коротко. В двух классах моментов: (1) горутина сама отдаёт управление — операция с каналом, select, sync-примитив под конкуренцией, time.Sleep, сетевой I/O, syscall, runtime.Gosched(), аллокация памяти с GC-assist, рост стека; (2) её вытесняют — sysmon видит, что горутина работает дольше 10 мс, или GC требует останова, и посылает потоку SIGURG (асинхронное вытеснение, Go 1.14+).

Глубже. До Go 1.14 вытеснение было только кооперативным: рантайм портил stackguard0 значением stackPreempt, и горутина «замечала» это в прологе следующей вызванной функции. Цикл вида for {} без вызовов и аллокаций мог навсегда занять P — классический вопрос про подвисание при GOMAXPROCS=1. Сейчас preemptM шлёт сигнал SIGURG, обработчик проверяет, что точка безопасна для остановки (asyncPreemptOK, наличие валидной информации о стеке), и переводит горутину в _Gpreempted. Важная оговорка: код внутри блокирующего syscall или cgo-вызова вытеснить нельзя — там ждут отдачи P через handoffp.

Какой-то странный вопрос про поведение планировщика при сетевых запросах.

Заголовок раздела «Какой-то странный вопрос про поведение планировщика при сетевых запросах.»

Коротко. Вероятно, спрашивали про интегрированный netpoller: при сетевом вызове горутина не блокирует поток — сокет неблокирующий, горутина паркуется в _Gwaiting («IO wait»), а M с тем же P продолжает выполнять другие горутины. Когда epoll сообщает о готовности, netpoll возвращает список горутин, и они становятся runnable.

Глубже. Практическое следствие, которое обычно и хотят услышать: 10 000 одновременных HTTP-запросов не порождают 10 000 потоков — потоков останется около GOMAXPROCS. Поллер опрашивается в трёх местах: неблокирующе в findRunnable, блокирующе, когда P совсем нечего делать, и из sysmon, если поллером давно никто не занимался. Разбуженные горутины попадают в глобальную очередь/локальную очередь через injectglist.

Что происходит с горутиной, когда она блокируется на чтении файла?

Заголовок раздела «Что происходит с горутиной, когда она блокируется на чтении файла?»

Коротко. Обычный файл нельзя опрашивать через epoll, поэтому read(2) выполняется как настоящий блокирующий syscall: горутина переходит в _Gsyscall, поток M вместе с ней уходит в ядро, а P отбирается и передаётся другому потоку, чтобы остальные горутины продолжали работать.

Глубже. Механика: entersyscall переводит P в _Psyscall и сохраняет контекст. Если syscall быстрый, exitsyscall пытается вернуть тот же P — это дешёвый путь. Если он длится дольше одного тика sysmon (порядка 20 мкс) — retake() вызывает handoffp, и P уходит свободному M или новому потоку. Для заведомо блокирующих операций рантайм сразу использует entersyscallblock. Отсюда наблюдаемый эффект: программа, интенсивно работающая с диском, создаёт заметно больше потоков ОС, чем GOMAXPROCS; лимит потоков — 10 000 по умолчанию (debug.SetMaxThreads), при его превышении — fatal error: thread exhaustion.

Какие отличия между сетевым запросом и чтением из файла с точки зрения планировщика?

Заголовок раздела «Какие отличия между сетевым запросом и чтением из файла с точки зрения планировщика?»

Коротко. Сеть — асинхронная: горутина паркуется, поток свободен, пробуждение через netpoller. Файл — синхронный syscall: поток блокируется в ядре и отдаёт P другому потоку. В первом случае число потоков не растёт, во втором — растёт.

Глубже. Причина в ядре: epoll на обычном файле всегда сообщает «готов», реального ожидания там выразить нельзя, поэтому internal/poll.FD инициализируется с pollable = false для регулярных файлов и с pollable = true для сокетов, пайпов и терминалов. В Go 1.24 рантайм не использует io_uring, так что обойти это стандартными средствами нельзя. Практические выводы: тяжёлый дисковый I/O стоит ограничивать своим пулом воркеров (иначе получите сотни потоков), а GOMAXPROCS при этом не помогает — он ограничивает P, а не M.

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

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

Коротко. Использовать errgroup.WithContext: производный контекст отменяется при первой ошибке, все запросы, которые его получили, прерываются, а g.Wait() возвращает эту ошибку. Руками то же самое строится через context.WithCancel + канал ошибок ёмкости 1 + sync.Once.

Глубже. Важно, чтобы отмена реально доходила до работы: контекст нужно прокидывать в http.NewRequestWithContext, в запросы к БД, в select внутри циклов. Просто «закрыть канал» недостаточно — горутина, висящая на resp.Body.Read, об этом не узнает.

func FetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(64)
for _, u := range urls {
g.Go(func() error { // Go 1.22+: u уже своя на каждой итерации
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("get %s: %w", u, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("get %s: status %d", u, resp.StatusCode)
}
return nil
})
}
return g.Wait() // первая ошибка; остальным ctx уже отменён
}

Если под «программа завершилась» имеется в виду буквально выход процесса — после g.Wait() делается log.Fatal(err), но в библиотечном коде так делать нельзя: os.Exit не выполняет defer.

Что будет, если в программе мы будем читать по ключу “a”, а писать по ключу “b”?

Заголовок раздела «Что будет, если в программе мы будем читать по ключу “a”, а писать по ключу “b”?»

Коротко. Всё равно будет fatal error: concurrent map read and map write. Встроенная map не потокобезопасна целиком, а не по ключам: разные ключи живут в общей хеш-таблице, и запись может вызвать рост/перестройку, которая ломает читателя.

Глубже. Рантайм ставит флаг «идёт запись» на всю таблицу и при обнаружении конкурентного доступа аварийно завершает процесс через fatal — это не panic, recover не поможет. Проверено на Go 1.24 (map уже на Swiss Tables): два цикла, один пишет m["b"], другой читает m["a"], падает за доли секунды; под -race детектор показывает WARNING: DATA RACE в mapassign_faststr/mapaccess. Обратите внимание: детекция «best effort» — иногда программа может проработать долго и упасть позже, поэтому отсутствие падения на тесте ничего не доказывает. Лечится sync.RWMutex, sync.Map (для read-mostly или непересекающихся наборов ключей) или шардированием.

Коротко. См. выше «Расскажите про планировщик»: GMP, локальные очереди по 256 + runnext, глобальная очередь с проверкой каждый 61-й тик, work stealing половины очереди соседа, netpoller, sysmon для вытеснения и отбора P у syscall’ов.

Глубже. Если вопрос повторяется, стоит добавить то, что редко называют: spinning-потоки (несколько M крутятся в поиске работы, чтобы не платить за парковку), handoffp при syscall, injectglist для пачки готовых горутин из поллера, отсутствие приоритетов у горутин, и инструменты наблюдения — GODEBUG=schedtrace=1000,scheddetail=1 и runtime/trace.

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

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

Коротко. Они лежат не в очередях планировщика, а внутри самого канала: в структуре hchan есть две очереди ожидания — recvq и sendq, списки sudog. Горутина, не сумевшая завершить операцию, оборачивается в sudog, встаёт в очередь и паркуется через gopark в состоянии _Gwaiting; парная операция снимает её оттуда и делает runnable через goready.

Глубже. Оптимизация, которую полезно назвать: при отправке в канал, где уже есть ожидающий получатель, значение копируется напрямую из стека отправителя в стек получателя, минуя буфер (send/recv в runtime/chan.go), — отсюда «нулевая» стоимость рандеву на небуферизованном канале. Разбуженная горутина обычно попадает в runnext текущего P, что даёт хорошую локальность для пинг-понга. Сам hchan защищён обычным mutex рантайма, поэтому канал под высокой конкуренцией — это точка contention: сотня горутин, пишущих в один канал, будет драться за один лок. close(ch) будит сразу всех в обеих очередях.

Коротко. Голодание — ситуация, когда готовая к работе горутина долго не получает CPU. В Go планировщик специально с этим борется: проверка глобальной очереди каждый 61-й тик, ограничение «наследования» кванта у runnext, вытеснение по 10 мс через sysmon, а начиная с Go 1.14 — асинхронное вытеснение даже циклов без вызовов функций.

Глубже. Типичные источники голодания в реальном коде: (1) горутина, которая монополизирует P тяжёлым CPU-циклом при малом GOMAXPROCS — до 1.14 это подвешивало всё, сейчас лишь ухудшает латентность; (2) sync.Mutex — у него есть режим starvation: если ожидающий не может получить лок дольше 1 мс (starvationThresholdNs = 1e6, теперь код живёт в internal/sync/mutex.go), мьютекс переключается в режим прямой передачи владения очереди, жертвуя пропускной способностью ради честности; (3) быстрый производитель и медленный потребитель на небуферизованном канале — здесь голодает не горутина, а прогресс системы; (4) GC-assist: горутина, много аллоцирующая, «отрабатывает» долг помощью сборщику и получает меньше полезного времени. Диагностика — runtime/trace (видно «goroutine analysis» с временем в runnable) и метрика /sched/latencies:seconds из runtime/metrics.

Коротко. Обрывок условия задачи (типовой live-coding про перевод денег между счетами). Смысл пункта — сервис должен проверять баланс перед списанием; ключевая мысль в ответе: проверка и списание обязаны быть одной атомарной операцией, иначе между if balance >= amount и balance -= amount вклинится параллельный запрос (TOCTOU).

Глубже. На собеседовании такой пункт разворачивают в вопрос «как сделать корректно». Правильный каркас: одна критическая секция на пару «проверка + изменение» (мьютекс на аккаунт или CAS-петля), деньги хранить в целых минимальных единицах, а не в float64, при переводе между двумя счетами — блокировать оба в детерминированном порядке (например, по возрастанию ID), иначе классический дедлок «A→B и B→A». Если состояние в БД — это SELECT ... FOR UPDATE или условный UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1 с проверкой числа затронутых строк.

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

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

Коротко. sync.RWMutex: запись под Lock(), чтение баланса под RLock() — читатели не блокируют друг друга и работают параллельно, блокируясь только на время самой записи. Альтернатива для одного числа — atomic.Int64: чтение вообще без блокировки.

Глубже. Нюансы, которые стоит проговорить: RWMutex выгоден только при явном перевесе чтений и достаточно долгой критической секции — на коротких секциях он медленнее обычного Mutex из-за более дорогого протокола; в Go писатель не голодает, так как после заявки писателя новые читатели встают в очередь. RLock не рекурсивен: рекурсивный RLock при ожидающем писателе даёт дедлок. Если нужен полностью неблокирующий доступ к чтению — атомик или copy-on-write снапшот структуры через atomic.Pointer[T].

type Account struct {
mu sync.RWMutex
balance int64 // минимальные единицы, не float64
}
func (a *Account) Balance() int64 {
a.mu.RLock()
defer a.mu.RUnlock()
return a.balance
}
func (a *Account) Withdraw(amount int64) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return ErrInsufficient
}
a.balance -= amount
return nil
}

Как ускорить или отказаться от проверки “if sourceAccount.Balance >= amount”

Заголовок раздела «Как ускорить или отказаться от проверки “if sourceAccount.Balance >= amount”»

Коротко. Отказаться от отдельной проверки и сделать её частью атомарного изменения: CAS-петля на atomic.Int64 или условный UPDATE ... WHERE balance >= amount в БД. Тогда «проверка» бесплатна и в принципе не может разъехаться со списанием.

Глубже. CAS-вариант ниже избавляет от мьютекса на горячем пути и хорошо ведёт себя при умеренной конкуренции; при высокой — петля начинает буксовать (много неудачных CAS), и мьютекс становится не хуже. Другие приёмы «ускорения»: шардировать счета (лок на аккаунт, а не глобальный), держать баланс в одной кеш-линии и избегать false sharing, при массовых списаниях — резервировать пачку средств заранее (батч/квота) и тратить локально. В распределённом сценарии проверка «на клиенте» вообще невалидна — единственная точка истины БД, и корректность обеспечивает транзакция с нужным уровнем изоляции либо оптимистическая блокировка по версии.

type AtomicAccount struct{ balance atomic.Int64 }
func (a *AtomicAccount) Withdraw(amount int64) error {
for {
cur := a.balance.Load()
if cur < amount {
return ErrInsufficient
}
if a.balance.CompareAndSwap(cur, cur-amount) {
return nil
}
}
}

В структуре Account какие могут быть проблемы?

Заголовок раздела «В структуре Account какие могут быть проблемы?»

Коротко. Типичный набор: баланс в float64 (потеря точности на деньгах), экспортированное поле Balance — читается и меняется мимо блокировки, мьютекс встроен значением, из-за чего структура нечаянно копируется вместе с ним, отсутствие валюты/версии, и sync.Mutex, защищающий не то, что нужно.

Глубже. Разворачивая: (1) деньги — только целые минимальные единицы (int64 копеек) или decimal; (2) поля под защитой должны быть неэкспортированными, доступ — через методы, иначе гарантия рушится на первом же внешнем acc.Balance++; (3) Account с sync.Mutex нельзя копировать — передавать только *Account, go vet ловит это через copylocks; (4) методы должны быть на указателе, иначе блокируется копия мьютекса; (5) при 64-битных атомиках на 32-битных платформах требовалось выравнивание — с типами atomic.Int64 (Go 1.19+) проблема решена автоматически; (6) в структуру полезно добавить version/updatedAt для оптимистических блокировок и currency, чтобы нельзя было сложить рубли с долларами.

Делаем нагрузочное тестирование. Данные с запросов мы складываем в in-memory хранилище. Что лучше юзать: каналы или мьютексы? Что будет работать быстрее?

Заголовок раздела «Делаем нагрузочное тестирование. Данные с запросов мы складываем в in-memory хранилище. Что лучше юзать: каналы или мьютексы? Что будет работать быстрее?»

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

Глубже. Аргументация, которую ценят: канал — это очередь с мьютексом внутри (hchan.lock), поэтому «канал вместо мьютекса» не убирает синхронизацию, а прячет её и добавляет планирование. Канал уместен, когда нужна не защита состояния, а передача владения, конвейер, ограничение параллелизма или сериализация в одну горутину-писателя (актор), — последнее, кстати, полезно в нагрузочнике: воркеры шлют события в канал, один агрегатор пишет без блокировок вообще. При очень высоком RPS лучший результат обычно даёт третий вариант: per-goroutine (или per-shard) аккумуляторы и слияние в конце — нулевая конкуренция на горячем пути; для счётчиков — atomic. И честный финал ответа: «это надо померить go test -bench с -cpu=1,4,8 и -benchmem, цифры зависят от размера записи и профиля конкуренции».

Коротко. G — горутина (структура с состоянием и стеком), M — поток ОС, P — логический процессор с локальной очередью горутин и ресурсами аллокатора (mcache). Чтобы исполнять Go-код, M должен владеть P; число P фиксировано и равно GOMAXPROCS, число M — динамическое.

Глубже. P появился в Go 1.1 именно чтобы дать каждой единице параллелизма локальную очередь и локальный кеш аллокаций и тем самым убрать глобальный лок с горячего пути. Отношения не «один к одному»: M может временно остаться без P (ушёл в syscall, отдал P через handoffp), P может быть без M (лежит в pidle), горутин на P — сколько угодно. Отдельно существуют g0 — служебная горутина каждого M с системным стеком, на которой выполняется сам код планировщика и GC, и системные потоки вроде sysmon, которые в GOMAXPROCS не входят.

Коротко. См. выше: цикл schedule → findRunnable → execute; источники работы — runnext, локальная очередь P, глобальная очередь (принудительно каждый 61-й тик), netpoller, кража у соседних P. Переключения происходят в точках парковки или по вытеснению от sysmon/GC.

Глубже. Что стоит добавить сверх повторения: планировщик «M:N» и полностью user-space, поэтому переключение стоит порядка десятков наносекунд (сохранить/восстановить несколько регистров и gobuf) против единиц микросекунд у переключения контекста в ядре. Балансировка нагрузки — pull-модель: простаивающий P сам идёт красть работу, никто не раздаёт задачи централизованно. Таймеры с Go 1.14 живут по P (кучей таймеров на каждом P), что убрало старую точку contention на одном глобальном таймерном треде; в Go 1.23 реализация таймеров была переработана ещё раз и time.Timer/Ticker перестали удерживать элементы от GC при отсутствии ссылок.

Какие существуют механизмы очистки одной горутины?

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

Коротко. Извне горутину «убить» нельзя — в Go нет kill. Есть только кооперативные механизмы: отмена через context, сигнальный канал done, закрытие входного канала (for range ch завершится сам), выход по условию и defer для освобождения ресурсов внутри самой горутины.

Глубже. Правило проектирования: у каждой запущенной горутины должен быть понятный владелец и понятный способ завершения — иначе это утечка. Внутри — defer для Close(), ticker.Stop(), wg.Done(), mu.Unlock(). Про runtime.Goexit() полезно знать, что он завершает текущую горутину, выполняя её defer, но не может завершить чужую (и завершение так main-горутины даёт «no goroutines… deadlock»). Утечки ловятся pprof goroutine-профилем и go.uber.org/goleak в тестах.

func worker(ctx context.Context, in <-chan int) error {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case v, ok := <-in:
if !ok {
return nil // источник закрыт
}
_ = v
case <-ticker.C:
}
}
}

Для чего нужны горутины? Почему нельзя использовать просто потоки?

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

Коротко. Горутины нужны, чтобы дёшево выражать конкурентность: тысячи одновременных задач при стоимости в килобайты и переключении без ухода в ядро. Потоки использовать можно, но дорого: ~8 МБ виртуального стека и десятки килобайт ядерных структур на поток, переключение контекста в микросекунды, а на 100k соединений система просто не поедет.

Глубже. Ещё две причины, кроме цены. Первая — модель программирования: с горутинами блокирующий по виду код (conn.Read) под капотом асинхронный, и не нужно писать callback/async-await-лапшу; рантайм сам превращает «поток на соединение» в event loop. Вторая — рантайм видит горутины и может ими управлять: интегрировать с GC, сетевым поллером, таймерами, вытеснять. С потоками ОС так нельзя: ядро ничего не знает о вашей программе. При этом потоки под капотом всё равно есть — горутины исполняются на них, и когда нужен настоящий параллелизм по ядрам, работают именно M.

Коротко. Формально «режимов» у планировщика Go нет — скорее всего, спрашивают про типы многозадачности: кооперативную (горутина сама отдаёт управление в точках парковки) и вытесняющую (асинхронное вытеснение сигналом SIGURG с Go 1.14, вытеснение по 10 мс через sysmon). Go сочетает обе.

Глубже. Если интервьюер имел в виду что-то другое, вероятные кандидаты: режимы работы M — spinning (крутится в поиске работы) и non-spinning/спящий; состояния P_Prunning, _Psyscall, _Pidle, _Pgcstop; режимы sync.Mutex — normal и starvation (переключение при ожидании >1 мс); режимы GC, влияющие на планирование, — фазы STW, конкурентная маркировка с mark assist и dedicated/fractional mark-воркерами, забирающими 25% CPU. Стоит уточнить у интервьюера, что именно имеется в виду, и перечислить эти варианты.

Что такое горутина? В чем ее преимущество перед потоками?

Заголовок раздела «Что такое горутина? В чем ее преимущество перед потоками?»

Коротко. См. выше «Что такое горутина». Преимущества: старт ~2 КБ вместо мегабайтов, создание без syscall, переключение в user space за десятки наносекунд, растущий стек, интеграция с netpoller — блокирующий по виду I/O не блокирует поток.

Глубже. Цифры для ответа: поток pthread по умолчанию резервирует 8 МБ виртуального адресного пространства под стек (ulimit -s), ядро держит task_struct и kernel stack, создание — syscall clone порядка десятков микросекунд; переключение контекста — 1–5 мкс с потерей TLB/кеша. Горутина: 2 КБ реальной памяти, создание — сотни наносекунд, переключение — сохранение нескольких регистров. Обратная сторона: горутины не дают изоляции и не вытесняются ядром, поэтому «зависшая» в блокирующем C-вызове горутина съедает поток; и параллелизм всё равно ограничен GOMAXPROCS.

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

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

Коротко. Горутины живут в одном адресном пространстве одного процесса, поэтому передача данных — это копирование внутри памяти процесса, без сериализации, без syscall’ов и без участия ядра. Синхронизация делается атомарными операциями и парковкой в user space.

Глубже. «Бесплатность» — фигура речи, и на собеседовании полезно её уточнить. Реальные затраты: у канала есть внутренний мьютекс, есть копирование значения (значит, крупные структуры лучше передавать указателем), есть стоимость gopark/goready — сотни наносекунд на рандеву. Оптимизация прямой передачи (значение копируется из стека отправителя сразу в стек получателя, минуя буфер) убирает одно копирование. Но по сравнению с межпроцессной коммуникацией (pipe, сокет — это syscall и копирование через ядро) это действительно на порядки дешевле. И отдельная плата: передача указателей между горутинами означает разделяемую память, а значит гонки и работу GC.

Всегда ли эффективно решать задачу с помощью горутины? В каких случаях они не дадут выигрыш?

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

Коротко. Нет. На чисто CPU-bound работе выигрыш ограничен числом ядер, а при мелких задачах накладные расходы на создание, синхронизацию и передачу данных съедают всю выгоду. Не помогут горутины и там, где есть общий узкий ресурс: один мьютекс, одно соединение с БД, один диск.

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

Как классифицировать операции, с которыми работают горутины? В каком случае будет преимущество?

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

Коротко. Делить на I/O-bound (сеть, диск, ожидание ответа сервиса) и CPU-bound (вычисления). Максимальный выигрыш горутины дают на I/O-bound: пока одна ждёт, поток занят другими, и число одновременных задач может кратно превышать число ядер. На CPU-bound потолок — GOMAXPROCS, и больше воркеров, чем ядер, только вредит.

Глубже. Третья категория — «смешанные» и блокирующие в ядре операции (файловый I/O, cgo, syscalls): они не паркуются, а занимают поток, поэтому их пул надо ограничивать отдельно. Практические правила: для сетевых задач размер пула — сотни-тысячи, подбирается по латентности и лимитам внешней системы (формула Литтла: concurrency ≈ RPS × latency); для CPU-задач — runtime.GOMAXPROCS(0); для дисковых — небольшой пул, соответствующий возможностям устройства. Отдельно стоит помнить, что cgo-вызов всегда занимает поток целиком и стоит порядка десятков-сотен наносекунд сверху, поэтому массовые cgo-вызовы из многих горутин легко упираются в лимит потоков.

Коротко. Это её собственная область памяти под кадры вызовов и локальные переменные. Стартует с 2 КБ, размещается в куче рантайма, растёт и сжимается динамически: при нехватке места стек копируется в вдвое больший блок с корректировкой указателей.

Глубже. Механика: компилятор вставляет в пролог каждой функции проверку SP против g.stackguard0; если места мало — morestacknewstackcopystack. Именно поэтому в Go нет «StackOverflowError» в привычном виде: стек растёт, пока не упрётся в maxstacksize (1 ГБ на 64 бит, 250 МБ на 32 бит), после чего — fatal error: stack overflow (типично при бесконечной рекурсии). Сжатие происходит во время GC, если используется меньше четверти. Тот же механизм stackguard0 до Go 1.14 использовался для кооперативного вытеснения: рантайм записывал туда stackPreempt, и ближайший вызов функции уходил в планировщик. Важное следствие копирования: адреса на стеке нестабильны, поэтому передавать «сырые» указатели на стековые переменные в C-код напрямую нельзя (правила cgo pointer passing), а переменные, чей адрес уезжает наружу, компилятор через escape-анализ отправляет в кучу.

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

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

Коротко. Механика — GMP и цикл schedule/findRunnable (см. выше). Переключение — по парковке (каналы, select, sync, time.Sleep, сетевой I/O, syscall, Gosched, рост стека, GC-assist) и по вытеснению (>10 мс через sysmon, SIGURG; остановка мира для GC). Использовать горутины стоит там, где есть независимая работа с ожиданием: обработка соединений, параллельные запросы к сервисам, конвейеры, фоновые задачи.

Глубже. «Критерии переключения» полезно свести в короткий список приоритетов: горутина теряет P, если (а) не может продолжить — нет данных в канале, лок занят, ждёт I/O; (б) добровольно уступила — runtime.Gosched(); (в) исчерпала квант — sysmon пометил её на вытеснение; (г) рантайму нужен останов — GC. Рекомендация по использованию: горутина должна иметь владельца, ограничение по количеству и способ отмены; фоновая горутина без context — почти всегда будущая утечка.

Гошка: что такое горутины, примитивы синхронизации, что такое мапа, как устроена, всякие эвакуации, решения коллизий и т. п.

Заголовок раздела «Гошка: что такое горутины, примитивы синхронизации, что такое мапа, как устроена, всякие эвакуации, решения коллизий и т. п.»

Коротко. Это «пакет» из трёх вопросов. Горутины и примитивы синхронизации — см. выше. Про map: это открытая хеш-таблица; до Go 1.23 включительно — бакеты по 8 слотов с массивом tophash, коллизии разрешаются цепочкой overflow-бакетов, при превышении фактора загрузки 6.5 таблица удваивается и перекладывается инкрементально (эвакуация по паре бакетов на каждую запись). В Go 1.24 реализация заменена на Swiss Tables.

Глубже. В Go 1.24 (internal/runtime/maps) map — это группы по 8 слотов с 64-битным control-словом, где по одному байту на слот хранится часть хеша, что позволяет отсеивать сразу всю группу несколькими арифметическими операциями; вместо одной большой таблицы с эвакуацией используется расширяемое хеширование — директория независимых таблиц, и рост означает расщепление одной таблицы, а не перестройку всей map, поэтому паузы ограничены. Что не изменилось и важно на собеседовании: порядок итерации случайный (рантайм намеренно стартует со случайного места), нельзя взять адрес элемента (&m[k] не компилируется — элементы перемещаются), удаление не возвращает память ОС, конкурентная запись роняет процесс через fatal error. Подробнее — в конспекте по подтеме maps.

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

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

Коротко. Обрывок теста: это вариант ответа на вопрос «чем канал отличается от массива/слайса». Утверждение по сути верное: канал — это средство коммуникации и синхронизации (с happens-before гарантиями), массив — просто хранилище значений, доступ к которому из нескольких горутин сам по себе не синхронизирован.

Глубже. Уточнение, которое стоит добавить: массив/слайс тоже можно использовать для обмена данными между горутинами, но тогда синхронизацию придётся строить самому (мьютекс, атомики), и модель памяти гарантий не даст. Канал же даёт: отправка в канал happens-before завершения соответствующего приёма.

Коротко. Обрывок теста — неверный вариант ответа на вопрос «для чего нужны каналы». Каналы не управляют памятью горутин; памятью занимается рантайм (стек растёт сам) и GC.

Коротко. Обрывок теста — неверный вариант. Канал не ускоряет горутины, наоборот, добавляет накладные расходы (внутренний лок, копирование, парковка/пробуждение); его назначение — коммуникация и синхронизация.

Чтобы контролировать выполнение горутин;

Заголовок раздела «Чтобы контролировать выполнение горутин;»

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

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

Коротко. Обрывок теста: вариант ответа на вопрос «что делает runtime.Gosched()». Неверный — Gosched не завершает горутину. Завершает текущую горутину runtime.Goexit() (с выполнением отложенных defer).

Коротко. Обрывок теста, неверный вариант для runtime.Gosched(). Новую горутину создаёт только оператор go (внутри — runtime.newproc).

Она позволяет другим горутинам выполняться;

Заголовок раздела «Она позволяет другим горутинам выполняться;»

Коротко. Обрывок теста — это правильный вариант для runtime.Gosched(): горутина добровольно уступает процессор, переходит в состояние runnable и встаёт в очередь, давая шанс выполниться другим.

Глубже. Технически Gosched вызывает mcall(gosched_m), кладёт текущую горутину в глобальную очередь и запускает schedule(). Она не блокируется и не спит — исполнение продолжится позже. В современном Go (1.14+, асинхронное вытеснение) необходимость в ручном Gosched почти исчезла; он остаётся полезен в редких случаях вроде спин-петель и микробенчмарков.

Она прерывает выполнение текущей горутины;

Заголовок раздела «Она прерывает выполнение текущей горутины;»

Коротко. Обрывок теста — вариант-ловушка для runtime.Gosched(). Формулировка неточная: выполнение приостанавливается, но не прерывается — горутина остаётся готовой к работе и продолжится с того же места. Прерывание с завершением — это runtime.Goexit().

Она устанавливает приоритет текущей горутины.

Заголовок раздела «Она устанавливает приоритет текущей горутины.»

Коротко. Обрывок теста, неверный вариант. Приоритетов у горутин в Go нет вообще: планировщик не поддерживает ни приоритетов, ни nice-подобных настроек, а Gosched тем более их не задаёт.

Коротко. Обрывок теста: это правильный вариант ответа на вопрос «что такое горутины». Уточнение для устного ответа — «легковесные потоки, управляемые рантаймом Go, а не ядром ОС».

Есть функция copy, она вызывает batchCopy и нужно сделать чтобы она возвращала прогресс копирования, нужно чтобы можно было ее отменить, чтобы если кто-то не захочет читать прогресс чтобы она не блокировалась

Заголовок раздела «Есть функция copy, она вызывает batchCopy и нужно сделать чтобы она возвращала прогресс копирования, нужно чтобы можно было ее отменить, чтобы если кто-то не захочет читать прогресс чтобы она не блокировалась»

Коротко. Возвращаем два канала: progress (буфер 1, запись через select с default, чтобы отсутствие читателя не блокировало) и done с итоговой ошибкой. Отмена — через context, проверяемый на каждой итерации. Оба канала закрываются в defer рабочей горутины, чтобы читатель не завис.

Глубже. Три требования задачи закрываются тремя приёмами: неблокирующая отправка — select { case ch <- v: default: }; отмена — ctx.Done() в цикле (а если batchCopy умеет принимать контекст, лучше прокинуть его внутрь, иначе отмена сработает только на границе порции); безопасность для читателя — закрытие каналов и буфер, чтобы последнее значение не потерялось. Альтернативный дизайн, часто более удобный, — не канал, а atomic.Int64 счётчик или колбэк onProgress func(int64), вызываемый не чаще раза в N миллисекунд; канал прогресса имеет смысл, когда потребитель хочет select вместе с другими событиями.

func Copy(ctx context.Context, dst io.Writer, src io.Reader) (<-chan int64, <-chan error) {
progress := make(chan int64, 1)
done := make(chan error, 1)
go func() {
defer close(progress)
defer close(done)
buf := make([]byte, 32*1024)
var total int64
for {
select {
case <-ctx.Done():
done <- ctx.Err()
return
default:
}
n, err := batchCopy(dst, src, buf)
total += int64(n)
select {
case progress <- total: // читатель есть
default: // читателя нет — не блокируемся
}
if err != nil {
if errors.Is(err, io.EOF) {
err = nil
}
done <- err
return
}
}
}()
return progress, done
}

Входит ли системная горутина sysmon в лимит потоков, заданный GOMAXPROCS?

Заголовок раздела «Входит ли системная горутина sysmon в лимит потоков, заданный GOMAXPROCS?»

Коротко. Нет. Во-первых, GOMAXPROCS ограничивает не потоки, а число P — логических процессоров, одновременно исполняющих Go-код. Во-вторых, sysmon — это отдельный поток (M), который работает без P и учитывается как системный (sched.nmsys++ в runtime/proc.go), то есть вне этого лимита.

Глубже. sysmon запускается в runtime.main через newm(sysmon, nil, -1) ещё до пользовательского кода, крутится в бесконечном цикле, засыпая от 20 мкс до 10 мс в зависимости от активности, и делает четыре вещи: опрашивает netpoller, если этого давно никто не делал; отбирает P у горутин, застрявших в syscall дольше тика, и у тех, кто выполняется дольше 10 мс (forcePreemptNS); форсирует GC при долгом простое; возвращает неиспользуемую память ОС (scavenger). Заодно полезно сказать, что общее число потоков процесса вообще не равно GOMAXPROCS — потоки создаются под блокирующие syscall’ы и cgo, лимит по умолчанию 10 000 (debug.SetMaxThreads).

Давать возможность «зарезервировать» ресурсы под заявку, чтобы не было гонки при параллельных запросах;

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

Коротко. Обрывок условия задачи. Суть решения: резервирование должно быть атомарной операцией «проверить остаток и уменьшить его», результатом которой является именованный hold; освобождение — по идентификатору заявки, обязательно с TTL, чтобы брошенные резервы возвращались.

Глубже. В памяти это мьютекс вокруг пары «остаток + карта холдов» (или CAS-петля, если ресурс — одно число). В распределённой системе — атомарный UPDATE ... WHERE free >= n в транзакции либо DECRBY в Redis с проверкой на отрицательное значение и Lua-скриптом для атомарности; ключ идемпотентности по ID заявки защищает от двойного резерва при ретраях. Обязательный элемент — истечение резерва (TTL/фоновая сборка просроченных), иначе упавший клиент навсегда съест квоту.

type Pool struct {
mu sync.Mutex
free int
holds map[string]int
}
func (p *Pool) Reserve(id string, n int) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.free < n {
return ErrNoCapacity
}
p.free -= n
p.holds[id] += n
return nil
}
func (p *Pool) Release(id string) {
p.mu.Lock()
defer p.mu.Unlock()
p.free += p.holds[id]
delete(p.holds, id)
}

Как реализован параллелизм в Go и какие средства синхронизации есть?

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

Коротко. Параллелизм даёт рантайм: горутины раскладываются на GOMAXPROCS логических процессоров P, каждый из которых исполняется на отдельном потоке ОС, — то есть реальный параллелизм ограничен числом ядер. Средства синхронизации: каналы и select, sync (Mutex, RWMutex, WaitGroup, Once, Cond, Map, Pool), sync/atomic, context, а также errgroup/semaphore/singleflight из golang.org/x/sync.

Глубже. Стоит подчеркнуть разделение: язык даёт конкурентность (go, каналы, select), а параллелизм — свойство исполнения, включающееся при GOMAXPROCS > 1. По умолчанию GOMAXPROCS равен runtime.NumCPU(); в контейнерах с CPU-лимитом это исторически было проблемой (нужен automaxprocs), в новых версиях Go рантайм учитывает cgroup-лимиты сам. Корректность любых конструкций определяется моделью памяти Go: операции sync и sync/atomic дают последовательную согласованность, канальные операции — happens-before между отправкой и приёмом.

Коротко. Открытый вопрос — отвечать структурой: (1) процесс/поток/горутина и цена каждого; (2) GMP и планировщик Go; (3) точки переключения и вытеснение; (4) конкурентность vs параллелизм; (5) средства синхронизации и их выбор; (6) типовые проблемы — гонки, дедлоки, утечки горутин, голодание — и инструменты диагностики.

Глубже. Хороший ответ строится «сверху вниз»: ОС планирует потоки, Go поверх них планирует горутины (M:N), поэтому «многопоточность в Go» на практике означает работу с горутинами, а потоки — деталь реализации, всплывающая в трёх местах: блокирующие syscall’ы, cgo и runtime.LockOSThread (нужен, например, для GUI-библиотек и работы с namespace в Linux). Дальше — разделяемое состояние: либо не разделять (передача владения через каналы), либо защищать (мьютексы, атомики), плюс -race в CI как обязательная практика.

Можешь рассказать про многопоточность и параллельность в Go?

Заголовок раздела «Можешь рассказать про многопоточность и параллельность в Go?»

Коротко. См. предыдущий вопрос. Кратко: многопоточность в Go выражена горутинами поверх пула потоков ОС; параллельность включается, когда GOMAXPROCS > 1 и есть свободные ядра; всё остальное — конкурентность, то есть структура программы, а не одновременность исполнения.

Глубже. Полезное отличие для устного ответа: на одноядерной машине программа с тысячей горутин полностью конкурентна и нисколько не параллельна, и при этом всё равно даёт выигрыш на I/O — потому что ожидание перекрывается. А параллельность на CPU-bound задаче ограничена ядрами и законом Амдала: если 20% кода последовательны, максимальное ускорение — 5x при любом числе ядер.

Какая свять между процессом, тредом и горутиной?

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

Коротко. Процесс — изолированное адресное пространство и набор ресурсов ОС; поток — единица планирования ядра внутри процесса, потоки делят память процесса; горутина — единица планирования рантайма Go, множество горутин мультиплексируется на потоки процесса. Иерархия: процесс ⊃ потоки (M) ⊃ горутины (G), связанные через P.

Глубже. Цена и видимость: ядро знает про процессы и потоки, про горутины — нет; переключение процессов дороже всего (смена адресного пространства и TLB), потоков — 1–5 мкс, горутин — десятки наносекунд. Память: процессы изолированы (нужен IPC), потоки и горутины делят кучу — отсюда и удобство, и гонки. В Go-программе всегда несколько потоков даже при GOMAXPROCS=1: как минимум sysmon, потоки под блокирующие syscall’ы, потоки GC-воркеров.

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

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

Коротко. Способы: каналы (передача владения), разделяемая память под мьютексом/атомиками, context для сигналов отмены, sync.WaitGroup/errgroup для координации завершения, замыкания и указатели. Проблемы: гонки данных, дедлоки, утечки горутин, паника при отправке в закрытый канал, потеря сообщений при неаккуратной отмене, голодание и деградация из-за contention.

Глубже. Разбор по способам: канал даёт синхронизацию бесплатно, но приносит свой набор ловушек — запись в закрытый и в nil-канал, close дважды, забытое закрытие (for range навсегда), небуферизованный канал без читателя = дедлок. Разделяемая память быстрее, но требует дисциплины: любое поле, доступное из двух горутин, должно быть либо иммутабельно, либо защищено; правило «мьютекс рядом с данными, которые он защищает, и документируй, что именно защищает». Обязательная практика — go test -race и go vet (проверки copylocks, loopclosure).

Почему у мьютекса и атомика нет проблем с конкурентностью?

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

Коротко. Потому что в их основе — атомарные инструкции процессора (LOCK CMPXCHG на x86, LDAXR/STLXR на ARM), которые аппаратно неделимы и согласованы протоколом когерентности кешей, плюс барьеры памяти, запрещающие компилятору и процессору переупорядочивать доступы вокруг них.

Глубже. sync.Mutex — не чистый спинлок: сначала несколько попыток CAS с активным ожиданием (выгодно при короткой критической секции), затем парковка горутины через runtime_SemacquireMutex (семафор рантайма, не syscall). Есть два режима — normal и starvation (переключение при ожидании дольше 1 мс), в Go 1.24 реализация переехала в internal/sync. Важная поправка к самой формулировке вопроса: «нет проблем» — только на уровне одной операции. Мьютекс не спасает от дедлоков и от того, что кто-то трогает данные мимо него; атомик защищает одно слово, но не составной инвариант из нескольких полей, и подвержен ABA-проблеме. Модель памяти Go (с версии 1.19 явно) объявляет операции sync/atomic последовательно согласованными, так что рассуждать о них можно как о SC-операциях.

Какие есть особенности конкурентной записи в мапу?

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

Коротко. Встроенная map небезопасна для конкурентного доступа: одновременные записи или чтение параллельно с записью роняют процесс с fatal error: concurrent map writes / concurrent map read and map write. Это не panic — перехватить через recover нельзя. Одновременные чтения без записей безопасны.

Глубже. Детекция встроена в рантайм и работает по флагу «идёт запись» в заголовке таблицы, поэтому срабатывает не всегда мгновенно, но обычно быстро; под -race то же самое видно как DATA RACE. Причина небезопасности — рост и перестройка таблицы: указатели на внутренние структуры меняются, читатель может провалиться в невалидную память. Варианты решения: sync.RWMutex (универсально, дёшево, предсказуемо), sync.Map (только для двух сценариев из документации: ключ пишется один раз и читается много, либо разные горутины работают с непересекающимися наборами ключей; в Go 1.24 реализация заменена на HashTrieMap), шардирование по хешу ключа на N мьютексов (лучший вариант при высокой конкуренции на запись).

В чем отличие параллелизма от конкурентности?

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

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

Глубже. Формулировка Роба Пайка: «Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once» (доклад «Concurrency is not Parallelism»). В Go это разделение буквально видно в коде: go, каналы и select дают конкурентность независимо от железа, а GOMAXPROCS управляет тем, сколько её превратится в параллелизм. Практический вывод: при GOMAXPROCS=1 вся синхронизация всё равно нужна — горутина может быть вытеснена в произвольный момент, и гонки прекрасно воспроизводятся на одном ядре.

используя функцию printNumber, последовательно напечатать числа от 1 до 10;

Заголовок раздела «используя функцию printNumber, последовательно напечатать числа от 1 до 10;»

Коротко. Обычный цикл без всякой конкурентности — это и есть ожидаемый ответ на первый шаг задачи.

func printNumber(n int) { fmt.Println(n) }
for i := 1; i <= 10; i++ {
printNumber(i)
}

измените код в main так, чтобы распараллелить печать;

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

Коротко. Запустить printNumber в горутинах и дождаться их через sync.WaitGroup. Порядок вывода при этом станет произвольным — об этом надо сказать вслух, интервьюер обычно ждёт именно этого замечания.

Глубже. С Go 1.22 переменная цикла создаётся на каждой итерации, поэтому передавать i параметром больше не обязательно (на Go ≤1.21 без этого все горутины напечатали бы одно и то же значение). Если задача звучит «распараллелить запуск, но сохранить порядок вывода», строится цепочка каналов: i-я горутина ждёт сигнала от (i−1)-й.

var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
printNumber(i) // Go 1.22+: i своя на каждой итерации
}()
}
wg.Wait()
// Параллельный запуск + строгий порядок вывода:
chans := make([]chan struct{}, 11)
for i := range chans {
chans[i] = make(chan struct{})
}
for i := 1; i <= 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-chans[i-1]
printNumber(i)
close(chans[i])
}()
}
close(chans[0])
wg.Wait()

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

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

Коротко. Два прочтения. Если нужны батчи по 5 (сначала 1–5, потом 6–10) — барьер WaitGroup между батчами. Если нужно «не больше 5 одновременно» — семафор на буферизованном канале ёмкости 5.

// Батчи по 5
for start := 1; start <= 10; start += 5 {
var wg sync.WaitGroup
for i := start; i < start+5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
printNumber(i)
}()
}
wg.Wait() // барьер между батчами
}
// Не больше 5 одновременно
sem := make(chan struct{}, 5)
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
printNumber(i)
}()
}
wg.Wait()

Глубже. Стоит уточнить у интервьюера, какое прочтение имеется в виду, и заодно заметить: даже с барьером «одновременность» вывода не гарантирована — fmt.Println сериализуется внутренним локом os.Stdout, так что физически строки печатаются по одной. Гарантируется только то, что пятёрка горутин работает в одном окне времени.

Что используется для передачи данных между горутинами?

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

Коротко. Каналы — идиоматичный способ («Do not communicate by sharing memory; instead, share memory by communicating»). Альтернатива — разделяемая память под защитой sync.Mutex/sync/atomic; для сигналов отмены — context.

Глубже. Выбор: канал — когда передаётся владение значением или строится конвейер; мьютекс — когда есть долгоживущее состояние, которое читают и меняют многие (кеш, счётчики, реестр). Смешанный подход тоже нормален: канал для задач, мьютекс для агрегата. Важная деталь: канал копирует значение, поэтому большие структуры передают указателем — но тогда после отправки исходной горутине трогать объект нельзя, владение перешло.

Коротко. См. выше: горутина — легковесный поток, управляемый рантаймом Go; нужны, чтобы дёшево выражать конкурентность — обрабатывать тысячи одновременных задач, перекрывать ожидание I/O и загружать все ядра, не платя цену потоков ОС.

Глубже. Если вопрос звучит второй раз, добавьте прикладной срез: каждое входящее соединение в net/http обслуживается своей горутиной — именно поэтому Go-сервер пишется в простом блокирующем стиле, но масштабируется как event-loop. И оговорку: горутина — не бесплатная абстракция, у неё есть цена (память, планирование, GC-сканирование стека) и есть требование управляемости — лимит и способ отмены.

Чем отличается горутина от системных тредов?

Заголовок раздела «Чем отличается горутина от системных тредов?»

Коротко. См. выше «Что такое горутина? В чем ее преимущество перед потоками»: планировщик в user space вместо ядра, 2 КБ растущего стека против ~8 МБ, создание и переключение на порядки дешевле, отсутствие приоритетов и отсутствие изоляции. Отношение M:N — много горутин на один поток.

Глубже. Разница, о которой часто забывают: поток ОС вытесняется ядром по таймеру абсолютно в любой точке, а горутина — только в safe points, которые рантайм умеет обрабатывать (даже асинхронное вытеснение сигналом проверяет, что остановка безопасна). Поэтому цикл в C-коде через cgo вытеснить нельзя, и такой вызов удерживает поток целиком.

Коротко. Сохраняет контекст текущей горутины в её gobuf (SP, PC и несколько регистров), переводит в нужное состояние (_Grunnable при вытеснении, _Gwaiting при парковке), затем schedule() выбирает следующую и execute()/gogo восстанавливает её контекст. Всё это в user space, без входа в ядро.

Глубже. Точки входа в планировщик: gopark (добровольная блокировка с постановкой в очередь ожидания объекта), goschedImpl (runtime.Gosched — в глобальную очередь), morestack при кооперативном вытеснении, обработчик SIGURG при асинхронном, entersyscall/exitsyscall вокруг системных вызовов. Пробуждение — goready, которая ставит горутину в runnext текущего P и при необходимости будит простаивающий M (wakep). Сам код планировщика выполняется на системном стеке g0 того же потока, чтобы не зависеть от стека пользовательской горутины.

Как горутины общаются между собой и синхронизируются?

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

Коротко. См. выше «Какие способы общение между горутинами…»: каналы и select для передачи данных, sync/atomic для защиты разделяемого состояния, context для отмены, WaitGroup/errgroup для ожидания завершения.

Глубже. Отличие акцента этого вопроса — на happens-before. Полезно назвать конкретные гарантии из модели памяти: отправка в канал происходит до завершения соответствующего приёма; закрытие канала — до приёма нулевого значения, вызванного закрытием; для небуферизованного канала приём происходит до завершения отправки; Unlock происходит до последующего Lock; once.Do(f)f завершается до возврата любого другого Do.

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

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

Коротко. Вопрос про личный опыт — отвечать надо конкретикой: назвать 3–4 паттерна, для каждого сказать задачу, почему выбран именно он и с какой проблемой столкнулись. Базовый набор: worker pool с ограничением параллелизма, fan-out/fan-in, pipeline со стадиями и отменой, semaphore/rate limiter, singleflight против дублирующих запросов, graceful shutdown через context + errgroup, батчинг с флашем по таймеру и размеру.

Глубже. Каркас хорошего ответа: (1) контекст задачи и цифры — «нужно было опрашивать 40k endpoint’ов каждые 5 минут»; (2) выбранное решение — «пул на 200 воркеров, задачи через канал, результаты в батчи по 1000 в ClickHouse»; (3) почему не иначе — «горутина на URL упиралась в лимит fd и память»; (4) что пошло не так и как чинили — «нашли утечку горутин, потому что не закрывали resp.Body при ошибке; поймали goroutine-профилем, добавили goleak в тесты»; (5) как проверяли — нагрузочный тест, -race в CI, метрики go_goroutines. Типичные ошибки кандидатов: перечислить паттерны из статьи без единого примера из практики, назвать «горутины и каналы» паттерном, не суметь ответить, как ограничивали параллелизм и как останавливали воркеры.

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

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

Коротко. В рантайме есть только детектор полного дедлока: fatal error: all goroutines are asleep - deadlock! — срабатывает, когда ни одна горутина не может продолжить. Частичные дедлоки ловятся по симптомам: рост числа горутин (runtime.NumGoroutine(), метрика go_goroutines), goroutine-профиль pprof с ?debug=2, дамп по SIGQUIT, runtime/trace, -race, goleak в тестах.

Глубже. Важная оговорка про встроенный детектор: он не сработает, если хотя бы одна горутина остаётся runnable, ждёт таймер или сидит в syscall/cgo, — то есть в реальном сервере (где есть listener и таймеры) он практически никогда не срабатывает. Поэтому на практике: в проде — метрика числа горутин и алерт на монотонный рост, ручной GET /debug/pprof/goroutine?debug=2 показывает стек каждой горутины и сколько минут она в этом состоянии (goroutine 42 [chan receive, 18 minutes]); в тестах — go.uber.org/goleak; для отладки лок-порядка — github.com/sasha-s/go-deadlock как drop-in замена мьютекса; для распределения времени ожидания на локах — runtime.SetMutexProfileFraction и /debug/pprof/mutex. Профилактика важнее детекции: единый порядок захвата локов, defer mu.Unlock(), таймауты и context на всех блокирующих операциях, отсутствие вызовов чужого кода под локом.

Есть ли опыт настройки планировщика? Для чего может потребоваться настройка планировщика?

Заголовок раздела «Есть ли опыт настройки планировщика? Для чего может потребоваться настройка планировщика?»

Коротко. Настраивать в планировщике можно немного: GOMAXPROCS (число P), debug.SetMaxThreads, runtime.LockOSThread для привязки горутины к потоку, косвенно — GOGC/GOMEMLIMIT, влияющие на GC и через него на планирование. Основной практический случай — контейнеры с CPU-лимитом: без настройки рантайм видел все ядра хоста и создавал лишние P.

Глубже. Что рассказать по делу: (1) в Kubernetes при limits.cpu: 2 на 64-ядерной ноде старый рантайм заводил 64 P, из-за чего росли задержки на work stealing и на STW-фазах; лечилось uber-go/automaxprocs или явным GOMAXPROCS; в свежих версиях Go рантайм учитывает cgroup-лимит сам, но проверить стоит. (2) GOMAXPROCS=1 иногда ставят для строго детерминированных бенчмарков или чтобы уменьшить contention в однопоточной по природе задаче. (3) LockOSThread обязателен для OpenGL/GUI-библиотек и для работы с Linux namespaces/setns, где состояние привязано к потоку. (4) Наблюдение — GODEBUG=schedtrace=1000 (сводка раз в секунду: число потоков, простаивающих P, длины очередей) и scheddetail=1, а также runtime/trace для картины по каждому P. Если опыта не было — честнее сказать «настраивал только GOMAXPROCS в контейнерах» и объяснить зачем, чем выдумывать.

Коротко. Основные: _Gidle (только создана), _Grunnable (готова, ждёт в очереди), _Grunning (выполняется на M с P), _Gsyscall (в системном вызове), _Gwaiting (заблокирована рантаймом — канал, лок, I/O, сон), _Gdead (завершилась или лежит в свободном списке). Плюс служебные _Gcopystack (растёт/копируется стек) и _Gpreempted (остановлена вытеснением, Go 1.14+).

Глубже. Определения — в runtime/runtime2.go; есть ещё _Gscan-варианты (побитовое ИЛИ с _Gscan), означающие, что GC сканирует стек этой горутины. В дампах и в pprof состояния видны в человеческом виде с уточнением причины ожидания: chan send, chan receive, select, semacquire, sleep, IO wait, GC assist wait, sync.Mutex.Lock — и время, проведённое в этом состоянии, что и делает дамп основным инструментом поиска зависаний. Ключевое отличие _Gwaiting от _Grunnable: первая не находится ни в одной очереди планировщика и её должен явно разбудить кто-то другой (goready).

Как работает конкурентность в Го? В какие моменты происходит переключение между горутинами?

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

Коротко. См. выше: конкурентность — горутины + планировщик GMP; переключение происходит при парковке (канал, select, sync, time.Sleep, сетевой I/O, syscall, Gosched, рост стека, GC-assist) и при вытеснении (sysmon >10 мс, SIGURG, останов для GC).

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

Если GOMAXPROCS=1 и происходит syscall в горутине, но если другие, работа программы останнавливается?

Заголовок раздела «Если GOMAXPROCS=1 и происходит syscall в горутине, но если другие, работа программы останнавливается?»

Коротко. Нет, не останавливается. При входе в системный вызов горутина с потоком уходят в ядро, но P освобождается: либо entersyscallblock отдаёт его сразу, либо sysmon отбирает через handoffp примерно через 20 мкс. Освободившийся P подхватывает другой поток и продолжает выполнять остальные горутины — даже при GOMAXPROCS=1.

Глубже. То есть GOMAXPROCS=1 означает «в один момент времени Go-код исполняет один поток», но не «в процессе один поток»: потоков будет несколько, просто Go-код на них не идёт параллельно. Отдельно стоит различать случаи: сетевой I/O при GOMAXPROCS=1 вообще не блокирует поток (netpoller), файловый I/O и cgo — блокируют поток, но P передаётся. Единственная ситуация, где программа действительно вставала при GOMAXPROCS=1, — тугой CPU-цикл без вызовов функций на Go до 1.14: вытеснить его было нечем. Начиная с 1.14 асинхронное вытеснение решает и это.

В чем разница между процессом ОС и горутиной?

Заголовок раздела «В чем разница между процессом ОС и горутиной?»

Коротко. Процесс — единица изоляции ОС: своё виртуальное адресное пространство, своя таблица дескрипторов, планируется ядром. Горутина — единица планирования внутри одного процесса: живёт в общей памяти с другими горутинами, планируется рантаймом Go в пользовательском пространстве и стоит килобайты вместо мегабайт.

Глубже. Порядки величин: создание процесса — сотни микросекунд-миллисекунды (fork+exec, копирование таблиц страниц), переключение контекста процессов — 1–10 мкс плюс промах TLB/кэшей из-за смены адресного пространства. Горутина: go f() — сотни наносекунд, старт стека 2 КБ, переключение — порядка 100–200 нс (сохранение SP/PC/BP в g.sched и переход на другой стек, всё в userspace). Изоляция при этом обратная: процессы защищены друг от друга MMU, горутины не защищены никак — любая может испортить общие данные, поэтому нужны мьютексы, каналы и -race. Ещё различие: у процесса есть собственный PID, сигналы, лимиты, его можно убить извне; горутину нельзя убить снаружи, она обязана завершиться сама.

Что такое конкурентность и она устроена в Go?

Заголовок раздела «Что такое конкурентность и она устроена в Go?»

Коротко. Конкурентность — способ структурировать программу как набор независимо развивающихся задач, чей порядок исполнения не задан. В Go это горутины (go f()) для независимых задач и каналы/sync для их взаимодействия, поверх которых рантайм-планировщик мультиплексирует горутины на потоки ОС по модели GMP.

Глубже. Модель Go восходит к CSP Хоара: единица — процесс, обменивающийся сообщениями по каналам, а не разделяемая память с блокировками. Отсюда лозунг «Do not communicate by sharing memory; instead, share memory by communicating» — он не про запрет мьютексов, а про то, что владение данными лучше передавать явно. Практически конкурентность в Go складывается из: примитива запуска (go), примитивов синхронизации (каналы, select, sync.Mutex/RWMutex/WaitGroup/Once, sync/atomic), управления временем жизни (context.Context), и модели памяти (The Go Memory Model), которая определяет отношение happens-before — что одна горутина гарантированно увидит из записей другой.

Всегда ли код в Go выполняется параллельно?

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

Коротко. Нет. go даёт конкурентность, а не параллелизм. При GOMAXPROCS=1, на одноядерной машине или когда в любой момент готова только одна горутина, всё исполняется строго по одной горутине за раз, просто чередуясь.

Глубже. Параллельность зависит от: runtime.GOMAXPROCS (число P), реального числа ядер/квоты cgroup, и от того, есть ли вообще независимая работа. Классическая ловушка на собеседовании — код без синхронизации, который «работает» на GOMAXPROCS=1 и разваливается на многоядерной машине; и обратная — бесконечный цикл без вызовов функций, который на старых версиях Go при GOMAXPROCS=1 вешал программу (с Go 1.14 асинхронная преемпция это чинит). Стоит помнить: до Go 1.5 GOMAXPROCS по умолчанию был 1, с 1.5 — число CPU; в Go 1.25 значение по умолчанию стало учитывать CPU-лимит cgroup в контейнерах.

Коротко. Планировщик Go — пользовательский, кооперативно-вытесняющий, work-stealing. GMP: G — горутина, M — поток ОС, P — логический процессор с локальной очередью готовых горутин; чтобы исполнять код, M должен держать P, а число P равно GOMAXPROCS.

Глубже. Цикл schedule() выглядит так: каждый 61-й тик заглянуть в глобальную очередь (чтобы не заморить её голодом), иначе взять runnext (слот для одной «горячей» горутины, повышает локальность при пинг-понге через канал), затем локальную очередь (кольцевой буфер на 256 элементов), затем глобальную очередь под локом, затем netpoller, затем украсть половину очереди у случайного P. Если работы нет — P переходит в idle-список, M паркуется. Переходы состояний горутины: _Grunnable_Grunning_Gwaiting (блокировка на канале/мьютексе/сне) или _Gsyscall, обратно в _Grunnable через goready. Вытеснение обеспечивает sysmon: горутина дольше 10 мс — асинхронная преемпция сигналом; M в syscall дольше ~20 мкс — P отбирается (retake) и отдаётся другому M.

Как реализовать конкурентную запись в хэш-таблицу в Golang?

Заголовок раздела «Как реализовать конкурентную запись в хэш-таблицу в Golang?»

Коротко. Встроенная map не потокобезопасна на запись — нужен внешний мьютекс (sync.RWMutex вокруг обычной map), либо sync.Map, либо шардированная map. Дефолтный выбор — структура с sync.RWMutex.

Глубже. Рантайм активно детектирует нарушение: при конкурентной записи вы получите fatal error: concurrent map writes — это throw, а не паника, его нельзя перехватить recover. sync.Map оптимизирована под два сценария: ключ пишется один раз и много раз читается, либо разные горутины работают с непересекающимися наборами ключей; на смешанной нагрузке с постоянными записями она обычно проигрывает мьютексу. При высокой контенции хорошо работает шардирование по хешу ключа. Отметим: в Go 1.24 встроенная map перешла на Swiss Tables, а sync.Map переписана на HashTrieMap — производительность изменилась, но требование внешней синхронизации для встроенной map осталось прежним.

type Counters struct {
mu sync.RWMutex
m map[string]int
}
func (c *Counters) Inc(k string) {
c.mu.Lock()
c.m[k]++
c.mu.Unlock()
}
func (c *Counters) Get(k string) (int, bool) {
c.mu.RLock()
v, ok := c.m[k]
c.mu.RUnlock()
return v, ok
}

Коротко. Горутина — лёгкий поток исполнения, управляемый рантаймом Go: структура runtime.g с сохранённым контекстом (g.sched), собственным растущим стеком от 2 КБ и состоянием, которую планировщик размещает на потоках ОС.

Глубже. go f(x) компилируется в runtime.newproc: аргументы вычисляются немедленно в текущей горутине и копируются, создаётся (или переиспользуется из gfree-списка) g, кладётся в runnext текущего P, вызывается wakep(), чтобы при наличии свободного P разбудить другой M. У горутины нет идентификатора в публичном API (намеренно — чтобы не появлялось goroutine-local storage), нет приоритетов, нет способа убить её извне. Завершается она только возвратом из своей функции — либо тем, что вся программа завершилась.

подумать о многопоточности (если сразу это не уточнил и не сделал).

Заголовок раздела «подумать о многопоточности (если сразу это не уточнил и не сделал).»

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

Глубже. Каркас того, что стоит сказать без напоминания: (1) будет ли структура использоваться из нескольких горутин, кто пишет, кто читает; (2) какой примитив выбираем — иммутабельность, канал-владелец, RWMutex, atomic, шардирование — и почему; (3) какие инварианты защищаем и какова граница критической секции; (4) что с временем жизни — кто завершает фоновые горутины, есть ли context; (5) как это проверяется — тест под go test -race, бенчмарк с -cpu=1,4,8. Эти пять пунктов закрывают почти любую «а вы подумали о многопоточности?».

Можно ли один и тот же byte slice в нескольких горутинах? А зачем так делать? )

Заголовок раздела «Можно ли один и тот же byte slice в нескольких горутинах? А зачем так делать? )»

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

Глубже. Смысл в производительности: буфер выделяется один раз, работа делится по диапазонам (buf[i*n:(i+1)*n]) — это классика для параллельного декодирования, хеширования блоками, заполнения изображения, разбиения файла на чанки. Модель памяти Go это разрешает: разные элементы массива — разные переменные, гонки нет. Подводные камни: (1) append может перевыделить массив, и часть горутин продолжит писать в старый — поэтому длину фиксируют заранее (make([]byte, n)) и пишут по индексу; (2) заголовок слайса (ptr/len/cap) — 3 слова, его конкурентная перезапись даёт «порванное» значение и потенциальный краш; (3) false sharing: соседние диапазоны в одной кэш-линии (64 байта) резко замедляют запись — на горячих путях диапазоны выравнивают; (4) переиспользование буфера между запросами (например, из sync.Pool или bytes.Buffer в HTTP-хендлере) — самая частая утечка данных между горутинами.

Как работать с малой в условиях конкурентности?

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

Коротко. (Читается как «с мапой».) Встроенная map безопасна только для конкурентного чтения; любая запись параллельно с чем угодно требует синхронизации — sync.RWMutex, sync.Map или шардирование.

Глубже. См. выше вопрос про конкурентную запись в хэш-таблицу. Дополнение: даже конкурентное чтение небезопасно, если хоть одна горутина пишет — рантайм проверяет флаг hashWriting и падает с fatal error: concurrent map read and map write. Проверка вероятностная, поэтому «у меня работает» ничего не доказывает; ловится это go test -race. Если map строится один раз на старте и дальше только читается — синхронизация не нужна при условии, что публикация произошла через happens-before (например, инициализация в init/sync.Once до запуска горутин).

Можно ли отловить панику в горутине из другой горутины?

Заголовок раздела «Можно ли отловить панику в горутине из другой горутины?»

Коротко. Нет. recover работает только в отложенной функции той же горутины, где произошла паника. Неперехваченная паника в любой горутине завершает весь процесс.

Глубже. Стек паники разворачивается только в пределах своей горутины: gopanic идёт по её цепочке _defer, и если recover не встретился, вызывается fatalpanic → печать всех стеков → exit(2). Поэтому defer recover() в родителе не спасёт. Правильный паттерн — ставить recover внутри самой горутины и превращать панику в ошибку, передаваемую через канал или errgroup:

func safeGo(fn func() error, errc chan<- error) {
go func() {
defer func() {
if r := recover(); r != nil {
errc <- fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
if err := fn(); err != nil {
errc <- err
}
}()
}

Отдельно: fatal error рантайма (concurrent map writes, deadlock, stack overflow) не перехватывается вообще ничем — это throw, а не паника.

Чем отличается concurrency от параллелизма, можно ли быть параллелизм на 1 ядре?

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

Коротко. Конкурентность — про структуру: несколько задач, чей порядок не определён, они могут перекрываться во времени. Параллелизм — про исполнение: несколько вычислений физически одновременно. На одном ядре настоящего параллелизма CPU-вычислений нет — есть чередование (time slicing).

Глубже. Строгая оговорка: «одно ядро» и «одновременно» зависят от уровня. Внутри одного ядра есть ILP и SIMD, есть SMT/Hyper-Threading (два логических ядра на одном физическом — это уже два P для Go), и есть параллелизм устройств: пока CPU считает, DMA-контроллер и сетевая карта работают физически одновременно с ним. Поэтому корректная формулировка на собеседовании: параллельное исполнение инструкций пользовательского кода на одном логическом ядре невозможно, но перекрытие вычислений с I/O — есть, и именно оно даёт основной выигрыш конкурентности на одном ядре. Канонический источник — доклад Роба Пайка «Concurrency is not Parallelism».

Коротко. Потому что это объекты рантайма, а не ядра: маленький растущий стек (2 КБ вместо 8 МБ), переключение без syscall (сохраняются лишь несколько регистров в g.sched), нет структур ядра на каждую горутину, и планирование идёт в пользовательском пространстве.

Глубже. Разложим на составляющие. Память: runtime.g — несколько сотен байт, стек стартует с 2 КБ и растёт удвоением через morestack/copystack, то есть платите только за фактическую глубину; поток ОС резервирует 8 МБ виртуального стека (пусть и лениво коммитит) плюс структуры ядра. Время: go f() — сотни наносекунд, часто с переиспользованием g из free-списка; pthread_create — десятки микросекунд. Переключение: между горутинами меняется несколько регистров и указатель стека без перехода в ядро и без смены адресного пространства (TLB не сбрасывается), между потоками — вход в ядро, сохранение полного контекста, работа планировщика ядра. Плюс планировщик Go «знает семантику»: он переключает горутину ровно там, где та блокируется на канале или I/O, и не тратит кванты впустую.

Чем ограничьено создание горутин? Сколько можно создать?

Заголовок раздела «Чем ограничьено создание горутин? Сколько можно создать?»

Коротко. Жёсткого лимита в рантайме нет — ограничение практическое: память под стеки и служебные структуры. При 2–8 КБ на горутину миллион горутин — это единицы гигабайт; сотни тысяч на обычном сервере вполне реальны.

Глубже. Что действительно упирается: (1) RAM — стек растёт, и если горутины держат буферы, средняя цена может быть не 2 КБ, а десятки килобайт; (2) работа GC — каждая горутина это корень сканирования стека, миллионы горутин удорожают фазу mark; (3) контенция и латентность — очереди планировщика длиннее, время до исполнения растёт; (4) косвенно — лимит потоков: runtime/debug.SetMaxThreads, по умолчанию 10000, и если ваши горутины массово уходят в блокирующие syscall’ы или cgo, вы упрётесь в fatal error: thread exhaustion; (5) внешние ресурсы — файловые дескрипторы, коннекты к БД, rate limit API. Практический вывод для собеседования: не «сколько влезет», а «зачем столько» — обычно нужен worker pool или семафор, ограничивающий параллелизм числом, соответствующим узкому ресурсу.

Коротко. См. выше «Как работает шедулер? Что такое GMP модель?». Кратко: G — горутина (задача), M — поток ОС (исполнитель), P — логический процессор (право исполнять + локальная очередь + mcache); M работает только держа P, len(P) == GOMAXPROCS.

Глубже. Отличие акцента в этой формулировке — назвать роли и зачем нужен именно P. P появился в Go 1.1 (до этого была модель M:N с глобальной очередью и глобальным локом). P решает три задачи: даёт локальную очередь без блокировок, служит держателем кэша аллокатора mcache (поэтому аллокации в горячем пути идут без лока), и является «квотой параллелизма» — ограничивает число одновременно исполняемого Go-кода значением GOMAXPROCS, при этом позволяя иметь гораздо больше потоков M (заблокированных в syscall).

Что происходит с горутиной, если она делает сетевой вызов?

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

Коротко. Сокет в Go неблокирующий и зарегистрирован в netpoller (epoll/kqueue/IOCP). Если данные не готовы, горутина паркуется в _Gwaiting, а её M вместе с P немедленно берёт другую горутину; когда дескриптор становится готов, netpoller делает горутину runnable.

Глубже. Цепочка: conn.Readinternal/poll.FD.Readsyscall.Read вернул EAGAINpollDesc.waitReadruntime_pollWaitgopark. Готовность собирает netpoll(), который вызывается из findRunnable в цикле планировщика и из sysmon; он возвращает список готовых g, которые попадают в очереди. Итог: код выглядит синхронным, но потоки не тратятся — 100 000 открытых соединений не требуют 100 000 потоков. Важное исключение: обычные файлы на Linux нельзя эффективно опрашивать через epoll, поэтому файловый I/O — это блокирующий syscall, там M действительно блокируется, и P у него отбирает sysmon. То же справедливо для cgo-вызовов.

Что такое кооперативная и вытесняющая многозадачность?

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

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

Глубже. В Go исторически была кооперативная модель: точки преемпции вставлялись в пролог функций (проверка g.stackguard0 == stackPreempt), плюс все блокирующие операции. Минус — «плотный» цикл вида for { i++ } без вызовов и аллокаций не отдавал P, что могло затормозить STW-фазу GC и заморить остальные горутины. С Go 1.14 появилась асинхронная преемпция: sysmon шлёт потоку SIGURG, обработчик безопасно останавливает горутину в произвольной точке (через asyncPreempt, опираясь на данные о безопасных точках в метаданных функций). Поэтому корректная формулировка сегодня: планировщик Go — кооперативный по устройству с механизмом асинхронного вытеснения. Планировщик ядра ОС — чисто вытесняющий.

Как устроен планировщик в Go? Расскажи про GMP модель

Заголовок раздела «Как устроен планировщик в Go? Расскажи про GMP модель»

Коротко. См. выше два вопроса про GMP и работу шедулера. Суть: user-space work-stealing планировщик, M:N-мультиплексирование горутин на потоки через P, локальные очереди + глобальная + netpoller + кража работы, вытеснение через sysmon.

Глубже. Что стоит добавить, если просят «как устроен» подробно: справедливость обеспечивается тремя механизмами — проверка глобальной очереди каждый 61-й тик, ограничение времени исполнения 10 мс через sysmon, и то, что горутина, положенная в runnext, наследует остаток кванта предшественника (чтобы пинг-понг двух горутин не голодал остальных). Разогрев/охлаждение потоков: spinning-потоки активно ищут работу перед тем как заснуть, чтобы не терять латентность на пробуждении; wakep() будит их при появлении новой работы. Отладка — GODEBUG=schedtrace=1000,scheddetail=1, go tool trace.

Коротко. См. выше. Нужна, чтобы дёшево исполнять сотни тысяч горутин на десятке потоков ОС: P даёт локальные очереди без глобального лока и ограничивает параллелизм, M абстрагирует поток, G — дешёвая задача.

Глубже. Если спрашивают «для чего», ответ формулируется через проблемы, которые она решает: контенция на единственной глобальной очереди (решено локальными очередями P), простой ядер при неравномерной нагрузке (решено work stealing), блокировка всего пула при syscall (решено handoff P другому M), потеря локальности кэша (решено runnext и тем, что горутина по возможности возвращается на тот же P), «залипание» CPU-bound горутин (решено sysmon + асинхронная преемпция).

О чем нужно позаботиться, когда стартуешь горутину?

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

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

Глубже. Развёрнутый чек-лист: (1) завершениеcontext.Context в аргументах и select на ctx.Done() в каждом блокирующем месте; (2) ожиданиеsync.WaitGroup или errgroup.Group, иначе main завершится и вы не узнаете, доделала ли она работу; (3) ошибки — канал ошибок или errgroup, потому что возвращаемое значение горутины отбрасывается; (4) паникаdefer recover() внутри, иначе падает весь процесс; (5) данные — что захвачено замыканием, нет ли гонок (с Go 1.22 переменная цикла своя на каждой итерации, так что классическая ошибка с for i := range больше не стреляет, но захват общего буфера или мапы — стреляет); (6) ограничение количества — семафор или пул, чтобы 10 000 запросов не породили 10 000 горутин, ломящихся в БД; (7) наблюдаемость — метрика runtime.NumGoroutine(), чтобы видеть утечки.

Как контролировать жизненный цикл горутины?

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

Коротко. Через context.Context (отмена/таймаут) для сигнала «пора выходить» и sync.WaitGroup/errgroup для ожидания фактического завершения. Убить горутину извне нельзя — только попросить.

Глубже. Каноническая форма: горутина принимает ctx первым аргументом и обязана проверять ctx.Done() в каждой точке ожидания.

func worker(ctx context.Context, in <-chan Job, out chan<- Result) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case j, ok := <-in:
if !ok {
return nil
}
select {
case out <- process(j):
case <-ctx.Done():
return ctx.Err()
}
}
}
}

Обратите внимание на второй select при отправке: отправка в небуферизованный канал — тоже блокировка, и без ctx.Done() горутина «утечёт», если читателя уже нет. Дополнительно: context.AfterFunc (Go 1.21) позволяет повесить колбэк на отмену, context.WithoutCancel — отвязать фоновую дозапись от отменённого запроса, context.WithDeadlineCause/WithCancelCause — передать причину отмены. Утечки ловятся go.uber.org/goleak в тестах и метрикой числа горутин в проде.

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

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

Коротко. Слайс — это заголовок (ptr, len, cap) плюс общий массив. Конкурентная запись в разные индексы безопасна, а вот append, любое изменение самого заголовка или запись параллельно с чтением тех же элементов — гонка.

Глубже. Конкретные грабли: (1) append из нескольких горутин теряет элементы и вызывает гонку данных — рост len не атомарен, а перевыделение массива «раздваивает» память; (2) даже если cap достаточен, две горутины запишут в один и тот же индекс; (3) запись заголовка (s = append(s, x)) — три слова, не атомарна, читатель может увидеть новый ptr со старым len; (4) -race ловит это надёжно, но только если тест реально исполняет обе ветки; (5) false sharing при разбиении на диапазоны. Решения: писать по фиксированным индексам в заранее выделенный слайс (res := make([]T, n), каждая горутина пишет res[i]), либо собирать результаты через канал и делать append в одной горутине, либо мьютекс вокруг слайса, либо локальные слайсы с последующей склейкой.

Сколько горутин максимально можно запустить? Что знаешь про GMP модель?

Заголовок раздела «Сколько горутин максимально можно запустить? Что знаешь про GMP модель?»

Коротко. См. выше «Чем ограничьено создание горутин» и «Что такое GMP». Практически — миллионы при достаточной памяти, ограничение — RAM, работа GC и лимит потоков (10 000 по умолчанию) для блокирующих syscall’ов.

Глубже. Полезная связка двух частей вопроса: число горутин никак не связано с GOMAXPROCS. Можно иметь 1 000 000 G и всего 8 P — рантайм просто мультиплексирует. Именно P ограничивает, сколько Go-кода исполняется одновременно, а M может быть больше, чем P, ровно на число потоков, застрявших в блокирующих syscall’ах.

Как сделать graceful shutdown? Как дождаться, чтобы все горутины завершили работу

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

Коротко. Подписаться на сигналы (signal.NotifyContext на SIGINT/SIGTERM), отменить корневой контекст, вызвать server.Shutdown(ctxWithTimeout) для HTTP, дождаться фоновых горутин через WaitGroup/errgroup с общим дедлайном и только затем закрывать БД, брокеры и прочие ресурсы.

Глубже. Порядок важен: сначала перестаём принимать новую работу (закрыть listener / убрать из балансировщика / вернуть 503 на readiness-пробе), затем даём доработать текущей, затем освобождаем ресурсы. Обязателен таймаут: если за N секунд не завершились — логируем и выходим, иначе pod будет убит SIGKILL без диагностики. В Kubernetes добавляется preStop-хук и terminationGracePeriodSeconds, который должен быть больше вашего внутреннего таймаута.

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})
g.Go(func() error {
<-gctx.Done()
shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return srv.Shutdown(shCtx)
})
if err := g.Wait(); err != nil {
log.Fatal(err)
}
}

Отдельная тонкость: http.Server.Shutdown не прерывает уже выполняющиеся хендлеры и не трогает hijacked-соединения (WebSocket) — их нужно закрывать самому по ctx.Done().

Чем отличается конкурентность от многопоточности?

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

Коротко. Конкурентность — свойство дизайна программы, многопоточность — один из способов её реализовать. Конкурентную программу можно исполнить и в одном потоке (event loop, корутины), а многопоточность бывает и без конкурентной логики (просто параллельные вычисления).

Глубже. В Go эти уровни разведены явно: вы пишете конкурентно (горутины), а рантайм решает, сколько потоков ОС под это выделить. Отсюда важное следствие для собеседования: «сколько потоков создаст мой сервис на 100k горутин?» — примерно GOMAXPROCS плюс несколько служебных плюс столько, сколько горутин одновременно висит в блокирующих syscall’ах. Другие реализации конкурентности без многопоточности: Node.js (один поток + event loop), Python asyncio, Erlang-процессы на планировщиках.

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

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

Коротко. 2 КБ (_StackMin = 2048 в runtime/stack.go), начиная с Go 1.4. До этого было 8 КБ, а в самых ранних версиях — 4 КБ с сегментированными стеками.

Глубже. Стек выделяется не системным аллокатором, а стековым аллокатором рантайма: маленькие стеки (2/4/8/16 КБ) раздаются из per-P кэшей stackcache, большие — из stackLarge/heap. Растёт стек не сегментами, а копированием: в прологе функции проверяется SP < g.stackguard0, при нехватке вызывается morestacknewstack, который выделяет вдвое больший блок, копирует содержимое и корректирует все указатели на стек (это возможно благодаря точной информации о типах). Сжатие происходит во время GC (shrinkstack), если используется меньше четверти.

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

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

Коротко. По умолчанию 1 ГБ на 64-битных платформах и 250 МБ на 32-битных. Значение меняется через runtime/debug.SetMaxStack.

Глубже. Лимит задаётся maxstacksize в рантайме и проверяется в newstack. Это лимит на одну горутину, а не на процесс. Отдельно есть maxstackceiling (потолок, который нельзя превысить через SetMaxStack). Практически повышать лимит имеет смысл крайне редко — глубокая рекурсия почти всегда признак ошибки; чаще, наоборот, лимит понижают, чтобы бесконечная рекурсия падала быстро, не съев гигабайт.

Что произойдет, если горутина попытается выделить слишком много памяти под стек?

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

Коротко. Рантайм упадёт с fatal error: stack overflow (в логе будет runtime: goroutine stack exceeds 1000000000-byte limit). Это не паника — recover не поможет, процесс завершается.

Глубже. Механика: newstack видит, что удвоенный размер превысит maxstacksize, печатает диагностику и вызывает throw("stack overflow"). Типичная причина — бесконечная рекурсия, в том числе неявная: метод String(), который вызывает fmt.Sprintf("%v", x) на самом себе, или MarshalJSON, обёрнутый над тем же типом. Отдельный сценарий — не хватило памяти под сам стек: тогда будет fatal error: out of memory (stackalloc). Оба случая неперехватываемые, поэтому единственная защита — ограничение глубины в коде.

Был ли у вас опыт осознанного использования блокировок в PostgreSQL для защиты данных от конкурентного изменения?

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

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

Глубже. Каркас ответа. (1) Кейс: «списание с баланса / резервирование остатка товара — нужно исключить двойное списание при параллельных запросах». (2) Механизм: SELECT ... FOR UPDATE на строке в транзакции — пессимистичная блокировка, простая и понятная, но держит блокировку на время транзакции; FOR NO KEY UPDATE, если не меняем ключ, чтобы не блокировать FK-проверки. (3) Альтернативы, которые стоит упомянуть: оптимистичная блокировка через колонку version и UPDATE ... WHERE version = $1 с проверкой числа затронутых строк; уровень изоляции REPEATABLE READ/SERIALIZABLE с ретраями на ошибку сериализации (40001); advisory-локи (pg_advisory_xact_lock) для защиты бизнес-операции, а не строки — например, чтобы только один воркер выполнял периодическую задачу; SELECT ... FOR UPDATE SKIP LOCKED для очередей задач в таблице. (4) Подводные камни, которые ценятся: порядок захвата строк ради предотвращения deadlock, lock_timeout и statement_timeout, рост числа соединений и то, что pgbouncer в режиме transaction ломает сессионные advisory-локи. (5) Типичная ошибка кандидата — сказать «использовал транзакции» и не отличить изоляцию от блокировок, либо считать, что SERIALIZABLE избавляет от необходимости ретраев.

Разница между асинхронностью и многопоточностью?

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

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

Глубже. Асинхронный I/O возможен в одном потоке (epoll + event loop), а многопоточность может быть полностью синхронной (пул потоков, каждый блокируется на своём вызове). В Go эти вещи спрятаны: вы пишете синхронный по виду код, а асинхронность обеспечивает netpoller, многопоточность — планировщик. Именно поэтому в Go нет async/await и «раскраски функций» (проблемы function coloring) — блокирующая с точки зрения горутины операция не блокирует поток.

Коротко. Потоки одного процесса делят адресное пространство, дескрипторы и сигналы; изолированы только стек, регистры и thread-local storage. У горутин изоляции ещё меньше: свой стек и регистры, но никакого goroutine-local storage в языке нет.

Глубже. Практический вывод: нельзя рассчитывать, что «моя горутина» имеет приватное состояние где-то кроме локальных переменных. Аналог TLS в Go намеренно не предоставляется (нет и goroutine ID в публичном API), потому что он провоцирует неявные зависимости; вместо этого контекст запроса передают явно через context.Context. Исключение — runtime.LockOSThread, который привязывает горутину к конкретному потоку: нужен для C-библиотек с TLS-состоянием, OpenGL, setns/namespace-операций в Linux. Полная изоляция достигается только на уровне процессов (отдельное адресное пространство) или контейнеров/namespace.

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

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

Коротко. Гонки данных, взаимоблокировки (deadlock), живые блокировки и голодание, нарушение атомарности составных операций, некорректная видимость памяти, а также утечки горутин и деградация из-за контенции и false sharing.

Глубже. По категориям. Корректность: data race (несинхронизированный доступ, хотя бы одна запись); race condition в бизнес-смысле (check-then-act: if !exists { create }); нарушение инвариантов между несколькими полями, защищёнными по отдельности. Блокировки: deadlock (взаимное ожидание — классика: разный порядок захвата двух мьютексов), livelock (все двигаются, но прогресса нет), starvation (одну горутину постоянно обходят; в Go у sync.Mutex есть starvation mode: ожидающий дольше 1 мс переводит мьютекс в честный режим). Ресурсы: утечка горутин, исчерпание пула соединений, thread exhaustion. Производительность: контенция на общем мьютексе, false sharing, слишком мелкая гранулярность задач (стоимость синхронизации больше пользы). Диагностика: -race, go tool trace, GODEBUG=schedtrace, профиль block/mutex, детектор дедлоков рантайма (all goroutines are asleep - deadlock!, срабатывает только когда спят вообще все).

Что такое thread pool, какого он размера и зачем он нужен?

Заголовок раздела «Что такое thread pool, какого он размера и зачем он нужен?»

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

Глубже. Размер выбирают по типу нагрузки: для CPU-bound — примерно GOMAXPROCS (больше не ускорит, только добавит переключений); для I/O-bound — по формуле Литтла, N ≈ пропускная_способность × средняя_латентность, либо по ёмкости узкого ресурса (например, MaxOpenConns у БД, rate limit внешнего API). В Go пул горутин почти никогда не нужен ради экономии на создании — горутина дешёвая; он нужен ради ограничения одновременной нагрузки на внешние ресурсы и предсказуемого потребления памяти. Часто достаточно семафора вместо полноценного пула.

func workerPool(ctx context.Context, jobs <-chan Job, n int) error {
g, ctx := errgroup.WithContext(ctx)
for i := 0; i < n; i++ {
g.Go(func() error {
for j := range jobs {
if err := handle(ctx, j); err != nil {
return err
}
}
return nil
})
}
return g.Wait()
}

Что происходит, когда вы отправляете данные из горутины в сетевое соединение?

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

Коротко. conn.Write копирует данные в буфер ядра неблокирующим write. Если буфер сокета заполнен и write вернул EAGAIN, горутина паркуется через netpoller до готовности сокета на запись, а поток уходит выполнять другие горутины.

Глубже. Полная цепочка: net.Conn.Writeinternal/poll.FD.Write в цикле, пока не записаны все байты → на EAGAIN вызывается pd.waitWritegopark, горутина в _Gwaiting; epoll с EPOLLOUT пробуждает её. Важные следствия: (1) успешный Write означает лишь «данные скопированы в буфер ядра», а не «доставлены пиру» — гарантии доставки даёт только протокол уровнем выше; (2) конкурентный Write из нескольких горутин в один net.Conn безопасен с точки зрения гонок (реализация держит fdMutex), но данные могут перемешаться на границах записей, поэтому для протоколов с фреймами нужен свой мьютекс или одна пишущая горутина; (3) медленный клиент, не читающий данные, приведёт к переполнению его окна TCP и залипанию вашей горутины — от этого спасают SetWriteDeadline и таймауты http.Server.

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

Глубже. Различие важно и его любят проверять: data race — свойство памяти, определяемое моделью памяти Go и обнаруживаемое -race; race condition — свойство логики, детектор его не увидит. Пример логической гонки без data race: два запроса делают exists, _ := repo.Get(id); if !exists { repo.Create(...) } — каждый вызов потокобезопасен, а результат — две записи. В Go data race — это undefined behavior: неатомарная запись интерфейса или слайса может дать «порванное» значение (ptr от одного, len/type от другого) и краш, а не просто устаревшее число. Классическая иллюстрация: i++ — это три операции (load, add, store), и без атомарности инкременты теряются.

Коротко. (Продолжение предыдущего вопроса.) Убрать разделяемое изменяемое состояние; если нельзя — защитить его мьютексом, атомиками или передачей владения через канал, а для логических гонок — сделать операцию атомарной на уровне бизнес-логики (транзакция, INSERT ... ON CONFLICT, CAS, идемпотентный ключ).

Глубже. Порядок выбора инструмента: (1) иммутабельность / копирование — самое дешёвое, гонки нет по построению; (2) владение одной горутиной — состояние держит один воркер, остальные шлют ему запросы через канал; (3) sync/atomic — для одиночных счётчиков и флагов; с Go 1.19 есть типы atomic.Int64, atomic.Pointer[T], atomic.Value, которые лучше функций, потому что нельзя случайно прочитать поле неатомарно; (4) sync.Mutex/RWMutex — для составных инвариантов; RWMutex выгоден только при явном преобладании чтений и достаточно длинных критических секциях; (5) шардирование при высокой контенции. Для логических гонок — уникальные индексы и ON CONFLICT, оптимистичная блокировка по версии, SELECT FOR UPDATE, распределённые локи. Обязательная гигиена: go test -race ./... в CI (помните, что детектор находит только фактически исполненные гонки и замедляет код в 5–10 раз), профили -blockprofile/-mutexprofile для контенции.

Коротко. Запустить N горутин и дождаться их через sync.WaitGroup либо errgroup.Group (последний ещё собирает первую ошибку и отменяет контекст). Если N велико — ограничить параллелизм семафором или пулом воркеров.

Глубже. Базовая форма с результатами по индексу — без гонок, потому что каждая горутина пишет в свой элемент:

func fetchAll(ctx context.Context, urls []string, limit int) ([][]byte, error) {
res := make([][]byte, len(urls))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit) // ограничение параллелизма
for i, u := range urls {
i, u := i, u // с Go 1.22 не обязательно, но безвредно
g.Go(func() error {
b, err := fetch(ctx, u)
if err != nil {
return err
}
res[i] = b
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return res, nil
}

С Go 1.22 переменная цикла создаётся заново на каждой итерации, поэтому старый баг «все горутины видят последнее значение i» больше не воспроизводится при GOEXPERIMENT по умолчанию и go >= 1.22 в go.mod. В Go 1.25 у sync.WaitGroup появился метод Go, убирающий ручной Add/Done. Если задачи однотипные и их поток бесконечен — вместо N горутин на N задач делают пул из K воркеров, читающих канал.

Коротко. go f(x) немедленно вычисляет аргументы и ставит горутину в очередь планировщика (в runnext текущего P), но исполнение начнётся, когда планировщик её выберет: сразу на другом ядре, если есть свободный P и M, либо после того как текущая горутина заблокируется или будет вытеснена.

Глубже. Никаких гарантий о моменте старта нет — это частый источник флаки-тестов вида «запустил горутину и сразу проверил результат». runtime.Gosched() даёт шанс запуститься, но не гарантирует порядок. Занятная деталь: помещение в runnext означает, что новая горутина будет следующей на этом P, вытеснив то, что лежало в runnext до неё, — это оптимизация локальности для паттерна «породил и сразу ждёшь результата». Если программа завершает main, все горутины умирают немедленно, даже не начав исполняться, — поэтому нужно явное ожидание.

С помощью wait group - что произойдет, если сделать wg.Add(1) внутри каждой горутины, а не перед их запуском?

Заголовок раздела «С помощью wait group - что произойдет, если сделать wg.Add(1) внутри каждой горутины, а не перед их запуском?»

Коротко. Это гонка: wg.Wait() может увидеть счётчик равным нулю раньше, чем горутины успеют выполнить Add, и вернуться немедленно — программа продолжит работу или завершится, не дождавшись задач.

Глубже. Документация sync.WaitGroup формулирует правило прямо: вызовы Add с положительным дельтой, которые начинают новую «единицу работы», должны происходить до Wait. Формально нарушается happens-before: между go func(){ wg.Add(1) ... }() и wg.Wait() нет упорядочивания. Возможные исходы: Wait вернулся слишком рано (типично), либо panic: sync: WaitGroup is reused before previous Wait has returned, либо panic: sync: negative WaitGroup counter при повторном использовании. Детектор гонок обычно это ловит. Правильно — wg.Add(1) в вызывающей горутине непосредственно перед go, а defer wg.Done() первой строкой внутри. Или wg.Add(n) один раз перед циклом.

Коротко. Тремя способами: канал результатов, запись в заранее выделенный слайс/массив по индексу (каждая горутина в свой элемент), либо errgroup + захваченные переменные с синхронизацией. Возвращаемое значение функции горутины отбрасывается.

Глубже. Выбор: канал удобен, когда результаты нужно обрабатывать по мере готовности (streaming) или их количество заранее неизвестно; буферизуйте его на N, чтобы отправители не залипли, если читатель ушёл по ошибке. Слайс по индексу — самый дешёвый вариант, когда N известно и порядок важен: синхронизация не нужна, достаточно wg.Wait() для установления happens-before. Мьютекс вокруг агрегата — когда результаты сливаются в map или суммируются. Для ошибок — errgroup.Group (первая ошибка + отмена контекста) или errors.Join для сбора всех. Антипаттерн: небуферизованный канал результатов плюс ранний return у читателя — все писатели утекают навсегда.

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

Глубже. По контексту соседних пунктов это условие задачи на пайплайн: источник отдаёт данные вызовами Next неограниченно долго, поэтому решение не может «сначала вычитать всё, потом обработать». Практический вывод: обработка должна быть потоковой, с ограниченным буфером и явным условием остановки — обычно context.Context, закрытие входного канала или сигнал ошибки; память не должна расти с числом обработанных элементов.

Источник никогда не возвращает более MaxItems записей за один вызов Next.

Заголовок раздела «Источник никогда не возвращает более MaxItems записей за один вызов Next.»

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

Глубже. Что от этого выигрывает решение: (1) можно сделать buf := make([]Item, 0, MaxItems) и не аллоцировать в цикле; (2) можно рассчитать верхнюю границу потребления памяти как MaxItems × число_воркеров; (3) можно безопасно передавать порцию дальше по пайплайну, если её копируют — переиспользуемый буфер нельзя отдавать в канал, иначе следующая итерация перезапишет данные, которые ещё читает потребитель. Это классическая ловушка таких задач.

В рамках одной “сессии ”(одного вызова функции Pipe) источник каждый раз возвращает новые данные на каждый вызов Next.

Заголовок раздела «В рамках одной “сессии ”(одного вызова функции Pipe) источник каждый раз возвращает новые данные на каждый вызов Next.»

Коротко. Ещё одно условие задачи: Next не повторяет ранее выданные записи в пределах одного вызова Pipe, то есть дедупликация не нужна, а каждый вызов Next продвигает курсор.

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

Не может обработать более MaxItems за один раз.

Заголовок раздела «Не может обработать более MaxItems за один раз.»

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

Глубже. Практически это означает батчинг с двумя условиями срабатывания — по размеру (len(batch) == MaxItems) и по таймеру (чтобы «хвост» не залёживался при редком потоке). Типовая реализация — цикл с select по входному каналу и time.Ticker/time.After, с обязательным сбросом остатка при закрытии входного канала или отмене контекста. Важно не забыть, что MaxItems источника и MaxItems приёмника могут не совпадать, и промежуточный слой обязан перегруппировать записи.

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

Глубже. Со стороны рантайма управление — это переходы состояний _Grunnable/_Grunning/_Gwaiting/_Gsyscall/_Gdead через gopark/goready/goexit, преемпция по сигналу и постановка в очереди P. Со стороны прикладного кода доступны только: передать сигнал (канал, context), дождаться (WaitGroup, канал завершения), ограничить (семафор), повлиять на планирование (runtime.Gosched, runtime.LockOSThread). Чего нет и не будет: kill, приоритетов, идентификаторов, принудительной приостановки.

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

Глубже. Точнее: структура runtime.g и стек живут в памяти, управляемой рантаймом; мелкие стеки (2/4/8/16 КБ) берутся из per-P stackcache и глобального stackpool, крупные — из stackLarge/mheap. Стек может физически переезжать при росте (copystack), поэтому указатели на стековые переменные корректируются рантаймом — но именно поэтому нельзя хранить адреса стековых объектов в C-коде без cgo.Handle. Escape analysis решает, где разместить переменную: если она «убегает» (возвращается по указателю, захватывается долгоживущим замыканием, попадает в интерфейс), она уедет в кучу и станет общей. Проверяется go build -gcflags='-m'.

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

Заголовок раздела «Есть две глобальные горутины, каждая из них запускает десять своих подгорутин, выполняющих свою бизнес логику. Как можно реализовать механизм возврата в консистентное состояние, если в одной из этих десяти подгорутин произошла ошибка?»

Коротко. Иерархия контекстов плюс errgroup на каждом уровне: подгорутины запускаются через errgroup.WithContext, первая ошибка отменяет контекст группы, остальные видят ctx.Done() и корректно сворачиваются; после g.Wait() родитель выполняет компенсирующие действия для уже применённых изменений.

Глубже. Ключевая мысль: отмена сама по себе не откатывает побочные эффекты, она только останавливает работу. Поэтому механизм состоит из двух частей. Остановка: parentCtxerrgroup.WithContext на каждую из двух глобальных горутин → 10 g.Go(...); если нужно валить всё дерево при ошибке в любой ветке — обе группы строятся от общего контекста. Возврат в консистентное состояние: если все изменения идут в одну БД — обернуть в транзакцию и сделать Rollback (но тогда подгорутины не могут делить одно соединение: *sql.Tx не предназначен для конкурентного использования, значит либо у каждой своя транзакция, либо запись выполняет одна горутина); если изменения в разных системах — паттерн Saga с компенсирующими операциями, которые регистрируются по мере успешного выполнения шагов (стек []func(context.Context) error) и прогоняются в обратном порядке при ошибке. Обязательные детали, которые ценятся на собеседовании: компенсации должны быть идемпотентными и выполняться под отдельным контекстом (context.WithoutCancel + свой таймаут), иначе они сами будут отменены; ошибки компенсаций нужно логировать и агрегировать через errors.Join; для распределённых случаев — transactional outbox и повторные попытки, потому что процесс может умереть посередине.

type Compensations struct {
mu sync.Mutex
stack []func(context.Context) error
}
func (c *Compensations) Add(f func(context.Context) error) {
c.mu.Lock()
c.stack = append(c.stack, f)
c.mu.Unlock()
}
func (c *Compensations) Run(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
var errs []error
for i := len(c.stack) - 1; i >= 0; i-- {
if err := c.stack[i](ctx); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}

Что такое семафор в контексте конкурентного программирования?

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

Коротко. Семафор — счётчик разрешений с операциями «занять» (acquire/P) и «освободить» (release/V), ограничивающий число одновременных обладателей ресурса. Бинарный семафор (счётчик = 1) по поведению близок к мьютексу.

Глубже. В Go нет семафора в стандартной библиотеке как отдельного типа, но идиома — буферизованный канал: sem := make(chan struct{}, N), sem <- struct{}{} для захвата и <-sem для освобождения. Для взвешенных разрешений (задача «весит» K единиц) есть golang.org/x/sync/semaphore.Weighted с Acquire(ctx, n), который умеет отменяться по контексту; errgroup.SetLimit(n) решает ту же задачу для групп. Важное отличие от мьютекса: семафор не имеет владельца — освободить его может любая горутина, что делает его пригодным для схемы «producer выдаёт разрешения». Внутри рантайма, кстати, sync.Mutex и sync.WaitGroup реализованы поверх runtime_Semacquire/runtime_Semrelease — низкоуровневых семафоров планировщика на хеш-таблице sudog-очередей.

sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
sem <- struct{}{}
go func(j Job) {
defer wg.Done()
defer func() { <-sem }()
handle(j)
}(job)
}
wg.Wait()

Что такое горутина? Почему ее «бесплатно» переключать?

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

Коротко. См. выше «Что такое горутина в контексте Go». Переключение дёшево, потому что происходит целиком в пользовательском пространстве: сохранить SP/PC/BP в g.sched, загрузить их из другой g — без syscall, без смены адресного пространства и без сброса TLB.

Глубже. «Бесплатно» — преувеличение: порядка 100–200 нс против ~1–2 мкс на переключение потоков ОС, плюс всё равно остаются промахи кэша, если горутины работают с разными данными. Дополнительная экономия в том, что Go-планировщик переключается в известных точках (блокировка на канале, вход в syscall), где живых регистров минимум и не нужно сохранять полный набор SSE/AVX-регистров, как это делает ядро при вытеснении. Асинхронная преемпция сигналом — исключение: там контекст сохраняется полнее, и она дороже, поэтому применяется только к «залипшим» горутинам.

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

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

Коротко. Да, это обычная проблема. Симптомы: рост потребления памяти и времени GC, увеличение латентности из-за длинных очередей планировщика, контенция на общих мьютексах и исчерпание внешних ресурсов (соединений к БД, дескрипторов). Лечение — ограничение параллелизма: пул воркеров или семафор, backpressure и таймауты.

Глубже. Механика «мешают»: (1) каждая горутина — корень для GC, миллионы горутин удлиняют фазу mark и увеличивают STW-подготовку; (2) память под стеки, особенно если горутина держит буфер на несколько килобайт; (3) при контенции на мьютексе много ожидающих означает много пробуждений и переключений — производительность падает нелинейно; (4) неограниченный fan-out к БД превращается в очередь на MaxOpenConns и таймауты; (5) в патологическом случае — thread exhaustion, если горутины уходят в блокирующие syscall’ы. Диагностика: runtime.NumGoroutine() как метрика, /debug/pprof/goroutine?debug=2 для стеков, go tool trace, профили block/mutex. Исправление по порядку: ограничить вход (errgroup.SetLimit, семафор, worker pool с фиксированным K), ввести backpressure (небуферизованный или маленький канал вместо «шлём всё сразу»), таймауты и дедлайны на каждый внешний вызов, батчинг вместо горутины на элемент, и отдельно — проверить, нет ли просто утечки горутин (число растёт монотонно и не падает).

Горутины и планировщик Go: базовые вопросы;

Заголовок раздела «Горутины и планировщик Go: базовые вопросы;»

Коротко. Обобщающий пункт: ожидается связный рассказ — что такое горутина, чем отличается от потока, GMP, очереди и work stealing, преемпция, netpoller, GOMAXPROCS, состояния горутины. Всё это разобрано выше.

Глубже. Каркас ответа на 3–4 минуты: (1) горутина = g + растущий стек 2 КБ, планируется рантаймом; (2) GMP и зачем нужен P; (3) как выбирается следующая горутина: runnext → локальная очередь → глобальная (каждый 61-й тик) → netpoller → кража у соседа; (4) как горутина уступает процессор: блокировка, вызов функции, sysmon + SIGURG с Go 1.14; (5) syscall: сетевой — netpoller без блокировки потока, блокирующий — P отбирается через retake; (6) чем измерять: GODEBUG=schedtrace, go tool trace.

Как реализовано конкурентное программирование в Go?

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

Коротко. См. выше «Что такое конкурентность и как она устроена в Go». Три слоя: горутины как дешёвые задачи, каналы/select/sync/context как средства взаимодействия и управления, GMP-планировщик с netpoller как исполнительная машина.

Глубже. Отличие акцента: здесь уместно перечислить конкретные инструменты стандартной библиотеки — go, chan и select (включая default для неблокирующих операций и time.After для таймаутов), sync.Mutex/RWMutex/WaitGroup/Once/Pool/Map, sync/atomic с типами из Go 1.19, context для отмены и дедлайнов, golang.org/x/sync (errgroup, semaphore, singleflight) как де-факто стандарт. И упомянуть, что корректность формально описывается документом «The Go Memory Model», а проверяется -race.

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

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

Коротко. Напрямую — никак: в Go нет kill/stop для горутины. Снаружи можно только попросить её завершиться — отменой context, закрытием канала-сигнала или закрытием входного канала — а она обязана это проверить и вернуться из функции.

Глубже. Это сознательное решение авторов языка: принудительное убийство оставило бы захваченные мьютексы, недописанные буферы и невыполненные defer (Java-опыт с Thread.stop() показал, что это неремонтопригодно). Практические приёмы: context.WithCancel/WithTimeout и select { case <-ctx.Done(): return } во всех точках ожидания; закрытие канала done (закрытый канал читается всеми — удобно для broadcast); закрытие входного канала, чтобы for range ch завершился сам; для операций, которые не умеют в контекст, — дедлайны на уровне соединения (conn.SetDeadline, SetReadDeadline), которые приводят к ошибке и выходу из блокировки. Если горутина висит в неотменяемом syscall или в C-коде, вытащить её невозможно вообще — это архитектурная ошибка, лечится изоляцией такой работы в отдельный процесс.

Коротко. См. выше «Как получить результаты из горутин»: канал, запись в предвыделенный слайс по индексу, либо errgroup с захваченными переменными. Возврат функции горутины теряется.

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

func callWithTimeout(ctx context.Context, d time.Duration) (Result, error) {
ctx, cancel := context.WithTimeout(ctx, d)
defer cancel()
type out struct {
r Result
err error
}
ch := make(chan out, 1) // буфер 1: горутина не утечёт при таймауте
go func() {
r, err := doWork(ctx)
ch <- out{r, err}
}()
select {
case o := <-ch:
return o.r, o.err
case <-ctx.Done():
return Result{}, ctx.Err()
}
}

Именно буфер 1 здесь принципиален: с небуферизованным каналом при срабатывании таймаута горутина навсегда зависнет на отправке.

  • Путают конкурентность и параллелизм и утверждают, что «горутины выполняются параллельно» — при GOMAXPROCS=1 параллелизма нет вообще.
  • Говорят «горутина занимает 2 КБ» как о постоянном размере, забывая, что стек растёт удвоением примерно до 1 ГБ и уменьшается только во время GC.
  • Называют планировщик Go вытесняющим без оговорок или, наоборот, чисто кооперативным: с Go 1.14 это гибрид с асинхронным вытеснением через SIGURG примерно после 10 мс.
  • Считают, что WaitGroup защищает от утечек горутин: если горутина зависла, Wait() зависнет вместе с ней; от утечки спасает только гарантированный путь выхода через context/закрытие канала.
  • Думают, что отмена контекста «убивает» горутину. Она лишь закрывает ctx.Done(); горутина, которая его не проверяет, продолжит работать вечно.
  • Пытаются перехватить панику из другой горутины или ловить recover в main — паника роняет весь процесс, recover работает только в defer той же горутины.
  • Утверждают, что чтение из map безопасно всегда: конкурентные чтения — да, но чтение одновременно с записью даёт fatal error: concurrent map read and map write, который не ловится recover.
  • Закрывают канал на стороне получателя или из нескольких горутин — получают панику close of closed channel или запись в закрытый канал.
  • Считают time.Sleep допустимым способом дождаться горутин — это источник флаки-тестов и потерянных результатов.
  • Не различают data race и race condition: устранив первую детектором -race, уверены, что логических гонок (TOCTOU) больше нет.
  • Говорят «горутина — это поток» или «горутина — это зелёный поток, который сам является потоком ОС». Правильно: структура рантайма, мультиплексируемая на потоки по схеме M:N.
  • Путают GOMAXPROCS с числом потоков. Он ограничивает P (сколько потоков одновременно исполняют Go-код), а потоков в процессе может быть сотни: блокирующие syscall’ы, cgo, sysmon, GC.
  • Утверждают, что планировщик переключает горутины «по таймеру, как ОС». Основной механизм — парковка в точках блокировки; вытеснение по времени (10 мс) — дополнение, и до Go 1.14 его в асинхронном виде вообще не было.
  • Считают, что map безопасна, если разные горутины пишут по разным ключам, или что конкурентную запись можно поймать recover. Это fatal error, процесс умирает.
  • wg.Add(1) внутри запускаемой горутины, wg.Wait() без соответствующих Done при ранних return, копирование структуры с мьютексом по значению.
  • «Каналы всегда лучше мьютексов» — нет. Канал содержит мьютекс внутри и добавляет копирование и планирование; для защиты состояния мьютекс обычно быстрее и проще.
  • Забывают про ограничение параллелизма и отмену: «запустим горутину на каждый элемент» без пула, без context и без закрытия resp.Body — это утечка горутин и исчерпание дескрипторов.
  • Путают конкурентность и параллелизм и на вопрос «зачем синхронизация при GOMAXPROCS=1» отвечают «она не нужна».
  • Путают конкурентность и параллелизм, а на уточняющий вопрос «а на одном ядре?» отвечают, что параллелизм есть, потому что «горутины же работают одновременно».
  • Говорят, что горутина — это «лёгкий поток ОС» или что «Go создаёт поток на каждую горутину»; путают GOMAXPROCS с максимальным числом горутин.
  • Утверждают, что панику из горутины можно поймать в main через defer recover(), и что fatal error: concurrent map writes перехватывается recover.
  • Считают sync.Map универсальной заменой мьютексу и не могут назвать два сценария, под которые она оптимизирована.
  • Не упоминают асинхронную преемпцию (Go 1.14) и рассказывают про планировщик как про чисто кооперативный, где бесконечный цикл вешает программу.
  • Забывают, что append из нескольких горутин — гонка, и что заголовок слайса это три слова, а не указатель.
  • Ставят wg.Add(1) внутри горутины, а defer wg.Done() — иногда не первой строкой, из-за чего ранний return или паника подвешивают Wait.
  • Отправляют результат в небуферизованный канал, а читателя защищают таймаутом — и получают гарантированную утечку горутины.
  • Считают, что отмена контекста «убивает» горутину и откатывает её побочные эффекты, вместо того чтобы говорить о кооперативной проверке ctx.Done() и компенсирующих действиях.
  • Называют начальный стек 4 или 8 КБ (актуально 2 КБ с Go 1.4) и не знают, что стек растёт копированием, а не сегментами.

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