Как отвечать: определение → как работает внутри → где ловушка → пример. Отвечайте вслух до чтения ответа — собеседование это устный жанр, и «понимаю про себя» не равно «объясняю словами».
1. Сколько стоит создание горутины и из чего складывается её память?
Создание горутины стоит сотни наносекунд, и это на порядки дешевле потока ОС. Память складывается из двух частей: служебная структура g в рантайме и стек горутины. Начальный стек — порядка двух килобайт (_StackMin в исходниках рантайма), дальше он растёт удвоением при глубокой рекурсии или больших локальных переменных.
Что из этого следует на практике. Первое: десять тысяч горутин — это единицы мегабайт стеков плюс служебные структуры, и это нормальная величина. Сто тысяч — уже десятки мегабайт, и это граница, на которой стоит задуматься, не нужен ли такой fan-out. Второе: горутина не существует бесплатно только до первого блокирования — пока она выполняется, она занимает слот P, то есть реальное ядро.
Отдельно про то, из-за чего путают эту тему: горутина, которая спит на канале, не занимает ядро, но продолжает занимать память. Именно поэтому утечка горутин — это утечка памяти, даже если все они заблокированы.
Что стоит проверить на собеседовании: назвать две цифры — размер начального стека и порядок стоимости запуска — и объяснить, почему «миллион горутин» звучит страшно, но на практике означает сотни мегабайт стеков, а не миллион потоков. Миллион горутин, конечно, потянет за собой проблемы с GC: сборщику надо просканировать все их стеки, и первый цикл после старта будет дорогим.
// Стоимость запуска: сотни наносекунд на горутину
start := time.Now()
for i := 0; i < 100000; i++ {
go func() { _ = i }()
}
fmt.Println(time.Since(start))
// Размер стека виден в профиле горутин:
// go tool pprof http://svc/debug/pprof/goroutine2. Как устроен канал внутри: что такое hchan и чем send на закрытый канал отличается от чтения?
Проблема, которую прячет удобный синтаксис каналов: отправитель и получатель должны разбудить друг друга, не потеряв и не продублировав значения, — и сделать это так, чтобы не возникло гонки между двумя одновременными отправителями.
Внутри канал — это структура hchan с мьютексом, кольцевым буфером (dataqsiz элементов, для небуферизованного — ноль), счётчиком отправителей и получателей, а также двумя очередями ожидания: recvq для «кто ждёт значение» и sendq для «кто ждёт, когда можно отправить». Значения в очереди ожидания лежат как sudog — структуры с указателем на горутину и на канал.
Отсюда видно, почему send на закрытый канал паникует, а receive — нет. Закрытие меняет поле closed под мьютексом и будит всех ожидающих: читатели получают нулевое значение с признаком ok=false, а отправители, стоявшие в sendq, получают панику прямо из рантайма — закрытый канал принимать не готов.
Второй вывод — про небуферизованный канал. Отправитель кладёт sudog в recvq получателя и засыпает; получатель, придя за значением, находит sudog в очереди, копирует значение напрямую и будит отправителя. То есть для небуферизованного канала значения в буфере не бывает вообще: есть только прямая передача от одного ожидающего другому.
И третий, практический: буфер — это кольцо с двумя индексами, sendx и recvx, и при полном буфере отправитель встаёт в sendq. Именно поэтому размер буфера влияет на поведение: буфер равен единице — это уже асинхронная передача с ограничением в один элемент.
// Что происходит на уровне API: ch := make(chan int, 1) // буфер на один элемент ch <- 1 // отправитель не спит: место есть close(ch) v, ok := <-ch // 1, true — значение успело попасть в буфер v, ok = <-ch // 0, false — канал закрыт и пуст
3. Как работает select внутри: почему при нескольких готовых case выбирается случайный?
Проблема, которую решает select, — ожидание сразу нескольких событий. Если бы выбор был всегда в пользу первого готового case, ветка, стоящая ниже, голодала бы: при постоянно готовом первом канале вторая ветка не выполнялась бы никогда.
Механика такая. Рантайм обходит все case и для каждого готового канала регистрирует sudog в его очереди ожидания. Если готовы несколько вариантов, рантайм выбирает один из них случайным образом — в реализации для этого выбирается случайное смещение, с которого начинается обход списка. Именно поэтому порядок case в select не задаёт приоритет.
Вторая деталь — блокировки. select берёт мьютексы всех каналов, участвующих в выражении, и берёт их в порядке адресов, чтобы два параллельных select'а не взяли каналы в противоположном порядке и не зависли. Это объясняет, почему select на каналах из разных мест не приводит к дедлоку из-за порядка блокировок.
Третья деталь — default. Если ни один case не готов, срабатывает default и select не блокируется вовсе. Это превращает select в операцию «попробовать без ожидания», и именно так проверяют, есть ли данные в канале, без блокировки.
Четвёртая — про таймаут. Ветка с time.After создаёт таймер и добавляет его канал в набор case. Отсюда практическое следствие: создавать time.After в цикле не стоит — каждый вызов оставляет таймер в куче до срабатывания. В Go 1.23 и новее таймеры сбрасываются сразу, но знать про это стоит.
// Приоритет в select не задаётся порядком case:
select {
case v := <-fast:
use(v)
case v := <-slow:
use(v)
}
// Приоритет вводится явно, отдельной проверкой
select {
case v := <-fast:
use(v)
default:
select {
case v := <-slow:
use(v)
}
}4. Как горутина засыпает и просыпается: что такое парковка и очередь ожидания?
Проблема: горутина, которая ждёт данные из канала, не должна занимать ядро, но должна проснуться ровно тогда, когда данные появились. Реализовать это без потерь — задача рантайма, и она решается очередями ожидания.
Механика. Когда горутина блокируется на канале, рантайм снимает её с P (паркует: gopark), кладёт её в очередь ожидания канала как sudog и переключается на другую горутину. Сама горутина при этом не теряется: её состояние, стек и точки продолжения сохранены в структуре g.
Когда отправитель кладёт значение в канал, он находит в очереди ожидания sudog получателя, копирует значение в его буфер или прямо в переменную получателя, переводит g получателя в состояние runnable и ставит его в очередь планировщика. Дальше планировщик, возможно на другом ядре, подхватит эту горутину. То есть «проснулась» она в произвольный момент — ровно так и работает мультиплексирование.
Тут же объясняется, почему канал называют синхронизацией: сама передача значения создаёт happens-before — всё, что записано до отправки, видно после получения. Модель памяти построена именно на этом.
Отдельно про select: горутина в select засыпает сразу в нескольких очередях ожидания — по одной на каждый канал, — и при срабатывании любого из них рантайм убирает sudog из остальных очередей. Отсюда сложность select: он входит в несколько мьютексов каналов, и именно поэтому выбор внутри select случайный, а блокировки идут в порядке адресов.
// Что видно в профиле горутин — состояние «chan receive»: // goroutine profile: N goroutines, 1 [chan receive]: // // runtime.gopark // chanrecv(...)
5. Как растёт стек горутины и сколько это стоит?
Проблема: горутина начинает с маленького стека, но вызовы могут быть глубокими — рекурсия, большие локальные массивы, длинная цепочка вызовов. Статический стек под это выделять нельзя: тогда каждый запуск горутины стоил бы мегабайты.
Решение — растущий стек. Начальный размер — порядка двух килобайт (_StackMin в рантайме). Когда кадр не помещается, рантайм выделяет новый стек вдвое больше и переносит в него старый, обновляя указатели. Перенос возможен потому, что в кадрах Go нет абсолютных адресов: указатели на локальные переменные хранятся относительно границы стека, и рантайм их пересчитывает при копировании.
Цена. Каждое удвоение — это копирование и выделение памяти. Для глубокой рекурсии это заметно, и потому, что стек растёт, а не сжимается, после пикового вызова память под стек не возвращается сразу: рантайм отдаёт её сборщику позже.
Практические следствия. Первое: глубокая рекурсия в горутине дороже, чем кажется, из-за копирований стека. Второе: не рекурсивно обрабатывать большие структуры по значению — локальные переменные-структуры увеличивают кадр и ускоряют рост стека. Третье: тысячи горутин с глубокими стеками дают десятки мегабайт памяти, и это надо считать при проектировании fan-out.
Отдельный сюжет — взаимодействие с GC. Сборщик должен просканировать стеки всех живых горутин, и это объясняет, почему первая сборка после старта сервиса с большим числом горутин бывает дороже обычной.
// Стек растёт автоматически — вручную ничего делать не нужно
func deep(n int) int {
var buf [1024]byte // локальный массив — растёт кадр
if n == 0 {
return len(buf)
}
return deep(n-1) + len(buf) // каждый вызов — новый кадр
}6. Чем горутина отличается от корутины и от потока ОС на уровне планирования?
Различия стоит разложить по трём осям: кто планирует, когда задача прерывается и что происходит при блокировке.
Поток ОС планирует операционная система: она выделяет потокам кванты времени по своему алгоритму, и прерывание может прийти в любой точке. Горутину планирует рантайм Go: он выбирает, какую горутину запустить на свободном P, и прерывает её в безопасных точках — точках, где рантайм знает, что состояние можно сохранить.
Корутина — ближе к горутине, чем к потоку: ею тоже управляет программа, и она не имеет своего стека ОС. Но горутина — это неявная корутина: её создаёт рантайм, её стек живёт в куче и ею управляет планировщик с правилами вытеснения. Обычная корутина в других языках создаётся явно и передаётся планировщику вручную.
Что из этого следует практически. Первое: блокирующий системный вызов из горутины может занять поток ОС — рантайм это переживает, но на короткое время появляется дополнительный поток. Второе: горутина может мигрировать между ядрами, поэтому код, полагающийся на «я один поток», должен использовать мьютекс, а не подразумевать однопоточность. Третье: вытеснение в Go работает — но только в безопасных точках, поэтому длительные вычисления без вызовов функций всё же задерживают другие горутины.
На собеседовании формулировка обычно такая: горутина — это задача планировщика Go, поток ОС — это ресурс, который рантайм использует для выполнения этих задач, и их количество связано, но не равно.
// Горутина может мигрировать между ядрами — данные надо защищать
var counter atomic.Int64
func worker() {
for i := 0; i < 1000; i++ {
counter.Add(1) // атомарно, без гонки
}
}7. Сколько стоят каналы и горутины на практике: как измерить накладные расходы?
Проблема: «горутины бесплатны» — миф, который мешает проектировать. У горутины есть цена запуска, у канала — цена передачи, у блокировки — цена ожидания. Если эти числа не измерены, решение «давайте на каждый элемент поднимем горутину» может оказаться дороже, чем обработка в цикле.
Что и как измерять. Стоимость создания горутины — бенчмарк с циклом запуска: разница во времени между запуском тысячи горутин и холостым циклом делённая на тысячу даёт порядок сотен наносекунд. Стоимость передачи через канал — бенчмарк, где продюсер пишет, а потребитель читает: разница между небуферизованным и буферизованным каналом показывает, сколько стоит синхронизация.
Стоимость конкуренции — b.RunParallel: если при росте параллелизма время на операцию растёт, значит, есть contention, и надо искать мьютекс или общий кэш.
Что из результатов обычно удивляет. Передача через канал не бесплатна и обходится в десятки наносекунд — сопоставимо с атомарной операцией, но не с обычным счётчиком. Один мьютекс на горячем пути стоит больше, чем атомарная операция, потому что при contention горутины переключаются. А массовый запуск сотни тысяч горутин упирается уже не в CPU, а в GC: сборщику надо просканировать все стеки.
Практическое правило, которое стоит произнести: горутины стоят дёшево, но не бесплатно, и решение «на каждую задачу — горутина» оправдано, когда задачи действительно независимы и блокирующие. Если задачи короткие и вычислительные, пул из десяти воркеров обгонит тысячу горутин.
func BenchmarkChannel(b *testing.B) {
ch := make(chan int)
go func() {
for range ch {
}
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ch <- i
}
}Прокачать тему в самоучителе
Читается быстро, запоминается практикой. Эти курсы закрывают тему с запуском кода прямо на странице:
- Продвинутые возможности языка Go — Продвинутый курс Go: замыкания, defer, горутины, каналы, контекст, интерфейсы, дженерики, рефлексия, тесты и JSON. 25 уроков с практикой в браузере.
- Язык Go с нуля: базовый синтаксис и конструкции — Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Asynq: полный курс по фоновым задачам в Go — Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.