深入理解 Go 語言 — 協程和調度

在今天的編程世界中,協程已經成爲了構建併發程序的重要工具。協程提供了一種在單個線程中處理多個任務的強大機制。

而在 Go 語言中,協程的概念被引入爲 “goroutine”,並作爲語言核心特性之一。在本文中,我們將深入探討 Go 語言中的協程及其調度機制。

一、協程的概念

協程並不是 Go 發明的概念,維基百科上記錄,協程術語 coroutine 最早出現在 1963 年發表的論文中,論文的作者爲美國計算機科學家 Melvin E. Conway (他提出過著名的康威定律)。

支持協程的編程語言有很多,比如大名鼎鼎的 Python、Perl 等等,但沒有那個語言像 Go 一樣把協程支持得如此優雅,Go 在語言層面直接提供對協程的支持成爲 goroutine 。

理解進程、線程、協程

在深入瞭解 goroutine 之前,我們先來看看進程、線程、協程區別。

進程:是應用程序的啓動實例,每個進程都有獨立的內存空間,不同進程通過進程間的通信方式來通信。

線程:線程從屬於進程,每個進程至少包含一個線程,線程是 CPU 調度的基本單位,多個線程之間可以共享進程的資源並通過共享內存等線程間的通信方式來通信。

協程
協程可理解爲一種輕量級線程,主要理解如下:

  1. 協程是用戶級別的線程:協程由用戶程序自行控制,而線程通常由操作系統調度。因此,協程在切換上下文時通常比線程更快,並且協程通常比線程更輕量級。

  2. 協程可以在一個線程內併發執行:多個協程可以在一個線程中併發執行,而線程則是併發執行的基本單位。

  3. 協程的棧大小是可變的:協程的棧大小可以根據需要動態增長和縮小,而線程的棧大小通常是固定的。

  4. 協程不受操作系統調度:與線程相比,協程調度器由用戶應用程序提供,協程調度器按照調度策略把協程調度到線程中運行。

Go 應用程序的協程調度器由 runtime 包提供,用戶使用 go 關鍵字即可創建協程,這就是在語言層面直接支持協程的含義。

協程的優點

在高併發應用中頻繁創建線程會造成不必要的開銷,所以有了線程池技術:該技術是在線程池中預先保存一定數量的線程,新任務將不再創建新線程的方式去執行,而是直接將任務發佈到任務隊列中,線程池中的線程不停地從任務隊列中取出任務並執行,比如 Java 語言的線程池,這樣有效地減少了創建和銷燬線程帶來的系統開銷。

上圖展示了一個典型的線程池,任務隊列中有多個任務(稱作 G),任務 G 在代碼中往往是一個函數。線程池的 Worker 線程不斷地從任務隊列中取出任務執行,而 Worker 線程則交給操作系統來進行調度。

如果 Worker 線程執行的 G 任務存在系統調用,則操作系統將會把該線程置爲阻塞狀態,這樣會導致消費任務隊列的 Worker 線程數量變少了,即線程池的消費任務的能力變弱了。

解決這個問題的一個方法是:**重新審視線程池中的線程數量,增加線程池中的數量可以在一定程度內提供消費能力。**但是如果線程數量增多,過多的線程會爭奪 CPU 資源,消費任務的能力有上限,甚至會出現消費能力下降的現象。

因爲過多的線程會導致上下文切換開銷變大(用戶態和內核態),而工作在用戶態的協程則能大大減少上下文切換的開銷。

協程的調度器把可運行的協程逐個調度到線程中執行,同時及時把阻塞的協程調度出線程,從而有效地避免了線程的頻繁切換,達到使用少量線程實現高併發的效果。

二、什麼是 goroutine?

在 Go 語言中,goroutine 是協程的具體實現。Go 語言的設計者在創建 goroutine 時,借鑑了協程的思想,但又進行了一些獨特的改進。

Go 中的每個程序至少有一個 goroutine:主 goroutine。當程序啓動時,主 goroutine 就開始運行。你可以把 goroutine 看作一個輕量級的線程。與線程不同,創建一個 goroutine 的成本很小,只需要幾千字節的內存。因此,一個 Go 程序可以輕易地啓動上百萬個 goroutine。

