golang調度器的一個陷阱

讓我們快速進入問題,不浪費時間。試著執(zhí)行下面的golang代碼片段。

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    var x int
    threads := runtime.GOMAXPROCS(0)
    println(threads)
    for i := 0; i < threads; i++ {
        go func() {
            for {
                x++
            }
        }()
    }
    time.Sleep(time.Second)
    fmt.Println("x =", x)
}

運行代碼

$ GOMAXPROCS=8 go run x.go

(旁注:熟悉Golang的同道想必知道GOMAXPROCS其實對應的CPU核心數,也就是線程數,這里應該是原作者運行示例時的計算機的CPU核數為8,因為根據文檔定義,如果runtime.GOMAXPROCS(0)傳入參數小于1,如果特殊指定,GOMAXPROCS就等于CPU核心數)

你觀察到程序從未終止嗎?這就是我說的golang陷阱。如果你用C/C++寫同樣的程序,你就不會發(fā)現這樣的問題?,F在讓我們修改程序,修改以下一行:

threads := runtime.GOMAXPROCS(0)-1

所以,我們只是減少了1個go協程的數量。如果你在這個改變后重新運行程序,你會發(fā)現程序正確地終止,并打印出結果。這非常令人驚訝,不是嗎?要了解這個問題背后的原因,我們需要了解一下golang運行時和調度器的實現。

揭開調度器的神秘面紗

Golang提供了用于并發(fā)的goroutine。它們類似于線程,但它們是輕量級的,開銷非常小。擁有數萬個goroutine的程序并不罕見,而擁有一萬個pthreads代價就非常高了。golang在用戶態(tài)中實現了goroutine。golang運行時為go程序創(chuàng)建的操作系統(tǒng)線程(pthreads)等于GOMAXPROCS的數量。Go協程被golang運行時安排在這些有限的OS線程上。

操作系統(tǒng)調度器

讓我們回顧一下操作系統(tǒng)是如何調度進程的。通常情況下,操作系統(tǒng)調度器會保存一份操作系統(tǒng)進程的列表,它們處于正在運行、可運行或不可運行的狀態(tài)。如果一個進程的運行時間超過了調度器的時間片,它就會搶占該進程,并安排在同一CPU上執(zhí)行另一個可運行的進程。搶占是通過定時器中斷來實現的。定時器中斷的頻率為調度器時間片的間隔。在一段代碼中正在執(zhí)行的進程會停止執(zhí)行,保存進程執(zhí)行上下文并執(zhí)行中斷處理程序。中斷處理程序會將執(zhí)行切換到調度器中?,F在,調度器可以決定在這個CPU上執(zhí)行哪個可運行的進程。調度器會選擇一個進程并切換到它的執(zhí)行上下文。

Golang的調度器

Golang實現了一個可協作的搶占式調度器。它沒有實現基于定時器中斷的搶占。但是,這個調度器應該方便在一個OS線程上同時運行多個goroutine。Golang在運行時提供的構造體、庫和系統(tǒng)調用(?此處翻譯的不好,構造體這個說法聽著怪怪的)中加入鉤子,可以與調度器進行協作。由于它避開了調用進入調度器的計時器,所以將運行時提供的函數作為進入調度器的入口。如果我們設法寫一個不使用任何運行時提供的封裝函數的goroutine,會發(fā)生什么?這正是這里發(fā)生的事情。那個goroutine不會調用到調度器,并導致goroutine的搶占。

在上面的程序中,我們執(zhí)行的goroutine等于GOMAXPROCS(操作系統(tǒng)線程)。主協程是一個額外的goroutine。每個go協程都運行一個無限循環(huán),并帶有一個整數增量操作,這為協程提供了沒有調用到調度器的范圍。因此,所有六個線程(GOMAXPROCS)都在運行無限循環(huán),它們永遠不會搶占。處于可運行狀態(tài)的主協程無法執(zhí)行,因為這六個線程中的任何一個線程都忙于執(zhí)行無限循環(huán),所以調度器永遠不會被執(zhí)行。當我們減少1個線程時,現在有一個OS線程變得空閑,能夠執(zhí)行主程序。

(旁注:假設系統(tǒng)是8個CPU,我們GOMAXPROCS減1以后運行程序,就會有一個核是空閑的,此時正好可以進入主線程中執(zhí)行,雖然原作者這里寫的是6,不過我覺得處于無限循環(huán)的線程應該等同于threads,當threads等于系統(tǒng)CPU核心數時,由于無限循環(huán),主協程沒有機會被調度到,所以就程序沒法退出,當將threads頭1時,主協程才有機會能夠執(zhí)行,GOMAXPROCS限制的是goroutine的最大并發(fā)能力,這個也是由golang自己的調度器實現的,那主協程能運行是由于golang調度所致嗎?此處先埋下伏筆。
我分別在不同的go版本下運行了示例程序:1.13、1.14,得到了不同的結果,1.13符合預期,但是1.14下程序卻有不同表現,主線程總能得到執(zhí)行,我想這應該是因為1.14版本的go調度器有較大變化所致,此處先埋點,后開坑)

在現實世界的程序中,這種情況是不太可能發(fā)生的,因為我們可能會使用運行時提供的功能,如channels、systemcalls、fmt.Sprint、Mutex、time.Sleep至少一次。你可以在無限循環(huán)中添加一個無害的time.Sleep(0),然后觀察程序不再掛起。

結論

雖然出現這個問題的幾率非常小,但還是有可能發(fā)生。解決這個問題的方法是在這種情況下,從程序中強行調用進入調度器。runtime.Gosched()的調用有利于強制進入調度器。

這篇博文的靈感來自于我的同事,他在玩golang的時候就遇到了這個問題。

(旁注:此文非常有意思,一個簡單的示例,卻可以發(fā)散讀者對于調度器的理解,有你當然,這篇文章還沒有講解goroutine為什么輕量,這是另一個有意思的話題,歡迎一起討論)


原文鏈接 pitfall-of-golang-scheduler

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • 該文章主要詳細具體的介紹Goroutine調度器過程及原理,可以對Go調度器的詳細調度過程有一個清晰的理解,花 ...
    劉丹冰Aceld閱讀 11,916評論 5 44
  • 前言 隨著服務器硬件迭代升級,配置也越來越高。為充分利用服務器資源,并發(fā)編程也變的越來越重要。在開始之前,需要了解...
    云爬蟲技術研究筆記閱讀 3,889評論 0 7
  • 前言 相信聽說go這門語言的同學都知道go在并發(fā)方面相對其它語言而言更突出,并發(fā)是所有的語言都有的功能,而為什么g...
    wp_nine閱讀 1,142評論 0 1
  • 概要 本文從幾個角度入手,描述和學習調度器原理 講解調度器的基本概念 go語言的作者實現的C的協程庫 libtas...
    zengfan閱讀 6,712評論 0 21
  • 久違的晴天,家長會。 家長大會開好到教室時,離放學已經沒多少時間了。班主任說已經安排了三個家長分享經驗。 放學鈴聲...
    飄雪兒5閱讀 7,814評論 16 22

友情鏈接更多精彩內容