β

Как стать автором
Курсы по фронтенду
Айтишники, аттеншн! Выберите лучший IT-бренд 2025

Моя лентаВсе потоки

godzie 23 мая 2020 в 12:50

GO Scheduler: теперь не кооперативный?

8 мин

44K

Go*

Технотекст 2020

Если вы читали release notes для версии GO 1.14, то возможно заметили несколько довольно интересных изменений в рантайме языка. Вот и меня очень заинтересовал пункт: «Goroutines are now asynchronously preemptible». Выходит что GO scheduler (планировщик) теперь не кооперативный? Что же, после прочтения по диагонали соответствующего proposal любопытство было удовлетворено.

Тем не менее, через некоторое время я решил более подробно исследовать нововведения. Результатами этих исследований и хотелось бы поделиться.

System Requirements

Описанные ниже вещи требуют от читателя помимо знания языка GO дополнительных познаний, а именно:

понимание принципов работы планировщика (хотя я и попытаюсь объяснить ниже, «на пальцах»)

понимание принципов работы сборщика мусора

понимание что такое GO assembler

В конце я оставлю пару ссылок которые по моему мнению хорошо раскрывают данные темы.

Кратко о планировщике

Для начала напомню, что такое кооперативная и не кооперативная многозадачность.

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

Для кооперативного планировщика характерно другое поведение — он спит пока одна из горутин явно не разбудит его с намеком о готовности отдать свое место другой. Планировщик далее сам решит, надо ли убирать из контекста текущую горутину, и если да, кого поставить на ее место. Примерно так и работал планировщик GO.

Кроме того рассмотрим краеугольные сущности, которыми оперирует планировщик это:

P — логические процессоры (их количество мы можем поменять функцией runtime.GOMAXPROCS), на каждом логическом процессоре в один момент времени может независимо выполняться одна горутина.

M — потоки ОС. Каждый P работает на потоке из М. Заметьте, что P не всегда равно M, например, поток может быть заблокирован syscall'ом и тогда для его P будет выделен другой поток. А еще есть CGO и прочие и прочие нюансы.

G — горутины. Ну тут понятно, на каждом P должна выполняться G и scheduler за этим следит.

И последний момент, который нужно знать, а когда же собственно горутина вызывает планировщик? Все просто, обычно инструкции для вызова вставляются компилятором в начало тела (пролог) функции (чуть позже мы поговорим об этом подробнее).

А в чем собственно проблема?

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

под спойлером

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() даст нашей программе завершиться.

Это только один из примеров, когда горутина захватывает P и подолгу не дает другим горутинам выполниться на этом P. Больше вариантов, когда такое поведение вызывает проблемы, вы можете найти прочитав proposal.

Разбираем proposal

Решение данной проблемы довольно простое. Давайте сделаем так же как и в планировщике ОС! Просто позволим рантайму GO вытеснить горутину из P и положить туда другую, а для этого воспользуемся средствами ОС.

Окей, как это реализовать? Мы позволим рантайму отправлять сигнал потоку на котором работает горутина. Обработчик этого сигнала зарегистрируем на каждом потоке из M, задача обработчика — определить можно ли вытеснить текущую горутину. Если да — сохраним ее текущее состояние (регистры и состояние стека) и дадим ресурсы другой, иначе — продолжим выполнение текущей горутины. Стоит заметить, что, концепция с сигналом — это решение для UNIX-base систем, в то время как, например, реализация для Windows несколько отличается. Кстати, сигналом для отправки был выбран SIGURG.

Наиболее сложная часть данной реализации состоит в определении — может ли горутина быть вытеснена. Дело в том, что некоторые места в нашем коде должны быть атомарными, с точки зрения garbage collector'а. Назовем такие места unsafe-point'ами. Если мы вытесним горутину в момент выполнения кода из unsafe-point'а, а затем запустится GC, то он застанет состояние нашей горутины, снятое в unsafe-point'e, и может натворить дел. Давайте рассмотрим концепцию safe/unsafe подробнее.

А туда ли ты зашел, GC?

В версии до 1.12 runtime Gosched использовал safe-points места, где точно можно вызвать планировщик без боязни, что мы попадем в атомарную для GC секцию кода. Как мы уже говорили, данные safe-points располагаются в прологе функции (но далеко не каждой функции, заметьте). Если вы разбирали go-шный ассемблер, то могли бы возразить — никаких очевидных вызовов планировщика там не видно. Да это так, но вы можете найти там инструкцию вызова runtime.morestack, а если заглянуть внутрь этой функции то обнаружится вызов планировщика. Под спойлером я спрячу комментарий из исходников GO, либо вы можете найти ассемблер для morestack сами.

