Go goroutine switching

Мне часто доводиться консультировать коллег по Go. И однажды был задан такой вопрос:

Есть простая функция consumer которая получает небуфферизированный канал, считывает из него значение и выводит в stdout.

func consumer(ch <-chan int) {
a := <- ch
fmt.Println(a)
}

Запустим ее в горутине:

go consumer(ch1)

Многие Go-разработчики знают, что если мы пытаемся читать из небуферизированного канала в котором нет данных, горутина будет переведена в режим ожидания и снята с исполняющего потока ОС.

Но как runtime Go узнаёт, с какого места программы нужно продолжить исполнение горутины, когда в канал ch1 будут переданы данные?

Сопутствующий вопрос может быть: Как восстанавливаются значения локальных переменных?

Ответ на второй вопрос, достаточно прост если посмотреть на описание структуры горутины:

type g struct {
stack stack
...
}

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

Ответ на первый вопрос найти несколько сложнее ведь он кроется в недрах компилятора и ассемблерного кода. Причем в Go используется свой собственный ассемблер пришедший из Plan9.

И так прежде, чем понять, что происходит когда планировщик решает снять горутину с исполнения, вспомним что вообще представляет из себя код программы на низком уровне. Выполним:

$ go build -gcflags -S main.go

и увидем что то вроде такого:

"".consumer STEXT size=182 args=0x8 locals=0x58
0x0000 00000 (main.go:5) TEXT "".consumer(SB), $88-8
0x0000 00000 (main.go:5) MOVQ (TLS), CX
0x0009 00009 (main.go:5) CMPQ SP, 16(CX)
0x000d 00013 (main.go:5) JLS 172
0x0013 00019 (main.go:5) SUBQ $88, SP
0x0017 00023 (main.go:5) MOVQ BP, 80(SP)
0x001c 00028 (main.go:5) LEAQ 80(SP), BP

Как видим наш код представлен в виде набора простых инструкций, каждая из которых имеет свой адрес в памяти. И чтобы условный процессор выполнил команду, её адрес должен быть помещен в специальный регистр. В Go-ассемблере для этого используется псевдо-регистр PC (Program Counter).

Взглянем еще раз на структуру горутины

type g struct {
stack stack
gopc uintptr
...
}

gopc — именно сюда сохраняется адрес текущий инструкции на которой остановилось выполнение горутины.

Таким образом получаем такой алгоритм (очень упрощенный):

  1. В горутине происходит блокирующий вызов
  2. Планировщик сохраняет текущие значение регистра PC в поле gopc
  3. Извлекает из очерди следующую горутину
  4. Восстанавливает значение PC-регистра значением gopc из структуры горутины.

Те кто хорошо знакомы с планировщиком какой либо ОС, этот механизм покажется очень знакомым и будут правы. Различия все же есть. Так реальному планировщику нужно восстанавливать и сохранять больше 16 регистров, то Go обходится всего 3-мя (Program Counter, Stack Pointer и DX).