Горутины в Go
Запустим сто тысяч горутин и посмотрим на счётчик: runtime.NumGoroutine показывает 100001, процесс жив и чувствует себя нормально. Теперь прикинь то же самое на потоках операционной системы: в Linux поток по умолчанию получает стек в 8 мегабайт, значит сто тысяч потоков попросят около восьмисот гигабайт. Вся тема - про эту разницу.
Стержень: горутина - задача, которую рантайм сам раскладывает по потокам ОС, а её стек начинается с двух килобайт и растёт по надобности.
// Формулировки: «чем горутина отличается от потока?», «что будет, если main вернулся?», «конкурентность или параллелизм?»
Почему их можно так много
Измерим цену горутины честно. Запускаем сто тысяч штук, все блокируются на канале, и смотрим статистику памяти: стеки выросли на 195,6 мегабайта, куча - на 56,2. Делим на сто тысяч: 2048 байт стека плюс 589 байт служебных структур, итого около 2,6 килобайта на горутину.
Два килобайта - это стартовый размер стека, и он не фиксированный. Когда горутине не хватает места, рантайм выделяет стек вдвое больше, копирует туда содержимое и правит указатели. Поэтому глубокая рекурсия работает, а мелкая функция не платит за чужие потребности.
Переключение тоже другое. Смена потока ОС идёт через ядро с сохранением полного контекста, смена горутины происходит в пользовательском пространстве силами планировщика Go, который сам мультиплексирует тысячи горутин на несколько потоков.
// «Дёшево» не значит «бесплатно». Двести пятьдесят мегабайт за сто тысяч горутин - вполне ощутимая сумма, а если каждая держит буфер или соединение, счёт идёт на гигабайты. Плюс утечка горутин - совершенно реальный класс багов, и растущий runtime.NumGoroutine - первый признак.
- горутина
- лёгкая задача рантайма Go, которую планировщик раскладывает по потокам ОС
- растущий стек
- стартует с 2 КБ и увеличивается копированием, когда места не хватает
Конкурентность против параллелизма
Конкурентность - про структуру программы: она описана как несколько независимо продвигающихся задач. Параллелизм - про физику: задачи реально исполняются одновременно на разных ядрах. Это разные вещи, и путать их на собеседовании - классическая ошибка.
Проверить легко. На машине с двумя ядрами runtime.NumCPU возвращает 2 и GOMAXPROCS по умолчанию тоже 2 - значит одновременно исполняются максимум две горутины, а сто тысяч запущенных просто чередуются. Программа при этом полностью конкурентна и написана правильно.
Обратное тоже верно: параллелизм сам по себе не ускоряет плохо структурированную программу. Если все горутины дерутся за один мьютекс, восемь ядер не помогут - они будут стоять в очереди.
// С Go 1.14 планировщик вытесняет горутины асинхронно, по сигналу от рантайма. До этого плотный счётный цикл без вызовов функций мог удерживать поток сколько угодно и мешал даже сборщику мусора. Сейчас такой цикл прервут принудительно.
- GOMAXPROCS
- сколько горутин могут исполняться параллельно; по умолчанию равно числу ядер
Жизненный цикл: main не ждёт никого
Горутина живёт ровно до возврата из своей функции. И есть жёсткое правило сверху: когда возвращается main, процесс завершается немедленно, не дожидаясь никого. Запустил горутину и вернулся из main - она может не выполниться вовсе, и это не гонка, а гарантированное поведение.
Отсюда необходимость явно дожидаться: sync.WaitGroup, канал завершения, контекст. time.Sleep в качестве ожидания - признак того, что человек не разобрался: он и не гарантирует ничего, и замедляет.
Второе правило неприятнее. Паника внутри горутины не перехватывается снаружи. Проверено: recover в main не спасает - процесс падает целиком с трассировкой той горутины, где рвануло, и строчка после ожидания не печатается никогда.
// Практический вывод: у каждой долгоживущей горутины, особенно обрабатывающей внешний ввод, свой defer с recover внутри неё самой. Иначе одна кривая запись из очереди роняет весь сервис.
go func() { panic("внутри горутины") }()
time.Sleep(time.Second)
// recover в main НЕ поможет: процесс падает целиком
// panic: внутри горутины
// goroutine 10 [running]: ...Как отвечать: «Чем горутина отличается от потока ОС?»
Тремя вещами: ценой, стеком и тем, кто её переключает. Поток ОС получает фиксированный стек - в Linux по умолчанию восемь мегабайт - и переключается через ядро. Горутина стартует со стека в два килобайта, который растёт копированием по мере надобности, и переключается планировщиком Go в пользовательском пространстве. Я мерил: сто тысяч горутин занимают около двухсот пятидесяти мегабайт, то есть примерно 2,6 килобайта на штуку - две тысячи стека плюс служебные структуры. Сто тысяч потоков ОС попросили бы сотни гигабайт, поэтому их и не бывает. Рантайм мультиплексирует горутины на небольшое число потоков, а сколько из них исполняется физически одновременно, задаёт GOMAXPROCS - по умолчанию по числу ядер. Отсюда же и разница между конкурентностью и параллелизмом: конкурентность - про структуру программы, параллелизм - про одновременное исполнение. И я бы добавил, что дёшево не значит бесплатно: горутина держит стек и запись в планировщике, утечка горутин - реальный класс багов, а паника внутри горутины роняет весь процесс, если не перехвачена там же.
Три оси сравнения с измеренными числами, разведены конкурентность и параллелизм, и сразу названы две практические опасности - видно, что человек эти горутины запускал в проде.
На чём валят
- −Считают горутину «лёгким потоком ОС» - у неё другой стек, другой планировщик и другая цена.
- −Ждут завершения горутин через time.Sleep вместо WaitGroup или канала.
- −Забывают, что возврат из main убивает процесс, не дожидаясь запущенных горутин.
- −Думают, что панику в горутине можно перехватить снаружи - нельзя, падает весь процесс.
- −Путают конкурентность и параллелизм и обещают ускорение от GOMAXPROCS там, где всё стоит на мьютексе.
- −Считают горутины бесплатными: сто тысяч штук - это четверть гигабайта на пустом месте.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 10, остальные разбираются в тренажёре.
- main запустил go worker() и сразу вернулся. Что произойдёт с worker?A)Горутина доработает в фоне сама — уже после выхода mainB)Программа автоматически ждёт все запущенные горутины перед выходомC)Процесс завершается по выходу main — недоработавшие горутины гибнутD)Рантайм откладывает выход main, пока горутина не завершится сама
показать ответ и разбор
+C)Процесс завершается по выходу main — недоработавшие горутины гибнут// разбор: Когда main возвращается, процесс завершается немедленно, и все ещё живые горутины убиваются, не доработав (их defer не выполнится). Go не ждёт горутины на выходе. Значит нужен явный барьер — sync.WaitGroup или приём из канала, — иначе результат горутины просто теряется. Частый баг новичка: «горутина ничего не напечатала».
- GOMAXPROCS=1. Могут ли несколько горутин выполняться конкурентно?A)Да, конкурентно — но не параллельно: планировщик чередует их на одном потокеB)Нет — при GOMAXPROCS=1 вторая инструкция go вернёт ошибкуC)Да, параллельно на нескольких ядрах — GOMAXPROCS на это не влияетD)На I/O-задачах — да; на чистой CPU-нагрузке горутины исполняются строго последовательно, без переключений
показать ответ и разбор
+A)Да, конкурентно — но не параллельно: планировщик чередует их на одном потоке// разбор: GOMAXPROCS ограничивает число потоков ОС, одновременно исполняющих Go-код, то есть параллелизм. При 1 горутины параллельно не идут, но конкурентность сохраняется: планировщик вытесняет их (на вызовах, операциях с каналами, а с Go 1.14 — асинхронно по таймеру) и чередует. Конкурентность — про структуру задачи, параллелизм — про одновременное исполнение.
- Чем конкурентность отличается от параллелизма в Go?A)Конкурентность — про структуру задач, параллелизм — про одновременное выполнениеB)Конкурентность работает на одном ядре, параллелизм требует нескольких машинC)Конкурентность даёт горутины, параллелизм — потоки операционной системыD)Это синонимы: в Go оба термина означают запуск через go
показать ответ и разбор
+A)Конкурентность — про структуру задач, параллелизм — про одновременное выполнение// разбор: Конкурентность — способ описать программу как независимо продвигающиеся задачи; она возможна и на одном ядре, где рантайм просто переключает горутины. Параллелизм — физически одновременное исполнение на нескольких ядрах, и его включает GOMAXPROCS. Поэтому конкурентная программа не обязана быть параллельной, а от параллелизма она сама по себе не ускоряется.
- Почему горутин можно держать сотни тысяч, а потоков ОС — нет?A)Горутины выполняются по очереди, а потоки требуют отдельного ядра каждыйB)Стек горутины стартует с пары килобайт и растёт по мере надобностиC)Горутины не имеют собственного стека и работают на стеке mainD)Рантайм ограничивает число потоков, а горутины ограничений не имеют
показать ответ и разбор
+B)Стек горутины стартует с пары килобайт и растёт по мере надобности// разбор: У потока ОС стек фиксирован и измеряется мегабайтами, поэтому десятки тысяч потоков съедают память и упираются в планировщик ядра. Горутина стартует с крошечного стека, который растёт и сжимается динамически, а рантайм мультиплексирует множество горутин на небольшое число потоков. Дешёвое переключение здесь тоже играет: оно идёт в пользовательском пространстве.
- Горутина паникует, и recover в ней не вызван. Что произойдёт с программой?A)Паника всплывёт в горутину, которая её запустила, и та сможет её перехватитьB)Горутина завершится, остальная программа продолжит работуC)Процесс аварийно завершится целикомD)Рантайм перезапустит горутину и повторит её работу
показать ответ и разбор
+C)Процесс аварийно завершится целиком// разбор: Паника раскручивает стек только своей горутины и, дойдя до его конца без recover, роняет весь процесс. Перехватить чужую панику снаружи нельзя: recover работает лишь в defer той же горутины, где паника случилась. Поэтому в горутины, которые исполняют внешний или ненадёжный код, defer с recover ставят прямо внутри — иначе один сбойный воркер уносит сервис.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.