《Go 語言併發之道》讀後感 - 第三章

sober-wang發表於2020-09-27

《Go 語言併發之道》讀後感 - 第三章

前兩章我們介紹了併發之苦,CSP 理論。這一章作者詳細的介紹了 Go 是如何支援併發的。

goroutine

goroutine 是 Go 語言程式中最近本的組織單位之一。每個 Go 語言程式至少有一個 goroutine: main goroutine , 它再程式開始時自動建立並啟動。我們經常聽人說到 goroutine ,它是什麼呢?

  • goroutine 是一個併發的函式。
  • goroutine 是協程,非系統執行緒,非綠色執行緒。

goroutine 並沒有定義自己的暫停方法或再執行點。Go 程式的 goroutine 排程機制決定,當 goroutine 阻塞的時候自動把它掛起,然後在它們不被阻塞時恢復它們。在 gouroutine 阻塞的時後會觸發搶佔。

上述這種託管機制是一個名為 M:N 排程器的實現,這意味著它將 M 各綠色執行緒對映到 N 個系統執行緒。然後 goroutine 執行在綠色執行緒上。當我們的 goroutine 數量超過可用的綠色執行緒時,排程程式處理分佈在可用執行緒上的 goroutine,並確保這些 goroutine 被阻塞時,其他 goroutine 可以執行。

Go 語言遵循 fork-join 的併發模型:

  • fork 指程式在任意節點,可以將子節點於父節點同時執行
  • join 將來在某個節點時,分支將會合並在一起

我們一起來看一個例子:

var wg sync.WaitGroup
sayHello := func(){
    defer wg.Done()
    fmt.Println("hello")
}
wg.Add(1)
go sayHello()
wg.Wait()
fmt.Println("bye")

這裡我們引入了,sync 用作同步,wg.Add() 到 wg.Wait() 定義了一個臨界區。臨界區內的操作完成後,才會繼續下面的 fmt.Println("bye")

輕如鴻毛

記憶體

開闢一個新的程式,或執行緒都需要消耗系統的資源。開闢一個執行緒需要消耗大概 8 MB 資源,通過下面的命令,可以檢視:

ulimit -s

在上一章結尾的我寫到可以認為 goroutine 是沒有任何代價的,下面我們來看一個例子,以下內容會開啟空的 goroutine :

memConsumed := func() uint64{
    runtime.GC()
    var s runtime.MemStats
    runtime.ReadMemStats(&s)
    return s.Sys
}

var c <-chan interface{}
var wg sync.WaitGroup
noop := func(){ wg.Done(); <-c }

const numGoroutines = 1e4
wg.Add(numGoroutines)
before := memConsumed()
for i := numGoroutines; i > 0 ; i--{
    go noop()
}
wg.Wait()
after := memConsumed()
fmt.Printf("%.3fkb",float64(after - before) / numGoroutines /1000)

Windows 10 下的執行結果:

Linux CentOS 7.4 下的執行結果:

上下文切換

taskset -c 0 perf bench sched pipe -T
# 如果你的機器沒有安裝 perf ,可以用如下命令
yum install perf 
apt install perf

goroutine 上下文切換

func Ben(b *testing.B) {
    var wg sync.WaitGroup
    begin := make(chan struct{})
    c := make(chan struct{})
    var token struct{}
    sender := func() {
        defer wg.Done()
        <-begin
        for i := 0; i < b.N; i++ {
            c <- token
        }
    }
    receiver := func() {
        defer wg.Done()
        <-begin
        for i := 0; i < b.N; i++ {
            <-c
        }
    }
    wg.Add(2)
    go sender()
    go receiver()
    b.StartTimer()
    close(begin)
    wg.Wait()
}

我們可以看到上下文切換,執行緒需要花費 2s 左右的時間,goroutine 上下文切換隻需要 0.002s。

sync

sync 包包含對低階別記憶體訪問同步最有用的併發原語,

WaitGroup

當你不關心併發操作的結果,或者你有其他方法來收集它們的結果時,WaitGroup 是等待一組併發操作完成的好方法。

var wg sync.WaitGroup 
wg.Add(1) // 引數為 1 ,表示一個 goroutine 開始了
go func() { 
    defer wg.Done() // 退出前執行 Done 操作,我們向 WaitGroup 表明我們已經退出了
    fmt.Println("1st goroutine sleeping...") 
    time.Sleep(1) 
}() 

wg.Add(1) 
go func() { 
    defer wg.Done() 
    fmt.Println("2nd goroutine sleeping...") 
    time.Sleep(2) 
}() 

wg.Wait() // 等待所有 goroutine 都執行完,再繼續下面的邏輯
fmt.Println("All goroutines complete.")

