一文看懂 Go 語言協程的設計與原理

背景

Go 語言最大的特色就是從語言層面支持併發(Goroutine),Goroutine 是 Go 中最基本的執行單元。事實上每一個 Go 程序至少有一個 Goroutine:main Goroutine。Go 程序從 main 包的 main() 函數開始,在程序啓動時,Go 程序就會爲 main() 函數創建一個默認的 goroutine。

爲了瞭解 Go 語言協程的設計,我們從歷史設計出發,來看看最終 Goroutine 怎麼一步一步到現在的設計的。

單進程時代

早期的操作系統每個程序就是一個進程,操作系統在一段時間只能運行一個進程,直到這個進程運行完,才能運行下一個進程,這個時期可以成爲單進程時代——串行時代。

如圖:進程之間串行執行,A、B、C 三個進程按順序執行。

單進程時代的兩個問題:

  1. 單一執行流程、計算機只能一個任務一個任務的處理。2. 進程阻塞所帶來的 CPU 浪費時間是不可避免的 (如進程 A 阻塞了,然後 CPU 是單進程沒有任何的切換能力,但是需要等待進程 A 結束後才能執行下個進程)

遇到這種問題,我們怎麼才能充分利用 CPU 呢?

多進程時代

後來操作系統就具有了最早的併發能力:多進程併發,當一個進程阻塞的時候,切換到另外等待執行的進程,這樣就能儘量把 CPU 利用起來,CPU 就不浪費了。

在多進程時代,有了時間片的概念,進程按照調度算法分時間片在 CPU 上執行,A、B、C 三個進程按照時間片併發執行。(調度算法)

這樣做有兩個優點:

  1. 對於單個核可以併發執行多個進程,應用場景更加豐富,2. 當某個進程 IO 阻塞時,也能保證 CPU 的利用率。

但是隨着時代的發展,CPU 通過進程來進行調度的缺點也越發的明顯。

進程切換需要:

  1. 切換頁目錄以使用新的地址空間 2. 切換內核棧和硬件上下文

因爲進程擁有太多資源,在創建、切換和銷燬的時候,都會佔用很長的時間,CPU 雖然利用起來了,但 CPU 有很大的一部分都被用來進行進程調度了。

怎麼才能提高 CPU 的利用率呢?

多線程時代

所以,輕量級的進程:線程誕生了。線程運行所需要的資源比進程少多了。

對於線程和進程,我們可以這麼理解:

• 當進程只有一個線程時,可以認爲進程就等於線程。• 當進程擁有多個線程時,這些線程會共享相同的虛擬內存和全局變量等資源。這些資源在上下文切換時是不需要修改的。• 線程也有自己的私有數據,比如棧和寄存器等,這些在上下文切換時也是需要保存的。

線程是 CPU 調度的最小單位, 進程是資源分配的最小單位。

• 進程:進程是資源分配的最小單位,進程在執行過程中擁有獨立的內存單元。• 線程:線程是 CPU 調度的最小單位,線程切換隻須保存和設置少量寄存器的內容。

雖然線程比較輕量,但是在調度時也有比較大的額外開銷。每個線程會都佔用 1M 以上的內存空間,在切換線程時不止會消耗較多的內存,恢復寄存器中的內容還需要向操作系統申請或者銷燬資源。

多進程、多線程已經提高了系統的併發能力,但是在當今互聯網高併發場景下,爲每個任務都創建一個線程是不現實的,因爲每個線程需要有自己的棧空間,大量的線程需要佔用大量的內存空間,同時線程的數量還受系統參數threads-max等參數的限制。

有沒有更輕量級的線程來支持當今互聯網的高併發場景呢。如何才能充分利用 CPU、內存等資源的情況下,實現更高的併發?

協程時代

協程作爲用戶態線程,也是輕量級的線程,用來解決高併發場景下線程切換的資源開銷。

