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

Поиск по урокам

Найдите урок, курс, термин или раздел

Senior ~24 мин 7 вопросов

Горутины: как они работают под капотом и почему это не потоки

Проблема, из которой растёт тема, формулируется на любом интервью: «почему горутины не являются потоками, и что будет, если запустить их миллион». Ответ без устройства рантайма — пересказ учебника; ответ с пониманием механизма объясняет, почему сервис на Go держит десятки тысяч соединений и не падает.

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

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

Отдельно стоит подчеркнуть, зачем нужна именно такая глубина. Обычных вопросов про горутины хватает джуну; middle и senior спрашивают про устройство, потому что именно оно объясняет аварии, которые не лечатся добавлением мьютекса: внезапный рост памяти, зависшие соединения, «дёрганая» латентность под нагрузкой.

Смежные темы: горутины и каналы — использование, планировщик GMP — распределение по ядрам, модель памяти — что гарантировано при синхронизации.

Как отвечать: определение → как работает внутри → где ловушка → пример. Отвечайте вслух до чтения ответа — собеседование это устный жанр, и «понимаю про себя» не равно «объясняю словами».

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/goroutine

2. Как устроен канал внутри: что такое 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
    }
}

Прокачать тему в самоучителе

Читается быстро, запоминается практикой. Эти курсы закрывают тему с запуском кода прямо на странице: