сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Горутины и каналы

Горутины в 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, остальные разбираются в тренажёре.

  1. #go_goroutines1 / 5
    main запустил go worker() и сразу вернулся. Что произойдёт с worker?
    A)Горутина доработает в фоне сама — уже после выхода main
    B)Программа автоматически ждёт все запущенные горутины перед выходом
    C)Процесс завершается по выходу main — недоработавшие горутины гибнут
    D)Рантайм откладывает выход main, пока горутина не завершится сама
    показать ответ и разбор
    +C)Процесс завершается по выходу main — недоработавшие горутины гибнут

    // разбор: Когда main возвращается, процесс завершается немедленно, и все ещё живые горутины убиваются, не доработав (их defer не выполнится). Go не ждёт горутины на выходе. Значит нужен явный барьер — sync.WaitGroup или приём из канала, — иначе результат горутины просто теряется. Частый баг новичка: «горутина ничего не напечатала».

  2. #go_goroutines2 / 5
    GOMAXPROCS=1. Могут ли несколько горутин выполняться конкурентно?
    A)Да, конкурентно — но не параллельно: планировщик чередует их на одном потоке
    B)Нет — при GOMAXPROCS=1 вторая инструкция go вернёт ошибку
    C)Да, параллельно на нескольких ядрах — GOMAXPROCS на это не влияет
    D)На I/O-задачах — да; на чистой CPU-нагрузке горутины исполняются строго последовательно, без переключений
    показать ответ и разбор
    +A)Да, конкурентно — но не параллельно: планировщик чередует их на одном потоке

    // разбор: GOMAXPROCS ограничивает число потоков ОС, одновременно исполняющих Go-код, то есть параллелизм. При 1 горутины параллельно не идут, но конкурентность сохраняется: планировщик вытесняет их (на вызовах, операциях с каналами, а с Go 1.14 — асинхронно по таймеру) и чередует. Конкурентность — про структуру задачи, параллелизм — про одновременное исполнение.

  3. #go_goroutines3 / 5
    Чем конкурентность отличается от параллелизма в Go?
    A)Конкурентность — про структуру задач, параллелизм — про одновременное выполнение
    B)Конкурентность работает на одном ядре, параллелизм требует нескольких машин
    C)Конкурентность даёт горутины, параллелизм — потоки операционной системы
    D)Это синонимы: в Go оба термина означают запуск через go
    показать ответ и разбор
    +A)Конкурентность — про структуру задач, параллелизм — про одновременное выполнение

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

  4. #go_goroutines4 / 5
    Почему горутин можно держать сотни тысяч, а потоков ОС — нет?
    A)Горутины выполняются по очереди, а потоки требуют отдельного ядра каждый
    B)Стек горутины стартует с пары килобайт и растёт по мере надобности
    C)Горутины не имеют собственного стека и работают на стеке main
    D)Рантайм ограничивает число потоков, а горутины ограничений не имеют
    показать ответ и разбор
    +B)Стек горутины стартует с пары килобайт и растёт по мере надобности

    // разбор: У потока ОС стек фиксирован и измеряется мегабайтами, поэтому десятки тысяч потоков съедают память и упираются в планировщик ядра. Горутина стартует с крошечного стека, который растёт и сжимается динамически, а рантайм мультиплексирует множество горутин на небольшое число потоков. Дешёвое переключение здесь тоже играет: оно идёт в пользовательском пространстве.

  5. #go_goroutines5 / 5
    Горутина паникует, и recover в ней не вызван. Что произойдёт с программой?
    A)Паника всплывёт в горутину, которая её запустила, и та сможет её перехватить
    B)Горутина завершится, остальная программа продолжит работу
    C)Процесс аварийно завершится целиком
    D)Рантайм перезапустит горутину и повторит её работу
    показать ответ и разбор
    +C)Процесс аварийно завершится целиком

    // разбор: Паника раскручивает стек только своей горутины и, дойдя до его конца без recover, роняет весь процесс. Перехватить чужую панику снаружи нельзя: recover работает лишь в defer той же горутины, где паника случилась. Поэтому в горутины, которые исполняют внешний или ненадёжный код, defer с recover ставят прямо внутри — иначе один сбойный воркер уносит сервис.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.