你可能知道:線程分爲內核態線程和用戶態線程,用戶態線程需要綁定內核態線程,CPU 並不能感知用戶態線程的存在,它只知道它在運行 1 個線程,這個線程實際是內核態線程。

用戶態線程實際有個名字叫協程(co-routine),爲了容易區分,我們使用協程指用戶態線程,使用線程指內核態線程。

協程跟線程是有區別的

• 線程 / 進程是內核進行調度,有 CPU 時間片的概念,進行 搶佔式調度(有多種調度算法)• 協程 對內核是透明的,也就是系統並不知道有協程的存在,是完全由用戶自己的程序進行調度的,因爲是由用戶程序自己控制,那麼就很難像搶佔式調度那樣做到強制的 CPU 控制權切換到其他進程 / 線程,通常只能進行 協作式調度,需要協程自己主動把控制權轉讓出去之後,其他協程才能被執行到。

協程的調度

1:1 調度

1 個協程綁定 1 個線程,這種最容易實現。協程的調度都由 CPU 完成了,但有一個缺點是協程的創建、刪除和切換的代價都由 CPU 完成,上下文切換很慢,同等於線程切換。

N:1 調度

N 個協程綁定 1 個線程,優點就是協程在用戶態線程即完成切換,不會陷入到內核態,這種切換非常的輕量快速。但也有很大的缺點,1 個進程的所有協程都綁定在 1 個線程上,一是某個程序用不了硬件的多核加速能力,二是一旦某協程阻塞,造成線程阻塞,本進程的其他協程都無法執行了,根本就沒有併發的能力了。

M:N 調度

M 個協程綁定 N 個線程,是 N:1 和 1:1 類型的結合,克服了以上 2 種模型的缺點,但實現起來最爲複雜。

go 語言協程調度

Go runtime 的調度器

Go 語言的調度器通過使用與 CPU 數量相等的線程減少線程頻繁切換的內存開銷,同時在每一個線程上執行額外開銷更低的 Goroutine 來降低操作系統和硬件的負載。

而每個 goroutine 非常輕量級,只佔幾 KB 的內存,這就能在有限的內存空間內支持大量 goroutine,支持了更多的併發。雖然一個 goroutine 的棧只佔幾 KB,但實際是可伸縮的,如果需要更多內容,runtime 會自動爲 goroutine 分配。

goroutine 建立在操作系統線程基礎之上,它與操作系統線程之間實現了一個多對多 (M:N) 的兩級線程模型。

這裏的 M:N 是指 M 個 goroutine 運行在 N 個內核線程之上,內核負責對這 N 個操作系統線程進行調度,而這 N 個系統線程又通過 goroutine 調度器負責對這 M 個 goroutine 進行調度和運行。

G-M 模型

Go1.0 的協程是 G-M 模型

•G 指 Goroutine,本質上是輕量級線程,包括了調用棧,重要的調度信息,例如 channel•M 指 Machine,一個 M 關聯一個內核 OS 線程,由操作系統管理。

M(內核線程)想要執行、放回 G 都必須訪問全局G隊列,並且 M 有多個,即多線程訪問同一資源需要加鎖進行保證互斥 / 同步,所以全局 G 隊列是有互斥鎖進行保護的。

這個調度器有幾個缺點:

• 存在單一全局互斥鎖和集中狀態。全局鎖保護所有 goroutine 相關操作(如:創建、完成、重新調度等),導致鎖競爭問題嚴重;•goroutine 傳遞問題:經常在 M 之間傳遞 “可運行” 的 goroutine,這導致調度延遲增大;• 每個線程 M 都需要做內存緩存(M.mcache),導致內存佔用過高,且數據局部性較差;• 系統調用頻繁地阻塞和解除阻塞正在運行的線程,導致額外的性能損耗。

G-P-M 模型

新的協程調度器引入了 P(Processor),成爲了完善的 GPM 模型。Processor,它包含了運行 goroutine 的資源,如果線程想運行 goroutine,必須先獲取 P,P 中還包含了可運行的 G 隊列。

