Планировщик
Выделяют 3 модели для нарезания вычислений на потоки.
-
N:1 - несколько пользовательских потоков запущено на едином потоке ядра операционной системы. Преимущество - осуществляется очень быстрое переключение контекстов, но нет возможности воспользоваться многоядерностью.
-
1:1 - каждый пользовательский поток выполнения совпадает с одним потоком операционной системы. Он использует все ядра автоматически, но переключение контекста происходит медленно, потому что требует прерываний работы операционной системы.
-
Go пытается взять лучшее из обоих миров с помощью М:N планировщика. При этом произвольное число Go-рутин M планируется на произвольное количество потоков N операционной системы. Получается одновременно быстрое переключение контекста + возможность воспользоваться всеми ядрами в вашей системе. Основным недостатком данного подхода является сложность его включения в планировщик.
GMP Model
Планировщик Go использует 3 сущности:
M Machine(Треугольник) thread операционной системы. Точнее абстракция над ним, в go runtime представлена в виде структуры. Выполнением такого потока управляет операционная система, и работает это во многом подобно вашим стандартным потокам POSIX.
- M создаются по мере необходимости (но ограничены
GOMAXPROCS
). - Если горутина блокируется в
syscall
, создается новый M. - System Monitor завершает неиспользуемые M через
forcegc
(чтобы не накапливались лишние потоки).
G Goroutine(Круг). Он включает стек, указатель команд и другую важную информацию для планирования горутины, такую как канал, который на ней может быть блокирован.
P Processor(Прямоугольник) Планировщик на ядре. Можно понимать как локальную версию планировщика c собственной очередью горутин под конкретный M c каким-то контекстом(регистры и т.д).
Local and Global Queues
В планировщике Go есть две разные очереди выполнения:
-
глобальная (GRQ)
-
локальная (LRQ) - может хранит до
256
горутин (FIFO) однако, есть такая оптимизация, что последняя горутина, может выполнится первой, без очереди, одноэлементный стэк LIFO. Это сделано потому, что, есть ненулевая вероятность, того что на M контекст выполнения соотвтетсвует тому, что нужен именно этой горутине, поэтому часто имеет смысл выполнить её вперёд остальных.
Каждому P присваивается LRQ, который управляет горутинами, назначенными для выполнения в контексте P. Эти горутины по очереди включаются и выключаются из контекста M, назначенного для этого P. GRQ предназначен для горутин, которые не были назначены для P. Существует процесс, чтобы переместить горутины из GRQ в LRQ
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll network.
}
-
С вероятностью 1/61 немедленно проверить GRQ и запустить горутиную оттуда. 61 выбрано чтобы число не было слишком низким, что приводило бы к чрезмерно частой проверке глобальной очереди выполнения, и не слишком высоким, чтобы не привести к голоданию горутин в GRQ.
-
приоритетнее запускать G в своем собственном LRQ
-
затем из LRQ других P. Воруется
1/2
локальной очереди другого(случайного) P (по умолчанию,128
из256
возможных). Интересная деталь: work-stealing в Go не очень агрессивен, поэтому иногда загрузка ядер может быть неравномерной. -
затем из GRQ
-
затем из netpoller.
Если горутины нет, поток засыпает (park()
), а System Monitor
позже его разбудит.
Work stealing
В многопоточных вычислениях, возникли две парадигмы в планировании:
- Work-sharing: Когда процессор генерирует новые потоки, он пытается мигрировать их на другие процессоры, в надежде, что они попадут к простаивающему или недостаточно нагруженному процессору.
- Work-stealing: Недостаточно нагруженный процессор активно ищет потоки других процессоров и "крадет" некоторые из них.
Когда новая G создается или существующая G становится готовой к исполнению, она помещается в локальную очередь готовых к исполнению горутин текущего P. Когда P заканчивается исполнение G, он пытается вытащить G из своей очереди. Если список пуст, P выбирает случайным образом другой процессор (P) и пытается украсть половину горутин из его очереди.
Sysmon
sysmon
(системный монитор) – это фоновый поток, который выполняет служебные задачи Go Runtime. Он не выполняет горутины, а следит за состоянием системы, работой вашего приложения и помогает справиться с узкими местами, с которыми может столкнуться ваша программа. Не связан ни с одним P, M или G в нашей модели.
-
Обнаруживает
M
, зависшие вsyscall
- Если
M
завис вsyscall
>10ms,sysmon
запускает новыйM
, чтобыP
не простаивал. - Когда
syscall
завершается,G
возвращается в очередь, аM
либо продолжает работать, либо паркуется.
- Если
-
Обслуживает
NetPoller
- Обрабатывает неблокирующие I/O (
net
,epoll
,kqueue
,IOCP
). Т.е чекает сетевые события (epoll/kqueue/IOCP
) и пробуждаетG
, ждущие данные.
- Обрабатывает неблокирующие I/O (
-
Пробуждает
P
, если он долго простаивает- Если
P
ничего не делал >10ms,sysmon
даёт ему работу из глобальной очереди.
- Если
-
Запускает
forcegc
(форсированный GC)- Если горутины долго не доходят до
runtime.GC()
,sysmon
запускает его принудительно.
- Если горутины долго не доходят до
Как sysmon
управляет M
, зависшими в syscall
?
Допустим, G
делает блокирующий syscall
, например, read()
.
M
блокируется, выполняяsyscall
.P
отвязывается(handoff
) отM
, чтобы не простаивать и ищет новыйM
.- Если свободного
M
нет,sysmon
создаёт новыйM
, чтобыP
продолжил работу. - Когда
syscall
завершится,M
не возвращается к своемуP
, а отправляет горутину в глобальную очередь. - Далее
M
либо продолжает работать, если есть свободныйP
, либо паркуется.
SysMon
также не учитывается в планировщике Go. Он всегда запущен, но достаточно умен, чтобы не потреблять ресурсы, когда нечего делать. Время его цикла динамично и зависит от текущей активности выполняемой программы.
Любая горутина, выполняющаяся более 10 мс
, помечается как вытесняемая (мягкое ограничение). Поэтому планировщик Go может решить, следует ли вернуть ее в LRQ
или продолжить выполнение в потоке.
Netpoller
В отличие от syscall
, который блокирует M
, NetPoller
позволяет G
ждать данные без блокировки M
.
NetPoller
– это слой абстракции над системными API для асинхронного I/O(epoll/kqueue/IOCP)
Когда G
делает net.Conn.Read()
, Go не блокирует M
, а:
- Регистрирует сокет в
NetPoller
(epoll/kqueue
). - Переключает
G
в состояние_Gwaiting
. P
продолжает выполнять другиеG
, аM
не простаивает.- Когда данные поступают,
sysmon
пробуждаетG
и ставит её в очередь_Grunnable
.
В результате M
остаётся свободным, можно обслуживать тысячи G
на одном M
.
Т.е Когда горутина ждет сетевой запрос (например, ожидает на узле), она добавляется в NetPoller
. Таким образом, горутина не блокирует основную дорогу (поток ядра). Вместо этого она терпеливо ожидает в очереди сетевого поллера, позволяя другим горутинам свободно продолжать выполнение.
Взаимодействие sysmon
и NetPoller
sysmon
каждые 10 мс проверяетNetPoller
.- Если есть активные события (
epoll_wait/kqueue_wait
),sysmon
пробуждает горутины. - Это позволяет обрабатывать сетевые соединения без блокировки
M
.
Вытесняющая многозадачность
Для начала напомню, что такое кооперативная и не кооперативная многозадачность.
С не кооперативной (вытесняющей) многозадачностью мы все с вами прекрасно знакомы на примере планировщика ОС. Данный планировщик работает в фоне, выгружает потоки на основании различных эвристик, а вместо выгруженных процессорное время начинают получать другие потоки.
Для кооперативного планировщика характерно другое поведение — он спит пока одна из горутин явно не разбудит его с намеком о готовности отдать свое место другой. Планировщик далее сам решит, надо ли убирать из контекста текущую горутину, и если да, кого поставить на ее место. Примерно так и работал планировщик GO.
в GO 1.14 изменился принцип работы планировщика, рассмотрим причины по которым эти изменения были сделаны. Взгляните на код:
func main() {
runtime.GOMAXPROCS(1)
go func() {
var u int
for {
u -= 2
if u == 1 {
break
}
}
}()
<-time.After(time.Millisecond * 5) // в этом месте main горутина разбудит планировщик, а он в свою очередь запустит горутину с циклом
fmt.Println("go 1.13 has never been here")
}
Если скомпилировать его с версией GO < 1.14, то строчку «go 1.13 has never been here» вы на экране не увидите. Происходит это потому, что, как только планировщик дает процессорное время горутине с бесконечным циклом, она всецело захватывает P, внутри этой горутины не происходит ни каких вызовов функций, а значит и планировщик мы больше не разбудим. И только явный вызов runtime.Gosched() даст нашей программе завершиться.
В версии до 1.12 runtime.Gosched
использовал safe-points места, где точно можно вызвать планировщик без боязни, что мы попадем в атомарную для GC секцию кода. Как мы уже говорили, данные safe-points располагаются в прологе функции (но далеко не каждой функции, заметьте). Если вы разбирали go-шный ассемблер, то могли бы возразить — никаких очевидных вызовов планировщика там не видно. Да это так, но вы можете найти там инструкцию вызова runtime.morestack, а если заглянуть внутрь этой функции то обнаружится вызов планировщика.
Состояния горутин (G
):
Горутина в Go может находиться в одном из следующих состояний:
_Gidle
(Неактивная)
Горутина не используется (ещё не запущена или завершилась и ожидает переиспользования). Обычно такие структуры g
находятся в пуле горутин (freelist), откуда их можно переиспользовать.
_Grunnable
(Готовая к выполнению)
Горутина готова к запуску, но ещё не выполняется. Она находится в очереди выполнения у P
(локальной) или в глобальной очереди планировщика.
_Grunning
(Выполняется)
Горутина активно выполняется на одном из M
(потоков ОС). В этот момент планировщик не может её прервать, пока не произойдёт блокирующая операция.
_Gwaiting
(Ожидание)
Горутина заблокирована и ждёт внешнего события:
- Synchronization calls – Синхронизационные вызовы (атомарные операции, мьютексы, операции с каналами, системные вызовы).
- Asynchronous calls – Асинхронные вызовы (сетевые вызовы).Asynchronous calls – Long-running – Долговыполняющиеся вызовы
- Ожидание
sync.Mutex
. time.Sleep()
- ожидание в
select {}
.
В этом состоянии горутина не находится в очереди выполнения, пока не получит сигнал от события.
_Gsyscall
(Системный вызов)
Горутина выполняет блокирующий системный вызов, например:
-
Чтение/запись в файл (
os.Open()
,os.ReadFile()
). -
Сетевые операции (
net/http
,net.Dial()
). -
Разница с
_Gwaiting
: тут блокируется поток ОС (M
), поэтому Go Runtime может создать новыйM
, чтобы продолжить выполнение других горутин.
_Gdead
(Завершена)
Горутина завершилась (return
, panic
или runtime.Goexit()
). Её структура (g
) может быть переиспользована для новых горутин.
_Gcopystack
(Перемещение стека)
Горутина меняет размер стека, потому что стек в Go динамический (начинается с 2 KB и растёт при необходимости). Это временное состояние, когда Go копирует стек горутины в новую область памяти.
Как это работает?
- Горутина создаётся (
newproc()
) и становится_Grunnable
. P
ставит её в локальную очередь.M
забирает её и запускает →_Grunning
.- Если
G
блокируется (syscall
, ожиданиеchan
и т. д.) →_Gsyscall
или_Gwaiting
. - Когда
syscall
или ожидание завершается → горутина возвращается в_Grunnable
. - Если горутина завершилась →
_Gdead
, и память под неё чистит GC.
Что делает park()
?
Функция park
в Go Runtime используется для остановки (заморозки) текущего M (потока ОС), когда у него нет активных горутин. Она является частью внутреннего механизма планировщика .
Когда M
паркуется?
-
Остановка потока
M
, когда у него нет работыЕсли
M
(поток ОС) выполнил все доступные горутины и в системе больше нет задач, он "паркуется" (переходит в спящее состояние) до тех пор, пока не появится новая работа. -
Блокировка
M
во времяsyscall
Когда
M
выполняет блокирующий системный вызов, напримерread()
,write()
, то Go Runtime может перевестиM
вpark
и переключиться на другой поток. -
Остановка
M
при уменьшении количества потоков (GOMAXPROCS
)Если значение
GOMAXPROCS
уменьшается, некоторыеM
могут стать избыточными и Go Runtime отправит их вpark
.
Как это выглядит?
M
понимает, что у него нет работы.M
вызываетpark()
, освобождая ресурсы ОС.- Если появится новая работа,
P
разбудитM
черезruntime.notewakeup()
.
Что делает handoff()
?
Функция handoff()
используется, чтобы переназначить P
другому M
, если текущий M
заблокировался (syscall
).
Функция handoff()
в Go используется внутри планировщика для передачи P
от одного M
другому. Это необходимо, когда поток M
заблокировался ожидая syscall и планировщик хочет, чтобы работа G
не простаивала.
Как это работает?
G1
выполняется наM1
(привязан кP1
).G1
вызываетsyscall
,M1
блокируется (_Gsyscall
).handoff()
отвязываетP1
отM1
и даёт его другомуM2
.M2
теперь выполняет горутиныP1
.- Когда
syscall
завершится,M1
отправитG1
в глобальную очередь.
В Go Runtime решение о том, делать немедленный handoff (открепление потока от M и передача P другому M) или оставить поток заблокированным, принимает системный монитор (sysmon) и логика планировщика.
Когда вы вернетесь из handed-off syscall
, планировщик Go выполнит следующие действия:
- Если старый
P
(тот, с которого был передан поток) доступен, горутина будет назначена в егоLRQ
- Если старый
P
не доступен, горутина будет привязана к любому свободному процессору. - Если нет свободных процессоров, горутина будет добавлена в глобальную очередь выполнения.
Кто создаёт новую горутину?
Горутины создаются с помощью go func()
, что вызывает runtime.newproc()
.
Алгоритм newproc()
:
- Берёт
P
текущегоM
, на котором выполняетсяgo func()
. - Выделяет новый объект
G
, инициализирует его стек и состояние. - Добавляет
G
в локальную очередьP
, если есть место. - Если локальная очередь
P
заполнена (256G
), некоторыеG
сбрасываются в глобальную очередь.
Дополнительно:
-
https://habr.com/ru/users/not91/publications/articles/
-
https://habr.com/ru/articles/333654/
-
https://habr.com/ru/articles/502506/