Горутины и планировщик Go: две тысячи байт, вытеснение и GOMAXPROCS, который ограничивает не то, что думают
Собеседование идёт лестницей: чем горутина отличается от потока — сколько она стоит — что такое GMP — что ограничивает GOMAXPROCS — вытесняющий ли планировщик — и когда горутина уходит с процессора. Замерено: 2000 байт стека плюс 500 байт структур, запуск с ожиданием дороже вызова в 82 раза, а уступка процессора стоит 106 нс.
Полное техническое изложение
TL;DR
Горутина — не поток операционной системы, а единица работы самой программы на Go. Её запускает одно слово перед обычным вызовом, стоит она несравнимо дешевле потока, и потому горутин у программы бывают тысячи, а потоков под ними — единицы. Кто из них и когда занимает процессор, решает сама программа, а не ядро.
Отсюда главное следствие: одновременно выполняется намного меньше горутин,
чем существует. Ровно на этом ломается самый частый ответ — «ограничено
число горутин». Замерено: четыре горутины со счётной работой идут 97,7 мс
против 28,4 мс у одной — вчетверо дольше, потому что считают по очереди. И
main не ждёт запущенных горутин: возврат из неё завершает процесс.
Дальше — буквы, числа и версии. G — горутина, M — поток ОС, P —
право выполнять код Go; число P — это и есть GOMAXPROCS, и ограничивает
оно выполняющихся одновременно, а не существующих: при GOMAXPROCS=2 те же
четыре горутины укладываются в 52,5 мс. Горутину можно взвесить: 2000 байт
стека плюс ~500 байт структур — против мегабайтов у потока ОС. Планировщик
вытесняющий с Go 1.14: цикл без единого вызова всё равно снимается с
процессора сигналом, а до 1.14 такой цикл вешал программу вместе со сборкой
мусора. И цена: прямой вызов 6,48 нс против 531,33 нс с запуском горутины и
ожиданием — в 82 раза, но на фоне работы в шесть наносекунд;
runtime.Gosched() добавляет 106 нс, и дорожает при этом парковка, а не
операция. Числа сняты на одной машине и одной версии рантайма: содержательны
кратности и их причины, а не сами наносекунды.
- программа может делать несколько дел сразу, и кто-то должен решать, какое из них сейчас на процессоре;
- поток операционной системы — дорогая сущность: их заводят единицами, а не тысячами;
- ядер у машины немного, и по-настоящему одновременно выполняется столько работ, сколько ядер.
- что такое
GOMAXPROCS, буквыG,M,P, netpoller иruntime.Gosched(); - чем вытесняющий планировщик отличается от кооперативного и что изменилось в Go 1.14;
- сколько байт занимает горутина и во что обходится её запуск.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Чем горутина отличается от потока?» — проверяют, назовёте ли вы число.
- «Сколько она стоит?» — вопрос на честность: «мало» без числа не ответ.
- «Что такое GMP?» — три буквы знают многие; смысл
P— немногие. - «Что ограничивает GOMAXPROCS?» — здесь чаще всего отвечают «число горутин», и это неверно.
- «Планировщик вытесняющий или кооперативный?» — вопрос с датой: до Go 1.14 и после.
- «Когда горутина уходит с процессора?» — про парковку и точки переключения.
Дальше урок идёт по этой лестнице. Спина у неё одна: горутина — это объект рантайма, а не объект операционной системы.
База: горутина — это не поток операционной системы
Начинать стоит с того, чем горутина не является: почти все неверные ответы на собеседовании растут из одного и того же переноса — из привычки думать о ней как о потоке.
Поток операционной системы — сущность ядра, и он дорогой: своя запись в ядре, свой стек в мегабайты, каждое переключение идёт через ядро. Потоков поэтому заводят единицы и десятки, и каждый запуск обдумывают заранее.
Горутина — сущность самой программы на Go, и три её свойства нужно назвать до всякой механики:
- Она не поток. Под тысячей горутин работает не тысяча потоков, а заметно меньше — счёт идёт на единицы.
- Её запуск стоит одного слова в коде. Перед обычным вызовом ставится
go, вызов начинает выполняться сам по себе, а программа идёт дальше, не дожидаясь его конца. - Их бывает очень много. Тысяча горутин — не нагрузка, а обычное состояние сервера, и упирается их число в память, а не в запрет.
Отсюда и главный вопрос урока: если горутин тысячи, а мест, где можно выполняться, единицы — кто и как раздаёт эти места? Ответ такой: этим занимается сама программа на Go. Внутри неё живёт планировщик, который решает, какая горутина сейчас на процессоре, и раскладывает тысячи горутин по нескольким потокам — не спрашивая ядро на каждое переключение.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования «чем горутина отличается от потока». Всё дальнейшее — про то, насколько именно она дешевле, кто и по каким правилам раздаёт процессорное время, что происходит, когда горутина ушла ждать, и почему одна горутина не может занять место навсегда.
Механизм 1: какую задачу решает планировщик
Прежде чем разбирать буквы, стоит сказать, зачем эта машинерия вообще существует. Задача у планировщика ровно одна, и формулируется она без единого термина Go:
Готовых к выполнению горутин на порядки больше, чем мест, где можно выполняться. Мест — по числу ядер; горутин — сколько создала программа.
Всё остальное в уроке — ответы на вопросы, которые из этой картинки следуют:
кто решает, какая G займёт свободное место; что делать, когда занявшая место
горутина ушла ждать; и как не дать одной горутине занять место навсегда.
Ответ Go на эту задачу состоит из двух решений. Первое — сделать единицу работы дешёвой, чтобы тысячи и миллионы были нормой. Второе — планировать их в пространстве пользователя, не обращаясь к ядру на каждое переключение.
Начнём с первого, потому что оно измеримо.
Горутина — структура рантайма, и её можно взвесить
Поток операционной системы — сущность ядра: своя таблица, свой стек в мегабайты, переключение через ядро. Горутина — структура в куче процесса Go со своим стеком, и всё переключение происходит внутри процесса.
Отсюда числа. Замерено на тысяче спящих горутин
(bench/gosched/practice.go), делением на тысячу:
| байт на горутину | |
|---|---|
стек (StackInuse) | 2000 |
структуры рантайма (HeapAlloc) | ~500 |
Два килобайта — это _StackMin из runtime/stack.go, «минимальный размер
стека для новых горутин». И это не предел, а старт: стек растёт по мере
надобности, копируясь на новое место, и потому не приходится закладываться на
максимум заранее.
И границу у этого числа называет то же место, откуда оно взято: _StackMin —
константа рантайма, а не обещание языка. Поэтому правило пишется не от двух
тысяч, а от порядка величины: горутина стоит килобайты там, где поток ОС
стоит мегабайты, — и переживёт смену версии именно эта разница, а не само
число.
Про эти два числа стоит сказать отдельно. Первая редакция замера считала
только HeapAlloc и получила ~500 байт — число, которое противоречит
общеизвестному «горутина стоит два килобайта». Противоречия нет: стек горутины
в HeapAlloc не входит вовсе, он учитывается отдельно, в StackInuse. Одно
число без другого даёт неверный ответ, и на собеседовании стоит называть оба.
Практический вывод, который и хотят услышать: миллион горутин — это гигабайты, а не безумие. Миллион потоков ОС невозможен, миллион горутин — вопрос памяти.
Механизм 2: G, M и P — и весь смысл в третьей букве
Три буквы заданы в комментарии к самому планировщику:
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.
G — горутина. M — рабочий поток, он же машина. P — процессор, ресурс, необходимый для выполнения кода Go. У M должен быть связанный P, чтобы выполнять код Go.
G и M понятны без объяснений. Весь смысл — в P, и объяснять его надо не
как «процессор», а как право: право выполнять код Go. Прав ровно
GOMAXPROCS штук, и поток, оставшийся без права, код Go не выполняет.
Зачем такая прослойка? Затем, что она разводит два разных ограничения:
- сколько горутин может считать одновременно — это число
P; - сколько потоков ОС существует — это число
M, и оно больше: когда горутина уходит в системный вызов, еёMблокируется вместе с ней, отдаёт своёPдругому потоку, и работа продолжается.
Именно поэтому в документации отдельно сказано:
The GOMAXPROCS limit does not count threads blocked in system calls.
Ограничение GOMAXPROCS не учитывает потоки, заблокированные в системных вызовах.
Очереди и кража работы
Из программы очереди не наблюдаемы, поэтому здесь без чисел — но устройство стоит назвать, потому что из него следует главное свойство планировщика.
Освободился P — берёт из своей локальной очереди; если пуста, из общей; если и
там пусто, крадёт половину у соседа.
Зачем локальные очереди вообще. Общая очередь на всех требовала бы замка на каждое планирование — то есть состязания тем сильнее, чем больше ядер. Локальная очередь обходится без синхронизации в обычном случае, и замок нужен только при краже.
Кража работы — то, почему нагрузка распределяется без диспетчера. Никто не
раскладывает горутины по процессорам; простаивающий P сам ищет себе работу. У
этого есть наблюдаемое следствие: горутина может начать выполняться на одном
ядре и продолжить на другом, и рассчитывать на «своё» ядро нельзя.
Механизм 3: системный вызов и ввод-вывод — два разных пути
Два способа «уйти ждать» устроены по-разному, и разницу спрашивают.
Системный вызов блокирует поток. M уходит в ядро вместе с горутиной — ядро
не знает ни про какие горутины. Чтобы простой не остановил остальную работу,
планировщик отбирает P у этого M и отдаёт другому потоку.
Отсюда и оговорка документации про GOMAXPROCS: заблокированные в системных
вызовах потоки в предел не входят. Число M больше числа P, и растёт оно
именно так.
Сетевой ввод-вывод поток не блокирует. Здесь работает netpoller: сокет
переводится в неблокирующий режим, горутина паркуется, а M остаётся со
своим P и берёт следующую работу. Когда данные пришли, ядро уведомляет
рантайм (epoll, kqueue, IOCP), и горутина возвращается в очередь готовых.
Вот почему сервер на Go держит десятки тысяч соединений на нескольких потоках: ожидание сети не стоит потока. А чтение файла — стоит, потому что для дисковых операций такого механизма нет.
Полный список причин уйти с процессора
Список короткий, и его стоит уметь назвать:
- сама заблокировалась — канал, мьютекс,
WaitGroup, сетевой ввод-вывод; - системный вызов —
Mуходит в ядро вместе с горутиной и отдаёт своёP; - ожидание сети — горутина паркуется, а
Mсо своимPработает дальше; runtime.Gosched()— добровольная уступка;- вытеснение по времени — примерно каждые 10 мс, сигналом, с Go 1.14;
- сборка мусора — на короткие остановки всего мира.
И отдельно — main не ждёт никого:
The function value and parameters are evaluated as usual in the calling
goroutine, but unlike with a regular call, program execution does not wait for
the invoked function to complete.
Значение функции и параметры вычисляются как обычно в вызывающей горутине, но, в отличие от обычного вызова, выполнение программы не ждёт завершения вызванной функции.
Возврат из main завершает процесс, и все запущенные горутины умирают на месте
— без отложенных вызовов и без шанса дописать что-либо. Ждать надо явно:
sync.WaitGroup, канал или errgroup.
Механизм 4: планировщик вытесняющий — но только с Go 1.14
Вопрос с датой, и знать надо обе половины.
До Go 1.14 планировщик был кооперативным: он мог переключиться только там, где горутина сама давала повод — вызов функции, обращение к каналу, выделение памяти. Цикл без единого вызова
for i := 0; i < 1e10; i++ {
x = x*1664525 + 1013904223
}не давал ни одной такой точки. При GOMAXPROCS=1 он вешал программу целиком —
включая сборку мусора, которой нужно остановить все горутины, а эту
остановить было нечем.
С Go 1.14 горутины вытесняются асинхронно, сигналом:
goroutines are now asynchronously preemptible. As a result, loops without
function calls no longer potentially deadlock the scheduler or significantly
delay garbage collection.
Горутины теперь вытесняются асинхронно. В результате циклы без вызовов функций больше не могут привести к взаимной блокировке планировщика или существенно задержать сборку мусора.
Проверено запуском: при GOMAXPROCS=1 соседняя горутина получила процессор,
пока счётный цикл работал, — true в прогоне bench/gosched/internals.go.
Почему это стоит знать, а не просто помнить дату. Потому что след того
времени остался в чужом коде: runtime.Gosched(), расставленный по циклам «чтобы
не подвесить планировщик». Сегодня он там не нужен: планировщик снимет горутину
и без него.
И это же место — образец того, как в теме устроены границы утверждений. Ответ «планировщик кооперативный» был верным и перестал им быть в конкретной версии: поведение планировщика — часть реализации, а не контракт языка. Поэтому любое утверждение про него называется вместе с версией, на которой оно проверено.
Механизм 5: GOMAXPROCS ограничивает одновременных, а не существующих
Это самая частая ошибка в ответах, и она проверяется замером.
GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously
and returns the previous setting.
GOMAXPROCS задаёт максимальное число процессоров, которые могут выполнять код одновременно, и возвращает прежнее значение.
Ключевое слово — одновременно. Горутин может быть сколько угодно;
ограничено число тех, что считают прямо сейчас. Переключайте GOMAXPROCS —
горутин на картинке всё время четыре, меняется только то, сколько из них на
процессорах:
Числа под картинкой — из прогона той же счётной задачи:
| 1 горутина | 4 горутины | |
|---|---|---|
GOMAXPROCS=1 | 28,4 мс | 97,7 мс |
GOMAXPROCS=2 | 24,4 мс | 52,5 мс |
При одном P вчетверо большая работа занимает вчетверо больше времени: горутины
считают по очереди. При двух — примерно вдвое. Горутин всё это время четыре.
Что стоит знать про значение по умолчанию. Оно равно числу доступных ядер.
В контейнере с лимитом CPU это часто оказывается числом ядер хоста, а не
лимитом контейнера, — и тогда рантайм создаёт заметно больше P, чем позволено
считать. Лечится либо явным GOMAXPROCS, либо библиотекой, читающей лимиты
cgroup.
И здесь стоит назвать границу, потому что её отсутствие и порождает сюрприз при
переезде. Сколько потоков реально исполняют код Go — свойство версии рантайма
и окружения, а не строчки в коде: документация прямо оговаривает, что
this call will go away when the scheduler improves
(этот вызов исчезнет, когда планировщик будет улучшен). Правило поэтому
пишется от границы: число одновременно исполняющих потоков выясняют у
запущенного процесса на той версии Go, на которой он работает, а не выводят
из числа ядер машины.
Глубже: дорожает не операция, а парковка
Здесь два замера, и вместе они дают правило.
Первый — запуск горутины. Одна и та же работа, один атомарный инкремент:
| время | |
|---|---|
| прямой вызов | 6,48 нс |
| запуск горутины и ожидание её | 531,33 нс |
| кратность | ×82 |
Восемьдесят два раза выглядит страшно ровно до тех пор, пока не сказано, на фоне чего: на фоне работы в шесть наносекунд. Это цена запуска плюс два переключения — и как только внутри горутины появляется настоящая работа, доля запуска падает до незаметной.
Второй — уступка процессора. Та же работа, но горутина отдаёт процессор:
| время | |
|---|---|
| атомарный инкремент | 6,39 нс |
он же + runtime.Gosched() | 112,90 нс |
он же + time.Sleep(0) | 8,80 нс |
| цена уступки | 106,51 нс |
Третья строка тут важнее второй: time.Sleep(0) почти бесплатен, потому что
возвращается сразу и не паркует горутину. То есть дорожает не «обращение к
планировщику», а именно снятие с процессора и последующее возвращение.
Отсюда и цена того самого runtime.Gosched(), расставленного по циклам в старом
коде: каждый такой вызов — около ста наносекунд, потраченных ни на что, потому
что с Go 1.14 планировщик снимет горутину и без просьбы.
Отсюда правило, которое переносится на каналы, мьютексы и WaitGroup: пока
горутина не блокируется, планировщик в её цене не участвует вовсе. Мьютекс без
конкуренции — это атомарная операция; канал с готовым получателем — копирование.
Дорожает ожидание, а не сама операция.
И граница у обоих замеров одна: они сняты на одной машине, двух ядрах и одной версии Go. Переносить отсюда нужно причину — что дорожает снятие с процессора и возвращение на него, — а не «в 82 раза» и не «106 нс»: у вас будут свои числа, а вывод останется тем же.
Как отвечать на собеседовании
Короткий ответ: горутина — не поток операционной системы, а единица работы
самой программы на Go. Её запускает одно слово go, стоит она килобайты
против мегабайтов у потока, а какая из них сейчас на процессоре — решает
планировщик внутри процесса. Поэтому горутин бывают тысячи при единицах потоков,
и поэтому GOMAXPROCS ограничивает не число горутин, а число выполняющихся
одновременно.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «чем отличается от потока» отвечайте числом. «Структура рантайма со своим стеком: два килобайта стека плюс сотни байт структур против мегабайтов у потока ОС; переключение внутри процесса, а не через ядро».
Про GMP объясняйте P как право. «P — право выполнять код Go, их ровно
GOMAXPROCS; поток без P код Go не выполняет. Отсюда и то, что потоков может
быть больше, чем P: ушедший в системный вызов отдаёт своё P другому».
Про GOMAXPROCS скажите «одновременно». «Ограничивает не число горутин, а
число выполняющихся одновременно; у меня четыре горутины при GOMAXPROCS=1
считали вчетверо дольше одной». Ответ «сколько горутин можно создать» —
самый частый неверный.
Про вытеснение назовите дату и последствие. «Вытесняющий с 1.14, до этого кооперативный; цикл без вызовов раньше вешал сборку мусора».
Про цену дайте оба числа и разделите их. «Запуск с ожиданием — сотни
наносекунд, но это на фоне работы в шесть; дорожает парковка, а не операция:
Gosched добавляет сто наносекунд, а Sleep(0) — почти ничего».
Дальше спросят
Сколько горутин можно запустить?
Ограничение — память: по замеру около 2,5 КБ на спящую горутину, то есть миллион это порядка двух с половиной гигабайт плюс то, на что они ссылаются. Жёсткого предела в рантайме нет.
Но вопрос обычно про другое. Практически ограничение приходит раньше и с другой стороны: миллион горутин, каждая с открытым соединением, упрётся в дескрипторы; миллион, ждущих ответа базы, — в размер её пула. Поэтому в реальном коде число горутин ограничивают семафором или пулом воркеров — не ради памяти, а ради ресурса, за который они конкурируют.
Что будет, если выставить GOMAXPROCS=1?
Код Go будет выполняться в один поток — но программа не станет однопоточной. Горутины продолжат переключаться, ввод-вывод продолжит идти параллельно, а системные вызовы по-прежнему будут выполняться в отдельных потоках ОС.
Что изменится: пропадёт параллелизм счётной работы, а вместе с ним и часть
гонок — они станут реже, но не исчезнут. Отсюда важное: GOMAXPROCS=1 не
делает код безопасным для конкурентного доступа и не заменяет синхронизацию.
И оговорка про границу: как именно рантайм распоряжается потоками при таком значении — часть реализации, а не контракт языка. Проверенное в этом уроке относится к go1.24.7; ответ «планировщик кооперативный», верный до Go 1.14, показывает, что бывает с утверждениями про планировщик, названными без версии.
Что такое work stealing?
У каждого P своя локальная очередь готовых горутин, и есть одна общая.
Освободившийся P берёт сначала из своей, потом из общей, а если пусто и там —
крадёт половину очереди у случайного соседа.
Смысл в отсутствии общего диспетчера: локальная очередь не требует блокировки на
каждой операции, а кража случается редко. Отсюда практическое следствие,
которое иногда спрашивают: горутина не привязана к P навсегда и может
продолжить выполнение на другом процессоре — поэтому никаких предположений про
«тот же поток» строить нельзя.
Почему горутину нельзя убить снаружи?
Потому что в языке нет такой операции — и это решение, а не упущение. Принудительное завершение оставило бы захваченные мьютексы захваченными, а незаконченные записи незаконченными: у убиваемой горутины нет шанса выполнить отложенные вызовы.
Вместо этого горутине сообщают, что пора закончить, — через context или
закрытие канала, — а она сама выходит из своего цикла. Ответ «через
runtime.Goexit» неверен: он завершает текущую горутину, а не чужую.
Как понять, что горутин слишком много?
Считать: runtime.NumGoroutine() в метриках. Растущее без причины число —
классический признак утечки: горутины, заблокированные навсегда, не собираются
сборщиком мусора.
Профиль goroutine из net/http/pprof показывает не только количество, но и
где они стоят, сгруппированные по стеку. Именно он и отвечает на вопрос «что
утекло»: обычно это один стек с тысячами горутин на нём.
Стек горутины растёт — что происходит в этот момент?
Компилятор вставляет в начало каждой функции проверку: хватает ли оставшегося стека. Не хватило — рантайм выделяет вдвое больший, копирует туда старый и правит указатели.
Отсюда две вещи, которые стоит знать. Первая: рост не бесплатен, и глубокая рекурсия платит за него несколько раз. Вторая: адрес переменной на стеке может измениться — но это безопасно, потому что рантайм правит все указатели, и именно поэтому в Go нельзя получить «указатель на старый стек».
Частые заблуждения
горутина — это лёгкий поток операционной системы
Это структура рантайма, а не ядра: своя запись, свой растущий стек, переключение внутри процесса. Замерено на тысяче спящих: 2000 байт стека плюс около 500 байт структур — против мегабайтов у потока ОС.
горутина стоит 500 байт
Столько занимают только структуры в куче. Стек горутины в HeapAlloc не входит вовсе — он учитывается отдельно, в StackInuse, и это ещё 2000 байт. Одно число без другого даёт неверный ответ на вопрос «сколько стоит горутина».
GOMAXPROCS ограничивает число горутин
Ограничивается число выполняющихся одновременно. Замерено: при GOMAXPROCS=1 четыре горутины считают 97,7 мс против 28,4 у одной — вчетверо, потому что идут по очереди. Горутин при этом всё время четыре.
потоков ОС ровно столько же, сколько P
Больше. Горутина, ушедшая в системный вызов, блокирует свой M вместе с собой и отдаёт своё P другому потоку — иначе одна операция чтения с диска останавливала бы весь параллелизм. Документация говорит прямо: ограничение GOMAXPROCS не учитывает потоки, заблокированные в системных вызовах.
планировщик Go кооперативный: цикл без вызовов его вешает
Так было до Go 1.14. С 1.14 горутины вытесняются асинхронно, сигналом, и цикл без единого вызова всё равно снимается с процессора — проверено прогоном при GOMAXPROCS=1. Расставленный по циклам runtime.Gosched() сегодня не нужен и стоит около 106 нс за вызов.
горутины медленные: запуск в 82 раза дороже вызова
Число верное, вывод нет: 82 раза — это на фоне работы в шесть наносекунд. Дорожает запуск и два переключения; как только внутри есть настоящая работа, доля запуска исчезает. Тот же замер с полезной нагрузкой даёт другую картину.
каналы и мьютексы медленные из-за планировщика
Планировщик участвует, только когда горутина паркуется. Мьютекс без конкуренции — атомарная операция, канал с готовым получателем — копирование. Цена уступки процессора замерена отдельно: runtime.Gosched() добавляет 106 нс, а time.Sleep(0), который не паркует, — около двух.
main дождётся запущенных горутин
Не дождётся: спецификация говорит, что выполнение «не ждёт завершения вызванной функции», а возврат из main завершает процесс. Горутины умирают на месте, без отложенных вызовов. Ждать надо явно — WaitGroup, канал или errgroup.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
fmt.Println(runtime.NumGoroutine())
before := runtime.NumGoroutine()
go func() {
defer wg.Done()
}()
fmt.Println(runtime.NumGoroutine() - before)
fmt.Println((m2.StackInuse - m1.StackInuse) / n / 100 * 100)
fmt.Println((m2.HeapAlloc - m1.HeapAlloc) / n / 100 * 100)Практика · оцените
Проверка знаний
GOMAXPROCS=1, запущены четыре горутины со счётной работой. Что произойдёт?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Горутина — не поток операционной системы, а единица работы самой программы на Go. Её запускает одно слово перед обычным вызовом, стоит она несравнимо дешевле потока, и потому горутин у программы бывают тысячи, а потоков под ними — единицы. Кто из них и когда занимает процессор, решает сама программа, а не ядро.
- Отсюда главное следствие: одновременно выполняется намного меньше горутин, чем существует. Ровно на этом ломается самый частый ответ — «ограничено число горутин». Замерено: четыре горутины со счётной работой идут 97,7 мс против 28,4 мс у одной — вчетверо дольше, потому что считают по очереди. И
mainне ждёт запущенных горутин: возврат из неё завершает процесс. - Дальше — буквы, числа и версии.
G— горутина,M— поток ОС,P— право выполнять код Go; числоP— это и есть GOMAXPROCS, и ограничивает оно выполняющихся одновременно, а не существующих: приGOMAXPROCS=2те же четыре горутины укладываются в 52,5 мс. Горутину можно взвесить: 2000 байт стека плюс ~500 байт структур — против мегабайтов у потока ОС. Планировщик вытесняющий с Go 1.14: цикл без единого вызова всё равно снимается с процессора сигналом, а до 1.14 такой цикл вешал программу вместе со сборкой мусора. И цена: прямой вызов 6,48 нс против 531,33 нс с запуском горутины и ожиданием — в 82 раза, но на фоне работы в шесть наносекунд;runtime.Gosched()добавляет 106 нс, и дорожает при этом парковка, а не операция. Числа сняты на одной машине и одной версии рантайма: содержательны кратности и их причины, а не сами наносекунды.
На самом деле
- Это структура рантайма, а не ядра: своя запись, свой растущий стек, переключение внутри процесса. Замерено на тысяче спящих: 2000 байт стека плюс около 500 байт структур — против мегабайтов у потока ОС.
- Столько занимают только структуры в куче. Стек горутины в
HeapAllocне входит вовсе — он учитывается отдельно, вStackInuse, и это ещё 2000 байт. Одно число без другого даёт неверный ответ на вопрос «сколько стоит горутина». - Ограничивается число выполняющихся одновременно. Замерено: при
GOMAXPROCS=1четыре горутины считают 97,7 мс против 28,4 у одной — вчетверо, потому что идут по очереди. Горутин при этом всё время четыре. - Больше. Горутина, ушедшая в системный вызов, блокирует свой
Mвместе с собой и отдаёт своёPдругому потоку — иначе одна операция чтения с диска останавливала бы весь параллелизм. Документация говорит прямо: ограничение GOMAXPROCS не учитывает потоки, заблокированные в системных вызовах. - Так было до Go 1.14. С 1.14 горутины вытесняются асинхронно, сигналом, и цикл без единого вызова всё равно снимается с процессора — проверено прогоном при
GOMAXPROCS=1. Расставленный по цикламruntime.Gosched()сегодня не нужен и стоит около 106 нс за вызов. - Число верное, вывод нет: 82 раза — это на фоне работы в шесть наносекунд. Дорожает запуск и два переключения; как только внутри есть настоящая работа, доля запуска исчезает. Тот же замер с полезной нагрузкой даёт другую картину.
- Планировщик участвует, только когда горутина паркуется. Мьютекс без конкуренции — атомарная операция, канал с готовым получателем — копирование. Цена уступки процессора замерена отдельно:
runtime.Gosched()добавляет 106 нс, аtime.Sleep(0), который не паркует, — около двух. - Не дождётся: спецификация говорит, что выполнение «не ждёт завершения вызванной функции», а возврат из
mainзавершает процесс. Горутины умирают на месте, без отложенных вызовов. Ждать надо явно —WaitGroup, канал илиerrgroup.
Что разобрано
- Что здесь на самом деле спрашивают
- База: горутина — это не поток операционной системы
- Механизм 1: какую задачу решает планировщик
- Механизм 2: G, M и P — и весь смысл в третьей букве
- Механизм 3: системный вызов и ввод-вывод — два разных пути
- Механизм 4: планировщик вытесняющий — но только с Go 1.14
- Механизм 5: GOMAXPROCS ограничивает одновременных, а не существующих
- Глубже: дорожает не операция, а парковка
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- Спецификация Go — Go statementsОфициальная документация. Определение, из которого следует главная практическая ловушка: «A "go" statement starts the execution of a function call as an independent concurrent thread of control, or goroutine, within the same address space» (Оператор go запускает выполнение вызова функции как независимого параллельного потока управления — горутины — в том же адресном пространстве). И дальше: «The function value and parameters are evaluated as usual in the calling goroutine, but unlike with a regular call, program execution does not wait for the invoked function to complete» (Значение функции и параметры вычисляются как обычно в вызывающей горутине, но, в отличие от обычного вызова, выполнение программы не ждёт завершения вызванной функции). Слова «не ждёт» — причина того, что горутины, запущенные из main, умирают вместе с ней.https://go.dev/ref/spec#Go_statements
- Пакет runtime — GOMAXPROCSОфициальная документация. Точная формулировка того, что именно ограничивается: «GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting» (GOMAXPROCS задаёт максимальное число процессоров, которые могут выполнять код одновременно, и возвращает прежнее значение). И сразу оговорка, которую забывают: «This call will go away when the scheduler improves» (Этот вызов исчезнет, когда планировщик будет улучшен). Отдельно про то, что ограничение не про число горутин: «The GOMAXPROCS limit does not count threads blocked in system calls» (Ограничение GOMAXPROCS не учитывает потоки, заблокированные в системных вызовах).https://pkg.go.dev/runtime#GOMAXPROCS
- Заметки к выпуску Go 1.14 — асинхронное вытеснениеОфициальная документация. Изменение, которое разделило планировщик на «до» и «после»: «goroutines are now asynchronously preemptible. As a result, loops without function calls no longer potentially deadlock the scheduler or significantly delay garbage collection» (горутины теперь вытесняются асинхронно. В результате циклы без вызовов функций больше не могут привести к взаимной блокировке планировщика или существенно задержать сборку мусора). До этого цикл без вызовов при GOMAXPROCS=1 вешал программу целиком.https://go.dev/doc/go1.14
- runtime/proc.go и runtime/runtime2.go — устройство планировщикаИсходный код Go. Три буквы заданы в комментарии к самому планировщику: «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» (G — горутина. M — рабочий поток, он же машина. P — процессор, ресурс, необходимый для выполнения кода Go. У M должен быть связанный P, чтобы выполнять код Go). Оттуда же про стартовый стек: `_StackMin = 2048` в `runtime/stack.go` — «minimum size of stack for new goroutines» (минимальный размер стека для новых горутин).https://go.dev/src/runtime/proc.go