Deep Engineering
Продвинутый·Опубликовано·30 МИН

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

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Чем горутина отличается от потока?» — проверяют, назовёте ли вы число.
  2. «Сколько она стоит?» — вопрос на честность: «мало» без числа не ответ.
  3. «Что такое GMP?» — три буквы знают многие; смысл P — немногие.
  4. «Что ограничивает GOMAXPROCS?» — здесь чаще всего отвечают «число горутин», и это неверно.
  5. «Планировщик вытесняющий или кооперативный?» — вопрос с датой: до Go 1.14 и после.
  6. «Когда горутина уходит с процессора?» — про парковку и точки переключения.

Дальше урок идёт по этой лестнице. Спина у неё одна: горутина — это объект рантайма, а не объект операционной системы.

База: горутина — это не поток операционной системы

Начинать стоит с того, чем горутина не является: почти все неверные ответы на собеседовании растут из одного и того же переноса — из привычки думать о ней как о потоке.

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

Горутина — сущность самой программы на Go, и три её свойства нужно назвать до всякой механики:

  1. Она не поток. Под тысячей горутин работает не тысяча потоков, а заметно меньше — счёт идёт на единицы.
  2. Её запуск стоит одного слова в коде. Перед обычным вызовом ставится go, вызов начинает выполняться сам по себе, а программа идёт дальше, не дожидаясь его конца.
  3. Их бывает очень много. Тысяча горутин — не нагрузка, а обычное состояние сервера, и упирается их число в память, а не в запрет.

Отсюда и главный вопрос урока: если горутин тысячи, а мест, где можно выполняться, единицы — кто и как раздаёт эти места? Ответ такой: этим занимается сама программа на Go. Внутри неё живёт планировщик, который решает, какая горутина сейчас на процессоре, и раскладывает тысячи горутин по нескольким потокам — не спрашивая ядро на каждое переключение.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования «чем горутина отличается от потока». Всё дальнейшее — про то, насколько именно она дешевле, кто и по каким правилам раздаёт процессорное время, что происходит, когда горутина ушла ждать, и почему одна горутина не может занять место навсегда.

Механизм 1: какую задачу решает планировщик

Прежде чем разбирать буквы, стоит сказать, зачем эта машинерия вообще существует. Задача у планировщика ровно одна, и формулируется она без единого термина Go:

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

Всё остальное в уроке — ответы на вопросы, которые из этой картинки следуют: кто решает, какая G займёт свободное место; что делать, когда занявшая место горутина ушла ждать; и как не дать одной горутине занять место навсегда.

контракт языкаПостановка задачи и гарантии языка. Устройство планировщика начинается с Механизма 2, и оно менялось между версиями.

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

Начнём с первого, потому что оно измеримо.

Горутина — структура рантайма, и её можно взвесить

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

Отсюда числа. Замерено на тысяче спящих горутин (bench/gosched/practice.go), делением на тысячу:

байт на горутину
стек (StackInuse)2000
структуры рантайма (HeapAlloc)~500
наблюдение замераbench/gosched/practice.go, go1.24.7 linux/amd64. Оба числа сняты на одной версии рантайма; устойчиво здесь то, что стек и структуры считаются раздельно, а не сами байты.

Два килобайта — это _StackMin из runtime/stack.go, «минимальный размер стека для новых горутин». И это не предел, а старт: стек растёт по мере надобности, копируясь на новое место, и потому не приходится закладываться на максимум заранее.

деталь реализации · Go 1.24_StackMin — константа из runtime/stack.go, то есть устройство конкретной версии рантайма, а не гарантия языка.

И границу у этого числа называет то же место, откуда оно взято: _StackMin — константа рантайма, а не обещание языка. Поэтому правило пишется не от двух тысяч, а от порядка величины: горутина стоит килобайты там, где поток ОС стоит мегабайты, — и переживёт смену версии именно эта разница, а не само число.

Про эти два числа стоит сказать отдельно. Первая редакция замера считала только HeapAlloc и получила ~500 байт — число, которое противоречит общеизвестному «горутина стоит два килобайта». Противоречия нет: стек горутины в HeapAlloc не входит вовсе, он учитывается отдельно, в StackInuse. Одно число без другого даёт неверный ответ, и на собеседовании стоит называть оба.

Практический вывод, который и хотят услышать: миллион горутин — это гигабайты, а не безумие. Миллион потоков ОС невозможен, миллион горутин — вопрос памяти.

Механизм 2: G, M и P — и весь смысл в третьей букве

деталь реализации · Go 1.24Три буквы — из комментария в runtime/proc.go: это описание устройства конкретной версии рантайма, а не контракт языка. Планировщик уже менялся между версиями — самый заметный пример на Механизме 4.

Три буквы заданы в комментарии к самому планировщику:

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.

runtime/proc.go

G и M понятны без объяснений. Весь смысл — в P, и объяснять его надо не как «процессор», а как право: право выполнять код Go. Прав ровно GOMAXPROCS штук, и поток, оставшийся без права, код Go не выполняет.

Зачем такая прослойка? Затем, что она разводит два разных ограничения:

  • сколько горутин может считать одновременно — это число P;
  • сколько потоков ОС существует — это число M, и оно больше: когда горутина уходит в системный вызов, её M блокируется вместе с ней, отдаёт своё P другому потоку, и работа продолжается.

