天天看點

GO學習筆記-7 并發安全以通信共享記憶體以共享記憶體通信

前面已經學了 channel 和 sync,現在我們學習如何使用它們解決并發安全問題

以通信共享記憶體

看下面的例子

type bank struct {
        balance int
    }

    func (this *bank) Deposit(amount int)  {
        this.balance += amount
        //b := this.balance
        //time.Sleep(time.Second)
        //this.balance = b + amount
    }
    func (b bank) GetBalance() int {
        return b.balance
    }

    func main() {
        b := new(bank)
        go func() {
            b.Deposit(200)
            fmt.Println(b.GetBalance())
        }()
        go func() {
            b.Deposit(100)
        }()
        time.Sleep(time.Second)
        fmt.Println(b.GetBalance())
    }
           

實際上,最終的餘額可能是 300 也可能是 200,當大量并發同時存在時,就可能會出現我們注釋的那種情況,一個協程擷取到 balance 是 0,而後該協程被切換走,其他協程存入了 100,再回到原協程時,就會在 0 的基礎上 +200 并再指派給 balance,100 塊就這樣莫名其妙的消失了

當對資料做隻讀操作時,并發是安全的,如果多個并發項對同一個資料做讀寫操作,稱之為資料競态,我們應該預設它是不安全的

在其他語言中(比如 java)并發程式共享記憶體,使用鎖的方式保護資料,即通過共享記憶體實作通訊,如下

var mu = new(sync.Mutex)
    type Bank struct {
        balance int
    }
    func (this *Bank)Deposit(amount int)  {
        mu.Lock()
        this.balance += amount
        mu.Unlock()
    }
    //或用通道,達到互斥鎖模式
    var same = make(chan struct{},1)
    type Bank struct {
        balance int
    }
    func (b Bank) GetBalance() int {
        return b.balance
    }
    func (this *Bank)Deposit(amount int)  {
        same <- struct{}{}
        this.balance += amount
        <- same
    }
           

而 go 推薦的做法是:通過通訊實作共享記憶體,即,資料隻放在一個協程内,其他的并發程式使用通道通路該資料

我們使用這種思想改編上面的代碼

var deposits = make(chan int)
    var balances = make(chan int)

    func taller() {
        var balance int
        select {
        case v := <-deposits:
            balance += v
        case balances <- balance:
        }
    }

    func deposit(amount int) {
        deposits <- amount
    }
    func balance() int {
        return <- balances
    }
    func main() {
        go taller()
    }
           

這樣的話,其他并發程式想要修改資料隻能通過通道,而真正對資料進行讀寫操作隻在一個協程中進行,就避免了并發安全的問題

以共享記憶體通信

互斥鎖

go 提供了 sync 去為共享記憶體加鎖,讓我們再次審視下面這段代碼

type bank struct {
        balance int
        mu *sync.Mutex
    }

    func NewBank() *bank {
        b := new(bank)
        b.mu = new(sync.Mutex)
        return b
    }

    func (this *bank) GetBalance() int {
        this.mu.Lock()
        v := this.balance
        this.mu.Unlock()
        return v
    }
    func (this *bank) Deposit(amount int) {
        this.mu.Lock()
        this.balance += amount
        this.mu.Unlock()
    }
    func (this *bank) WithDraw(amount int) bool {
        this.Deposit(-amount)
        if this.GetBalance() < 0 {
            this.Deposit(amount)
            return false
        }
        return true
    }
           

首先要了解 mu.Lock 加鎖的機制,實際它是對 mu 内的一個變量加上鎖,當其他程式再次試圖對它 Lock 時會被拒絕,直到 Unlock 為止,這樣就實作了臨界條件,同時 WithDraw 加鎖之後,調用 Deposit,它再次試圖 Lock,這樣就會造成死鎖,我們不應該在加鎖的函數内調用另一個對同一個鎖執行 Lock 的函數,将代碼抽離如下

func (this *bank) Deposit(amount int) {
        this.mu.Lock()
        this.deposit(amount)
        this.mu.Unlock()
    }
    func (this *bank) WithDraw(amount int) bool {
        this.mu.Lock()
        defer this.mu.Unlock()
        this.deposit(amount)
        if this.balance < 0 {
            this.deposit(amount)
            return false
        }
        return true
    }
    func (this *bank) deposit(amount int)  {
        this.balance += amount
    }
           

讀寫鎖

GetBalance,為什麼一個讀取操作需要加鎖?這需要了解 cpu 的工作原理

現代計算機一般都有多個處理器,每個處理器都有自己的寄存器,為了提高效率,對記憶體的寫入都是緩存在寄存器中,隻有必要的時候才會再次刷回記憶體,甚至刷回記憶體的順序和程式執行的順序并不相同,像通信通道或互斥原語都會讓處理器把之前累計的寫草走刷回記憶體并送出,在送出之前運作在其他處理器的 goroutine 并不可見,如果 GetBalance 沒有加鎖,他可能會插在 withDraw 中間執行,而 withDraw 可能還沒有刷回記憶體

但使用互斥鎖可能會造成 GetBalance 調用次數太多而其他方法無法調用,着對于一個簡單的讀操作來說未免太過浪費了,可以使用 sync.RWMutex 讀寫鎖,它讓該調用過程獨有作業系統的讀或寫權限,避免了

var murw sync.RWMutex
    func (this *bank) GetBalance() int {
        murw.RLock()
        v := this.balance
        murw.RUnlock()
        return v
    }
           

看如下程式

var x, y int

	go func() {
		x = 1
		fmt.Print("y:",y)
	}()

	go func() {
		y = 1
		fmt.Print("x:",x)
	}()

           

對于這樣一個并發程式,我們可能期望到多種結果,但是你可能沒預料到這樣兩種結果 x:0 y:0 或 y:0 x:0,事實上在某些編譯器和處理器上的确有這種結果,因為指派和 print 函數對應不同變量,是以調換了他們的順序,或者他們在兩個處理器上執行,因為沒有回刷記憶體而導緻彼此不可見

關于并發通信,有着各種各樣的可能以及扯淡的結果,我們應該盡可能的将共享變量限制在一個順序執行的 goroutine,避免這些難以預料的問題