🧠 Планировщик Go (GMP): Как 100 000 горутин работают на 4 ядрах Джуны часто думают, что горутины это магия.
Джуны часто думают, что горутины это магия. Написал go func(), и оно как-то само параллелится. Сеньоры знают, что под капотом работает безжалостная и гениальная машина Go Scheduler.
Понимание того, как он работает, спасает от жестких просадок производительности в Kubernetes. Погнали в хардкор.
ОС (Linux/Windows) ничего не знает про горутины. Она умеет работать только с потоками ОС (OS Threads). Но потоки ОС тяжелые: у них большой стек (1-8 МБ) и их очень дорого переключать (context switch). Если создать 100 000 потоков ОС, ваш сервер умрет.
Поэтому создатели Go написали свой планировщик (уровня user-space), который работает по модели G-M-P.
• G (Goroutine) - Сама горутина. Легкая (стек всего ~2 КБ), содержит код, который нужно выполнить.
• M (Machine) - Поток операционной системы. Именно он выполняет инструкции на процессоре.
• P (Processor) - Логический процессор / Контекст. У него есть локальная очередь (Local Run Queue) из горутин (G), которые ждут выполнения.
Количество P строго ограничено и по умолчанию равно количеству ядер вашего процессора (переменная GOMAXPROCS).
Как это работает в динамике?
Поток ОС (M) берет логический процессор (P), достает из его очереди горутину (G) и начинает её выполнять.
Если горутина делает сетевой запрос или засыпает (time.Sleep), поток M не блокируется! Он откладывает эту G в сторону, берет из очереди P следующую G и продолжает работу. Безотходное производство.
А что если горутина сделала тяжелый системный вызов (CGO или чтение файла)?
Тут поток M неизбежно блокируется на уровне ОС.
В этот момент планировщик Go делает финт ушами: он отрывает логический процессор P от заблокированного M, создает (или берет из пула) новый поток ОС M_new, прикрепляет к нему этот P и продолжает выполнять оставшиеся в очереди горутины. Старый M остается спать вместе со своей системной горутиной.
🔥 Киллер-фича: Work Stealing (Кража работы)
Представьте, что один P быстро выполнил все свои горутины, а у другого P в очереди их еще 100. Чтобы ядра не простаивали, свободный P пойдет к соседу и украдет у него ровно половину горутин из очереди. Коммунизм в чистом виде.
☝️ Senior Tip: Ловушка Kubernetes и CFS Quota
Вот ради чего всё это писалось.
Если вы деплоите Go-сервис в Docker/K8s и ставите ему лимит limits.cpu: "2", вы думаете, что приложению дадут 2 ядра.
Но Go при старте смотрит не на лимиты контейнера, а на железо хост-машины! Если на ноде 64 ядра, Go создаст 64 P (логических процессора) и наплодит 64 потока ОС.
Linux увидит, что контейнер с лимитом в 2 ядра пытается выполнить 64 потока, и начнет их жестко троттлить (душить). Ваш сервис начнет дико тормозить на ровном месте.
Лечение:
Всегда синхронизируйте GOMAXPROCS с лимитами контейнера. Лучший способ - добавить один импорт в main.go, который сделает это автоматически при старте:
import _ "go.uber.org/automaxprocs"
#golang #underhood #architecture #performance #devops
👉 @golang_lib