Внутренности планировщика Go
В настоящий момент занимаюсь наставничеством разработчиков на языке Golang и один из студентов принес очередной вопрос, который заставил задуматься и вникнуть глубже в устройство планировщика Go.
Почему данный код всегда будет выводить одинаковый результат?
func main() {
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i)
wg.Done()
}()
}
wg.Wait()
}
// 4 0 1 2 3
Преамбула
Для ответа на этот вопрос предлагаю сначала углубиться в runtime.GOMAXPROCS и как он устроен. Уверен, что всем Golang разработчикам хорошо известна модель G-M-P, но приведут ссылку на документ от разработчиков планировщика. Из всего документа интересует вот этот абзац
When a new G is created or an existing G becomes runnable, it is pushed onto a list of runnable goroutines of current P. When P finishes executing G, it first tries to pop a G from own list of runnable goroutines; if the list is empty, P chooses a random victim (another P) and tries to steal a half of runnable goroutines from it.
Горутины помещаются в локальный FIFO процессора P. В рамках документа P-processor не означает, что это как-то ассоциировано с CPU, на мой взгляд можно подобрать синоним «Исполнитель», те это набор ресурсов, которые необходимы для выполнения G.
Но здесь ничего не сказано о том, как работает GOMAXPROCS и поэтому решил углубиться в исходники runtime. Для начала посмотрим, что из себя представляет этот вызов runtime.GOMAXPROCS
// GOMAXPROCS sets the maximum number of CPUs that can be executing
// simultaneously and returns the previous setting. It defaults to
// the value of [runtime.NumCPU]. If n < 1, it does not change the current setting.
// This call will go away when the scheduler improves.
func GOMAXPROCS(n int) int {
if GOARCH == "wasm" && n > 1 {
n = 1 // WebAssembly has no threads yet, so only one CPU is possible.
}
lock(&sched.lock)
ret := int(gomaxprocs)
unlock(&sched.lock)
if n <= 0 || n == ret {
return ret
}
stw := stopTheWorldGC(stwGOMAXPROCS)
// newprocs will be processed by startTheWorld
newprocs = int32(n)
startTheWorldGC(stw)
return ret
}
Из комментария видно, что происходит установка переменной newprocs, а в комментариях к коду сказано, что данный метод устанавливает максимальное количество доступных CPU, что подтверждается исходным кодом. Именно установка переменной и не более того.
Далее по коду видно, что это приводит к остановке stopTheWorldGC с указанием причины stwGOMAXPROCS и далее запуском startTheWorldGC.
Теперь предлагаю посмотреть, что происходит дальше внутри startTheWorld
// reason is the same STW reason passed to stopTheWorld. start is the start
// time returned by stopTheWorld.
//
// now is the current time; prefer to pass 0 to capture a fresh timestamp.
//
// stattTheWorldWithSema returns now.
func startTheWorldWithSema(now int64, w worldStop) int64 {
assertWorldStopped()
mp := acquirem() // disable preemption because it can be holding p in a local var
if netpollinited() {
list, delta := netpoll(0) // non-blocking
injectglist(&list)
netpollAdjustWaiters(delta)
}
lock(&sched.lock)
procs := gomaxprocs
if newprocs != 0 {
procs = newprocs
newprocs = 0
}
p1 := procresize(procs)
Меня интересуют больше строчки начиная с 18 (что эквивалентно 1685 в исходном коде). Здесь видно, что устанавливается переменная procs (из названия уже понятно, что это о P) и далее происходит pocresize (procs) . А внутри уже как раз происходит освобождение ранее занятых ресурсов и инициализация новых.
// Change number of processors.
//
// sched.lock must be held, and the world must be stopped.
//
// gcworkbufs must not be being modified by either the GC or the write barrier
// code, so the GC must not be running if the number of Ps actually changes.
//
// Returns list of Ps with local work, they need to be scheduled by the caller.
func procresize(nprocs int32) *p
Таким образом GOMAXPROCS устанавливает задействованых в runtime P, которые как раз и участвуют в планировании и выполнение Goroutines и никак не влияют на кол-во доступных M (Thread) Тк они могут добавляться и удаляться при необходимости. Подробнее об этих случаях можно почитать в официальной документации по планировщику.
Копаем дальше
Для более детального понимания картины, решил запустить код в режиме отладки, чтобы посмотреть как он реально выполняется. Результат прилагаю на сринах
Видно, что Все Goroutines добавлены в очередь и стоят на паузе и лишь последняя добавленная Goroutine встала на выполнение в P с назначенным потоком Thread X. Это уже интересно. ЧТобы понять почему такое происходит, решил взглянуть как Goroutine встает на выполнение после вызова go func (){}. Предлагаю пройти внутрь runtime/proc.go
// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
gp := getg()
pc := sys.GetCallerPC()
systemstack(func() {
newg := newproc1(fn, gp, pc, false, waitReasonZero)
pp := getg().m.p.ptr()
runqput(pp, newg, true)
if mainStarted {
wakep()
}
})
}
Отсюда видно, что go func трансформируется в вызов newproc (*fn), внутри которого создается newproc и далее кладется в локальную очередь runqput. Пока ясно не стало, потому что выглядит, что все должно выводиться из очереди, как описано в документации. Смотрю дальше.
Теперь смотрю, как работает runqput.
// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the pp.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(pp *p, gp *g, next bool)
Меня в этой функции интересует только строка 6730 где видно, что когда передается next, то происходит установка runnext внутри P. А как видно в функции newproc, вызов как раз происходит с значением true. Вот теперь, кажется, что развязка близка. Смотрю, что означает эта переменная runnext.
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice. If a set of goroutines is locked in a
// communicate-and-wait pattern, this schedules that set as a
// unit and eliminates the (potentially large) scheduling
// latency that otherwise arises from adding the ready'd
// goroutines to the end of the run queue.
//
// Note that while other P's may atomically CAS this to zero,
// only the owner P can CAS it to a valid G.
runnext guintptr
Из комментария видно, что если эта переменная установлена, то планировщик берет G на выполнение именно из этой переменной, а после уже смотрит локальную очередь, а при отсутствии работы, лезет в глобальную очередь. Бинго!
Проверяем ИИ на присутствие интеллекта
Решил не останавливаться на достигнутом и проверить, справится ли ИИ с поставленой задачей. Для проверки решил воспользоваться Claude 3.5 Sonnet через сервис Cody. Прежде чем задавать конкретный вопрос, решил добавить предварительного контекста и поспрашивал сеть про GOMAXPROCS, устройство планировщика Golang и другие наводящие вопросы. Получив довольно разумные ответы, задал конкретный вопрос.
Конечно же был предоставлен неверный ответ (ок, частично неверный, тк в Golang < 1.22 скорее всего так и было бы)
Пришлось сообщить, что ответ неверный и попросил исправиться. Но, ИИ решил, что он умный и начал доказывать, что его ответ верный. После чего, ему была предоставлена информация о том, что ответ будет 4 0 1 2 3. В итоге ИИ извинился и исправился, но предоставил какой-то абсурдный вывод.
Больше не стал мучать ИИ и сразу начал направлять его на исходные данные, чтобы он посмотрел в кодик и сделал новое предположение.
Снова мимо, теперь ИИ попытался подкрепить свой ответ ссылками на исходный код. Но вывод был мимо. В итоге, попросил обратить внимание на runqput и переменную runnext, что ожидаемо привело к верному ответу.
Всем спасибо за внимание!