What's in the box!? Исследуем минимальное количество тредов golang-программы
- пятница, 7 марта 2025 г. в 00:00:08
Именно с такой мыслью и именно с интонацией Брэда Питта я ушел спать вчера (сегодня) в 3:40 утра. После того, как в 23:10 "споткнулся" об утверждение Коли Тузова, о том, что рантайм голенга создает треды заранее. Не верилось, настолько что я пошел перечитывать сорцы рантайма снова, тем более я туда с 1.17 не заглядывал.
Кстати, если еще не смотрели видос Коли про планировщик - посмотрите.
Но только после того как дочитаете эту статью🤭
Коля в видосе запускал тестовую программу с дефолтным количеством процессоров, и лишь в рантайме сдувал их до 2 штук, что приводило к тому что оставалось 5 тредов. Результат безусловно подозрительный, но мне в целом немного странным показалось, что в шедулере может быть логика про создание тредов "заранее", а потом отказ их сдувать "чтоб былО".
Да и в целом целом наличие магической логики про дополнительные треды (и, видимо, хардкода на эту тему) кажется странным когда всё остальное в шедулере простое, логичное и переиспользуемое. Да и к тому же гошка умеет в WASM, а там какбы сисмона нет и тред вообще один.
Эксперименты я проводил, на аналогичном Колиному железе и той же платформе, так что расхождений не будет - всё на arm64 Darwin.
Код тестовой программы тривиальный - просто считаем до 10 миллиардов и выходим. Горутины нам сейчас не нужны, потому что мы просто исследуем минимальный минимум.
package main
import (
"runtime"
)
func main() {
runtime.GOMAXPROCS(1)
a := []int{}
i := 0
for {
a = append(a, i)
i++
if i == 10_000_000_000 {
a = nil
i = 0
break
}
}
}
Правильным способом проверить минимальное количество тредов для golang программы будет изначальный запуск с минимальным количеством процессоров, то есть надо запускать программу с выставленной энвой GOMAXPROCS=1
.
Я буду показывать только трейс который возникает через секунду после запуска, когда уже все раскрутилось.
> go build && GOMAXPROCS=1 GODEBUG=schedtrace=1000 ./threadstest
SCHED 1002ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=0 [1]
И вот, у нас уже не 5, а 3 треда. Ситуёвина стала даже интереснее. Получается, что "минимум" это 3 треда, но в то же время, если в какой-то момент времени мы имели бОльшее количество процессоров - шедулер не сдует количество тредов меньше 5. Щито, блин, происходит!?
Cделаем трейсинг шедулера более подробными при помощи флага scheddetail=1
- "картинка" становится намного более информативной и интересной:
> go build && GOMAXPROCS=1 GODEBUG=schedtrace=1000,scheddetail=1 ./threadstest
SCHED 1002ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
P0: status=1 schedtick=50 syscalltick=0 m=0 runqsize=1 gfreecnt=0 timerslen=0
M2: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=nil
M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=nil
G1: status=2(flushing proc caches) m=0 lockedm=nil
G2: status=4(force gc (idle)) m=nil lockedm=nil
G3: status=1(GC sweep wait) m=nil lockedm=nil
G4: status=4(GC scavenge wait) m=nil lockedm=nil
G5: status=4(GC worker (idle)) m=nil lockedm=nil
Далее, для краткости, выровняем нейминг с самим го:
P - процессор в терминологии шедулера го
M - машина, в терминологии шедулера го.
G - горутина.
Кстати, обращу внимание, что М - гошная интерпретация как раз треда ОС и они маппятся 1 к 1. А то значение в трейсе, которое мы видим как threads
- по-просту разница между кумулятивным количеством созданных и кумулятивным количеством высвобожденных М. К слову, отсюда еще одно наблюдение, т.к. айди М - int64
и нет никаких защит от переполнения, а даже напротив, явная паника - го-программа за весь свой жизненный цикл может создать "всего лишь" 4,294,967,295 тредов.
Как и ожидалось - у нас 1 процессор, но 3 машины и аж 5 горутин, 4 из которых относятся к сборщику мусора. Выключаем! Естественно во имя науки =)
> go build && GOGC=off GOMAXPROCS=1 GODEBUG=schedtrace=1000,scheddetail=1 ./threadstest
SCHED 1011ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
P0: status=1 schedtick=19 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
M2: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=nil
M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=nil
G1: status=2(chan receive) m=0 lockedm=nil
G2: status=4(force gc (idle)) m=nil lockedm=nil
G3: status=4(GC sweep wait) m=nil lockedm=nil
G4: status=4(GC scavenge wait) m=nil lockedm=nil
И толком ничего не поменялось – все еще 3 M, лишь G на 1 поменьше.
Что же это за треды и откуда они взялись?
К сожалению, никакого флага, который позволял бы нам как-то трейсить процесс создания тредов ОС или хотябы M. Но, к с частью, го написан на го, а значит закатываем рукава повыше и лезем в сорцы.
И выясняется, что флага нет, а код есть😁
if false {
print("newosproc stk=", stk, " m=", mp, " g=", mp.g0, " id=", mp.id, " ostk=", &mp, "\n")
}
Но сам по себе этот принт нам ничего не дает, мы и так знаем количество тредов и номера соответствующих М. Продолжаем копаться в сорцах чтобы раскрутить почему и в каких ситуациях треды создаются.
Новый тред создается только в двух случаях:
При создании новой М.
При создании треда-шаблона. Template thread, по сути, это тред созданный сугубо для того чтобы создавать новые М. Причина существования этого товарища весьма проста - если мы шедулер находится в залоченной М, а нужно нужно создать новую, шедулер попросит об этом как раз темплейт-тред. Такая ситуация крайне редка, но просто ради полноты картины добавим принт перед созданием темплейт треда.
Поднимаемся выше, новый М создается в следующих ситуациях:
Безусловное создание эксклюзивно под нужды sysmon.
При изменении количества P во время процедуры StartTheWorld
M сразу же создается для тех P, у которых есть работа, но нет М. STW выполняется в начале работы программы, во время работы GC и во время программного изменения количества процессоров (runtime.GOMAXPROCS
).
По необходимости во время handoff
.
Еще несколько особых случаев специфичных для разных ОС. Например, при включенном профайлинге CPU на Windows создается эксклюзивный М.
Несмотря на то что handoff
содержит 6 вызовов создания М - мы рассматриваем спинап программы, когда нет работы для ГЦ, М не спиннятся и т.д. - здравый смысл говорит что у нас явным образом не пустой global run queue
- для этого кейса и добавим принт. А sysmon
даже покрывать не будем, это в любом случае, всегда М1, потому как сисмон спавнится всегда.
> go build && GOGC=off GOMAXPROCS=1 GODEBUG=schedtrace=1000,scheddetail=1 ./threadstest
newosproc stk=0x0 m=0x14000044008 g=0x14000004540 id=1 ostk=0x16b2cb2d8
handoff: global runq not empty
newosproc stk=0x0 m=0x14000044808 g=0x14000004e00 id=2 ostk=0x16b2cb178
handoff: global runq not empty
SCHED 1001ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
P0: status=1 schedtick=18 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
M2: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=nil
M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=nil
G1: status=2(chan receive) m=0 lockedm=nil
G2: status=4(force gc (idle)) m=nil lockedm=nil
G3: status=4(GC sweep wait) m=nil lockedm=nil
G4: status=4(GC scavenge wait) m=nil lockedm=nil
Бинго! Два хендоффа, один из которых приводит к спавну М, но все еще не совсем понятно почему вообще происходит хендофф. Р уже ведь работает, под него выделен М0, он не залочен сисколлом или чем-то подобным.
Но перед этим поговорим о слоне в комнате - почему при выключенном GC 3/4 горутин все еще про гарбеджколлектор. Некоторые из читателей, вероятно, знают об этом, но ГЦ нельзя отключить совсем.
Глобально, фоновый ГЦ состоит из двух модулей:
Свипер - очищает высвобожденные спаны виртуальной памяти, тот что залочен в горутине это фоновый свипер, его запускает ГЦ - любой его вариант, воркер или тот который запускается сисмоном.
Скевенджер - занимается высвобождением целых страниц системной памяти, его запускает свипер или сисмон напрямую. Оба процесса живут в самостоятельных горутинах, которые паркуются и лочатся сразу же после запуска, в ожидании команды на выполнение работы. В то время как скевенджер может быть запущен сисмоном точечно, свипер запускается через горутину принудительного ГЦ раз в две минуты. Горутина эта спавнится просто по факту запуска программы на стадии инициализации. Этот момент, кстати, мне не понятен, потому как горутину ГЦ повесили на инит, а горутины свипера и скевенджера запускаются "вручную", хотя, технически, между ними 10 строк кода..
Не будь принудительного ГЦ гошные приложения бы текли по памяти, как минимум на размер стека каждого удаляемого треда.
С тем, откуда взялись горутины в GRQ еще до запуска пользовательского кода разобрались. Ну а с причиной отселения этих горутин на отдельный тред тоже все тривиально - главная горутина блокирует главный тред перед инициализацией и разблокирует его перед переходом к выполнению пользовательского кода.
Нужно это единицам программ, но само по себе дает наблюдаемый эффект, который сам по себе положителен.
Проверить правдивость утверждения просто - комментируем лок и анлок, запускаемся.
> go build && GOGC=off GOMAXPROCS=1 GODEBUG=schedtrace=1000,scheddetail=1 ./threadstest
newosproc stk=0x0 m=0x14000044008 g=0x14000004540 id=1 ostk=0x16b4372d8
SCHED 1002ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
P0: status=1 schedtick=18 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=nil
G1: status=2(chan receive) m=0 lockedm=nil
G2: status=4(force gc (idle)) m=nil lockedm=nil
G3: status=4(GC sweep wait) m=nil lockedm=nil
G4: status=4(GC scavenge wait) m=nil lockedm=nil
Вуаля! Запущены только две М.
И мы плавно подходим к ответу на изначальный вопрос - откуда берутся именно 5 М?
Если точнее - не обязательно столько, 5 штук мы достигнем когда у нас будет минимум 3 Р. При двух Р - будет 4 М, при трех - уже 5 М.
Как я уже упоминал выше, каждому Р для которого есть работа спавнится М (если у него еще нет), т.к. все Р, кроме главного, будут бездействовать на момент инициализации - шедулер даст им работу и для них будут отспавнены М.
А т.к. программа пока не дошла до пользовательского кода - наш runtime.GOMAXPROCS(1)
еще по-просту не выполнялся.
Остается одна "странность" - горутины, которые исполнялись на тех М, паркуются и отвязываются от М, это можно увидеть по трейсу. Но эти М не останавливаются не лочатся, не паркуются и не находятся в состоянии спина (активного поиска работы). Более того, им даже работу можно подкинуть.
Если подкинем в нашу программку горутину с сисколом
go func() {
unix.Read(unix.Stdin, make([]byte, 1))
}()
Распределение М поменяется и у нас не останется бездействующих М.
> go build && GOGC=off GOMAXPROCS=1 GODEBUG=schedtrace=1000,scheddetail=1 ./threadstest
SCHED 1007ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
P0: status=1 schedtick=21 syscalltick=5 m=2 runqsize=0 gfreecnt=0 timerslen=0
M2: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=nil
M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
M0: p=nil curg=6 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=nil
G1: status=2(chan receive) m=2 lockedm=nil
G2: status=4(force gc (idle)) m=nil lockedm=nil
G3: status=4(GC sweep wait) m=nil lockedm=nil
G4: status=4(GC scavenge wait) m=nil lockedm=nil
G5: status=4(finalizer wait) m=nil lockedm=nil
G6: status=3() m=0 lockedm=nil
Здесь мы видим, во первых новую горутину, файналайзер - именно он отвечает за выполнение (как можно догадаться) файналазеров и новоиспеченных клинапов. Спавнится файналазйер лениво, когда создается хоть один объект с соответствующим свойством, после чего он переходит в то же состояние что и горутинки ГЦ - ожидание.
Все дело в том, что эти горутины паркуются и переводят свой М в состояние мягкой блокировки - сон. Фактически, он ждет разблокировки семафора, который находится в собственности главного треда. Делает он это посредством спинлока, а не мютекса, а значит его (спинлок) можно прервать и дать полезную работу.
Ровно это и позволяет оставить тред "в живых" даже при уменьшении количества Р - коль уж заспавнили тред, пущай живет, авось пригодится.
И вот, наконец, дело раскрыто🕵️
Нет в шедулере логики предварительного спавна, а лишь более ленивое удаление.
https://t.me/laxcity_lead - там анонсы стримов, а статьи выходят сначала туда.
YouTube - а тут стримы с лайвкодингом