互斥鎖和讀寫鎖

Mutex 是 "互斥" 的意思,是保護程式中臨界區的以重方式。它提供了一種安全的方式來表示對這些共享資源的獨佔訪問。

var lock sync.Mutex
n := 1
plus := func(){
    lock.Lock() // 加鎖
    defer lock.Unlock // 函式執行完成釋放鎖
    n++
    fmt.Println("plus n =>",n)
}
subtr := func(){
    lock.Lock()
    defer locl.Unlock
    n--
    fmt.Println("subtr n =>",n)
}

go subtr()
go plus()
fmt.Println(n)

Mutex 互斥鎖,對臨界區強限制,goroutine 必須先獲得鎖然後再進行臨界區操作。

有的時候我們希望下游讀取臨界區操作可以併發,以便提升程式碼讀操作的效能,畢竟一旦加鎖整體都需要等待鎖釋放,如果 Lock() 和 Unlock() 之間的邏輯阻塞,大家都的等待。RWMutex() 就應運而生了。

var rwLock sync.RWMutex
// 獲取鎖,讀寫鎖,其他 goroutine 不可對臨界區內容進行讀寫操作
rwLock.Lock()
// 釋放鎖
rwLock.Unlock()
// 獲取讀鎖,限制其他 goroutine 寫,但不限制讀
rwLock.RLock()
// 釋放讀鎖
rwLock.RUnlock()

sync.NewCond

在 Golang 原始碼中很好的描述了,cond 型別的用途:

一個 goroutine 的集合點,等待或釋出一個 event。

使用方式如下:

c := sync.NewCond(&sync.Mutex{})
c.L.Lock()
for !condition(){
    c.Wait()
}
c.L.Unlock()

sync.Once

sync 包為我們提供了一個專門的方案解決一次性初始化的問題: sync.One。

使用方式如下:

var count int 
increment := func(){
    count++
}
var once sync.Once

var increments sync.WaitGroup
increments.Add(100)
for i := 0; i < 100; i++{
    go func(){
        defer increments.Done()
        once.Do(increment)
    }
}

increments.Wait()
fmt.Println("Count is %d \n",count)

sync.Pool { }

Pool 池 是併發安全實現。用於約束建立昂貴的場景,例如: 連結 Redis,MySQL,或其他呼叫遠端服務的時候。只建立固定數量的例項,保障對端服務可用。

myPool := &sync.Pool{
    New: func() interface{} {
        fmt.Println("Create new connection")
        return struct{}{}
        },
}
instance := myPool.Get() // 獲取例項
....
myPool.Put(instance)    // 釋放例項,供其他人使用

當你使用 Pool 工作是,記住以下幾點:

  • 當例項化 sync.Pool,使用 new 方法建立一個成員變數,在呼叫時時執行緒安全的。
  • 當你收到一個來自 Get 的例項時,不要對所接收的物件的狀態做出任何假設。
  • 當你用完一個從 Pool 中取出來的物件時,一定要呼叫 Put,否則,Pool 就無法複用這個例項了。通常情況下,這是用 defer 完成的。
  • Pool 內的分佈必須大致均勻。

channel

channel 是由 Hoare 的 CSP 派生的同步原語之一。

// 定義雙向 channel
var ds chan interface{}
ds = make(chan interface{})
// 定義 只讀 channel
var or <-chan interface{}
or = make(<-chan interface{})
// 定義只寫 channel
var ow chan<- interface{}
ow = make(chan<- interface{})

// 建立緩衝 channel。
bufferChan := make(chan interface{} ,4)

goroutine 是被動排程的,沒有辦法保證它會在程式退出之前執行。Go 語言中的 channel 是阻塞的,這樣在不同的 goroutine 操作同一個 channel 的時候就會被 channel 阻塞,我們還需要注意,不要試圖從一個空 channel 中讀取資料,如果只讀取將會觸發死鎖,讀資料的 goroutine 將等待至少一條資料被寫入 channel 後才行。

個人對於緩衝 channel 的一些看法

  • 當生產者速度遠大於消費者速度,建立緩衝 channel 是一種正向優化
  • 當消費者具有阻塞性質或 syscall 時(例如:資料寫入磁碟,請求外部介面,遠端服務)
  • 當消費者速度大於生產者速度,消費者側無阻塞性質,設定緩衝 channel 可能是一種負優化

對於只讀只寫 channel 的一些個人經驗:

我們的函式往往是一層一層的呼叫的,當我們需要使用 channel 構建併發的時候,我們需要知道當前操作的函式對需要操作的 channel 是生產者,或消費者。這樣構建時就可以防止一些死鎖,channel 未關閉的問題。這是我個人的使用經驗。

