Именно с такой мыслью и именно с интонацией Брэда Питта я ушел спать вчера (сегодня) в 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 во время процедуры
StartTheWorldM сразу же создается для тех 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 — а тут стримы с лайвкодингом
ссылка на оригинал статьи https://habr.com/ru/articles/888384/
Добавить комментарий