Go 底層探索 -七-: 協程的由來
1. 介紹
Go
語言以簡單、高效地編寫高併發程序而聞名,這離不開Go
語言原語中協程(Goroutine
)的設計,也正對應了多核處理器的時代需求。
與傳統多線程開發不同,GO
語言通過更輕量級的協程讓開發更便捷,同時避免了許多傳統多線程開發需要面對的困難。因此,Go
語言在設計和使用方式上都與傳統的語言有所不同,必須理解其基本的設計哲學和使用方式才能正確地使用。
2. 系統進化史
爲了瞭解Go
語言協程的設計,我們從系統歷史設計出發,來看看最終Goroutine
怎麼一步一步到現在的設計;
2.1 單進程時代
早期的操作系統每個程序就是一個進程,操作系統在一段時間只能運行一個進程,直到這個進程運行完,才能運行下一個進程,這個時期可以成爲單進程時代——串行時代,如下圖:
單進程時代的問題:
-
單一執行流程、計算機只能一個一個任務的處理。
-
進程阻塞所帶來的
CPU
資源浪費; 比如上圖中的進程A
阻塞 (需要消耗 30 分鐘),就會導致後面進程一直在等待。單進程時代, CPU 沒有切換能力。
2.2 多進程時代
從單進程時代,可以看出CPU
沒有被充分利用,直到後來系統升級爲多進程;當一個進程阻塞時,CPU
就會切換到另外一個進程並執行,這樣就能儘量把CPU
利用起來;這樣系統就具有了最早期的併發能力。
在多進程時代,有了時間片的概念,進程按照調度算法, 分時間片在
CPU
上執行,由於 CPU 執行速度很快, 1 秒中可能切換進程好幾千次, 這樣看上去就像多個進程在同時運行一樣。
多進程時代的問題:
多進程帶來的優點顯而易見,即便是單核系統,也可以併發執行多個進程,即使某個進程IO
阻塞時,也能保證CPU
的利用率。除了優點之外,還有以下不可忽視的缺點:
-
上下文切換:進程切換時,需要保存當前正在運行進程的上下文信息(包括寄存器的狀態、程序計數器、棧指針等),然後加載下一個要執行的進程的上下文信息。這個上下文切換的過程涉及到寄存器的保存和恢復,棧的切換等操作,會消耗一定的時間和計算資源。
-
內核開銷:進程切換涉及到內核的介入。當進程切換時,操作系統需要完成一系列的任務,如更新進程控制塊、調度算法的執行、權限切換等。這些任務需要進行系統調用和內核操作,會帶來額外的開銷。
-
緩存失效:當進程切換時,
CPU
的緩存中可能包含了當前正在執行的進程的數據和指令。切換到另一個進程後,之前的緩存內容可能會變得無效,需要重新加載新進程的數據和指令。這會導致緩存失效,從而降低CPU
的效率。 -
虛擬內存切換:如果系統使用了虛擬內存技術,進程切換時還需要進行頁面表的切換和頁表的更新。這涉及到虛擬內存的管理和頁表的加載,會引入額外的開銷。
@注: 進程是資源分配的最小單位。
2.3 多線程時代
多進程時代雖然可以提高CPU
使用率,但CPU
大部分時間都被用於進程調度,除此之外,多進程之間還存在:資源不能進行共享、進程切換消耗大、創建進程開銷大等原因;
基於多進程的一些缺點,後來就誕生出:多線程(輕量級的進程) 。
2.3.1 線程和進程的關係
-
當進程只有一個線程時,可以認爲進程就等於線程。
-
當進程擁有多個線程時,這些線程會共享相同的虛擬內存和全局變量等資源。這些資源在上下文切換時是不需要修改的。
-
線程也有自己的私有數據,比如棧和寄存器等,這些在上下文切換時也是需要保存的。
@注: 線程是 CPU 調度的最小單位
2.3.2 多線程運行
線程默認棧大小:
在 32 位
Windows
系統上,每個線程默認的棧大小爲1MB
;在 64 位Windows
系統上,每個線程默認的棧大小爲2MB
。在
Linux
系統上,每個線程默認的棧大小可以通過ulimit
命令或pthread
庫的相關函數進行配置; 通常情況下,默認的棧大小爲8MB
。
多線程時代的問題:
-
內存佔用: 每個線程會都佔用
1M
以上的內存空間, 滿足不了當今需求 (動不動就是成千上萬的併發量); -
調度器: 線程的調度是由操作系統內核負責,線程之間的切換需要進行上下文的保存和恢復,這會引入一定的開銷。頻繁的線程切換會消耗大量的
CPU
時間,降低系統的整體性能; -
數量上限: 由於由操作系統的設計和實現原理,一般都對線程數量也進行了限制; (
threads-max
是一個系統參數,用於指定系統中允許的最大線程數。)
2.4 協程時代
由於線程是CPU
執行的最小單位,所以線程的切換, 執行的調度器依然是操作系統在調度的, 我們稱之爲 內核態
,後來發現了用戶態的線程
, 也就是協程(輕量級的線程)。
@注:
CPU
並不能感知到用戶態線程的存在, 它只能感知到內核態的線程,所以用戶態線程往往都需要綁定到內核態線程上。
2.4.1 協程和線程的區別
協程和線程是兩種不同的併發編程概念,它們具有一些重要的區別。
-
調度機制:線程的調度和切換由操作系統內核完成,而協程的調度和切換是由程序員手動控制的。線程的切換需要進行系統調用,涉及上下文切換和保存線程狀態,而協程的切換隻涉及用戶態的切換,開銷更小。
-
併發性:線程是操作系統級別的併發單元,多個線程可以同時執行在不同的
CPU
核心上。而協程在任意時刻只有一個在運行,其他協程處於暫停狀態,需要等待當前協程主動釋放控制權。協程通過協作式調度實現併發,多個協程之間需要顯式地進行切換。 -
內存佔用:線程需要爲每個線程分配獨立的堆棧和上下文信息,而協程的堆棧可以共用,所以協程的內存佔用更小。
-
編程模型:線程的併發編程通常需要使用鎖、條件變量等機制進行同步和通信,而協程通過消息傳遞、共享變量等方式進行通信和同步,更加簡潔和直觀。
總的來說,線程是由操作系統內核進行調度和切換的併發模型,具有系統級別的併發能力,而協程是由程序員手動控制調度和切換的併發模型,更加輕量級且具有更高的性能。協程在編程模型上更加靈活和簡潔,但需要顯式地進行切換管理。
3. Go 協程和線程
爲了更深入地理解Go
協程與線程的區別,我們從調度方式、上下文切換的速度、調度策略、棧的大小這四個方面,分析線程與協程的不同之處。
3.1 調度方式
在Go
語言中,協程的管理,依賴Go
語言運行時自身提供的調度器。同時,Go
語言中的協程從屬於某一個線程;協程與線程的對應關係爲 M:N,即多對多, 如下圖所示:
Go
語言調度器可以將多個協程調度到一個線程中,一個協程也可能切換到多個線程中執行。
3.2 上下文切換速度
協程(Goroutines
)是Go
語言中的輕量級線程,由Go
語言運行時環境(runtime
)管理。與傳統的操作系統線程相比,協程的上下文切換開銷通常較小。下面是協程上下文切換和線程上下文切換的速度對比:
-
協程上下文切換速度:協程的上下文切換由
Go
語言的運行時環境自行管理,通常不需要與操作系統進行交互,因此上下文切換的開銷較小。 -
線程上下文切換速度:線程的上下文切換由操作系統內核負責管理。線程上下文切換需要保存和恢復線程的執行狀態,包括寄存器、棧等信息,這些操作都需要與內核進行交互,開銷相對較大。
總體而言,協程的上下文切換速度通常比線程的上下文切換速度更快。這是由於協程的調度是在用戶態完成的,而線程的調度需要涉及內核態和用戶態之間的切換。協程的輕量級特性和協作式調度機制使得上下文切換的開銷較小,這使得Go
語言在高併發場景下能夠更高效地利用系統資源。
上下文切換的速度受到諸多因素的影響,這裏列出一些值得參考的量化指標:線程切換的速度大約爲 1~2 微秒,Go 語言中協程切換的速度比它快數倍,爲 0.2 微秒左右。
3.3 調度策略
3.3.1 協程調度策略
Go 語言的協程調度採用了協作式調度策略。在協作式調度中,協程主動讓出CPU
資源給其他協程運行,而不是由操作系統進行強制性的搶佔。當一個協程遇到阻塞操作時,如等待I/O
完成或休眠時間到達,它會主動交出控制權,讓其他就緒的協程運行。這種調度策略避免了搶佔式調度帶來的頻繁上下文切換和鎖競爭的開銷。
Go
語言的協程調度器會根據一些策略在協程之間平均分配CPU
時間片,以實現公平調度。此外,調度器還會根據當前系統負載和協程的阻塞情況等因素進行動態調整,以提高系統的整體性能。
3.3.2 線程調度策略
傳統的線程調度採用了搶佔式調度策略。在搶佔式調度中,操作系統內核可以在任何時間中斷正在執行的線程,並將CPU
資源分配給其他就緒的線程。這種調度策略依賴於時鐘中斷或其他事件觸發的機制,以進行上下文切換和線程的調度。
線程調度器通常採用一些調度算法,如時間片輪轉、優先級調度等,以決定哪個線程能夠執行,並在需要時進行上下文切換。這種調度策略可以在多核系統中更好地利用硬件資源,並允許操作系統對線程進行強制性的搶佔。
3.4 棧的大小
線程的棧大小一般是在創建時指定的,爲了避免出現棧溢出(Stack Overflow)
,默認的棧會相對較大(例如2MB
),這意味着每創建1000
個線程就需要消耗2GB
的虛擬內存,大大限制了線程創建的數量(64
位的虛擬內存地址空間已經讓這種限制變得不太嚴重)。
而Go
語言中的協程棧默認爲2KB
,在實踐中,經常會看到成千上萬的協程存在。同時,線程的棧在運行時不能更改,但是Go
語言中的協程棧在Go
運行時的幫助下會動態檢測棧的大小,並動態地進行擴容。
4. 主協程和子協程
4.1 示例代碼
package main
import "fmt"
func main() {
// 在循環中開啓協程打印1~10的數字
for i := 1; i <= 10; i++ {
n := i
go func() {
fmt.Println("i:", n)
}()
}
}
@注: 當執行上述程序,不會有任何輸出;原因是: 當主協程退出時,程序就會直接退出,這是主協程與其他協程的顯著區別。
4.2 定義和區別
4.2.1 定義
-
主協程:主協程是指程序的主要執行線程,在程序啓動時由系統自動創建。主協程負責執行程序的入口函數(通常是
main()
函數)以及其他頂層協程的創建和管理。主協程一般不會被阻塞或長時間執行耗時操作,它通常負責啓動並管理整個程序的併發執行流程。 -
子協程:子協程是由主協程創建的額外協程,用於執行併發任務。子協程可以通過
Go
關鍵字創建,例如go funcName()
,其中funcName()
是一個函數或匿名函數。子協程可以獨立執行,並且可以與其他協程併發運行。
4.2.2 區別
主協程和子協程之間的區別主要體現在以下幾個方面:
-
創建方式:主協程是由系統自動創建的,而子協程需要通過
go
關鍵字顯式創建。 -
主要任務:主協程負責程序的整體控制流程和頂層邏輯,而子協程用於執行具體的併發任務。
-
生命週期:主協程的生命週期與整個程序的生命週期相同,而子協程可以在任意時刻創建和銷燬。
-
管理關係:主協程負責管理子協程的創建、執行和等待,確保協程的協同工作。主協程可能會等待子協程的完成或等待子協程的結果。
-
阻塞與非阻塞:主協程可能會被阻塞,例如等待子協程的完成或等待通道的消息。而子協程通常是非阻塞的,並且可以獨立執行,不會阻塞主協程或其他子協程的運行。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/85sEdjQUrmkOYaKG5Uthtg