Go 協程池 -1-: 線程 vs 協程

衆所周知,Goroutine(也叫協程) 運行在用戶態,由 Go runtime 管理。而操作系統線程同時處於用戶態和內核態。兩者的差別體現在四個方面:

  1. 數據結構

  2. 創建時的內存佔用

  3. 運行時狀態

  4. 上下文切換

數據結構

對於每個線程,內核中都維護一個 task_struct 對象,它還有一個名字叫 thread control block,簡稱 TCB。這個結構中記錄了線程當前的狀態、執行的上下文和調度信息。TCB 由內核管理,用戶態的代碼無法直接訪問。TCB 中的關鍵字段有:

在 Linux 內核中, 線程和進程共用 task_struct 結構,所以該結構中存在大量涉及到進程狀態的字段,這裏不展開說。

對於協程而言,Go Runtime 在用戶態維護一個 struct g 對象,關鍵字段有:

創建時的內存佔用

在 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
}

運行時狀態

線程的運行時依賴很多寄存器, 包括:

如需查看所有寄存器,可 Google 搜索 "x86 Registers"。

協程運行時只依賴三個寄存器:

上下文切換

線程在執行時通常處於用戶態,如果需要 CPU 在更高的權限級別執行,則需要通過系統調用陷入內核態執行。內核態能訪問的資源有:

進行上下文切換時,需要:

上下文切換通常消耗數百到數千個 CPU 週期,線程數比較多或系統負載比較高時,耗費時間更長。線程中常見的 CPU 計算、內存訪問均可以在用戶態下完成,而線程的調度在內核態由 linux 內核完成。所以線程切換意味着,線程必須首先從用戶態進入內核態。

協程進行上下文切換時,流程如下:

  1. 保存當前的 CPU 上下文: 其實就是上面提到的三個寄存器程序計數器、棧指針和 data register

  2. 切換到調度器,準備選擇新的 goroutine 去執行

  3. 調度器選擇新的 goroutine,並恢復其狀態,然後觸發執行

可以看到,協程的上下文切換中,只需要處理這幾個寄存器,更新調度器狀態,全部在用戶態下完成。

總的來說,由於協程比線程更爲輕量級,操作系統下可以支持數量有限的線程,但可以輕鬆支持上萬個甚至上百萬個協程。在 GMP 模型下,M (machine) 在邏輯上是線程,Go 推薦 M 的數量與 CPU 的核心數保持一致,即 GOMAXPROCS.

最後,我們簡單討論下協程池的問題: 協程是如此的輕量級,是否還需要協程池呢?

首先,協程的創建、銷燬和上下文切換也是有代價的。如果這個代價在整個 CPU 的時間片中使用佔比過高,仍然有創建協程池的必要。IO 密集型的服務正好符合這個條件,比如高併發的 Web 服務器。相反,CPU 密集型的服務,大部分 CPU 時間花在了計算邏輯上,就不需要協程池。

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