圖解 Go 協程原理及實戰

引言

Golang 的語法和運行時直接內置了對併發的支持。Golang 裏的併發指的是能讓某個函數獨立於其他函數運行的能力。當一個函數創建爲 goroutine 時,Golang 會將其視爲一個獨立的工作單元。這個單元會被調度到可用的邏輯處理器上執行。

Golang 運行時的調度器是一個複雜的軟件,能管理被創建的所有 goroutine 併爲其分配執行時間。這個調度器在操作系統之上,將操作系統的線程與語言運行時的邏輯處理器綁定,並在邏輯處理器上運行 goroutine。調度器在任何給定的時間,都會全面控制哪個 goroutine 要在哪個邏輯處理器上運行。Golang 的併發同步模型來自一個叫作通信順序進程 (Communicating Sequential Processes,CSP) 的範型(paradigm)。CSP 是一種消息傳遞模型,通過在 goroutine 之間傳遞數據來傳遞消息,而不是對數據進行加鎖來實現同步訪問。用於在 goroutine 之間同步和傳遞數據的關鍵數據類型叫作通道(channel)。

調度器對可以創建的邏輯處理器的數量沒有限制,但語言運行時默認限制每個程序最多創建 10000 個線程。這個限制值可以通過調用 runtime/debug 包的 SetMaxThreads 方法來更改。如果程序試圖使用更多的線程,就會崩潰。

一、mac 的 cpu 處理器個數、核數、超線程

查看 CPU 信息

sysctl machdep.cpu

其中

machdep.cpu.xsave.extended_state: 31 832 1088 0
machdep.cpu.xsave.extended_state1: 15 832 256 0
machdep.cpu.tlb.data.small: 64
machdep.cpu.tlb.data.small_level1: 64
machdep.cpu.tlb.inst.large: 8
machdep.cpu.thermal.ACNT_MCNT: 1
machdep.cpu.thermal.core_power_limits: 1
machdep.cpu.thermal.dynamic_acceleration: 1
machdep.cpu.thermal.energy_policy: 1
machdep.cpu.thermal.fine_grain_clock_mod: 1
machdep.cpu.thermal.hardware_feedback: 0
machdep.cpu.thermal.invariant_APIC_timer: 1
machdep.cpu.thermal.package_thermal_intr: 1
machdep.cpu.thermal.sensor: 1
machdep.cpu.thermal.thresholds: 2
machdep.cpu.mwait.extensions: 3
machdep.cpu.mwait.linesize_max: 64
machdep.cpu.mwait.linesize_min: 64
machdep.cpu.mwait.sub_Cstates: 286531872
machdep.cpu.cache.L2_associativity: 4
machdep.cpu.cache.linesize: 64
machdep.cpu.cache.size: 256
machdep.cpu.arch_perf.events: 0
machdep.cpu.arch_perf.events_number: 7
machdep.cpu.arch_perf.fixed_number: 3
machdep.cpu.arch_perf.fixed_width: 48
machdep.cpu.arch_perf.number: 4
machdep.cpu.arch_perf.version: 4
machdep.cpu.arch_perf.width: 48
machdep.cpu.address_bits.physical: 39
machdep.cpu.address_bits.virtual: 48
machdep.cpu.tsc_ccc.denominator: 2
machdep.cpu.tsc_ccc.numerator: 300
machdep.cpu.brand: 0
machdep.cpu.brand_string: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz
machdep.cpu.core_count: 8
machdep.cpu.cores_per_package: 8
machdep.cpu.extfamily: 0
machdep.cpu.extfeature_bits: 1241984796928
machdep.cpu.extfeatures: SYSCALL XD 1GBPAGE EM64T LAHF LZCNT PREFETCHW RDTSCP TSCI
machdep.cpu.extmodel: 9
machdep.cpu.family: 6
machdep.cpu.feature_bits: 9221960262849657855
machdep.cpu.features: FPU VME DE PSE TSC MSR PAE MCE CX8 APIC SEP MTRR PGE MCA CMOV PAT PSE36 CLFSH DS ACPI MMX FXSR SSE SSE2 SS HTT TM PBE SSE3 PCLMULQDQ DTES64 MON DSCPL VMX SMX EST TM2 SSSE3 FMA CX16 TPR PDCM SSE4.1 SSE4.2 x2APIC MOVBE POPCNT AES PCID XSAVE OSXSAVE SEGLIM64 TSCTMR AVX1.0 RDRAND F16C
machdep.cpu.leaf7_feature_bits: 43804591 1073741824
machdep.cpu.leaf7_feature_bits_edx: 3154118144
machdep.cpu.leaf7_features: RDWRFSGS TSC_THREAD_OFFSET SGX BMI1 AVX2 SMEP BMI2 ERMS INVPCID FPU_CSDS MPX RDSEED ADX SMAP CLFSOPT IPT SGXLC MDCLEAR IBRS STIBP L1DF ACAPMSR SSBD
machdep.cpu.logical_per_package: 16
machdep.cpu.max_basic: 22
machdep.cpu.max_ext: 2147483656
machdep.cpu.microcode_version: 234
machdep.cpu.model: 158
machdep.cpu.processor_flag: 1
machdep.cpu.signature: 591597
machdep.cpu.stepping: 13
machdep.cpu.thread_count: 16
machdep.cpu.vendor: GenuineIntel

