Go 語言 sync 包的應用詳解

KevinYan發表於2020-05-05

在併發程式設計中同步原語也就是我們通常說的鎖的主要作用是保證多個執行緒或者 goroutine在訪問同一片記憶體時不會出現混亂的問題。Go語言的sync包提供了常見的併發程式設計同步原語,上一期轉載的文章《Golang 併發程式設計之同步原語》中也詳述了 MutexRWMutexWaitGroupOnceCond 這些同步原語的實現原理。今天的文章裡讓我們回到應用層,聚焦sync包裡這些同步原語的應用場景,同時也會介紹sync包中的PoolMap的應用場景和使用方法。話不多說,讓我們開始吧。

sync.Mutex

sync.Mutex可能是sync包中使用最廣泛的原語。它允許在共享資源上互斥訪問(不能同時訪問):

mutex := &sync.Mutex{}

mutex.Lock()
// Update共享變數 (比如切片,結構體指標等)
mutex.Unlock()

必須指出的是,在第一次被使用後,不能再對sync.Mutex進行復制。(sync包的所有原語都一樣)。如果結構體具有同步原語欄位,則必須通過指標傳遞它。

sync.RWMutex

sync.RWMutex是一個讀寫互斥鎖,它提供了我們上面的剛剛看到的sync.MutexLockUnLock方法(因為這兩個結構都實現了sync.Locker介面)。但是,它還允許使用RLockRUnlock方法進行併發讀取:

mutex := &sync.RWMutex{}

mutex.Lock()
// Update 共享變數
mutex.Unlock()

mutex.RLock()
// Read 共享變數
mutex.RUnlock()

sync.RWMutex允許至少一個讀鎖或一個寫鎖存在,而sync.Mutex允許一個讀鎖或一個寫鎖存在。

通過基準測試來比較這幾個方法的效能:

BenchmarkMutexLock-4       83497579         17.7 ns/op
BenchmarkRWMutexLock-4     35286374         44.3 ns/op
BenchmarkRWMutexRLock-4    89403342         15.3 ns/op

可以看到鎖定/解鎖sync.RWMutex讀鎖的速度比鎖定/解鎖sync.Mutex更快,另一方面,在sync.RWMutex上呼叫Lock()/ Unlock()是最慢的操作。

因此,只有在頻繁讀取和不頻繁寫入的場景裡,才應該使用sync.RWMutex

sync.WaitGroup

sync.WaitGroup也是一個經常會用到的同步原語,它的使用場景是在一個goroutine等待一組goroutine執行完成。

sync.WaitGroup擁有一個內部計數器。當計數器等於0時,則Wait()方法會立即返回。否則它將阻塞執行Wait()方法的goroutine直到計數器等於0時為止。

要增加計數器,我們必須使用Add(int)方法。要減少它,我們可以使用Done()(將計數器減1),也可以傳遞負數給Add方法把計數器減少指定大小,Done()方法底層就是通過Add(-1)實現的。

在以下示例中,我們將啟動八個goroutine,並等待他們完成:

wg := &sync.WaitGroup{}

for i := 0; i < 8; i++ {
  wg.Add(1)
  go func() {
    // Do something
    wg.Done()
  }()
}

wg.Wait()
// 繼續往下執行...

每次建立goroutine時,我們都會使用wg.Add(1)來增加wg的內部計數器。我們也可以在for迴圈之前呼叫wg.Add(8)

與此同時,每個goroutine完成時,都會使用wg.Done()減少wg的內部計數器。

main goroutine會在八個goroutine都執行wg.Done()將計數器變為0後才能繼續執行。

sync.Map

sync.Map是一個併發版本的Go語言的map,我們可以:

  • 使用Store(interface {},interface {})新增元素。
  • 使用Load(interface {}) interface {}檢索元素。
  • 使用Delete(interface {})刪除元素。
  • 使用LoadOrStore(interface {},interface {}) (interface {},bool)檢索或新增之前不存在的元素。如果鍵之前在map中存在,則返回的布林值為true
  • 使用Range遍歷元素。
m := &sync.Map{}

// 新增元素
m.Store(1, "one")
m.Store(2, "two")

// 獲取元素1
value, contains := m.Load(1)
if contains {
  fmt.Printf("%s\n", value.(string))
}

// 返回已存value,否則把指定的鍵值儲存到map中
value, loaded := m.LoadOrStore(3, "three")
if !loaded {
  fmt.Printf("%s\n", value.(string))
}

m.Delete(3)

// 迭代所有元素
m.Range(func(key, value interface{}) bool {
  fmt.Printf("%d: %s\n", key.(int), value.(string))
  return true
})

上面的程式會輸出:

one
three
1: one
2: two

如你所見,Range方法接收一個型別為func(key,value interface {})bool的函式引數。如果函式返回了false,則停止迭代。有趣的事實是,即使我們在恆定時間後返回false,最壞情況下的時間複雜度仍為O(n)

我們應該在什麼時候使用sync.Map而不是在普通的map上使用sync.Mutex

  • 當我們對map有頻繁的讀取和不頻繁的寫入時。
  • 當多個goroutine讀取,寫入和覆蓋不相交的鍵時。具體是什麼意思呢?例如,如果我們有一個分片實現,其中包含一組4個goroutine,每個goroutine負責25%的鍵(每個負責的鍵不衝突)。在這種情況下,sync.Map是首選。

