golang

Горутины изнутри, часть 1

  • пятница, 14 августа 2026 г. в 00:00:27
https://habr.com/ru/articles/1070050/

Эта серия для тех, кто выучил синтаксис Go, написал первые go func() и хочет понять, что за ними стоит. Многопоточность знать не нужно: начнем с того, что такое поток. К концу первой части ты будешь понимать, почему поток операционной системы дорогой, что такое горутина физически и как Go пришел к схеме GMP.

План серии:

  • Почему потоки ОС не масштабируются и как Go придумал GMP

  • Переезжающий стек, сигнал SIGURG и жизнь горутины. Скоро…

  • syscall, netpoll, полный круг планировщика и свежий Go Скоро…

TL;DR для сканирующих: поток создает ядро ОС, он резервирует мегабайты адресного пространства, переключается за микросекунды, а его количество упирается в лимиты системы. Горутина живет в памяти процесса, стартует с 2 КБ и переключается за сотню наносекунд. Схема GMP появилась в Go 1.1 как ответ на общий лок, в который упирались все ядра. В Go 1.2 рантайм научился прерывать жадные горутины, но только на вызове функции, и дыра этого трюка проживет шесть лет.

Начну с демонстрации. Программа goroutine_cost запускает миллион горутин, каждая виснет на приеме из канала, и считает память:

// goroutine_cost/main.go (фрагмент)
done := make(chan struct{})
for i := 0; i < 1_000_000; i++ {
	go func() { <-done }()
}

Вывод на моей машине (Intel Core i9-14900KF, Linux, Go 1.26, тут и дальше; сокращено):

NumGoroutine    = 1000001
StackInuse      = 1954.9 MB
на горутину     = 2049 байт стека + 601 байт кучи
Sys (у ОС)      = 2634.4 MB

Миллион одновременных задач за 2.6 ГБ, и машина этого даже не заметила.

Теперь поиграем в Вангу, первый вопрос из этой серии. Я попрошу Claude Code написать такой же цикл на C, только вместо горутин он будет создавать потоки операционной системы. Сколько потоков успеет создаться до первой ошибки? Миллион? Сто тысяч? Несколько тысяч? Запомни свой прогноз, проверим его через несколько минут.

Сразу отвечу и на вопрос, который задают все: в какой версии Go появились горутины. Ни в какой. Первый коммит планировщика датирован 14 июля 2008 года, почти за полтора года до того, как Go вообще показали публике. Отправил его Кен Томпсон, один из создателей языка.

Горутины и каналы были в языке с первого дня. Менялась не семантика go f(), а ее цена, и вот эта история изменений куда интереснее вопроса «в какой версии».

Маршрут серии по годам и релизам
Маршрут серии по годам и релизам

План серии такой. Первая часть: что такое поток, что такое горутина и как Go дошел от «все под одним мьютексом» до GMP. Вторая: стек, который переезжает по памяти, вытеснение сигналом, жизнь горутины от рождения до пула мертвых и то, почему блокировка на канале почти бесплатна. Третья: системные вызовы, сеть, полный круг планировщика, сборщик мусора и все свежее из Go 1.22-1.27. В конце серии вывод GODEBUG=schedtrace будет читаться как обычный лог.

Все замеры в тексте сделаны на одной машине (Linux, характеристики писал выше, в примере с миллионом горутин), код всех демо лежит в репозитории dsbasko/sandbox-gorutines. Где на macOS картина принципиально другая, я оговариваю это рядом. Цитаты из исходников и коммитов оставляю на английском и перевожу своими словами рядом. У каждой цитаты стоит ссылка на файл и строку, и ведет она на тег релиза, а не на главную ветку. Внутри тега номера строк неизменны; исходники цитирую по Go 1.27, точнее по кандидату go1.27rc2.

Поток: за что платит ядро

Чтобы понять, от чего Go отталкивался, понадобится несколько терминов. Программа, которую ты запустил, становится процессом: у него своя память, свои открытые файлы, свой идентификатор. Код внутри процесса исполняют потоки. Потоком (thread) называют независимую последовательность выполнения: свой стек, свой набор регистров, а память процесса общая на всех.

Стеком называют кусок памяти, где живут локальные переменные функций и адреса возврата: при вызове функции он подрастает, при возврате опадает. Регистры (маленькие сверхбыстрые ячейки прямо в процессоре) хранят то, с чем процессор работает прямо сейчас, включая указатель на текущую инструкцию и вершину стека.

Ядер у процессора меньше, чем потоков в системе, поэтому в ядре ОС живет планировщик: он решает, какой поток сейчас бежит на каком ядре, а какой ждет своей очереди. Каждые несколько миллисекунд он снимает один поток и ставит другой. Этот механизм и делает возможным «одновременный» браузер, музыку и компилятор на четырех ядрах.

