這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前兩天一位網友在微博私信我這樣一個問題:
抱歉打擾您諮詢您一個關於Go的問題:對於goroutine的概念我是明了的,但很疑惑goroutine的調度問題, 根據《Go語言編程》一書:“當一個任務正在執行時,外部沒有辦法終止它。要進行任務切換,只能通過由該任務自身調用yield()來主動出讓CPU使用權。” 那麼,假設我的goroutine是一個死迴圈的話,是否其它goroutine就沒有執行的機會呢?我測試的結果是這些goroutine會輪流執行。那麼除了syscall時會主動出讓cpu時間外,我的死迴圈goroutine 之間是怎麼做到切換的呢?
我在第一時間做了回複。不過由於並不瞭解具體的細節,我在回覆中做了一個假定,即假定這位網友的死迴圈帶中沒有調用任何可以交出執行權的代碼。事後,這位網友在他的回複後道出了死迴圈goroutine切換的真實原因:他在死迴圈中調用了fmt.Println。
事後總覺得應該針對這個問題寫點什麼? 於是就構思了這樣一篇文章,旨在循著這位網友的思路通過一些例子來step by step示範如何分析go schedule。如果您對Goroutine的調度完全不瞭解,那麼請先讀一讀這篇前置文 《也談goroutine調度器》。
一、為何在deadloop的參與下,多個goroutine依舊會輪流執行
我們先來看case1,我們順著那位網友的思路來構造第一個例子,並回答:“為何在deadloop的參與下,多個goroutine依舊會輪流執行?”這個問題。下面是case1的源碼:
//github.com/bigwhite/experiments/go-sched-examples/case1.gopackage mainimport ( "fmt" "time")func deadloop() { for { }}func main() { go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") }}
在case1.go中,我們啟動了兩個goroutine,一個是main goroutine,一個是deadloop goroutine。deadloop goroutine顧名思義,其邏輯是一個死迴圈;而main goroutine為了展示方便,也用了一個“死迴圈”,並每隔一秒鐘列印一條資訊。在我的macbook air上運行這個例子(我的機器是兩核四線程的,runtime的NumCPU函數返回4):
$go run case1.goI got scheduled!I got scheduled!I got scheduled!... ...
從運行結果輸出的日誌來看,儘管有deadloop goroutine的存在,main goroutine仍然得到了調度。其根本原因在於機器是多核多線程的(硬體執行緒哦,不是作業系統線程)。Go從1.5版本之後將預設的P的數量改為 = CPU core的數量(實際上還乘以了每個core上硬線程數量),這樣case1在啟動時建立了不止一個P,我們用一幅圖來解釋一下:
我們假設deadloop Goroutine被調度與P1上,P1在M1(對應一個os kernel thread)上運行;而main goroutine被調度到P2上,P2在M2上運行,M2對應另外一個os kernel thread,而os kernel threads在作業系統調度層面被調度到物理的CPU core上運行,而我們有多個core,即便deadloop佔滿一個core,我們還可以在另外一個cpu core上運行P2上的main goroutine,這也是main goroutine得到調度的原因。
Tips: 在mac os上查看你的硬體cpu core數量和硬體執行緒總數量:
$sysctl -n machdep.cpu.core_count2$sysctl -n machdep.cpu.thread_count4
二、如何讓deadloop goroutine以外的goroutine無法得到調度?
如果我們非要deadloop goroutine以外的goroutine無法得到調度,我們該如何做呢?一種思路:讓Go runtime不要啟動那麼多P,讓所有使用者級的goroutines在一個P上被調度。
三種辦法:
- 在main函數的最開頭處調用runtime.GOMAXPROCS(1);
- 設定環境變數export GOMAXPROCS=1後再運行程式
- 找一個單核單線程的機器^0^(現在這樣的機器太難找了,只能使用雲端服務器實現)
我們以第一種方法為例:
//github.com/bigwhite/experiments/go-sched-examples/case2.gopackage mainimport ( "fmt" "runtime" "time")func deadloop() { for { }}func main() { runtime.GOMAXPROCS(1) go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") }}
運行這個程式後,你會發現main goroutine的”I got scheduled”字樣再也無法輸出了。這裡的調度原理可以用下面圖示說明:
deadloop goroutine在P1上被調度,由於deadloop內部邏輯沒有給調度器任何搶佔的機會,比如:進入runtime.morestack_noctxt。於是即便是sysmon這樣的監控goroutine,也僅僅是能給deadloop goroutine的搶佔標誌位設為true而已。由於deadloop內部沒有任何進入調度器代碼的機會,Goroutine重新調度始終無法發生。main goroutine只能躺在P1的local queue中徘徊著。
三、反轉:如何在GOMAXPROCS=1的情況下,讓main goroutine得到調度呢?
我們做個反轉:如何在GOMAXPROCS=1的情況下,讓main goroutine得到調度呢?聽說在Go中 “有函數調用,就有了進入調度器代碼的機會”,我們來實驗一下是否屬實。我們在deadloop goroutine的for-loop邏輯中加上一個函數調用:
// github.com/bigwhite/experiments/go-sched-examples/case3.gopackage mainimport ( "fmt" "runtime" "time")func add(a, b int) int { return a + b}func deadloop() { for { add(3, 5) }}func main() { runtime.GOMAXPROCS(1) go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") }}
我們在deadloop goroutine的for loop中加入了一個add函數調用。我們來運行一下這個程式,看是否能達成我們的目的:
$ go run case3.go
“I got scheduled!”字樣依舊沒有出現在我們眼前!也就是說main goroutine沒有得到調度!為什麼呢?其實所謂的“有函數調用,就有了進入調度器代碼的機會”,實際上是go compiler在函數的入口處插入了一個runtime的函數調用:runtime.morestack_noctxt。這個函數會檢查是否擴容連續棧,並進入搶佔調度的邏輯中。一旦所在goroutine被置為可被搶佔的,那麼搶佔調度代碼就會剝奪該Goroutine的執行權,將其讓給其他goroutine。但是上面代碼為什麼沒有實現這一點呢?我們需要在彙編層次看看go compiler產生的程式碼是什麼樣子的。
查看Go程式的彙編代碼有許多種方法:
- 使用objdump工具:objdump -S go-binary
- 使用gdb disassemble
- 構建go程式同時產生彙編代碼檔案:go build -gcflags ‘-S’ xx.go > xx.s 2>&1
- 將Go代碼編譯成彙編代碼:go tool compile -S xx.go > xx.s
- 使用go tool工具反編譯Go程式:go tool objdump -S go-binary > xx.s
我們這裡使用最後一種方法:利用go tool objdump反編譯(並結合其他輸出的彙編形式):
$go build -o case3 case3.go$go tool objdump -S case3 > case3.s
開啟case3.s,搜尋main.add,我們居然找不到這個函數的彙編代碼,而main.deadloop的定義如下:
TEXT main.deadloop(SB) github.com/bigwhite/experiments/go-sched-examples/case3.go for { 0x1093a10 ebfe JMP main.deadloop(SB) 0x1093a12 cc INT $0x3 0x1093a13 cc INT $0x3 0x1093a14 cc INT $0x3 0x1093a15 cc INT $0x3 ... ... 0x1093a1f cc INT $0x3
我們看到deadloop中對add的調用也消失了。這顯然是go compiler執行產生代碼最佳化的結果,因為add的調用對deadloop的行為結果沒有任何影響。我們關閉最佳化再來試試:
$go build -gcflags '-N -l' -o case3-unoptimized case3.go$go tool objdump -S case3-unoptimized > case3-unoptimized.s
開啟 case3-unoptimized.s尋找main.add,這回我們找到了它:
TEXT main.add(SB) github.com/bigwhite/experiments/go-sched-examples/case3.gofunc add(a, b int) int { 0x1093a10 48c744241800000000 MOVQ $0x0, 0x18(SP) return a + b 0x1093a19 488b442408 MOVQ 0x8(SP), AX 0x1093a1e 4803442410 ADDQ 0x10(SP), AX 0x1093a23 4889442418 MOVQ AX, 0x18(SP) 0x1093a28 c3 RET 0x1093a29 cc INT $0x3... ... 0x1093a2f cc INT $0x3
deadloop中也有了對add的顯式調用:
TEXT main.deadloop(SB) github.com/bigwhite/experiments/go-sched-examples/case3.go ... ... 0x1093a51 48c7042403000000 MOVQ $0x3, 0(SP) 0x1093a59 48c744240805000000 MOVQ $0x5, 0x8(SP) 0x1093a62 e8a9ffffff CALL main.add(SB) for { 0x1093a67 eb00 JMP 0x1093a69 0x1093a69 ebe4 JMP 0x1093a4f... ...
不過我們這個程式中的main goroutine依舊得不到調度,因為在main.add代碼中,我們沒有發現morestack函數的蹤跡,也就是說即便調用了add函數,deadloop也沒有機會進入到runtime的調度邏輯中去。
不過,為什麼Go compiler沒有在main.add函數中插入morestack的調用呢?那是因為add函數位於調用樹的leaf(葉子)位置,compiler可以確保其不再有新棧幀產生,不會導致棧分裂或超出現有棧邊界,於是就不再插入morestack。而位於morestack中的調度器的搶佔式檢查也就無法得以執行。下面是go build -gcflags ‘-S’方式輸出的case3.go的彙編輸出:
"".add STEXT nosplit size=19 args=0x18 locals=0x0 TEXT "".add(SB), NOSPLIT, $0-24 FUNCDATA $0, gclocals·54241e171da8af6ae173d69da0236748(SB) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) MOVQ "".b+16(SP), AX MOVQ "".a+8(SP), CX ADDQ CX, AX MOVQ AX, "".~r2+24(SP) RET
我們看到nosplit字樣,這就說明add使用的棧是固定大小,不會再split,且size為24位元組。
關於在for loop中的leaf function是否應該插入morestack目前還有一定爭議,將來也許會對這樣的情況做特殊處理。
既然明白了原理,我們就在deadloop和add之間加入一個dummy函數,見下面case4.go代碼:
//github.com/bigwhite/experiments/go-sched-examples/case4.gopackage mainimport ( "fmt" "runtime" "time")//go:noinlinefunc add(a, b int) int { return a + b}func dummy() { add(3, 5)}func deadloop() { for { dummy() }}func main() { runtime.GOMAXPROCS(1) go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") }}
執行該代碼:
$go run case4.goI got scheduled!I got scheduled!I got scheduled!
Wow! main goroutine果然得到了調度。我們再來看看go compiler為程式產生的彙編代碼:
$go build -gcflags '-N -l' -o case4 case4.go$go tool objdump -S case4 > case4.sTEXT main.add(SB) github.com/bigwhite/experiments/go-sched-examples/case4.gofunc add(a, b int) int { 0x1093a10 48c744241800000000 MOVQ $0x0, 0x18(SP) return a + b 0x1093a19 488b442408 MOVQ 0x8(SP), AX 0x1093a1e 4803442410 ADDQ 0x10(SP), AX 0x1093a23 4889442418 MOVQ AX, 0x18(SP) 0x1093a28 c3 RET 0x1093a29 cc INT $0x3 0x1093a2a cc INT $0x3... ...TEXT main.dummy(SB) github.com/bigwhite/experiments/go-sched-examples/case4.sfunc dummy() { 0x1093a30 65488b0c25a0080000 MOVQ GS:0x8a0, CX 0x1093a39 483b6110 CMPQ 0x10(CX), SP 0x1093a3d 762e JBE 0x1093a6d 0x1093a3f 4883ec20 SUBQ $0x20, SP 0x1093a43 48896c2418 MOVQ BP, 0x18(SP) 0x1093a48 488d6c2418 LEAQ 0x18(SP), BP add(3, 5) 0x1093a4d 48c7042403000000 MOVQ $0x3, 0(SP) 0x1093a55 48c744240805000000 MOVQ $0x5, 0x8(SP) 0x1093a5e e8adffffff CALL main.add(SB)} 0x1093a63 488b6c2418 MOVQ 0x18(SP), BP 0x1093a68 4883c420 ADDQ $0x20, SP 0x1093a6c c3 RET 0x1093a6d e86eacfbff CALL runtime.morestack_noctxt(SB) 0x1093a72 ebbc JMP main.dummy(SB) 0x1093a74 cc INT $0x3 0x1093a75 cc INT $0x3 0x1093a76 cc INT $0x3.... ....
我們看到main.add函數依舊是leaf,沒有morestack插入;但在新增的dummy函數中我們看到了CALL runtime.morestack_noctxt(SB)的身影。
四、為何runtime.morestack_noctxt(SB)放到了RET後面?
在傳統印象中,morestack是放在函數入口處的。但實際編譯出來的彙編代碼中(見上面函數dummy的彙編),runtime.morestack_noctxt(SB)卻放在了RET的後面。解釋這個問題,我們最好來看一下另外一種形式的彙編輸出(go build -gcflags ‘-S’方式輸出的格式):
"".dummy STEXT size=68 args=0x0 locals=0x20 0x0000 00000 TEXT "".dummy(SB), $32-0 0x0000 00000 MOVQ (TLS), CX 0x0009 00009 CMPQ SP, 16(CX) 0x000d 00013 JLS 61 0x000f 00015 SUBQ $32, SP 0x0013 00019 MOVQ BP, 24(SP) 0x0018 00024 LEAQ 24(SP), BP ... ... 0x001d 00029 MOVQ $3, (SP) 0x0025 00037 MOVQ $5, 8(SP) 0x002e 00046 PCDATA $0, $0 0x002e 00046 CALL "".add(SB) 0x0033 00051 MOVQ 24(SP), BP 0x0038 00056 ADDQ $32, SP 0x003c 00060 RET 0x003d 00061 NOP 0x003d 00061 PCDATA $0, $-1 0x003d 00061 CALL runtime.morestack_noctxt(SB) 0x0042 00066 JMP 0
我們看到在函數入口處,compiler插入三行彙編:
0x0000 00000 MOVQ (TLS), CX // 將TLS的值(GS:0x8a0)放入CX寄存器 0x0009 00009 CMPQ SP, 16(CX) //比較SP與CX+16的值 0x000d 00013 JLS 61 // 如果SP > CX + 16,則jump到61這個位置
這種形式輸出的是標準Plan9的彙編文法,資料很少(比如JLS跳轉指令的含義),注釋也是大致猜測的。如果跳轉,則進入到 runtime.morestack_noctxt,從 runtime.morestack_noctxt返回後,再次jmp到開頭執行。
為什麼要這麼做呢?按照go team的說法,是為了更好的利用現代CPU的“static branch prediction”,提升執行效能。
五、參考資料
- 《A Quick Guide to Go’s Assembler》
- 《Go’s work-stealing scheduler》
文中的代碼可以點擊這裡下載。
微博:@tonybai_cn
公眾號:iamtonybai
github.com: https://github.com/bigwhite
讚賞:
2017, bigwhite. 著作權.