golang

Внутренности планировщика Go

  • пятница, 15 ноября 2024 г. в 00:00:08
https://habr.com/ru/articles/858490/

В настоящий момент занимаюсь наставничеством разработчиков на языке Golang и один из студентов принес очередной вопрос, который заставил задуматься и вникнуть глубже в устройство планировщика Go.

Верно для go 1.22

Почему данный код всегда будет выводить одинаковый результат?

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 и далее происходит 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 устанавливает количество задействованых P в runtime, которые участвуют в выполнение G и никак не влияют на кол-во доступных M (Thread). M могут добавляться и удаляться при необходимости. Подробнее об этих случаях можно почитать в официальной документации по планировщику.

Копаем дальше

Для более детального понимания картины, решил запустить код в режиме отладки, чтобы посмотреть как он реально выполняется. Результат прилагаю на сринах

Видно, что все G добавлены в очередь и стоят на паузе и лишь последняя добавленная G встала на выполнение в P с назначенным потоком Thread X. Это уже интересно. Чтобы понять почему такое происходит, решил взглянуть как G добавляется на выполнение после вызова 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. В итоге ИИ извинился и исправился, но предоставил какой-то абсурдный вывод.

Добавляю ИИ больше контекста и прошу его взглянуть на исходный код runtime.

В этот раз ИИ попытался подкрепить свой ответ ссылками на исходный код. Но вывод был неверный. В итоге, попросил обратить внимание на runqput и переменную runnext, что ожидаемо привело к верному ответу.

Всем спасибо за внимание!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Знали лы вы об этом нюансе?
7.14% Да1
92.86% Нет13
Проголосовали 14 пользователей. Воздержались 2 пользователя.