Процесс, потоки и планировщик ядра
Процесс, потоки и планировщик ядра

Ключевое слово выше: «в ядре ОС». Поток нельзя завести силами самой программы, это просьба к ядру. Такая просьба называется системным вызовом (syscall): процессор переключается в привилегированный режим, ядро выполняет работу, возвращает управление. На Linux потоки создает системный вызов clone(2), и рантайм Go зовет его напрямую, без прослоек. Вот дословный список флагов из исходников (os_linux.go:156):

// src/runtime/os_linux.go:156 (Go 1.27)
cloneFlags = _CLONE_VM | /* share memory */
	_CLONE_FS | /* share cwd, etc */
	_CLONE_FILES | /* share fd table */
	_CLONE_SIGHAND | /* share sig handler table */
	_CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */
	_CLONE_THREAD /* revisit - okay for now */

Читается как определение потока: раздели с родителем память, каталоги, файлы и обработчики сигналов, а стек и регистры заведи свои. А несколькими строками ниже рантайм обрабатывает отказ ядра (os_linux.go:195):

// src/runtime/os_linux.go:195 (Go 1.27), обработка ошибки clone
print("runtime: failed to create new OS thread ...")
if ret == _EAGAIN {
	println("runtime: may need to increase max user processes (ulimit -u)")
}

«Возможно, придется поднять лимит процессов пользователя». Авторы рантайма прямо в коде признают, что упереться в лимит потоков реально. Почему лимиты вообще существуют?

Память: восемь мегабайт за штуку

Под стек каждого потока Linux резервирует кусок виртуальной памяти. Размер берется из лимита, который показывает команда ulimit -s, а дефолт этого лимита зашит в ядре константой STKLIM с почти извиняющимся комментарием: «8MB seems reasonable» (8 МБ выглядят разумно).

Виртуальной эта память называется потому, что физические страницы под зарезервированные адреса подкладываются только при первом обращении. Сами 8 МБ почти бесплатны, но учет в ядре ведется сразу.

Я померил это на десяти тысячах спящих потоков (демо pthread_cost):

потоков         = 10000
VmSize delta    = 80041.8 MB (8196 КБ на поток)
VmRSS delta     = 81.1 MB (8.3 КБ на поток)

Восемьдесят гигабайт адресного пространства против восьмидесяти мегабайт настоящей памяти. Ходовое «поток стоит восемь мегабайт» верно только про адреса: реально поток занимает 8.3 КБ. Эли Бендерский получил ровно тот же расклад на своей машине еще в 2018 году.

На macOS дефолт для новых потоков другой, 512 КБ, и там ulimit -s описывает только главный поток. Эти два числа в интернете постоянно смешивают, поэтому оговорка.

Количество: ответ на предсказание

Обещанный цикл на C целиком (демо pthread_limit), каждый поток засыпает навсегда:

// pthread_limit/main.c (фрагмент)
pthread_t t;
for (int i = 1;; i++) {
	int err = pthread_create(&t, NULL, idle, NULL);
	if (err != 0) {
		printf("поток #%d не создался: %s (errno %d)\n", i, strerror(err), err);
		return 0;
	}
	if (i % 1000 == 0)
		printf("создано %d потоков...\n", i);
}

Запускаю:

gcc -O2 -o /tmp/pthread_limit pthread_limit/main.c -lpthread
/tmp/pthread_limit
создано 73000 потоков...
создано 74000 потоков...
создано 75000 потоков...
поток #75275 не создался: Resource temporarily unavailable (errno 11)

75 275. Не миллион, но и не четыре тысячи. Самое интересное здесь не само число, а кто его назначил: ни одного лимита с таким значением в ядре нет. Отсекает systemd, который каждому сеансу выдает TasksMax в 15% от системного потолка задач:

systemctl show -p DefaultTasksMax --value
75299
Лестница лимитов на число потоков
Лестница лимитов на число потоков

Дальше начинается лестница. Снимаешь лимит systemd, упираешься в ulimit -u (лимит задач пользователя, на этой машине 250 997). Поднимаешь его, упираешься в /proc/sys/kernel/threads-max (501 995) и в память под стеки. Проверяю первую ступеньку:

systemd-run --user --scope -q -p TasksMax=infinity \
    bash -c 'ulimit -u 150000; /tmp/pthread_limit'
поток #149273 не создался: Resource temporarily unavailable (errno 11)

Сто пятьдесят тысяч потоков Linux создал не поморщившись. Так что «поток дорогой» сказано не про количество: на macOS потолок в 4096 действительно жесткий, а здесь его можно раздвинуть настройками. Дорог поток по двум другим статьям, память и время. Время сейчас и посчитаем.