machdep.cpu.core_count 核數爲 8machdep.cpu.thread_count cpu 數量爲 16,使用了超線程技術。

二、進程、線程和協程

進程

當運行一個應用程序的時候,操作系統會爲這個應用程序啓動一個進程。可以將這個進程看作一個包含了應用程序在運行中需要用到和維護的各種資源的容器。這些資源包括但不限於內存地址空間、文件和設備的句柄以及線程。 

線程

一個線程是一個執行空間,這個空間會被操作系統調度來運行函數中所寫的代碼。每個進程至少包含一個線程,每個進程的初始線程被稱作主線程。因爲執行這個線程的空間是應用程序的本身的空間,所以當主線程終止時,應用程序也會終止。操作系統將線程調度到某個處理器上運行,這個處理器並不一定是進程所在的處理器。下圖展示了一個運行中的應用程序的進程和線程視圖:

https://github.com/golang/go/blob/master/src/runtime/runtime2.go

type gobuf struct {
  // The offsets of sp, pc, and g are known to (hard-coded in) libmach.
  //
  // ctxt is unusual with respect to GC: it may be a
  // heap-allocated funcval, so GC needs to track it, but it
  // needs to be set and cleared from assembly, where it's
  // difficult to have write barriers. However, ctxt is really a
  // saved, live register, and we only ever exchange it between
  // the real register and the gobuf. Hence, we treat it as a
  // root during stack scanning, which means assembly that saves
  // and restores it doesn't need write barriers. It's still
  // typed as a pointer so that any other writes from Go get
  // write barriers.
  sp   uintptr
  pc   uintptr
  g    guintptr
  ctxt unsafe.Pointer
  ret  uintptr
  lr   uintptr
  bp   uintptr // for framepointer-enabled architectures
}

上述的說的 go 的 4 個寄存器是基於我的 CPU 查看的。具體幾個要看 CPU 型號,寄存器是和 CPU 強關聯的實現,具體可參考:

https://github.com/golang/go/tree/master/src/runtime1

三、邏輯處理器與本地運行隊列

(一)邏輯處理器

Golang 的運行時會在邏輯處理器上調度 goroutine 來運行。每個邏輯處理器都與一個操作系統線程綁定。在 Golang 1.5 及以後的版本中,運行時默認會爲每個可用的物理處理器分配一個邏輯處理器。

(二)本地運行隊列

每個邏輯處理器有一個本地運行隊列。如果創建一個 goroutine 並準備運行,這個 goroutine 首先會被放到調度器的全局運行隊列中。之後,調度器會將全局運行隊列中的 goroutine 分配給一個邏輯處理器,並放到這個邏輯處理器的本地運行隊列中。本地運行隊列中的 goroutine 會一直等待直到被分配的邏輯處理器執行。下圖展示了操作系統線程、邏輯處理器和本地運行隊列之間的關係:

有時,正在運行的 goroutine 需要執行一個阻塞的系統調用,如打開一個文件。當這類調用發生時,線程和 goroutine 會從邏輯處理器上分離,該線程會繼續阻塞,等待系統調用的返回。與此同時,這個邏輯處理器就失去了用來運行的線程。所以,調度器會創建一個新線程,並將其綁定到該邏輯處理器上。之後,調度器會從本地運行隊列裏選擇另一個 goroutine 來運行。一旦被阻塞的系統調用執行完成並返回,對應的 goroutine 會放回到本地運行隊列,而之前的線程會保存好,以便之後可以繼續使用。

如果一個 goroutine 需要做一個網絡 I/O 調用,流程上會有些不一樣。在這種情況下,goroutine 會和邏輯處理器分離,並移到集成了網絡輪詢器的運行時。一旦該輪詢器指示某個網絡讀或者寫操作已經就緒,對應的 goroutine 就會重新分配到邏輯處理器上來完成操作。 