在 Go 語言中,啓動一個 goroutine 的語法非常簡單,只需要在函數調用前加上 go 關鍵字:

go doSomething()

這段代碼將在一個新的 goroutine 中運行 doSomething 函數,而主 goroutine 將繼續執行後續的代碼。

三、goroutine 的調度

講解 goroutine 的調度之前,先簡單介紹一下線程的調度模型:
線程可分爲用戶線程和內核線程,用戶線程由用戶創建、同步和銷燬,內核線程則由內核來管理。根據用戶線程管理方式的不同,可以分爲三種線程模型:

Go 語言擁有自己的調度器,這個調度器在用戶態和內核態之間進行切換。Go 調度器的工作方式類似於操作系統的線程調度,但它只關注 goroutine。

Go 調度器使用了 M:N 調度模型,即 M 個 goroutine 映射到 N 個 OS 線程。這種模型充分利用了多核處理器的優勢,並且能夠在任何 goroutine 阻塞時切換到其他非阻塞的 goroutine,從而保持 CPU 的利用率。
Go 協程調度模型中包含了三個關鍵實體,machine(簡稱 M),processor (簡稱 P)和 goroutine(簡稱 G):

M 必須持有 P 才能執行代碼,和系統的其他線程一樣,M 也會被系統調用阻塞。P 的個數在程序啓動時決定。默認情況下等同於 CPU 核數,可以使用環境變量 GOMAXPROCS 或在程序中使用 runtime.GOMAXPROCS()指定 P 的個數。

// 使用環境變量設置 GOMAXPROCS 爲 80
export GOMAXPROCS=80

// 使用 runtime.GOMAXPROCS() 方法設置 GOMAXPROCS 爲 80
runtime.GOMAXPROCS(80)

注意:M 的個數一般稍大於 P 的個數,因爲除了運行 go 代碼,runtime 包還有其他內置任務需要處理。

簡單的調度器模型如下:

上圖包括兩個工作線程 M,每個 M 持有一個處理器 P,並且每個 M 中有一個協程 G 在運行(即綠色的 G),旁邊的橘黃色的 G 正在等待被調度,它們位於 runqueues 隊列中。

每一個處理器 P 擁有一個 runqueues 隊列,此外還有一個全局的 runqueues 隊列,它由多個處理器 P 共享。

爲什麼會設計局部和全局的 runqueues 隊列呢?

原因是:在早期的調度器實現中(Go 1.1 之前)只有全局 runqueues ,多個處理器 P 通過互斥鎖來調度隊列中的協程,在多核 CPU 環境中,多個處理器需要爭搶鎖來調度全局的隊列協程,嚴重影響了併發執行的效率。後來引入了局部的 runqueues ,每個處理器 P 訪問自己的 runqueues 隊列時不需要加鎖,大大提高了效率。

一般來說,處理器 P 中的協程 G 額外再創建的協程會加入本地的 runqueues 隊列中,但是如果本地的隊列已滿或阻塞的協程被喚醒,則協程會被放入全局的 runqueues 隊列中,處理器 P 除了調度本地的 runqueues 隊列中的協程以外,還會週期性的從全局 runqueues 隊列中摘取協程來調度,這就是接下來要介紹的調度策略。

四、調度策略

Go 的調度策略也是不斷進行演進的,讓 Go 支持越來越多的調度策略,以滿足不同場景的併發需求。

隊列輪轉

每個處理器 P 維護着一個協程 G 的隊列,處理器 P 依次將協程 G 調度到 M 中執行。
同時,每個 P 會週期性的查詢全局隊列中是否有 G 待運行,如果有則將其調度到 M 中執行,這樣做爲了避免全局隊列中的 G 長時間得不到調度機會而被 “餓死”。

系統調用

前面提到,當線程在執行系統調用時,可能會阻塞,對應到調度器模型,如果一個協程發起系統調用,那麼對應的工作線程會被阻塞,這樣會導致處理器 P 的 runqueues 隊列中的協程得不到調度,相當於隊列中的所有協程都會被阻塞。

