Как происходит переключение контекста между горутинами в Go
Что проверяют
Знаете ли вы, что переключение горутин делает рантайм в пространстве пользователя, а не ядро, и в какие моменты оно вообще возможно. Вопрос отделяет заученное «горутины это лёгкие потоки» от понимания модели GMP.
Как отвечать
- Модель GMP: G это горутина, M это поток ОС, P это логический процессор, их
GOMAXPROCS. Параллельно выполняется столько горутин, сколько есть P. У каждого P своя очередь на 256 элементов и слотrunnext, есть глобальная очередь и воровство работы у соседних P. - Переключение не требует системного вызова: рантайм сохраняет несколько регистров (счётчик команд и указатель стека) в структуре
gи передаёт управление планировщику черезgogo/mcall. Это десятки наносекунд против единиц микросекунд при переключении потоков ядром, а стек горутины начинается с двух килобайт и растёт копированием. - Точки переключения: операции с каналами и мьютексами (
goparkи парнаяgoready), сон, сетевой ввод-вывод через netpoller поверх epoll или kqueue, явныйruntime.Gosched(), проверка вытеснения при входе в функцию. - С Go 1.14 работает асинхронное вытеснение: если горутина занимает P дольше примерно 10 мс, sysmon шлёт потоку сигнал SIGURG, и цикл без вызовов функций больше не подвешивает планировщик.
- Блокирующий системный вызов: M уходит в ядро вместе с G, sysmon примерно через 20 мкс отбирает у него P и отдаёт другому M, поэтому остальные горутины продолжают выполняться. Для сети это не нужно, там работает netpoller.
Где спотыкаются
Говорят, что планировщик периодически опрашивает горутины. Опроса нет: горутина сама паркуется на операции и её будят через goready, а sysmon занимается вытеснением и отъёмом P.
Считают планирование полностью вытесняющим, как в ядре, и не знают, что до версии 1.14 чистый счётный цикл мог удерживать P неограниченно.
Путают блокировку горутины с блокировкой потока. Канал и мьютекс паркуют только G, а вызов через cgo и файловые операции занимают M целиком.
Крутят GOMAXPROCS вместо профилирования. До Go 1.25 рантайм не учитывал лимиты cgroup и в контейнере видел все ядра хоста.