注意:Golang 運行時默認限制每個程序最多創建 10000 個線程。這個限制值可以通過調用 runtime/debug 包的 SetMaxThreads 方法來更改。如果程序試圖使用更多的線程,就會崩潰。

四、併發與並行

併發 (concurrency) 與並行 (parallelism) 不同。並行是讓不同的代碼片段同時在不同的物理處理器上執行。並行的關鍵是同時做很多事情,而併發是指同時管理很多事情,這些事情可能只做了一半就被暫停去做別的事情了 (Golang 的併發通過切換多個線程達到減少物理處理器空閒等待的目的)。在很多情況下,併發的效果比並行好,因爲操作系統和硬件的總資源一般很少,但能支持系統同時做很多事情。這種 “使用較少的資源做更多的事情” 的哲學,也是指導 Golang 設計的哲學。如果希望讓 goroutine 並行,必須使用多於一個邏輯處理器。當有多個邏輯處理器時,調度器會將 goroutine 平等分配到每個邏輯處理器上。這會讓 goroutine 在不同的線程上運行。不過要想真的實現並行的效果,用戶需要讓自己的程序運行在有多個物理處理器的機器上。否則,哪怕 Golang 運行時使用多個線程,goroutine 依然會在同一個物理處理器上併發運行,達不到並行的效果。下圖展示了在一個邏輯處理器上併發運行 goroutine 和在兩個邏輯處理器上並行運行兩個併發的 goroutine 之間的區別:

五、一個進程最多能創建多少個線程?

在 Linux 操作系統中,虛擬地址空間的內部又被分爲內核空間和用戶空間兩部分,不同位數的系統,地址空間的範圍也不同。比如最常⻅的 32 位和 64 位系統,如下所示:

通過這裏可以看出:

在前面我們知道,在 32 位 Linux 系統裏,一個進程的虛擬空間是 4G,內核分走了 1G,留給用戶用的只有 3G。那麼假設創建一個線程需要佔用 10M 虛擬內存,總共有 3G 虛擬內存可以使用。於是我們可以算出,最多可以創建差不多 300 個(3G/10M)左右的線程。

如果想使得進程創建上千個線程,那麼我們可以調整創建線程時分配的棧空間大小,比如調整爲 512k:

$ ulimit -s 512

簡單總結下:

六、基本用法

runtime 調度器是個非常有用的東西,關於 runtime 包幾個方法:

七、等待 goroutine 完成任務

package main
import (
    "time"
    "fmt"
    "sync"
)
func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    say2("hello", &wg)
    say2("world", &wg)
    fmt.Println("over!")
}
func say2(s string, waitGroup *sync.WaitGroup) {
    defer waitGroup.Done()
    for i := 0; i < 3; i++ {
        fmt.Println(s)
    }
}

輸出

hello
hello
hello
world
world
world
over!

八、鎖

(一)原子鎖

對於一個整數類型 T,sync/atomic 標準庫包提供了下列原子操作函數。其中 T 可以是內置 int32、int64、uint32、uint64 和 uintptr 類型

func AddT(addr *T, delta T)(new T)
func LoadT(addr *T) (val T)
func StoreT(addr *T, val T)
func SwapT(addr *T, new T) (old T)
func CompareAndSwapT(addr *T, old, new T) (swapped bool)

sync/atomic 標準庫包也提供了一個 Value 類型。以它爲基的指針類型 * Value 擁有兩個方法:Load 和 Store。Value 值用來原子讀取和修改任何類型的 Go 值。

func (v *Value) Load() (x interface{})
func (v *Value) Store(x interface{})

一旦 v.Store 方法( (v).Store 的簡寫形式)被曾經調用一次,則傳遞給值 v 的後續方法調用的實參的具體類型必須和傳遞給它的第一次調用的實參的具體類型一致;否則,將產生一個恐慌。nil 接口類型實參也將導致 v.Store() 方法調用產生恐慌。比如:

package main
import (
   "fmt"
   "sync/atomic"
)
func main() {
   type T struct{ a, b, c int }
   var ta = T{1, 2, 3}
   var v atomic.Value
   v.Store(ta)
   var tb = v.Load().(T)
   fmt.Println(tb)       // {1 2 3}
   fmt.Println(ta == tb) // true
   v.Store("hello") // 將導致一個恐慌
}

輸出結果:

