六. Go併發程式設計--WaitGroup

failymao發表於2021-11-01

一. 序言

WaitGroup是Golang應用開發過程中經常使用的併發控制技術。

WaitGroup,可理解為Wait-Goroutine-Group,即等待一組goroutine結束。比如某個goroutine需要等待其他幾個goroutine全部完成,那麼使用WaitGroup可以輕鬆實現。

下面是一段demo.go示例

package main

import (
	"fmt"
	"sync"
)

func worker(i int) {
	fmt.Println("worker: ", i)
}

func main() {
    // 例項化一個 wg
	var wg sync.WaitGroup
	
	// 啟動10個worker協程
	for i := 0; i < 10; i++ {
		// 協程執行前 加1
		wg.Add(1)
		go func(i int) {
		    // 執行完畢後執行done,相當於計數減1
			defer wg.Done()
			worker(i)
		}(i)
	}
	// 等待所有的協程執行完畢後,執行其他邏輯
	wg.Wait()
}

demo2.go
下面程式展示了一個goroutine等待另外兩個goroutine結束的例子:

package main

import (
    "fmt"
    "time"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    wg.Add(2) //設定計數器,數值即為goroutine的個數
    go func() {
        //Do some work
        time.Sleep(1*time.Second)

        fmt.Println("Goroutine 1 finished!")
        wg.Done() //goroutine執行結束後將計數器減1
    }()

    go func() {
        //Do some work
        time.Sleep(2*time.Second)

        fmt.Println("Goroutine 2 finished!")
        wg.Done() //goroutine執行結束後將計數器減1
    }()

    wg.Wait() //主goroutine阻塞等待計數器變為0
    fmt.Printf("All Goroutine finished!")
}

簡單的說,上面程式中wg內部維護了一個計數器:

  1. 啟動goroutine前將計數器通過Add(2)將計數器設定為待啟動的goroutine個數。
  2. 啟動goroutine後,使用Wait()方法阻塞自己,等待計數器變為0。
    3 . 每個goroutine執行結束通過Done()方法將計數器減1。
  3. 計數器變為0後,阻塞的goroutine被喚醒。

如何支援多個 goroutine 等待一個 goroutine 完成後再幹活呢?

二.原始碼分析

2.1 訊號量

訊號量是Unix系統提供的一種保護共享資源的機制,用於防止多個執行緒同時訪問某個資源。

可簡單理解為訊號量為一個數值:

  • 當訊號量>0時,表示資源可用,獲取訊號量時系統自動將訊號量減1;
  • 當訊號量==0時,表示資源暫不可用,獲取訊號量時,當前執行緒會進入睡眠,當訊號量為正時被喚醒;

由於在WaitGroup實現中也是用了訊號量,因此做一個簡單介紹

WaitGroup是一個結構體

type WaitGroup struct {
	noCopy noCopy

	// 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
	// 64-bit atomic operations require 64-bit alignment, but 32-bit
	// compilers do not ensure it. So we allocate 12 bytes and then use
	// the aligned 8 bytes in them as state, and the other 4 as storage
	// for the sema.
	state1 [3]uint32
}

結構十分簡單,由 nocopystate1 兩個欄位組成,其中 nocopy 是用來防止複製的.

nocopy是一個空結構體,包含兩個方法

type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

由於嵌入了 nocopy 所以在執行 go vet 時如果檢查到 WaitGroup 被複制了就會報錯。這樣可以一定程度上保證 WaitGroup 不被複制,對了直接 go run 是不會有錯誤的,所以我們程式碼 push 之前都會強制要求進行 lint 檢查,在 ci/cd 階段也需要先進行 lint 檢查,避免出現這種類似的錯誤。

state1是個長度為3的陣列,其中包含了state和一個訊號量,而state實際上是兩個計數器:

  • counter: 當前還未執行結束的goroutine計數器
  • waiter count: 等待goroutine-group結束的goroutine數量,即有多少個等候者
  • semaphore: 訊號量

state1 的設計非常巧妙,這是一個是十二位元組的資料,這裡面主要包含兩大塊,counter 佔用了 8 位元組用於計數,sema 佔用 4 位元組用做訊號量.

為什麼要這麼搞呢?直接用兩個欄位一個表示 counter,一個表示 sema 不行麼?
不行,我們看看註釋裡面怎麼寫的。

程式碼註釋中大概意思是:在做 64 位的原子操作的時候必須要保證 64 位(8 位元組)對齊,如果沒有對齊的就會有問題,但是 32 位的編譯器並不能保證 64 位對齊所以這裡用一個 12 位元組的 state1 欄位來儲存這兩個狀態,然後根據是否 8 位元組對齊選擇不同的儲存方式。

這個操作巧妙在哪裡呢?

  • 如果是 64 位的機器那肯定是 8 位元組對齊了的,即第一種方式
  • 如果在 32位的機器上
    • 如果恰好 8 位元組對齊,那也是第一種方式取前面的8位元組
    • 如果是沒有對其,但是32位4位元組是對齊的,所以只需要後裔四個位元組,那個8個位元組就對齊了

