golang

What's in the box!? Исследуем минимальное количество тредов golang-программы

  • пятница, 7 марта 2025 г. в 00:00:08
https://habr.com/ru/articles/888384/

Именно с такой мыслью и именно с интонацией Брэда Питта я ушел спать вчера (сегодня) в 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")
	}

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

Новый тред создается только в двух случаях:

  1. При создании новой М.

  2. При создании треда-шаблона. Template thread, по сути, это тред созданный сугубо для того чтобы создавать новые М. Причина существования этого товарища весьма проста - если мы шедулер находится в залоченной М, а нужно нужно создать новую, шедулер попросит об этом как раз темплейт-тред. Такая ситуация крайне редка, но просто ради полноты картины добавим принт перед созданием темплейт треда.

Поднимаемся выше, новый М создается в следующих ситуациях:

  1. Безусловное создание эксклюзивно под нужды sysmon.

  2. При изменении количества P во время процедуры StartTheWorld M сразу же создается для тех P, у которых есть работа, но нет М. STW выполняется в начале работы программы, во время работы GC и во время программного изменения количества процессоров (runtime.GOMAXPROCS).

  3. По необходимости во время handoff.

  4. Еще несколько особых случаев специфичных для разных ОС. Например, при включенном профайлинге 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 - а тут стримы с лайвкодингом