найдено в исходниках

Synchronous safe-points are implemented by overloading the stack bound check in function prologues. To preempt a goroutine at the next synchronous safe-point, the runtime poisons the goroutine's stack bound to a value that will cause the next stack bound check to fail and enter the stack growth implementation, which will detect that it was actually a preemption and redirect to preemption handling.

Очевидно, что при переходе на вытесняющую концепцию сигнал о вытеснении может застать нашу горутину в любом месте. Но авторы GO решили не уходить от safe-points, а объявить safe-points everywhere! Ну конечно, есть подвох, almost averywhere на самом-то деле. Как говорилось выше, есть некоторые unsafe-points, где вытеснять мы никого не будем. Давайте напишем простой unsafe-point.

j := &someStruct{}
p := unsafe.Pointer(j)
// unsafe-point start
u := uintptr(p)
//do some stuff here
p = unsafe.Pointer(u)
// unsafe-point end

Чтобы понять, в чем же тут проблема, примерим на себя шкуру сборщика мусора. Каждый раз когда мы выходим на работу надо узнать корневые узлы (указатели на стеке и в регистрах), с которых мы начнем маркировку. Так как в рантайме нельзя сказать, являются 64 байта в памяти указателем или просто числом, обратимся к картам стека и регистров (некоторому кэшу с мета информацией), любезно предоставленным нам компилятором GO. Информация в этих картах позволяет нам найти указатели. Итак, нас разбудили и отправили работать, когда GO исполнял строчку No4. Придя на место и заглянув в карты, мы обнаружили, что там пусто (и это действительно так, ведь uintptr с точки зрения GC — число а не указатель). Что же, вчера мы слышали о выделении памяти под j, раз до этой памяти теперь не добраться — надо бы ее подчистить, и удалив память отправляемся спать. Что дальше? Ну, разбудило начальство, ночью, криками, ну вы и сами поняли.

С теорией на этом все, предлагаю рассмотреть на практике, как работают все эти сигналы, unsafe-point'ы и карты регистров да стеков.

Перейдем к практике

Я дважды прогнал (go 1.14 и go 1.13) пример из начала статьи профайлером perf, дабы посмотреть какие системные вызовы происходят и сравнить их. Нужный syscall в 14й версии нашелся довольно быстро:

15.652 ( 0.003 ms): main/29614 tgkill(tgid: 29613 (main), pid: 29613 (main), sig: URG ) = 0

Отлично, очевидно рантайм послал SIGURG в тред на котором крутится горутина. Взяв это знание за отправную точку, я пошел смотреть коммиты в GO, чтобы найти, где и по какой причине этот сигнал посылается, а также найти место, где устанавливается обработчик данного сигнала. Начнем с посылки, функцию отправки сигнала мы найдем в runtime/os_linux.go

func signalM(mp *m, sig int) {
tgkill(getpid(), int(mp.procid), sig)
}

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

при suspend'е горутины, если она находится в состоянии «running». Запрос на suspend исходит от сборщика мусора. Тут пожалуй я не буду добавлять код, но его можно найти в файле runtime/preempt.go (suspendG)

если планировщик решает, что горутина работает слишком долго, runtime/proc.go (retake)

if pd.schedwhen+forcePreemptNS <= now {
signalM(_p_)
}

forcePreemptNS — константа равная 10мс, pd.schedwhen — время когда крайний раз вызывался планировщик для потока pd

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

С тем, как и когда рантайм шлет сигнал в M мы разобрались. Теперь давайте найдем обработчик данного сигнала и посмотрим, что делает поток при его приеме.

func doSigPreempt(gp *g, ctxt *sigctxt) {
if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
// Inject a call to asyncPreempt.
ctxt.pushCall(funcPC(asyncPreempt))
}
}

Из этой функции видно, что бы «заприемтиться» надо пройти 2 проверки:

wantAsyncPreempt — проверяем «хочет ли» G вытесняться, тут, например, проверится валидность текущего статуса горутины.