所以通過 sema 訊號量這四個位元組的位置不同,保證了 counter 這個欄位無論在 32 位還是 64 為機器上都是 8 位元組對齊的,後續做 64 位原子操作的時候就沒問題了。

考慮到位元組是否對齊,三者出現的位置不同,為簡單起見,依照位元組已對齊情況下,三者在記憶體中的位置如下所示:

state 方法實現如下

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
    
    // 取8的餘數如果餘數為0說明8位元組對齊了
	if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
		return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
	} else {
		return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
	}
}

state 方法返回 counter 和訊號量,通過 uintptr(unsafe.Pointer(&wg.state1))%8 == 0 來判斷是否 8 位元組對齊

2.1 Add

func (wg *WaitGroup) Add(delta int) {
    // 先從 state 當中把資料和訊號量取出來
	statep, semap := wg.state()

    // 在 waiter 上加上 delta 值
	state := atomic.AddUint64(statep, uint64(delta)<<32)
    // 取出當前的 counter
	v := int32(state >> 32)
    // 取出當前的 waiter,正在等待 goroutine 數量
	w := uint32(state)

    // counter 不能為負數
	if v < 0 {
		panic("sync: negative WaitGroup counter")
	}

    // 這裡屬於防禦性程式設計
    // w != 0 說明現在已經有 goroutine 在等待中,說明已經呼叫了 Wait() 方法
    // 這時候 delta > 0 && v == int32(delta) 說明在呼叫了 Wait() 方法之後又想加入新的等待者
    // 這種操作是不允許的
	if w != 0 && delta > 0 && v == int32(delta) {
		panic("sync: WaitGroup misuse: Add called concurrently with Wait")
	}
    // 如果當前沒有人在等待就直接返回,並且 counter > 0
	if v > 0 || w == 0 {
		return
	}

    // 這裡也是防禦 主要避免併發呼叫 add 和 wait
	if *statep != state {
		panic("sync: WaitGroup misuse: Add called concurrently with Wait")
	}

	// 喚醒所有 waiter,看到這裡就回答了上面的問題了
	*statep = 0
	for ; w != 0; w-- {
		runtime_Semrelease(semap, false, 0)
	}
}

2.2 Wait

wait 主要就是等待其他的 goroutine 完事之後喚醒

func (wg *WaitGroup) Wait() {
	// 先從 state 當中把資料和訊號量的地址取出來
    statep, semap := wg.state()

	for {
     	// 這裡去除 counter 和 waiter 的資料
		state := atomic.LoadUint64(statep)
		v := int32(state >> 32)
		w := uint32(state)

        // counter = 0 說明沒有在等的,直接返回就行
        if v == 0 {
			// Counter is 0, no need to wait.
			return
		}

		// waiter + 1,呼叫一次就多一個等待者,然後休眠當前 goroutine 等待被喚醒
		if atomic.CompareAndSwapUint64(statep, state, state+1) {
			runtime_Semacquire(semap)
			if *statep != 0 {
				panic("sync: WaitGroup is reused before previous Wait has returned")
			}
			return
		}
	}
}

2.3 Done

Done是對 Add的封裝

func (wg *WaitGroup) Done() {
	wg.Add(-1)
}

三. 總結

簡單說來,WaitGroup通常用於等待一組“工作協程”結束的場景,其內部維護兩個計數器,這裡把它們稱為“工作協程”計數器和“坐等協程”計數器,
WaitGroup對外提供的三個方法分工非常明確:

  • Add(delta int)方法用於增加“工作協程”計數,通常在啟動新的“工作協程”之前呼叫;
  • Done()方法用於減少“工作協程”計數,每次呼叫遞減1,通常在“工作協程”內部且在臨近返回之前呼叫;
  • Wait()方法用於增加“坐等協程”計數,通常在所有”工作協程”全部啟動之後呼叫;

WaitGroup 可以用於一個 goroutine 等待多個 goroutine 幹活完成,也可以多個 goroutine 等待一個 goroutine 幹活完成,是一個多對多的關係

多個等待一個的典型案例是 singleflight,這個在後面將微服務可用性的時候還會再講到,感興趣可以看看原始碼

3.1 注意事項

  1. Done()方法除了負責遞減“工作協程”計數以外,還會在“工作協程”計數變為0時檢查“坐等協程”計數器並把“坐等協程”喚醒。
    需要注意的是,Done()方法遞減“工作協程”計數後,如果“工作協程”計數變成負數時,將會觸發panic,這就要求Add()方法呼叫要早於Done()方法。
  2. 此外,通過Add()方法累加的“工作協程”計數要與實際需要等待的“工作協程”數量一致,否則也會觸發panic
  3. 當“工作協程”計數多於實際需要等待的“工作協程”數量時,“坐等協程”可能會永遠無法被喚醒而產生列鎖,此時,Go執行時檢測到死鎖會觸發panic
  4. 當“工作協程”計數小於實際需要等待的“工作協程”數量時,Done()會在“工作協程”計數變為負數時觸發panic。

四. 參考

  1. https://lailin.xyz/post/go-training-week3-waitgroup.html
  2. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/
  3. https://www.topgoer.cn/docs/gozhuanjia/chapter055.2-waitgroup

相關文章