上圖中各個模塊的作用如下:

• 全局隊列:存放等待運行 G•P 的本地隊列:和全局隊列類似,存放的也是等待運行的 G,存放數量上限 256 個。新建 G 時,G優先加入到P的本地隊列,如果隊列滿了,則會把本地隊列中的一半G移動到全局隊列•P 列表:所有的 P 都在程序啓動時創建,保存在數組中,最多有 GOMAXPROCS 個,可通過 runtime.GOMAXPROCS(N) 修改,N 表示設置的個數。•M:每個 M 代表一個內核線程,操作系統調度器負責把內核線程分配到 CPU 的核心上執行。

簡單的來說,一個 G 的執行需要 M 和 P 的支持。一個 M 在與一個 P 關聯之後形成了一個有效的 G 運行環境【內核線程 + 上下文環境】。每個 P 都會包含一個可運行的 G 的隊列 (runq)。

I5a9ZJ

調度策略:調度器核心思想是儘可能避免頻繁的創建、銷燬線程,對線程進行復用以提高效率。

1.work stealing 機制(竊取式)

當本線程無 G 可運行時,從其他線程綁定的 P 竊取 G,而不是直接銷燬線程。

1.hand off 機制

當本線程 M1 因爲 G 進行的系統調用阻塞時,線程釋放綁定的 P,把 P 轉移給其他空閒的 M0 執行。

搶佔

一個 goroutine 最多佔用 CPU 10ms,防止其他 goroutine 等待太久得不到執行被 “餓死”。

全局 G 隊列

全局 G 隊列是有互斥鎖保護的,訪問需要競爭鎖,新的調度器將其功能弱化了,當 M 執行 work stealing 從其他 P 竊取不到 G 時,纔會去全局 G 隊列獲取 G。

總結

本文只是從歷史和宏觀角度解釋了 Goroutine 的設計原理,當然具體的代碼實現遠比這個複雜。後續會繼續更新 Go 語言協程的源碼到底是怎麼實現的。

P 的作用是什麼?

對比我們可以發現:

•G-M 模型 起初的 Go 併發性能並不十分亮眼,協程和系統線程的調度比較粗暴,導致很多性能問題,如全局資源鎖、M 的內存過高等造成許多性能損耗。• 加入 P 的設計之後實現了一個通過 work stealing 的調度算法:由 P 來維護 Goroutine 隊列並選擇一個適當的 M 綁定。P 提供了相關的執行環境 (Context),如內存分配狀態(mcache),任務隊列(G) 等,P 的數量決定了系統內最大可並行的 G 的數量。

goroutine 存在的意義是什麼?

goroutine 的存在必然是爲了換個方式解決操作系統線程的一些弊端

  1. 創建和切換太重 操作系統線程的創建和切換都需要進入內核,而進入內核所消耗的性能代價比較高,開銷較大;2. 內存使用太重 內核在創建操作系統線程時默認會爲其分配一個較大的棧內存,內核在創建操作系統線程時默認會爲其分配一個較大的棧內存,同時會有溢出的風險;

goroutine 的優勢也就是 開銷小

1.goroutine 是用戶態線程,其創建和切換都在用戶代碼中完成而無需進入操作系統內核,所以其開銷要遠遠小於系統線程的創建和切換;2.goroutine 啓動時默認棧大小隻有 2k,這在多數情況下已經夠用了,即使不夠用,goroutine 的棧也會自動擴大,同時,如果棧太大了過於浪費它還能自動收縮,這樣既沒有棧溢出的風險,也不會造成棧內存空間的大量浪費。

其他語言的協程

協程是個好東西,不少語言支持了協程,比如:Lua、Erlang,就算語言不支持,也有庫支持協程,比如 C 語言的 coroutine、Java 的 coroutines、C++ 的 libco 和 libgo、Kotlin 的 kotlinx.coroutines、Python 的 gevent。

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