На просторах интернета наткнулся на интересный код, который заставил задуматься и вникнуть глубже в устройство планировщика 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, что ожидаемо привело к верному ответу.

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

Комментарии (3)


  1. evgeniy_kudinov
    14.11.2024 10:22

    Полезно, спасибо. Только в начале текста укажите, пожалуйста, версию 1.22, чтобы было понятнее.
    Цитата:"начиная с 18 (что эквивалентно 1685 в исходном коде)" - думаю можно нумерацию во фрагментах как в исходниках приводить и в тексте на него указывать.
    И в конце текста с ботом непонятно зачем, как будто мусор не по теме.


  1. artemmoscow
    14.11.2024 10:22

    Мне о1 ответил на первом запросе вот что

    В приведённом коде на языке Go используется цикл for, в котором запускаются пять горутин. Каждая горутина выводит значение переменной i и завершает работу через wg.Done(). Однако переменная i захватывается замыканием по ссылке, а не по значению. Это означает, что все горутины обращаются к одной и той же переменной i, которая изменяется в цикле.

    Вот почему код всегда выводит результат 4 0 1 2 3:

    1. Установка GOMAXPROCS(1):

      • Эта установка ограничивает количество операционных системных потоков, используемых для выполнения горутин, одним. То есть все горутины будут выполняться последовательно на одном потоке.

    2. Запуск горутин в цикле:

      • Цикл for i := 0; i < 5; i++ быстро запускает пять горутин.

      • Поскольку GOMAXPROCS установлен в 1, горутины не могут выполняться параллельно. Вместо этого они ставятся в очередь и выполняются по очереди.

    3. Поведение замыкания:

      • Каждая горутина захватывает переменную i по ссылке. Это означает, что они все обращаются к одной и той же переменной i, которая изменяется в цикле.

      • Поскольку GOMAXPROCS равен 1, первая горутина начинает выполняться только после того, как цикл завершится и i станет равным 5. Однако в последней итерации цикла i равен 4, так как цикл заканчивается, когда i достигает значения 5.

    4. Порядок выполнения горутин:

      • Первая запущенная горутина увидит значение i=4, так как она начинает выполняться после завершения цикла.

      • Остальные горутины начинают выполняться до того, как i успевает измениться на 5, и видят значения i=0, 1, 2, 3 соответственно.

    Таким образом, вывод всегда будет следующим:

    Копировать код4
    0
    1
    2
    3
    

    Как избежать этой проблемы: Чтобы каждая горутина захватывала текущее значение i на момент запуска, можно передать i как параметр в анонимную функцию:

    goКопировать кодfor i := 0; i < 5; i++ {
        go func(n int) {
            fmt.Println(n)
            wg.Done()
        }(i)
    }
    

    В этом случае каждая горутина будет иметь своё собственное копию значения i, и вывод будет ожидаемым: 0, 1, 2, 3, 4.


    1. ekubyshin Автор
      14.11.2024 10:22

      лукавит