prod := func(n chan<- int){
    defer close(n)
    n <- 1
}
consum := func(n <-chan int) <-chan int{
    m := make(chan int)
    tmp := <-n 
    fmt.Println(tmp)
    go func(){
        defer close(m)
        m <- tmp
    }()
    return
}

num := make(chan int)
go prod(num)
mm := consum(num)
for i := range mm{
    fmt.Println(i)
}

從上方的程式碼段可以看出一些技巧

  • channel 的輸入向都需要一個 goroutine.
  • 在 consum 函式內部定義 channel 返回一個只讀 channel ,有效的管理了臨界區
  • 全域性定義的 num ,在傳入 函式時轉換了性質,防止在一個 goroutine 種對同一 channel 既讀又寫

這裡要說明一下單向 channel 無法向雙向 channel 轉換,雙向 channel 可以向單向 channel 轉換。

channel 狀態機

從 channel 的所有者說起。當一個 goroutine 擁有一個 channel 時應該:

  1. 初始化該 channel
  2. 執行寫入操作,或將所有權交給另一個 goroutine
  3. 關閉該通道
  4. 將此前列入的三件事封裝在一個列表中,並通過訂閱 channel 將其公開

通過將這些責任分配給 channel 的所有者,會發生一些事情:

  • 因為我們是初始化 channel 的人,所以我們要了解寫入空 channel 會帶來死鎖的風險
  • 因為我們是初始化 channel 的人,所以我們要了解關閉空 channel 會帶來 panic 的風險
  • 因為我們是決定 channel 何時關閉的人,所以我們要了解寫入已關閉的 channel 會帶來 panic 的風險
  • 因為我們是決定何時關閉 channel 的人,所以我們要了解多次關閉 channel 會帶來 panic 的風險
  • 我們在編譯時使用型別檢查器來防止對 channel 進行不正確的寫入

作為一個消費者,需要只需要擔心兩件事:

  • channel 什麼時候會被關閉
  • 處理基於任何原因出現的阻塞

Select

channel 將 goroutine 粘合在一起,讓我們構建起一條非常健壯,高效能的生產線。那麼程式中有多條生產線,select 語句就是幫我們多個 channel 組合在一起。

// 一起看一下 select 的用法
var ca,cb,cc <-chan interface{}
var cd chan<- string
select {
    case <- ca:
        // 業務邏輯
    case <- cb:
        // 監控邏輯
    case <- cc:
        // 告警邏輯
    case cd <- "Hello Sober":
        // 佛系邏輯
}

關於 select-case 排程疑問

乍一看 select 於 switch 類似根據不同 case 判斷並執行邏輯。 我們知道既然是 channel ,那麼一定是有資料需要傳遞的,不能簡單的條件判斷而已,例如我想讓 cb 執行需要什麼條件呢?其實 select 內部實現一種均衡排程,保證每個 case 都會被執行,所有 case 執行次數相對均衡,你可以用如下程式碼測試一下:

c1 := make(chan int)
close(c1)
c2 := make(chan int)
close(c2)

var c1n ,c2n int
for i := 100; i > 0; i-- {
    select {
        case <- c1:
            c1n++
        case <- c2:
            c2n++
    }
}

fmt.Printf("c1n: %d \nc2n: %d\n",c1n,c2n)

如何關閉已經我們認為已完成的工作流?

我們來看這個例子,讓我們記住它,這將是高併發程式的核心一環,併發控制的根基:

var c <-chan int
select {
    case <- c: // 這裡我們幹一件愚蠢的事,從空的 channel 中讀取資料,如果沒有 select 它將觸發死鎖,在select 中他將永遠不被執行
    case <-time.After(1*time.Second):   // 1秒後關閉整個工作流
        fmt.Println("The pipeline is end")
}

預設值

select {
    case <- c1:
        // 神鬼邏輯
    case <- c2:
        // 鬼神邏輯
    default:
    fmt.Println("沒有可用的 channel,觸發預設操作 ....")
}

永久阻塞

select {}

GOMAXPROCS 控制

這裡需要提一下 GMP 模型,M 就是 GOMAXPROCS 的配置,通常為當前計算節點最大 OS 執行緒數。

// 由 runtime 包控制
runtime.GOMAXPROCS(runtime.NumCPU())

結束語

雖然是第三章,但是我認為這是全書技巧篇第一章,接下會有更精彩的技巧,例如:如何構建一個 pipeline ;如何控制併發中大規模層級呼叫,訊息傳遞。

更多原創文章乾貨分享,請關注公眾號
  • 《Go 語言併發之道》讀後感 - 第三章
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章