Goroutine調度執行個體簡要分析

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

前兩天一位網友在微博私信我這樣一個問題:

抱歉打擾您諮詢您一個關於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. 著作權.

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.