isAsyncSafePoint — проверяем, можно ли вытеснить прямо сейчас. Самая интересная из проверок здесь — находится ли G в safe или unsafe point'е. Кроме того, мы должны быть уверены, что поток, на котором выполняется G, так же готов к вытеснению G.

Если обе проверки пройдены — из исполняемого кода будет совершен вызов инструкций, которые сохранят состояние G и вызовут планировщик.

И еще об unsafe

Предлагаю разобрать новый пример, он проиллюстрирует еще один кейс с unsafe-point:

еще одна бесконечная программа

//go:nosplit
func infiniteLoop() {
var u int
for {
u -= 2
if u == 1 {
break
}
}
}

func main() {
runtime.GOMAXPROCS(1)
go infiniteLoop()
<-time.After(time.Millisecond * 5)

fmt.Println("go 1.13 and 1.14 has never been here")
}

Как вы могли бы догадаться, надпись «go 1.13 and 1.14 never been here» мы не увидим и в GO 1.14. Это происходит потому, что мы явно запретили прерывать функцию infiniteLoop (go:nosplit). Реализован такой запрет как раз с помощью unsafe-point, которой является все тело функции. Посмотрим, что сгенерировал компилятор для функции infiniteLoop.

Осторожно ассемблер

0x0000 00000 (main.go:10) TEXT "".infiniteLoop(SB), NOSPLIT|ABIInternal, $0-0
0x0000 00000 (main.go:10) PCDATA $0, $-2
0x0000 00000 (main.go:10) PCDATA $1, $-2
0x0000 00000 (main.go:10) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:10) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:10) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:10) XORL AX, AX
0x0002 00002 (main.go:12) JMP 8
0x0004 00004 (main.go:13) ADDQ $-2, AX
0x0008 00008 (main.go:14) CMPQ AX, $3
0x000c 00012 (main.go:14) JNE 4
0x000e 00014 (main.go:15) PCDATA $0, $-1
0x000e 00014 (main.go:15) PCDATA $1, $-1
0x000e 00014 (main.go:15) RET

В нашем случае интерес представляет инструкция PCDATA. Когда линковщик видит эту инструкцию, он не преобразует ее в «реальный» ассемблер. Вместо этого в карту регистров или стека (определяется 1м аргументом) будет помещено значение 2го аргумента с ключом равным соответствующему programm counter (число которое можно наблюдать слева от имени функции+строка).

Как мы видим на строках 10 и 15 мы кладем в мапы $0 и $1 значения -2 и -1 соответственно. Запомним этот момент и заглянем внутрь функции isAsyncSafePoint, на которую я уже обращал ваше внимание. Там мы увидим следующие строки:

isAsyncSafePoint

smi := pcdatavalue(f, _PCDATA_RegMapIndex, pc, nil)
if smi == -2 {
return false
}

Именно в этом месте мы и проверяем, находится ли горутина в настоящий момент в safe-point'е. Мы обращаемся к карте регистров (_PCDATA_RegMapIndex = 0), и передав ей текущий pc проверяем значение, если -2 то G не в safe-point'e, а значит не может быть вытеснена.

Заключение

На этом я остановил свои «изыскания», надеюсь статья была полезна и для вас.

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

GO scheduler — раз и два.

Assembler GO.

Теги:

go

go-internals

Хабы:

Go

+51

173

9
+9

66

Карма

0

Рейтинг

Костя Д. @godzie

Пользователь

Подписаться

Комментарии 9
+9

Публикации

Лучшие за суткиПохожие

A_Tsymbalyuk 13 часов назад

Физика ошибок не прощает. Она догонит тебя на крыше и заставит платить (дважды)

Простой

6 мин

8.2K

Кейс

+40

35

25
+25

BabayMazay 12 часов назад

Самодельные французские радиолампы 1920 г. Часть 3. Разрядная трубка, лампа накаливания

Средний

9 мин

1.5K

Ретроспектива

+33

9

11
+11

Bright_Translate 8 часов назад

Как я от нечего делать замедлил Postgres в 42 000 раз

Средний

11 мин

2.7K

Кейс

Перевод

+27

21

2
+2

butivskiydm 11 часов назад

Моё мини-исследование про продолжительность жизни в России

Простой

2 мин

6.4K

Мнение

+22

17

63
+63

lokosst 13 часов назад

Выделенные серверы + Kubernetes: практическое руководство

17 мин

2.4K

Туториал

+21

24

0

JetHabr 11 часов назад

«Плохие парни работают просто»: пентестеры разбирают реальные кейсы

