Конкурентность в Go
Стоимость горутин, планировщик GMP, очереди выполнения, work-stealing, обработка syscall и вытеснение в рантайме Go.
13 вопросов
JuniorТеорияОчень частоЧто такое goroutine и чем она отличается от потока ОС?
Что такое goroutine и чем она отличается от потока ОС?
Goroutine — это лёгкая функция, управляемая runtime Go, а не ОС. runtime мультиплексирует множество горутин на несколько потоков ОС, поэтому они стартуют с крошечным стеком и переключаются в пользовательском пространстве без переключения контекста в ядре.
Типичные ошибки
- ✗Считать, что каждая goroutine отображается 1:1 на поток ОС, а не мультиплексируется на небольшой пул
- ✗Полагать, что переключение горутины стоит как переключение контекста в ядре, а не дешёвое переключение в пользовательском пространстве
- ✗Думать, что горутины не могут работать параллельно, путая конкурентность с однопоточным выполнением
Уточняющие вопросы
- →Как runtime решает, какой поток ОС выполнит конкретную goroutine?
- →Почему начальный стек goroutine намного меньше, чем у потока?
JuniorТеорияЧастоЧем управляет GOMAXPROCS в планировщике Go?
Чем управляет GOMAXPROCS в планировщике Go?
GOMAXPROCS задаёт число P — логических процессоров — поэтому ограничивает, сколько горутин одновременно выполняют код Go параллельно. По умолчанию оно равно числу ядер CPU и не ограничивает, сколько потоков ОС runtime может создать в целом.
Типичные ошибки
- ✗Путать GOMAXPROCS с пределом числа горутин, а не числа параллельных P
- ✗Считать, что оно ограничивает общее число потоков ОС, тогда как ограничено лишь число P потоков, исполняющих код
- ✗Полагать, что повышение GOMAXPROCS выше числа ядер даёт дополнительное параллельное ускорение
Уточняющие вопросы
- →Каково значение GOMAXPROCS по умолчанию и когда его стоит менять?
- →Почему runtime может держать больше потоков ОС, чем значение GOMAXPROCS?
JuniorТеорияЧастоКакого размера стек goroutine при создании и как он растёт?
Какого размера стек goroutine при создании и как он растёт?
Goroutine стартует с маленьким стеком около 8 КБ. Когда функции грозит переполнение, runtime выделяет больший непрерывный стек, копирует туда все кадры, исправляет указатели и освобождает старый — то есть рост происходит копированием, а не сцеплением сегментов.
Типичные ошибки
- ✗Называть стартовым размером стека goroutine значение потока ОС в 1 МБ
- ✗Считать, что стек растёт сцеплением сегментов, а не копированием в новый непрерывный блок
- ✗Полагать, что стек может только расти и runtime никогда не уменьшает его обратно
Уточняющие вопросы
- →Когда и как runtime уменьшает слишком большой стек goroutine?
- →Почему копирование стека требует от runtime переписать указатели на него?
MiddleТеорияЧастоЧто такое context switch, и почему переключение между горутинами дешевле, чем между потоками ОС?
Что такое context switch, и почему переключение между горутинами дешевле, чем между потоками ОС?
Context switch сохраняет состояние одного исполнения (указатель стека, регистры, instruction pointer) и загружает состояние другого. Переключение потока ОС уходит в kernel mode, что дорого. Планировщик Go переключает горутины в user space без захода в ядро, восстанавливая лишь стек и program counter горутины — намного дешевле, хотя и не бесплатно.
Типичные ошибки
- ✗Думать, что переключение горутины заходит в ядро, как переключение потока ОС
- ✗Считать переключение горутин полностью бесплатным, а не просто более дешёвым
- ✗Путать переключение планировщика с паузой stop-the-world сборщика мусора
Уточняющие вопросы
- →Когда планировщик Go всё же передаёт горутину на настоящее переключение потока ОС?
- →Почему блокирующий syscall вынуждает рантайм задействовать планировщик ядра?
MiddleТеорияЧастоОбъясните абстракции G, M и P в планировщике Go.
Объясните абстракции G, M и P в планировщике Go.
G — это goroutine, единица работы. M — поток ОС, единственное, что реально исполняет код. P — логический процессор: контекст планирования с очередью выполнения. Чтобы исполнять код Go, M обязан удерживать P, а число P равно GOMAXPROCS.
Типичные ошибки
- ✗Путать, какая буква — поток, а какая — goroutine: M это поток ОС, G это goroutine
- ✗Считать, что M может исполнять код Go, не захватив сначала P
- ✗Полагать, что P привязан к физическому ядру CPU, а не является логическим контекстом планирования
Уточняющие вопросы
- →Что происходит с P и его очередью, когда удерживающий его M блокируется?
- →Почему число P фиксировано значением GOMAXPROCS, а число M — нет?
MiddleТеорияИногдаПочему GOMAXPROCS должен соответствовать квоте CPU контейнера?
Почему GOMAXPROCS должен соответствовать квоте CPU контейнера?
По умолчанию рантайм ставит GOMAXPROCS равным числу логических CPU хоста, а не квоте CPU из cgroup контейнера. На 64-ядерном хосте с лимитом в 2 ядра он создаёт 64 P, поэтому ядро троттлит процесс через CFS — всплески задержек и лишние переключения контекста. Исправление: задайте значение по квоте или используйте automaxprocs.
Типичные ошибки
- ✗Считать, что рантайм Go читает квоту CPU из cgroup по умолчанию — до 1.25 это не так
- ✗Думать, что больше
P, чем выделено ядер, повышает пропускную способность, а не вызывает троттлинг CFS - ✗Считать, что
GOMAXPROCSограничивает только потоки GC, а не планирование горутин
Уточняющие вопросы
- →Как
automaxprocsопределяет квоту cgroup при старте? - →Что изменилось в Go 1.25 в учёте квоты CPU контейнера?
MiddleТеорияИногдаЧто Go runtime хранит для каждой goroutine?
Что Go runtime хранит для каждой goroutine?
Каждая goroutine — это структура g в runtime, а не поток ОС. В ней есть собственный растущий stack с границами, gobuf, сохраняющий program counter и stack pointer, чтобы её можно было приостановить и возобновить, статус, id и ссылки планировщика.
Типичные ошибки
- ✗Считать goroutine потоком ОС, а не структурой
gruntime, мультиплексируемой на потоки - ✗Забывать, что
gobufсохраняет program counter и stack pointer, чтобы goroutine можно было возобновить - ✗Полагать, что заблокированная goroutine занимает свой поток ОС, а не паркуется в статусе waiting
Уточняющие вопросы
- →Как логический процессор P выбирает следующую runnable
gдля своего OS-потока M? - →Что происходит со статусом goroutine и её ссылками в очереди, когда она блокируется на канале?
MiddleТеорияИногдаЧто такое сетевой поллер (netpoller) и какова его роль в планировщике?
Что такое сетевой поллер (netpoller) и какова его роль в планировщике?
netpoller — это событийный мост рантайма к API готовности ОС (epoll, kqueue, IOCP). Когда goroutine блокируется на сетевом вводе-выводе, рантайм паркует её и регистрирует дескриптор в netpoller, освобождая M для других; когда дескриптор готов, поллер делает goroutine готовой к запуску.
Типичные ошибки
- ✗Думать, что каждое заблокированное сетевое соединение стоит OS-потока — netpoller освобождает
M - ✗Путать сетевую блокировку (через netpoller) с файловой или syscall-блокировкой (
Mостаётся в ядре) - ✗Считать, что netpoller активно опрашивает сокеты, а не ждёт событие готовности от ОС
Уточняющие вопросы
- →Чем файловая блокировка ввода-вывода отличается от сетевой внутри рантайма?
- →Какой поток выполняет ожидание netpoller и когда он опрашивается?
MiddleТеорияИногдаЗачем планировщик держит локальные очереди у каждого логического процессора P вместе с глобальной?
Зачем планировщик держит локальные очереди у каждого логического процессора P вместе с глобальной?
Локальная очередь принадлежит одному P, поэтому большинство операций обходятся без блокировки и остаются в кэше. Глобальная очередь требует блокировки; в ней лежат горутины, вытеснённые из переполненных локальных очередей, и её периодически опрашивают, чтобы они не голодали.
Типичные ошибки
- ✗Думать, что локальная очередь хранит заблокированные горутины — она хранит готовые к запуску, как и глобальная
- ✗Полагать, что каждое извлечение берёт глобальную блокировку, упуская, что доступ к локальной очереди безблокировочный
- ✗Считать, что глобальная очередь проверяется при каждом планировании, а не лишь периодически
Уточняющие вопросы
- →Какова фиксированная вместимость локальной очереди P и что происходит при переполнении?
- →Как часто P опрашивает глобальную очередь и зачем нужен этот интервал?
MiddleТеорияИногдаКак work-stealing перераспределяет горутины между логическими процессорами P в планировщике Go?
Как work-stealing перераспределяет горутины между логическими процессорами P в планировщике Go?
Когда локальная очередь P пустеет, его M пробует глобальную очередь, затем крадёт у другого случайно выбранного P около половины его локальной очереди. Это держит каждый P занятым и балансирует нагрузку без центрального диспетчера.
Типичные ошибки
- ✗Воображать центральный поток-балансировщик вместо того, что каждый простаивающий P крадёт сам
- ✗Думать, что кража берёт одну goroutine, тогда как она забирает около половины очереди жертвы
- ✗Полагать, что P-жертва выбирается по метрикам нагрузки, а не случайно
Уточняющие вопросы
- →Почему крадущий P забирает половину очереди жертвы, а не одну goroutine?
- →Что делает OS-поток M, когда попытка кражи находит очереди всех остальных P пустыми?
SeniorТеорияРедкоКак runtime вытесняет goroutine, застрявшую в плотном цикле без вызовов функций?
Как runtime вытесняет goroutine, застрявшую в плотном цикле без вызовов функций?
С Go 1.14 runtime применяет асинхронное вытеснение: поток sysmon шлёт M этой goroutine сигнал (SIGURG). Обработчик сигнала останавливает goroutine в безопасной точке и перепланирует её, так что цикл без вызовов больше не монополизирует свой P.
Типичные ошибки
- ✗Считать, что сигнал может остановить goroutine на любой инструкции, игнорируя, что приостановка возможна лишь в безопасной точке с согласованными метаданными стека
- ✗Путать роль sysmon: думать, что sysmon сам вытесняет goroutine, а не только помечает её и шлёт сигнал
SIGURG - ✗Ожидать, что асинхронное вытеснение срабатывает мгновенно, а не после того как sysmon заметит, что goroutine работает дольше ~10ms-дедлайна планирования
Уточняющие вопросы
- →Что такое безопасная точка и почему обработчик сигнала не может остановить goroutine где угодно?
- →Какой поток runtime шлёт сигнал вытеснения и с каким интервалом?
SeniorТеорияРедкоПочему параллельные чтения файлов раздувают число OS-потоков, а параллельные чтения сокетов — нет?
Почему параллельные чтения файлов раздувают число OS-потоков, а параллельные чтения сокетов — нет?
Каждое блокирующее чтение файла держит свой M застрявшим в ядре, поэтому рантайм вынужден поднять или взять свежий M, чтобы загрузить переданный P — N параллельных чтений файлов закрепляют до N потоков. Чтение сокета вместо этого паркует goroutine и регистрирует её дескриптор в netpoller, возвращая M планировщику, так что один M обслуживает много ждущих сокетов и число потоков остаётся ровным.
Типичные ошибки
- ✗Считать, что
GOMAXPROCSограничивает все OS-потоки — он ограничиваетP, а блокирующие чтения файлов могут поднять числоMнамного выше - ✗Думать, что всплеск потоков идёт от открытия файлов, а не от того, что каждое блокирующее чтение закрепляет свой
Mв ядре - ✗Ожидать, что параллелизм сокетов тоже растит потоки, упуская, что netpoller позволяет одному
Mобслуживать много припаркованных сокетов
Уточняющие вопросы
- →Как рантайм решает, переиспользовать ли простаивающий
Mили создать новый под переданныйP? - →Какая настройка рантайма ограничивает, сколько
Mмогут создать блокирующие чтения файлов, и каково её значение по умолчанию?
SeniorТеорияРедкоЧто происходит с логическим процессором P, когда его goroutine входит в блокирующий syscall?
Что происходит с логическим процессором P, когда его goroutine входит в блокирующий syscall?
M остаётся заблокированным в ядре вместе с этой goroutine, но сначала отсоединяет свой P. Поток sysmon (или сам M) передаёт освобождённый P другому M — простаивающему или вновь созданному — так что очередь выполнения этого P продолжает исполнять другие горутины.
Типичные ошибки
- ✗Думать, что P остаётся закреплён за заблокированным M, замораживая всю его очередь выполнения
- ✗Считать, что P уничтожается и пересоздаётся, а не передаётся целиком
- ✗Путать настоящий блокирующий syscall с сетевой операцией, припаркованной на netpoller
Уточняющие вопросы
- →Что происходит с OS-потоком M и его goroutine, когда блокирующий syscall наконец возвращается?
- →Чем эта передача отличается от того, как netpoller обрабатывает сетевое чтение?