Время: микросекунды на переключение

Осталась третья цена. Когда планировщик ОС снимает поток с ядра процессора и ставит другой, происходит переключение контекста. Ядро сохраняет все регистры уходящего, восстанавливает регистры приходящего, переключает таблицы памяти, и все это в привилегированном режиме. Я померил на двух потоках, которые перекидываются «мячиком» (демо ctx_switch_c): каждый по очереди будит соседа и засыпает сам, в одном круге два переключения.

gcc -O2 -o /tmp/ctx_switch_c ctx_switch_c/main.c -lpthread
/tmp/ctx_switch_c
пинг-понг через condvar: 2720 нс на круг, ~1360 нс на переключение
pthread_create + join:   13201 нс

Почти полторы микросекунды на переключение и 13 микросекунд на цикл «создать поток и дождаться его завершения». У Бендерского вышло 1.2-1.5 мкс с привязкой потоков к ядрам и около 2.2 мкс без нее: порядок тот же. И это еще оптимистичная оценка. Микробенчмарк не ловит испорченные кеши процессора, которые после переключения приходится греть заново.

Соберем картину. Поток стоит мегабайты адресного пространства, полторы микросекунды на переключение, и его количество упирается в лимиты системы. А теперь вспомни, как выглядит типичный сервер: десять тысяч открытых соединений, по каждому изредка прилетают данные. Заводить по потоку на соединение означает упереться в потолок ядра еще до всякой нагрузки.

Эту задачу каждый язык решает по-своему: колбэки, async/await, акторы. Go ответил горутинами.

Горутина: структура вместо потока

Горутина не поток и даже не «легкий поток», как пишут в половине статей. Легкими (или зелеными) потоками называют единицы исполнения, которые тасует рантайм языка, а ядро ОС о них не знает. Имя пришло из ранней Java, где все зеленые потоки программы жили внутри одного потока ОС; так же устроены процессы Erlang, задачи Tokio и виртуальные потоки Java 21. Про цену ярлык не врет, про устройство врет: он обещает уменьшенную копию потока, где все на месте, только дешевле. А у горутины нет ни приоритета, ни публичного идентификатора, снять ее извне тоже нельзя, и в htop ты ее не найдешь.

Физически горутина состоит из структуры данных по имени g в памяти твоего же процесса и маленького стека, который рантайм выделяет из кучи Go. Кучей называют память, которой программа распоряжается сама: в Go ей управляет рантайм, туда попадают твои объекты, и туда же, рядом с ними, ложатся стеки горутин. Вот создание горутины в исходниках (proc.go:5311):

