Go 協程池 -1-: 線程 vs 協程
衆所周知,Goroutine(也叫協程) 運行在用戶態,由 Go runtime 管理。而操作系統線程同時處於用戶態和內核態。兩者的差別體現在四個方面:
-
數據結構
-
創建時的內存佔用
-
運行時狀態
-
上下文切換
數據結構
對於每個線程,內核中都維護一個 task_struct 對象,它還有一個名字叫 thread control block,簡稱 TCB。這個結構中記錄了線程當前的狀態、執行的上下文和調度信息。TCB 由內核管理,用戶態的代碼無法直接訪問。TCB 中的關鍵字段有:
-
進程 ID (pid): 當前線程從屬的進程
-
線程 ID (tid): 線程的唯一 ID, 內核通過這個 ID 識別和管理線程
-
程序計數器 (program counter, pc): 指向下一個要執行指令的寄存器
-
棧指針 (stack pointer, sp):
指向線程棧頂部的寄存器
-
寄存器集 (register set): 一組用來執行代碼的 CPU 寄存器
-
調度信息: 線程的優先級、調度策略、時間片等
-
內存管理: 線程的內存使用情況
-
等等
在 Linux 內核中, 線程和進程共用 task_struct 結構,所以該結構中存在大量涉及到進程狀態的字段,這裏不展開說。
對於協程而言,Go Runtime 在用戶態維護一個 struct g 對象,關鍵字段有:
-
stack: 通過兩個字段 lo 和 hi 分別記錄棧的底部和頂部的地址
-
stacksize: 棧大小, 默認是 2KB
-
goid: 協程的唯一 ID,runtime 用於管理該協程
-
status: 協程的狀態, 可以是 _Gwaiting, _Grunning, _Gdead, _Gsyscall(系統調用), _Gscan(GC 掃描), _Gpreempted 等
-
sched: 調度狀態, sp, pc 等寄存器的狀態都存儲在這個結構裏
-
gopc: 創建當前 goroutine 對應的 go 語句的 pc
-
等等
創建時的內存佔用
在 x86_64 架構下,每一個活躍的線程都有一個內核棧,大小是 THREAD_SIZE=16KB, 計算公式是:
// 位置: arch/x86/include/asm/page_types.h
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT) // => 4096
// 位置: arch/x86/include/asm/page_64_types.h
#define THREAD_SIZE_ORDER 2 (或3)
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER) // => 16384
struct thread_struct 本身也會佔用一些內存,在調用 do_fork()
時,內核會給新線程的 task_struct 分配內存,並初始化內核棧。
對於協程而言,stacksize = 2048,分配 stacksize=2KB 的棧空間,相關的代碼如下:
// 位置: src/runtime/stack.go
const (
// The minimum size of stack used by Go code
_StackMin = 2048
創建協程時,g 本身也會佔用內存空間,不過這部分內存不會動態增長。下面這段代碼給出了分配內存的邏輯:
// 位置: runtime/proc.go
// 創建一個新的g,狀態是 _Grunnable
// callerpc是對應的 `go func`語句的地址
// 調用方負責把新的g添加到調度器
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
_g_ := getg()
// ...省略部分代碼
acquirem() // disable preemption because it can be holding p in a local var
_p_ := _g_.m.p.ptr()
newg := gfget(_p_)
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}
// 分配一個新的g,並分配棧內存
func malg(stacksize int32) *g {
newg := new(g)
if stacksize >= 0 {
stacksize = round2(_StackSystem + stacksize)
systemstack(func() {
newg.stack = stackalloc(uint32(stacksize))
})
newg.stackguard0 = newg.stack.lo + _StackGuard
newg.stackguard1 = ^uintptr(0)
// Clear the bottom word of the stack. We record g
// there on gsignal stack during VDSO on ARM and ARM64.
*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
}
return newg
}
運行時狀態
線程的運行時依賴很多寄存器, 包括:
-
程序計數器 (Program Counter)
-
通用寄存器,可以有 16 個,包含棧指針 (Stack Pointer)
-
Segment registers, 存儲代碼片段、數據片段等
-
Index and Pointers, 用於字符串、字節數組的拷貝、存儲棧的地址、下一個指令的地址等
-
Indicator, 存儲處理器的狀態信息
如需查看所有寄存器,可 Google 搜索 "x86 Registers"。
協程運行時只依賴三個寄存器:
-
程序計數器 (program counter, pc)
-
棧指針 (stack pointer, sp), 存儲棧內存的高位地址
-
DX (data register) 用於乘除運算,是通用寄存器的一種
上下文切換
線程在執行時通常處於用戶態,如果需要 CPU 在更高的權限級別執行,則需要通過系統調用陷入內核態執行。內核態能訪問的資源有:
-
硬件設備: 常見的有驅動程序
-
操作系統內存: 需要內核完成一些功能,比如線程調度
進行上下文切換時,需要:
-
保存當前線程的寄存器,恢復新線程的寄存器,涉及到內存和 CPU cache 之間的數據拷貝;
-
內核調度器存儲了線程的狀態信息,這部分需要更新
-
切換線程棧
上下文切換通常消耗數百到數千個 CPU 週期,線程數比較多或系統負載比較高時,耗費時間更長。線程中常見的 CPU 計算、內存訪問均可以在用戶態下完成,而線程的調度在內核態由 linux 內核完成。所以線程切換意味着,線程必須首先從用戶態進入內核態。
協程進行上下文切換時,流程如下:
-
保存當前的 CPU 上下文: 其實就是上面提到的三個寄存器程序計數器、棧指針和 data register
-
切換到調度器,準備選擇新的 goroutine 去執行
-
調度器選擇新的 goroutine,並恢復其狀態,然後觸發執行
可以看到,協程的上下文切換中,只需要處理這幾個寄存器,更新調度器狀態,全部在用戶態下完成。
總的來說,由於協程比線程更爲輕量級,操作系統下可以支持數量有限的線程,但可以輕鬆支持上萬個甚至上百萬個協程。在 GMP 模型下,M (machine) 在邏輯上是線程,Go 推薦 M 的數量與 CPU 的核心數保持一致,即 GOMAXPROCS.
最後,我們簡單討論下協程池的問題: 協程是如此的輕量級,是否還需要協程池呢?
首先,協程的創建、銷燬和上下文切換也是有代價的。如果這個代價在整個 CPU 的時間片中使用佔比過高,仍然有創建協程池的必要。IO 密集型的服務正好符合這個條件,比如高併發的 Web 服務器。相反,CPU 密集型的服務,大部分 CPU 時間花在了計算邏輯上,就不需要協程池。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/-qms3hEl2mNMkDN1S4ZQoQ