goroutine 1 [running]:
sync/atomic.(*Value).Store(0x14000110210, {0x102f47900, 0x102f58038})
        /opt/homebrew/Cellar/go@1.17/1.17.10/libexec/src/sync/atomic/value.go:77 +0xf4
main.main()
        /Users/tomxiang/github/go-demo/hello/routine/routine07.go:17 +0x218
Process finished with the exit code 2

一個 CompareAndSwapT 函數調用傳遞的舊值和目標值的當前值匹配的情況下才會將目標值改爲新值,並返回 true;否則立即返回 false 。

import (
   "fmt"
   "sync/atomic"
)
func main() {
   type T struct{ a, b, c int }
   var x = T{1, 2, 3}
   var y = T{4, 5, 6}
   var z = T{7, 8, 9}
   var v atomic.Value
   v.Store(x)
   fmt.Println(v) // {{1 2 3}}
   old := v.Swap(y)
   fmt.Println("old:", old)
   fmt.Println("v:", v)            // {{4 5 6}}
   fmt.Println("old.(T)", old.(T)) // {1 2 3}
   swapped := v.CompareAndSwap(x, z)
   fmt.Println(swapped, v) // false {{4 5 6}}
   swapped = v.CompareAndSwap(y, z)
   fmt.Println(swapped, v) // true {{7 8 9}}
}

輸出結果:

{{1 2 3}}
old: {1 2 3}
v: {{4 5 6}}
old.(T) {1 2 3}
false {{4 5 6}}
true {{7 8 9}}

(二)互斥鎖

mutex.Lock

// Package main 這個示例程序展示如何使用互斥鎖來
// 定義一段需要同步訪問的代碼臨界區
// 資源的同步訪問
package main
import (
   "fmt"
   "runtime"
   "sync"
)
var (
   // counter是所有goroutine都要增加其值的變量
   counter int
   // wg用來等待程序結束
   wg sync.WaitGroup
   // mutex 用來定義一段代碼臨界區
   mutex sync.Mutex
)
// main 是所有Go程序的入口
func main() {
   // 計數加2,表示要等待兩個goroutine
   wg.Add(2)
   // 創建兩個goroutine
   go incCounter(1)
   go incCounter(2)
   // 等待goroutine結束
   wg.Wait()
   fmt.Printf("Final Counter: %d\n", counter)
}
// incCounter 使用互斥鎖來同步並保證安全訪問,
// 增加包裏counter變量的值
func incCounter(id int) {
   // 在函數退出時調用Done來通知main函數工作已經完成
   defer wg.Done()
   for count := 0; count < 2; count++ {
      // 同一時刻只允許一個goroutine進入
      // 這個臨界區
      mutex.Lock()
      { // 使用大括號只是爲了讓臨界區看起來更清晰,並不是必需的。
         // 捕獲counter的值
         value := counter
         // 當前goroutine從線程退出,並放回到隊列
         runtime.Gosched()
         // 增加本地value變量的值
         value++
         // 將該值保存回counter
         counter = value
      }
      mutex.Unlock()
      // 釋放鎖,允許其他正在等待的goroutine
      // 進入臨界區
   }
}

輸出結果:

4

對 counter 變量的操作在第 46 行和第 60 行的 Lock() 和 Unlock() 函數調用定義的臨界區裏被保護起來。同一時刻只有一個 goroutine 可以進入臨界區。之後,直到調用 Unlock() 函數之後,其他 goroutine 才能進入臨界區。當第 52 行強制將當前 goroutine 退出當前線程後,調度器會再次分配這個 goroutine 繼續運行。當程序結束時,我們得到正確的值 4,競爭狀態不再存在。

參考資料:

1.Golang 入門 : 理解併發與並行

  1. 淺談內存管理單元 (MMU)

  2. 一個進程最多能創建多少個線程?

4.Golang 入門: 等待 goroutine 完成任務

5.Golang 中 runtime 的使用

6.sync/atomic 標準庫包中提供的原子操作

7.Go 學習筆記(23)— 併發(02)[競爭,鎖資源,原子函數 sync/atomic、互斥鎖 sync.Mutex]

8.Go 標準庫——sync.Mutex 互斥鎖

  1. 寄存器數目

** 作者簡介**

圖片

向晨宇

騰訊專家工程師

騰訊專家工程師,目前負責騰訊醫藥和騰訊健康藥箱的兩款應用程序的開發工作。座右銘:  熱愛編程,追求完美,個性執着。

騰訊雲開發者 騰訊雲官方社區公衆號,匯聚技術開發者羣體,分享技術乾貨,打造技術影響力交流社區。

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