上面說到 P 的個數默認等於 CPU 的核數,每個 M 必須持有一個 P 才能執行 G。所以一般情況下我們可以設置 M 的個數會稍大於 P 的個數,多出來的 M 將會在 G 產生系統調用時發揮作用。這一點和線程池類似,Go 也會提供一個 M 的池子,需要 M 時從池子中獲取,用完再放回池子,不夠用時再創建一個新的 M。

如上圖所示,當 G0 即將進入系統調用時,M0 將釋放 P,進而某個冗餘的 M1 獲取了 P,繼續執行 P 隊列中剩下的 G,這樣保證了 P 不空閒,充分利用了 CPU 資源。

當 G0 結束系統調用後,根據 M0 是否能獲取到 P,對 G0 進行不同的處理:

工作量竊取

通過 go 關鍵字創建的協程通常會優先放入當前協程對應的處理器中,這樣做可能會出現有些協程自身不斷的派生新的協程,而有些協程不派生協程,這樣會造成多個處理器 P 中維護的 G 隊列時不均衡的。如果不加以控制的話,則可能會出現部分處理器 P 非常忙碌,而部分處理器 P 空閒的情況。

爲了解決這個問題,Go 調度器提供了工作量竊取策略,即當某個處理器 P 沒有需要調度的協程時,將從其他的處理器中竊取協程,示例如下:

發起竊取之前,處理器 P 會查詢全局隊列,如果全局隊列中也沒有協程需要調度的,則會從另一個正在運行的處理器 P 中竊取協程,每次偷取一半,偷取的效果如上圖所示。

搶佔式調度

搶佔式調度指的是:避免某個協程長時間執行,而阻礙其他協程被調度的機制。
調度器會監控每個協程的執行時間,一旦發現協程執行時間過長其有其他協程在等待時,會把協程暫停,轉而調度等待的協程,以達到類似於時間片輪轉的效果。

在 Go 1.14 之前,Go 的協程調度器搶佔式調度有一定的缺陷,在該設計中,在函數調用間隙進行檢查該協程是否可被搶佔,如果協程沒有函數調用,則會無限期地佔用執行權,以下代碼在 Go 1.14 之前會陷入協程無限循環中,協程永遠無法被搶佔,導致主協程無法繼續執行。Go 1.14 調度器引入了基於信號的搶佔機制,該問題才得以解決。

package main

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

func main() {
    // 打印 go version
    fmt.Println(runtime.Version())

    // 設置 指定 P 的個數
    runtime.GOMAXPROCS(1)

    go func() {
        for {
            // 無函數調用的無限循環
        }
    }()

    time.Sleep(1 * time.Second) // 協同調用,出讓執行權給上面的協程

    println("main done.")
}

Go 1.13.5 版本執行結果

Go 1.19.4 版本執行結果

GOMAXPROCS 對性能的影響

一般來說,程序運行時就將 GOMAXPROCS 的大小設置爲 CPU 核數,可以讓 Go 程序充分利用 CPU,這個適合計算型應用。但是在某些 I/O 密集型的應用中,這個值可能並不意味着性能最好。

理論上當某個 goroutine 進入系統調用時,會有一個新的 M 被啓用或創建,繼續佔滿 CPU,但是這個只是理論上,因爲 Go 調度器檢測到 M 被阻塞是有一定延遲的,即舊的 M 被阻塞和新的 M 得到運行之間是有一定時間間隔的。

所以在 I/O 密集型的應用中不妨把 GOMAXPROCS 的值設置的大一些,這樣或許會有更好的效果。

五、總結

Go 語言的 goroutine 是一種非常強大的併發工具。通過 goroutine,我們可以在同一個程序中同時運行多個任務。Go 語言的內置調度器確保了 goroutine 之間的高效調度。

goroutine 是輕量級的,創建和銷燬的成本低,因此可以在一個 Go 程序中創建大量的 goroutine。通過 Channel,goroutine 之間可以安全、有效地進行通信。

總的來說,Go 語言爲我們提供了一種簡單、高效的併發模型。無論你是正在構建一個高併發的網絡服務,還是需要進行大量的並行計算,Go 語言都是一個非常好的選擇。

希望本文能幫助你深入理解 Go 語言的 goroutine,也希望你能在你的 Go 語言編程旅程中充分利用這個強大的工具。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/eHHEjsODoi5i2numHGUEGg