面試官讓我用 channel 實現 sync 包裡的同步鎖,是不是故意為難我?

KevinYan發表於2020-05-21

Go語言提供了channel和sync包兩種併發控制的方法,每種方法都有他們適用的場景,並不是所有併發場景都適合應用channel的,有的時候用sync包裡提供的同步原語更簡單。今天這個話題純屬是為了通過用channel實現同步鎖的功能來學習掌握channel擁有的強大能力,並不適合在實際中使用。而且面試中有時候就是會出一些奇奇怪怪的題考應聘者對知識的理解以及靈活運用的應變能力。

大家仔細看看文章裡用channel實現幾種常用的同步鎖的思路,沒準兒哪次面試就碰上這樣的面試官了呢。

今天,我將深入探討Go語言channelselect語句的表達能力。為了演示只用這兩個原語就可以實現多少功能,我將從頭開始用它們重寫sync包。

sync包提供的同步原語的有哪些以及如何使用我們已經在之前的文章裡介紹過了,所以這裡不會再去介紹用channel實現的這些同步原語應該怎麼用。如果對用法有疑問請回看之前的文章: Go語言sync包的應用詳解

Once

once是一個簡單而強大的原語,可確保在並行程式中一個函式僅執行一次。

channel版的Once我們使用帶有一個緩衝的通道來實現
第一次呼叫Do(func ())goroutine從通道中接收到值後,後續的goroutine將會被阻塞中,直到Do的引數函式執行完成後關閉通道為止。其他goroutine判斷通道已關閉後將不執行任何操作並立即返回。

type Once chan struct{}

func NewOnce() Once {
    o := make(Once, 1)
    // 只允許一個goroutine接收,其他goroutine會被阻塞住
    o <- struct{}{}
    return o
}

func (o Once) Do(f func()) {

    _, ok := <-o
    if !ok {
        // Channel已經被關閉
        // 證明f已經被執行過了,直接return.
        return
    }
    // 呼叫f, 因為channel中只有一個值
    // 所以只有一個goroutine會到達這裡

    f()
    // 關閉通道,這將釋放所有在等待的
    // 以及未來會呼叫Do方法的goroutine
    close(o)
}

Mutex

大小為N的訊號量最多允許N個goroutine在任何給定時間保持其鎖。互斥鎖是大小為1的訊號量的特例。

訊號量(英語:semaphore)又稱為訊號標,是一個同步物件,用於保持在0至指定最大值之間的一個計數值。當執行緒完成一次對該semaphore物件的等待(wait)時,該計數值減一;當執行緒完成一次對semaphore物件的釋放(release)時,計數值加一。當計數值為0,則執行緒直至該semaphore物件變成signaled狀態才能等待成功。semaphore物件的計數值大於0,為signaled狀態;計數值等於0,為nonsignaled狀態.

我們先用channel實現訊號量的功能

type Semaphore chan struct{}

func NewSemaphore(size int) Semaphore {
    return make(Semaphore, size)
}

func (s Semaphore) Lock() {
    // 只有在s還有空間的時候才能傳送成功
    s <- struct{}{}
}

func (s Semaphore) Unlock() {
    // 為其他訊號量騰出空間
    <-s
}

上面也說了互斥鎖是大小為1的訊號量的特例。那麼在剛才實現的訊號量的基礎上實現互斥鎖只需要:

type Mutex Semaphore

func NewMutex() Mutex {
    return Mutex(NewSemaphore(1))
}

RWMutex

RWMutex是一個稍微複雜的原語:它允許任意數量的併發讀鎖,但在任何給定時間僅允許一個寫鎖。還可以保證,如果有執行緒持有寫鎖,則任何執行緒都不能持有或獲得讀鎖。

sync標準庫裡的RWMutex還允許如果有執行緒嘗試獲取寫鎖,則其他讀鎖將排隊等待,以避免餓死嘗試獲取寫鎖的執行緒。為了簡潔起見,在用channel實現的RWMutex裡我們忽略了這部分邏輯。

RWMutex具有三種狀態:空閒,存在寫鎖和存在讀鎖。這意味著我們需要兩個通道分別標記RWMutex上的讀鎖和寫鎖:空閒時,兩個通道都為空;當獲取到寫鎖時,標記寫鎖的通道里將被寫入一下空結構體;當獲取到讀鎖時,我們向兩個通道中都寫入一個值(避免寫鎖能夠向標記寫鎖的通道傳送值),其中標記讀鎖的通道里的值代表當前RWMutex擁有的讀鎖的數量,讀鎖釋放的時候除了更新通道里存的讀鎖數量值,也會抽空寫鎖通道。

type RWMutex struct {
    write   chan struct{}
    readers chan int
}

func NewLock() RWMutex {
    return RWMutex{
        // 用來做一個普通的互斥鎖
        write:   make(chan struct{}, 1),
        // 用來保護讀鎖的數量,獲取讀鎖時通過接受通道里的值確保
        // 其他goroutine不會在同一時間更改讀鎖的數量。
        readers: make(chan int, 1),
    }
}

func (l RWMutex) Lock() { l.write <- struct{}{} }
func (l RWMutex) Unlock() { <-l.write }