// src/runtime/proc.go:5311 (Go 1.27), сокращено
// Allocate a new g, with a stack big enough for stacksize bytes.
func malg(stacksize int32) *g {
	newg := new(g)
	if stacksize >= 0 {
		stacksize = round2(stackSystem + stacksize)
		systemstack(func() {
			newg.stack = stackalloc(uint32(stacksize))
		})
	}

Обрати внимание, чего здесь нет: системного вызова. Ядро ОС о создании горутины не узнает вообще. stackalloc берет память из хозяйства самого рантайма, и стартовый размер этой памяти задан константой stackMin = 2048 (stack.go:78). Два килобайта против восьми мегабайт у потока: в четыре тысячи раз меньше.

Отсюда и числа из вступления: миллион горутин по 2049 байт стека и 601 байт кучи на штуку уместился в 2.6 ГБ. Стек горутины при этом не приговор на всю жизнь. Он умеет расти и переезжать по памяти, и во второй части мы разберем этот механизм по винтикам.

Переключение за шесть полей

С памятью разобрались, теперь скорость. Помнишь, что делает ядро при переключении потока: полный набор регистров туда, полный обратно, таблицы памяти, привилегированный режим. А вот все, что рантайм Go хранит о приостановленной горутине (runtime2.go:303):

// src/runtime/runtime2.go:303 (Go 1.27), сокращено
type gobuf struct {
	sp   uintptr        // вершина стека
	pc   uintptr        // текущая инструкция
	g    guintptr       // ссылка на свою горутину
	ctxt unsafe.Pointer // замыкание
	lr   uintptr        // адрес возврата (ARM)
	bp   uintptr        // указатель кадра
}

Шесть полей. Восстанавливает горутину по этой структуре функция gogo: ассемблер, пятнадцать инструкций на amd64, а подпись в исходниках лишена ложной скромности (asm_amd64.s:401): «restore state from Gobuf; longjmp» (восстановить состояние из Gobuf; прыгнуть).

Почему так мало? Ядро обязано уметь прервать поток в любой точке, поэтому сохраняет все. Рантайм Go переключает горутины в известных ему точках, где компилятор и так уже сложил лишние регистры на стек по соглашению о вызовах. Сохранять почти нечего.

Теперь тот же пинг-понг, что я гонял на потоках, но на двух горутинах и канале (демо ctx_switch):

go build -o /tmp/ctx_switch ./ctx_switch
/tmp/ctx_switch
пинг-понг через канал: 213 нс на круг, ~106 нс на переключение
создание горутины:     390 нс

Сведем в одно место. Переключение: ~106 нс у горутины против ~1360 нс у потока, в тринадцать раз дешевле. Создание (вместе с ожиданием завершения): 390 нс против 13 201 нс, в тридцать четыре раза. На macOS разрыв шире (тот же замер на M3 Pro дает 23 и 44 раза), потому что линуксовые pthread-примитивы работают через futex и сами по себе быстрее. Пропасть между «сотня наносекунд» и «полторы микросекунды» никуда при этом не девается.

Два этажа переключений: горутины и потоки
Два этажа переключений: горутины и потоки

Одна оговорка про это железо. У моего процессора ядра разного сорта: восемь производительных и шестнадцать энергоэффективных. Тот же замер, прибитый к энергоэффективному ядру, дает 142 нс на переключение вместо 107 на производительном. Планировщик Linux обычно кладет такую задачу на производительное ядро, но разброс в тридцать процентов между прогонами на гибридном процессоре считается нормой, а не ошибкой замера.

И заметь: замер горутин шел при GOMAXPROCS=1, на одном ядре. Дешевизна горутин не из параллельности, а из того, что ядро ОС в этом процессе не участвует.

Кто исполняет горутину

Здесь пора убить заблуждение, которое портит новичкам всю картину: «горутина выполняется сама». Не выполняется. Горутина, как мы выяснили, структура данных, а исполнять код умеет только процессор, на который ядро ОС ставит потоки.

Go не отменил потоки. Go перестал заводить по потоку на задачу. В исходниках это правило прибито гвоздями (proc.go:3356):

// src/runtime/proc.go:3356 (Go 1.27), функция execute
// Assign gp.m before entering _Grunning so running Gs have an M.
mp.curg = gp
gp.m = mp

Перевод: прежде чем пометить горутину бегущей, привяжи ее к потоку, чтобы у всякой бегущей горутины был поток. В терминах рантайма поток называется M (machine), горутина G. И есть третья буква: P, processor. Это не физический процессор, а право исполнять Go-код. Пока поток не подержит P, пользовательский код ему недоступен, и таких прав в системе ровно GOMAXPROCS.

Служебный документ рантайма HACKING.md формулирует роль планировщика одной фразой, лучше которой я не встречал (HACKING.md:33):

The scheduler’s job is to match up a G (the code to execute), an M (where to execute it), and a P (the rights and resources to execute it).

Работа планировщика: свести G (что исполнять), M (где исполнять) и P (по какому праву). Вот и вся расшифровка аббревиатуры GMP, которой пугают на собеседованиях. Зачем понадобилась третья сущность P и почему не хватило пары «горутины и потоки», станет ясно чуть позже. Появилась эта буква не сразу и не от хорошей жизни.

GMP: путь go f() до ядра процессора
GMP: путь go f() до ядра процессора

Важное следствие: GOMAXPROCS ограничивает параллелизм, а не число горутин. Документация рантайма говорит это прямым текстом (extern.go:237):

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously.

Лимита на количество горутин в рантайме нет вообще. Для потоков есть константа-предохранитель (10 000, встретимся с ней в третьей части), для горутин парной константы не существует: их число ограничено только памятью. GOMAXPROCS - это ширина дороги, а не длина очереди машин перед ней.

Проверим всю конструкцию разом. Вопрос на предсказание: пустая Go-программа держит несколько потоков ОС, на моей машине четыре или пять (да, даже hello world многопоточен: часть потоков рантайм заводит для своих нужд). Я запускаю в ней 100 000 горутин, каждая блокируется навсегда (демо threads_count). Сколько станет потоков?

go build -o /tmp/threads_count ./threads_count
/tmp/threads_count
старт:  1 горутина, 5 потоков ОС
теперь: 100001 горутин, 11 потоков ОС

Одиннадцать. Сто тысяч горутин обошлись системе в шесть дополнительных потоков, и это на процессоре, где GOMAXPROCS равен 32. От прогона к прогону выходит то 11, то 33: рантайм заводит потоки под освободившиеся P, а не под горутины. Порядок в любом случае один: десятки потоков на сотню тысяч задач.

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

Вот это соотношение (сто тысяч задач на горсть потоков) в литературе по операционным системам называют моделью M:N. В исходниках Go, к слову, термина «M:N» нет, там описана модель без ярлыка, но суть та самая.

Модель M:N: рой горутин на горсти потоков
Модель M:N: рой горутин на горсти потоков

Конкурентность и параллелизм

Последний термин первой части, и он важнее, чем кажется. В январе 2012 года Роб Пайк, один из создателей Go, прочитал доклад «Concurrency is not Parallelism», и лучшего объяснения с тех пор никто не придумал. Конкурентность: способ структурировать программу как набор независимо выполняющихся задач. Параллелизм: одновременное физическое исполнение вычислений. Пайк формулирует так:

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once. Concurrency is about structure, parallelism is about execution.

Конкурентность про то, как справляться со многими вещами сразу; параллелизм про то, как многие вещи делать сразу. Первое про структуру, второе про исполнение. go f() дает тебе конкурентность: еще одну независимую задачу. Побежит ли она параллельно, решают GOMAXPROCS и планировщик. При GOMAXPROCS=1 сто горутин честно работают по очереди на одном ядре, и наш замер пинг-понга это показал: два участника, одно ядро, все крутится.

У Пайка на слайдах есть типичная жалоба: «I ran the prime sieve with 4 processors and it got slower!» (запустил решето простых чисел на четырех процессорах, и оно стало медленнее). Конкурентная структура не обещает ускорения. На задаче, где потоки только и делают, что передают друг другу данные, лишние ядра добавляют накладных расходов.

Там же отдельный слайд: «This design is not automatically parallel!» Вспоминай его каждый раз, когда пишешь go в надежде «станет быстрее». Быстрее становится тогда, когда задачи независимы и ядер хватает.

Конкурентность против параллелизма
Конкурентность против параллелизма

Итого у нас есть дешевые горутины, дорогие потоки и планировщик, который сводит одни с другими. Звучит стройно. Теперь самое интересное: в 2009 году ничего из этой стройности не существовало. Поехали смотреть, с чего все началось.

2009: все под одним замком

Публичный релиз Go состоялся 10 ноября 2009 года. Весь планировщик тогда умещался в один файл src/pkg/runtime/proc.c на 895 строк (сегодняшний proc.go перевалил за восемь тысяч).

Расширение .c в пути не опечатка: рантайм тех лет написан на C, а C я не знаю. Весь код на C в исторических главах я разбирал вместе с Claude Code: спрашивал построчно, что делает функция. Ответы сверял с комментариями в самом файле, сообщениями коммитов и дизайн-доками. Цитаты и ссылки на строки от этого не пострадали, а если я где-то понял механику неправильно, напиши в комментариях.

В шапке файла авторы честно описали свои амбиции (proc.c:37):

In general, one could imagine all sorts of refinements to the scheduler, but the goal now is just to get something working on Linux and OS X.

«Можно вообразить какие угодно улучшения планировщика, но сейчас цель в том, чтобы хоть что-то работало на Linux и OS X». Это не скромность задним числом, это дословная цитата из кода 2009 года, и устройство планировщика ей полностью соответствовало.

Все готовые к работе горутины лежали в одной глобальной очереди внутри структуры Sched (proc.c:41): буквально связный список G ghead; G gtail. Доступ к очереди защищал один мьютекс. Мьютексом (mutex, сокращение от mutual exclusion, «взаимное исключение») называют замок, который в каждый момент может держать только один поток: остальные, кто пришел за ним, стоят и ждут. Любая операция планировщика (запустить горутину, снять горутину, отдать процессор) начиналась со взятия этого единственного замка.

При одном исполняющем потоке такая схема работает. И Go тогда работал ровно с одним: значение GOMAXPROCS по умолчанию было зашито в код как единица (proc.c:108).

// src/pkg/runtime/proc.c:108 (релиз 2009 года)
sched.gomaxprocs = 1;
p = getenv("GOMAXPROCS");
if(p != nil && (n = atoi(p)) != 0)
	sched.gomaxprocs = n;

Комментарий рядом объяснял причину без прикрас (proc.c:23): «The default maximum number of ms is one: go runs single-threaded. This is because some locking details have to be worked out (…)» (по умолчанию максимум один m: Go работает однопоточно, потому что детали блокировок еще не доделаны). Язык, который сегодня рекламируют словом «конкурентность», в 2009 году исполнял пользовательский код строго в один поток, если руками не попросить больше.

Стек горутины тогда занимал 4 КБ и был сегментированным: при нехватке места рантайм пришивал к нему новый кусок, при возврате из функции отшивал обратно. Запомни слово «сегментированный»: во второй части выяснится, что эта конструкция умела превращаться в бомбу замедленного действия, и лечить ее пришлось два релиза.

Go 1.0 вышел в марте 2012 года и знаменит обещанием совместимости: программы на Go 1 не сломаются следующими релизами. Внутри же планировщика не изменилось ничего существенного. Та же одна очередь, тот же один мьютекс, тот же GOMAXPROCS=1. «By default, Go keeps only one kernel thread (m) running user code at a single time», по-прежнему писал комментарий (proc.c:34). Рантайм в основном состоял из C: 66 файлов .c против 33 на Go.

Теперь вопрос на предсказание, второй в серии. Возьми эту архитектуру (одна очередь, один мьютекс) и поставь GOMAXPROCS=8 на восьмиядерной машине. Что сломается первым? Ответ будет дальше, и он определил дизайн Go на годы вперед.

2013, Go 1.1: рождение GMP

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

Чинить это взялся Дмитрий Вьюков. 1 марта 2013 года он влил коммит 779c45a, у которого скучный заголовок и 1766 измененных строк в одном файле:

runtime: improved scheduler Distribute runnable queues, memory cache and cache of dead G’s per processor. Faster non-blocking syscall enter/exit. More conservative worker thread blocking/unblocking.

«Улучшенный планировщик: раздать очереди готовых горутин, кеш памяти и кеш мертвых G по процессорам». Кеш памяти оставим статьям про аллокатор, наши герои тут очереди и кеш мертвых G. Слово «processor» в этом сообщении и есть та самая буква P: Вьюков ввел новую сущность, у которой появилась собственная очередь горутин.

Смысл конструкции такой. Раз глобальная очередь под глобальным замком душит всех, раздадим каждому «праву исполнения» § свою локальную очередь. Поток, у которого есть P, берет следующую горутину из личной очереди своего P, куда другие потоки почти не заглядывают, и глобальный замок для этого не нужен. Глобальная очередь осталась, но превратилась в запасную площадку, куда работа попадает по остаточному принципу. Горячий путь планировщика перестал проходить через общий мьютекс, и вот тогда GMP сложился целиком: G исполняется на M, который держит P.

Комментарий, объясняющий три буквы, появился в том же коммите (proc.c:16) и дожил до Go 1.27 дословно (proc.go:29):

// src/pkg/runtime/proc.c:16 (Go 1.1); тот же текст живет в src/runtime/proc.go:29 сегодня
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
//     M must have an associated P to execute Go code, however it can be
//     blocked or in a syscall w/o an associated P.

Дизайн описан в документе «Scalable Go Scheduler Design Doc» (ссылку golang.org/s/go11sched в код добавил Расс Кокс тремя днями позже), и он до сих пор читается как хорошее введение в тему.

Осталась дырка: а если у одного P очередь пуста, а у соседнего лежит сотня горутин? Простаивать нечестно. Для этого Вьюков добавил воровство работы (work stealing): поток с пустой очередью выбирает случайного соседа и забирает у него половину горутин. Половину, чтобы не бегать за добавкой каждую секунду; случайного, чтобы воры не толпились у одного и того же богатого P.

Детали механики (как выбирается жертва и сколько у нее забирают) отложим до третьей части, до разбора findRunnable по шагам. Здесь достаточно идеи: работа сама растекается по ядрам, без центрального диспетчера.

Оговорка для точности: в Go 1.1 локальная очередь P еще была обычным массивом под маленьким локальным мьютексом. Знаменитое lock-free кольцо на 256 слотов, про которое рассказывают на собеседованиях, приедет только в Go 1.3. И еще одно рождение того же релиза, которое аукнется во второй части: появился sysmon, фоновый поток-надзиратель, живущий вне правил GMP. Пока запомни имя.

Планировщик до и после Go 1.1
Планировщик до и после Go 1.1

Сеть: убрать посредника

В тот же релиз въехала вторая перестройка, которую новичок наверняка пропустит, а зря: она объясняет, почему в Go можно писать conn.Read() в лоб и не думать. Проблема была такая: сокет (сетевое соединение) чаще всего молчит, данные по нему прилетают изредка. Если каждая горутина, ждущая данных, займет настоящий блокирующий системный вызов, она унесет с собой целый поток ОС. Десять тысяч соединений превратились бы в десять тысяч потоков: смерть, мы это посчитали в начале статьи.

В Go 1.0 выкручивались через посредника. В пакете net жила специальная горутина pollServer. Все, кто ждал данных, записывались к ней через канал, а она одна сидела в системном вызове epoll (механизм Linux «скажи, на каком из этих сокетов появились данные»). Чтобы разбудить pollServer и добавить ей нового клиента, горутина писала байт в специальную трубу. Комментарий из исходников Go 1.0 описывает эту кухню открытым текстом (net/fd.go:50):

A pollServer helps FDs determine when to retry a non-blocking read or write after they get EAGAIN. (…) WaitRead/WaitWrite writes a byte to the pipe, causing the pollServer’s poll system call to return.

Работало, но каждое сетевое ожидание оплачивалось пересылками по каналам и лишними переключениями горутин. В Go 1.1 посредника уволили. Опрос сокетов встроили прямо в рантайм (файлы netpoll_epoll.c, netpoll_kqueue.c): планировщик сам спрашивает ядро о готовых сокетах, заодно с поиском обычной работы. Горутина, ждущая сеть, теперь стоит рантайму одной записи в epoll: ни потока, ни горутины-посредника, ни трубы с будильником.

Эта конструкция называется netpoll. В третьей части я попробую показать, где именно планировщик ее опрашивает и почему с файлами на диске такой фокус не проходит.

Сетевое ожидание до и после netpoll
Сетевое ожидание до и после netpoll

Финальный штрих этой истории я люблю больше всего. Открой release notes Go 1.1 и поищи слово «scheduler». Его там нет. Ни разу. Крупнейшая перестройка планировщика за всю историю языка уехала к пользователям вообще без анонса, единственный след оставила вот эта фраза мелким шрифтом:

Due to tighter coupling of the run-time and network libraries, fewer context switches are required on network operations.

«Из-за более тесной связи рантайма и сетевых библиотек сетевые операции требуют меньше переключений контекста». За этим предложением стоят GMP, воровство работы и netpoll разом. Скромность, которой сегодняшним release notes иногда не хватает.

Go 1.2: горутину стало можно прервать

GMP решил проблему «потоки дерутся за замок». Осталась проблема похуже, и она была заложена в саму философию планировщика: он был кооперативным. Горутина отдавала процессор только добровольно: заблокировалась на канале, ушла в системный вызов, позвала runtime.Gosched(). А что делает горутина, которая ничего из этого не делает?

go func() {
	for i := 0; ; i++ {
	}
}()

Правильный ответ образца Go 1.1: захватывает поток навсегда. Планировщику нечем ее снять: вся его власть держалась на том, что горутины сами регулярно к нему заходят, а эта не заходит. При GOMAXPROCS=1 такой цикл останавливал вообще всю программу.

Хуже того, страдали даже программы с запасом ядер, и виноват в этом сборщик мусора. Сборщиком мусора (GC, garbage collector) называют часть рантайма, которая находит объекты, до которых программа уже не может дотянуться, и возвращает их память. Детальный разбор GC я готовлю довольно давно: тема тяжелая, надеюсь дожать ее и выложить на Хабр отдельной статьей.

Сборщику тех лет для работы требовалось остановить весь мир (stop the world): попросить каждую горутину замереть и дождаться, пока замрут все. Все, включая нашу невоспитанную. Она не замирала никогда, и сборщик ждал ее вечно, повесив заодно всех остальных, кто уже честно остановился.

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

Честное решение этой задачи появится только в Go 1.14, и до него мы доберемся во второй части. А в Go 1.2 сделали хак, и хак настолько изящный, что живет в рантайме до сих пор.

19 июля 2013 года Вьюков закоммитил bc31bcc:

runtime: preempt long-running goroutines If a goroutine runs for more than 10ms, preempt it. Update #543.

«Если горутина работает дольше 10 мс, вытеснить ее». Вытеснением (preemption) называют принудительное снятие задачи с процессора, в противовес добровольной кооперации. Механика встала на неожиданную опору. Компилятор Go и так вставлял в начало почти каждой функции пролог: пару инструкций, проверяющих «хватит ли моего стека на эту функцию». Зачем нужна такая проверка, разберем во второй части, она там главный герой.

Идея хака: записать в поле, с которым пролог сравнивает вершину стека, заведомо невозможное значение. Первая же вызванная функция «провалит» проверку стека и уйдет в рантайм, а тот вместо наращивания стека отправит горутину в планировщик. Флаг вытеснения подмешали в уже существующую проверку, не потратив ни одной новой инструкции.

Невозможное значение выбрали с юмором (stack.h:109):

// src/pkg/runtime/stack.h:109 (Go 1.2)
// Stored into g->stackguard0 to cause split stack check failure.
// Must be greater than any real sp.
// 0xfffffade in hex.
#define StackPreempt ((uint64)-1314)

Минус 1314, потому что в шестнадцатеричном виде это 0xfffffade: fade, затухание. Пасхалка в константе, через которую с тех пор прошло каждое вытеснение каждой Go-программы.

Расплату за изящество ты уже видишь: пролог есть только у функций. Наш for { i++ } не вызывает ни одной функции, значит, ни один пролог не сработает, значит, вытеснить его по-прежнему нечем. Хак закрыл большинство случаев: реальный код постоянно вызывает функции. Остаток превратился в известную грабельку «цикл без вызовов вешает GC», на которую наступали шесть лет подряд, до самого 2020 года.

Вытеснение через подделанную проверку стека
Вытеснение через подделанную проверку стека

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

В тот же релиз въехали еще две вещи, которые доживут до наших дней. Первая: справедливость к глобальной очереди. Обнаружился сценарий: две горутины бесконечно порождают друг друга, локальная очередь P никогда не пустеет, и до глобальной очереди руки планировщика не доходят вообще. Из описания фикса:

Currently global runqueue is starved if a group of goroutines constantly respawn each other (local runqueue never becomes empty).

Решение: каждый 61-й тик планировщика принудительно заглядывать в глобальную очередь, даже если локальная полна (proc.c:1301). Почему именно 61, а не 42 или 69 (если понимаешь, о чем я), комментарий в коде не объясняет: там только про справедливость. Насколько я понимаю логику: число достаточно редкое, чтобы не тормозить быстрый путь, достаточно частое, чтобы никто не голодал, и простое, чтобы период не резонировал с циклами в самой программе. Это правило пережило все перестройки рантайма и работает в Go 1.27 в том же виде (proc.go:3455); в третьей части встретим его в исходниках findRunnable лично.

Вторая: GODEBUG=schedtrace, режим, в котором рантайм раз в N миллисекунд печатает состояние планировщика. Сколько потоков, что в очередях, кто спит. Тот самый инструмент, читать который я обещал научить: обещание исполним в третьей части, когда будет понятен каждый участник этой печати.

И последняя строчка в летописи Go 1.2: стек горутины вырос с 4 до 8 КБ. Погоди, серия же обещала путь к 2 КБ, откуда рост вдвое? Оттуда, что рост был осознанным лечением симптома. Сегментированные стеки на некоторых программах взрывались нырянием туда-обратно через границу сегмента, и увеличение стартового размера снижало шансы попасть на границу. Расс Кокс в коммите написал честно: «for Go 1.2 this is a reasonable stopgap» (разумная затычка). Настоящее лечение (выкинуть сегменты совсем) случится в Go 1.3, и с него начнется вторая часть.

Что в итоге

Пройдемся по маршруту. В 2009 году Go стартовал с планировщиком на 895 строк: одна очередь, один мьютекс, однопоточное исполнение по умолчанию. Авторы прямо писали, что цель «чтобы хоть что-то работало». К концу 2013 года в рантайме уже жили GMP с локальными очередями, воровство работы, netpoll вместо горутины-посредника и вытеснение через подделанную проверку стека. Четыре года, два человека в главных ролях, и ни одного изменения в семантике языка: go f() из 2009-го компилируется и работает сегодня.

Запомни из этой части три вещи. Поток: объект ядра ценой в мегабайты адресного пространства и микросекунды на переключение, а его количество упирается в лимиты системы. Горутина: структура в куче ценой в килобайты и сотню наносекунд, поэтому их можно заводить миллионами. GMP: способ свести первых со вторыми, не выстраивая все ядра в очередь к одному замку.

А теперь заглянем за угол. Стек горутины в конце этой части весит 8 КБ, вчетверо больше обещанных 2 КБ. Цикл for { i++ } все еще вешает сборщик мусора, и лечить его будут сигналом ОС, который обычно означает «пришли срочные данные по сети». Горутина умеет умирать, но ее тело рантайм не хоронит, а складывает в пул и переиспользует. Обо всем этом расскажет вторая часть: стек, который переезжает, вытеснение, которое стреляет, жизнь горутины от go f() до пула мертвых и каналы с мьютексами под капотом.

Выйдет она через неделю, анонс кину в tg-канал. Там же лежит то, что в серию не поместилось: отдельные механизмы рантайма, замеры на другом железе и куски исходников, до которых в статьях не дошли руки. Все три части уже готовы и замеры сняты. Остались шлифовка и вычитка. Серия будет закончена и выложена целиком, а пока предлагаю подписаться на мою тгшку и подискутировать в комментариях к этой статье.

В прошлой статье про SIMD был ценный комментарий, который позволил лучше раскрыть тему. Надеюсь, и в этой серии найдутся энтузиасты, которые помогут усилить пользу от материала. Статью планирую доработать после релиза Go 1.27, он уже совсем скоро.

Надеюсь, смог нанести пользу и время на чтение ты потратил не зря!