sync.Pool

sync.Pool是一個併發池,負責安全地儲存一組物件。它有兩個匯出方法:

  • Get() interface{} 用來從併發池中取出元素。
  • Put(interface{}) 將一個物件加入併發池。
pool := &sync.Pool{}

pool.Put(NewConnection(1))
pool.Put(NewConnection(2))
pool.Put(NewConnection(3))

connection := pool.Get().(*Connection)
fmt.Printf("%d\n", connection.id)
connection = pool.Get().(*Connection)
fmt.Printf("%d\n", connection.id)
connection = pool.Get().(*Connection)
fmt.Printf("%d\n", connection.id)

輸出:

1
3
2

需要注意的是Get()方法會從併發池中隨機取出物件,無法保證以固定的順序獲取併發池中儲存的物件。

還可以為sync.Pool指定一個建立者方法:

pool := &sync.Pool{
  New: func() interface{} {
    return NewConnection()
  },
}

connection := pool.Get().(*Connection)

這樣每次呼叫Get()時,將返回由在pool.New中指定的函式建立的物件(在本例中為指標)。

那麼什麼時候使用sync.Pool?有兩個用例:

第一個是當我們必須重用共享的和長期存在的物件(例如,資料庫連線)時。第二個是用於優化記憶體分配。

讓我們考慮一個寫入緩衝區並將結果持久儲存到檔案中的函式示例。使用sync.Pool,我們可以通過在不同的函式呼叫之間重用同一物件來重用為緩衝區分配的空間。
第一步是檢索先前分配的緩衝區(如果是第一個呼叫,則建立一個緩衝區,但這是抽象的)。然後,defer操作是將緩衝區放回sync.Pool中。

func writeFile(pool *sync.Pool, filename string) error {
    buf := pool.Get().(*bytes.Buffer)

  defer pool.Put(buf)

    // Reset 快取區,不然會連線上次呼叫時儲存在快取區裡的字串foo
    // 程式設計foofoo 以此類推
    buf.Reset()

    buf.WriteString("foo")

    return ioutil.WriteFile(filename, buf.Bytes(), 0644)
}

sync.Once

sync.Once是一個簡單而強大的原語,可確保一個函式僅執行一次。在下面的示例中,只有一個goroutine會顯示輸出訊息:

once := &sync.Once{}
for i := 0; i < 4; i++ {
    i := i
    go func() {
        once.Do(func() {
            fmt.Printf("first %d\n", i)
        })
    }()
}

我們使用了Do(func ())方法來指定只能被呼叫一次的部分。

sync.Cond

sync.Cond可能是sync包提供的同步原語中最不常用的一個,它用於發出訊號(一對一)或廣播訊號(一對多)到goroutine。讓我們考慮一個場景,我們必須向一個goroutine指示共享切片的第一個元素已更新。建立sync.Cond需要sync.Locker物件(sync.Mutexsync.RWMutex):

cond := sync.NewCond(&sync.Mutex{})

然後,讓我們編寫負責顯示切片的第一個元素的函式:

func printFirstElement(s []int, cond *sync.Cond) {
    cond.L.Lock()
    cond.Wait()
    fmt.Printf("%d\n", s[0])
    cond.L.Unlock()
}

我們可以使用cond.L訪問內部的互斥鎖。一旦獲得了鎖,我們將呼叫cond.Wait(),這會讓當前goroutine在收到訊號前一直處於阻塞狀態。

讓我們回到main goroutine。我們將通過傳遞共享切片和先前建立的sync.Cond來建立printFirstElement池。然後我們呼叫get()函式,將結果儲存在s[0]中併發出訊號:

s := make([]int, 1)
for i := 0; i < runtime.NumCPU(); i++ {
    go printFirstElement(s, cond)
}

i := get()
cond.L.Lock()
s[0] = i
cond.Signal()
cond.L.Unlock()

這個訊號會解除一個goroutine的阻塞狀態,解除阻塞的goroutine將會顯示s[0]中儲存的值。

但是,有的人可能會爭辯說我們的程式碼破壞了Go的最基本原則之一:

不要通過共享記憶體進行通訊;而是通過通訊共享記憶體。

確實,在這個示例中,最好使用channel來傳遞get()返回的值。但是我們也提到了sync.Cond也可以用於廣播訊號。我們修改一下上面的示例,把Signal()呼叫改為呼叫Broadcast()

i := get()
cond.L.Lock()
s[0] = i
cond.Broadcast()
cond.L.Unlock()

在這種情況下,所有goroutine都將被觸發。
眾所周知,channel裡的元素只會由一個goroutine接收到。通過channel模擬廣播的唯一方法是關閉channel

當一個channel被關閉後,channel中已經傳送的資料都被成功接收後,後續的接收操作將不再阻塞,它們會立即返回一個零值。

但是這種方式只能廣播一次。因此,儘管存在很大爭議,但這無疑是sync.Cond的一個有趣的功能。

推薦閱讀

學會使用context取消goroutine執行的方法

使用SecureCookie實現客戶端Session管理

Go Web程式設計–解析JSON請求和生成JSON響應

本作品採用《CC 協議》,轉載必須註明作者和本文連結

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章