func (l RWMutex) RLock() {
    // 統計當前讀鎖的數量,預設為0
    var rs int
    select {
    case l.write <- struct{}{}:
    // 如果write通道能傳送成功,證明現在沒有讀鎖
    // 向write通道傳送一個值,防止出現併發的讀-寫
    case rs = <-l.readers: 
    // 能從通道里接收到值,證明RWMutex上已經有讀鎖了,下面會更新讀鎖數量
    }
    // 如果執行了l.write <- struct{}{}, rs的值會是0
    rs++
    // 更新RWMutex讀鎖數量
    l.readers <- rs
}

func (l RWMutex) RUnlock() {
    // 讀出讀鎖數量然後減一
    rs := <-l.readers
    rs--
    // 如果釋放後讀鎖的數量變為0了,抽空write通道,讓write通道變為可用
    if rs == 0 {
        <-l.write
        return
    }
    // 如果釋放後讀鎖的數量減一後不是0,把新的讀鎖數量傳送給readers通道
    l.readers <- rs
}

WaitGroup

WaitGroup最常見的用途是建立一個組,向其計數器中設定一個計數,生成與該計數一樣多的goroutine,然後等待它們完成。每次goroutine執行完畢後,它將在組上呼叫Done表示已完成工作。可以通過呼叫WaitGroupDone方法或以負數呼叫Add方法減少計數器的計數。當計數器達到0時,被Wait方法阻塞住的主執行緒會恢復執行。

WaitGroup一個鮮為人知的功能是在計數器達到0後,如果呼叫Add方法讓計數器變為正數,這將使WaitGroup重回阻塞狀態。 這意味著對於每個給定的WaitGroup,都有一點”世代”的意味:

  • 當計數器從0移到正數時開始”世代”。
  • 當計數器重回到0時,WaitGroup的一個世代結束。
  • 當一個世代結束時,被該世代的所阻塞住的執行緒將恢復執行。

下面是用channel實現的WaitGroup同步原語,真正起到阻塞goroutine作用的是世代裡的wait通道,然後通過用WaitGroup通道包裝generation結構體實現WaitGroupWaitAdd等功能。用文字很難描述清楚還是直接看下面的程式碼吧,程式碼裡的註釋會幫助理解實現原理。

type generation struct {
    // 用於讓等待者阻塞住的通道
    // 這個通道永遠不會用於傳送,只用於接收和close。
    wait chan struct{}
    // 計數器,標記需要等待執行完成的job數量
    n int
}

func newGeneration() generation {
    return generation{ wait: make(chan struct{}) }
}
func (g generation) end() {
    // close通道將釋放因為接受通道而阻塞住的goroutine
    close(g.wait)
}

//這裡我們使用一個通道來保護當前的generation。
//它基本上是WaitGroup狀態的互斥量。
type WaitGroup chan generation

func NewWaitGroup() WaitGroup {
    wg := make(WaitGroup, 1)
    g := newGeneration()
    // 在一個新的WaitGroup上Wait, 因為計數器是0,會立即返回不會阻塞住執行緒
    // 它表現跟當前世代已經結束了一樣, 所以這裡先把世代裡的wait通道close掉
    // 防止剛建立WaitGroup時呼叫Wait函式會阻塞執行緒
    g.end()
    wg <- g
    return wg
}

func (wg WaitGroup) Add(delta int) {
    // 獲取當前的世代
    g := <-wg
    if g.n == 0 {
        // 計數器是0,建立一個新的世代
        g = newGeneration()
    }
    g.n += delta
    if g.n < 0 {
        // 跟sync庫裡的WaitGroup一樣,不允許計數器為負數
        panic("negative WaitGroup count")
    }
    if g.n == 0 {
    // 計數器回到0了,關閉wait通道,被WaitGroup的Wait方法
    // 阻塞住的執行緒會被釋放出來繼續往下執行
        g.end()
    }
    // 將更新後的世代傳送回WaitGroup通道
    wg <- g
}

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

func (wg WaitGroup) Wait() {
    // 獲取當前的世代
    g := <-wg
    // 儲存一個世代裡wait通道的引用
    wait := g.wait
    // 將世代寫回WaitGroup通道
    wg <- g
    // 接收世代裡的wait通道
    // 因為wait通道里沒有值,會把呼叫Wait方法的goroutine阻塞住
    // 直到WaitGroup的計數器回到0,wait通道被close後才會解除阻塞
    <-wait
}

總結

今天這篇文章用通道實現了Go語言sync包裡常用的幾種同步鎖,主要的目的是演示通道和select語句結合後強大的表達能力,並沒有什麼實際應用價值,大家也不要在實際開發中使用這裡實現的同步鎖。

有關通道和同步鎖都適合解決什麼種類的問題我們後面的文章再細說,今天這篇文章,需要充分理解Go語言通道的行為才能理解文章裡的程式碼,如果有哪裡看不懂的可以留言,只要時間允許我都會回答。

如果還不瞭解sync包裡的同步鎖的使用方法,請先看這篇文章 Go語言sync包的應用詳解。後面的文章我會介紹併發程式設計裡的資料競爭問題以及解決方法,以及考慮給大家留一道思考題,請大家留意公眾號裡的動態。

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

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

相關文章