Средний

9 мин

2.2K

Кейс

+17

19

1
+1

k0mar0v 14 часов назад

HTC Desire HD: история, дизайн и возможности легендарного смартфона

6 мин

2.6K

+17

5

5
+5

MaFrance351 13 часов назад

Домофон Лидер-М. Как скрестить трубку с ключом и всем проиграть

Простой

4 мин

2.6K

+16

5

6
+6

psygalaxy 14 часов назад

Как мы ускорили проверку документации с помощью AI-агента: от боли к решению

Средний

25 мин

1.8K

Обзор

+16

30

9
+9

kubelet 16 часов назад

DNS-хаос, зомби-поды и майнеры в кластере: самые невероятные случаи при работе с Kubernetes

Простой

14 мин

1.2K

Кейс

+16

23

1
+1

AI и майндсет айтишников — выясняем, как изменились IT-работодатели за год
Турбо

Показать еще

Минуточку внимания

Турбо

Полный свайп: оцени, подходит ли тебе работа в IT-ритейле

Событие

Что ни день – то ивент, если следить за Календарём

Событие

Хабр приглашает маркетологов и пиарщиков на тест-драйв

Курсы

PRO Go. Базовый курс по структурам данных
По желаниюStepik

Магистратура Разработка IT-продукта
По факту набораНетология

Go-разработчик
По факту набораБруноям

Проектирование микросервисной архитектуры
По факту набораСлёрм

Автоматизированное тестирование веб-сервисов на Go
По факту набораOTUS

Больше курсов на Хабр Карьере

Читают сейчас

Как превратить ноутбук в полноценное рабочее место не хуже стационарного ПК

12K

71
+71

Нашел ЛЯП в игре 5букв от Т-банка, который позволяет со 100% вероятностью угадать скрытое слово

54K

115
+115

Как выйти из IT?.. и пойти слесарем на завод. Моя попыточка дауншифтинга [4\5]

1.6K

12
+12

Моё мини-исследование про продолжительность жизни в России

6.4K

63
+63

GPT-5 получит модуль для борьбы с «галлюцинациями» — The Information

849

3
+3

AI и майндсет айтишников — выясняем, как изменились IT-работодатели за год
Турбо

Истории

История Яндекс Почты

С Днём сисадмина!

Как расти в ИТ: советы, гайды и опыт сеньоров

1 055 450 очков в облаке – попробуй обогнать

Годнота из блогов компаний

Ближайшие события

7 августа

Мем-пати: как построить IT-бренд через вирусный контент и юмор

Москва

Больше событий в календаре

Маркетинг

13 августа

Как повысить эффективность врача в стоматологии на 25% с помощью ИИ?

Онлайн

Больше событий в календаре

Менеджмент

13 августа

Тест-драйв Хабра для маркетологов и пиарщиков

Москва

Больше событий в календаре

Маркетинг

16 августа

«ИТ-летник» — конференция от Совкомбанк Технологий

Казань

Больше событий в календаре

Разработка

Аналитика

3 сентября

«GoCloud Tech 2025» — IT-конференция про облака и AI

Москва • Онлайн

Больше событий в календаре

Разработка

18 – 19 сентября

Конференция по корпоративному онлайн-обучению iSpring Days 2025

Москва • Онлайн

Больше событий в календаре

Администрирование

Менеджмент

Другое

19 сентября

Премия для технологических стартапов Startech.Awards 2025

Москва • Онлайн

Больше событий в календаре

Другое

30 сентября

VII премия в сфере фармацевтического маркетинга и креатива MedMen Awards 2025

Онлайн

Больше событий в календаре

Маркетинг

Другое

30 октября

Премия «Проксима»

Москва

Больше событий в календаре

Маркетинг

Реклама

ООО «Хабр»

ИНН 7705756279

erid: 2VSb5wN9Qsb

IT в ритейле

Свайпни и узнай, твоё ли это

Ваш аккаунт

Профиль

Трекер

Диалоги

Настройки

ППА

Разделы

Статьи

Новости

Хабы

Компании

Авторы

Песочница

Информация

Устройство сайта

Для авторов

Для компаний

Документы

Соглашение

Конфиденциальность

Услуги

Корпоративный блог

Медийная реклама

Нативные проекты

Образовательные программы

Стартапам

Настройка языкаТехническая поддержка
© 2006–2025, Habr