Именно поэтому в документации отдельно сказано:

The GOMAXPROCS limit does not count threads blocked in system calls.

перевод

Ограничение GOMAXPROCS не учитывает потоки, заблокированные в системных вызовах.

Пакет runtime — GOMAXPROCS

Очереди и кража работы

Из программы очереди не наблюдаемы, поэтому здесь без чисел — но устройство стоит назвать, потому что из него следует главное свойство планировщика.

Освободился P — берёт из своей локальной очереди; если пуста, из общей; если и там пусто, крадёт половину у соседа.

Зачем локальные очереди вообще. Общая очередь на всех требовала бы замка на каждое планирование — то есть состязания тем сильнее, чем больше ядер. Локальная очередь обходится без синхронизации в обычном случае, и замок нужен только при краже.

Кража работы — то, почему нагрузка распределяется без диспетчера. Никто не раскладывает горутины по процессорам; простаивающий P сам ищет себе работу. У этого есть наблюдаемое следствие: горутина может начать выполняться на одном ядре и продолжить на другом, и рассчитывать на «своё» ядро нельзя.

Механизм 3: системный вызов и ввод-вывод — два разных пути

деталь реализации · Go 1.24Передача P и netpoller — устройство рантайма, а не гарантия языка: наблюдаемое следствие (ожидание сети не занимает поток) устойчиво, конкретная механика между версиями менялась.

Два способа «уйти ждать» устроены по-разному, и разницу спрашивают.

Системный вызов блокирует поток. 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.

перевод

Значение функции и параметры вычисляются как обычно в вызывающей горутине, но, в отличие от обычного вызова, выполнение программы не ждёт завершения вызванной функции.

Спецификация Go — Go statements

Возврат из main завершает процесс, и все запущенные горутины умирают на месте — без отложенных вызовов и без шанса дописать что-либо. Ждать надо явно: sync.WaitGroup, канал или errgroup.

Механизм 4: планировщик вытесняющий — но только с Go 1.14

Вопрос с датой, и знать надо обе половины.

До Go 1.14 планировщик был кооперативным: он мог переключиться только там, где горутина сама давала повод — вызов функции, обращение к каналу, выделение памяти. Цикл без единого вызова

GO
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.

перевод

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

Заметки к выпуску Go 1.14

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

Пакет runtime — GOMAXPROCS

Ключевое слово — одновременно. Горутин может быть сколько угодно; ограничено число тех, что считают прямо сейчас. Переключайте GOMAXPROCS — горутин на картинке всё время четыре, меняется только то, сколько из них на процессорах:

Числа под картинкой — из прогона той же счётной задачи:

1 горутина4 горутины
GOMAXPROCS=128,4 мс97,7 мс
GOMAXPROCS=224,4 мс52,5 мс
наблюдение замераbench/gosched, go1.24.7 linux/amd64, два ядра. Устойчива кратность — вчетверо при одном P и вдвое при двух, — а не сами миллисекунды: на другой машине они будут своими.

При одном P вчетверо большая работа занимает вчетверо больше времени: горутины считают по очереди. При двух — примерно вдвое. Горутин всё это время четыре.

Что стоит знать про значение по умолчанию. Оно равно числу доступных ядер. В контейнере с лимитом CPU это часто оказывается числом ядер хоста, а не лимитом контейнера, — и тогда рантайм создаёт заметно больше P, чем позволено считать. Лечится либо явным GOMAXPROCS, либо библиотекой, читающей лимиты cgroup.

деталь реализации · Go 1.24Значение по умолчанию и то, как рантайм относится к лимитам cgroup, — поведение конкретной версии, а не контракт: документация сама предупреждает, что вызов исчезнет, когда планировщик будет улучшен.

И здесь стоит назвать границу, потому что её отсутствие и порождает сюрприз при переезде. Сколько потоков реально исполняют код Go — свойство версии рантайма и окружения, а не строчки в коде: документация прямо оговаривает, что this call will go away when the scheduler improves (этот вызов исчезнет, когда планировщик будет улучшен). Правило поэтому пишется от границы: число одновременно исполняющих потоков выясняют у запущенного процесса на той версии Go, на которой он работает, а не выводят из числа ядер машины.

Глубже: дорожает не операция, а парковка

наблюдение замераbench/gosched, go1.24.7 linux/amd64, два ядра. Числа сняты на конкретной машине; содержательна кратность и её причина, а не абсолютные наносекунды.

Здесь два замера, и вместе они дают правило.

Первый — запуск горутины. Одна и та же работа, один атомарный инкремент:

время
прямой вызов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.

Практика

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

Практика · что напечатает

Сколько горутин в пустой программе, на сколько их станет больше после одного go, и сколько байт стека и кучи приходится на одну спящую горутину?
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)

Практика · оцените

Один атомарный инкремент. Во сколько раз дороже выполнить его в новой горутине и дождаться её, чем вызвать напрямую?
раз

Проверка знаний

Вопрос 1 из 6

GOMAXPROCS=1, запущены четыре горутины со счётной работой. Что произойдёт?

Источники и что читать дальше

4 ИСТОЧНИКА

  1. Спецификация 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
  2. Пакет 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
  3. Заметки к выпуску 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
  4. 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