概述
我一直在找一種好的方法來解釋 go 語言的並行存取模型:
不要通過共用記憶體來通訊,相反,應該通過通訊來共用記憶體
但是沒有發現一個好的解釋來滿足我下面的需求:
1.通過一個例子來說明最初的問題
2.提供一個共用記憶體的解決方案
3.提供一個通過通訊的解決方案
這篇文章我就從這三個方面來做出解釋。
讀過這篇文章後你應該會瞭解通過通訊來共用記憶體的模型,以及它和通過共用記憶體來通訊的區別,你還將看到如何分別通過這兩種模型來解決訪問和修改共用資源的問題。
前提
設想一下我們要訪問一個銀行帳號:
複製代碼 代碼如下:
type Account interface {
Withdraw(uint)
Deposit(uint)
Balance() int
}
type Bank struct {
account Account
}
func NewBank(account Account) *Bank {
return &Bank{account: account}
}
func (bank *Bank) Withdraw(amount uint, actor_name string) {
fmt.Println("[-]", amount, actor_name)
bank.account.Withdraw(amount)
}
func (bank *Bank) Deposit(amount uint, actor_name string) {
fmt.Println("[+]", amount, actor_name)
bank.account.Deposit(amount)
}
func (bank *Bank) Balance() int {
return bank.account.Balance()
}
因為 Account 是一個介面,所以我們提供一個簡單的實現:
複製代碼 代碼如下:
type SimpleAccount struct{
balance int
}
func NewSimpleAccount(balance int) *SimpleAccount {
return &SimpleAccount{balance: balance}
}
func (acc *SimpleAccount) Deposit(amount uint) {
acc.setBalance(acc.balance + int(amount))
}
func (acc *SimpleAccount) Withdraw(amount uint) {
if acc.balance >= int(mount) {
acc.setBalance(acc.balance - int(amount))
} else {
panic("傑克窮死")
}
}
func (acc *SimpleAccount) Balance() int {
return acc.balance
}
func (acc *SimpleAccount) setBalance(balance int) {
acc.add_some_latency() //增加一個延時函數,方便示範
acc.balance = balance
}
func (acc *SimpleAccount) add_some_latency() {
<-time.After(time.Duration(rand.Intn(100)) * time.Millisecond)
}
你可能注意到了 balance 沒有被直接修改,而是被放到了 setBalance 方法裡進行修改。這樣設計是為了更好的描述問題。稍後我會做出解釋。
把上面所有部分弄好以後我們就可以像下面這樣使用它啦:
複製代碼 代碼如下:
func main() {
balance := 80
b := NewBank(bank.NewSimpleAccount(balance))
fmt.Println("初始化餘額", b.Balance())
b.Withdraw(30, "馬伊琍")
fmt.Println("-----------------")
fmt.Println("剩餘餘額", b.Balance())
}
運行上面的代碼會輸出:
複製代碼 代碼如下:
初始化餘額 80
[-] 30 馬伊琍
-----------------
剩餘餘額 50
沒錯!
不錯在現實生活中,一個銀行帳號可以有很多個附屬卡,不同的附屬卡都可以對同一個帳號進行存取錢,所以我們來修改一下代碼:
複製代碼 代碼如下:
func main() {
balance := 80
b := NewBank(bank.NewSimpleAccount(balance))
fmt.Println("初始化餘額", b.Balance())
done := make(chan bool)
go func() { b.Withdraw(30, "馬伊琍"); done <- true }()
go func() { b.Withdraw(10, "姚笛"); done <- true }()
//等待 goroutine 執行完成
<-done
<-done
fmt.Println("-----------------")
fmt.Println("剩餘餘額", b.Balance())
}
這兒兩個附屬卡並發的從帳號裡取錢,來看看輸出結果:
複製代碼 代碼如下:
初始化餘額 80
[-] 30 馬伊琍
[-] 10 姚笛
-----------------
剩餘餘額 70
這下把文章高興壞了:)
結果當然是錯誤的,剩餘餘額應該是40而不是70,那麼讓我們看看到底哪兒出問題了。
問題
當並發訪問共用資源時,無效狀態有很大可能會發生。
在我們的例子中,當兩個附屬卡同一時刻從同一個帳號取錢後,我們最後得到銀行帳號(即共用資源)錯誤的剩餘餘額(即無效狀態)。
我們來看一下執行時候的情況:
複製代碼 代碼如下:
處理情況
--------------
_馬伊琍_|_姚笛_
1. 擷取餘額 80 | 80
2. 取錢 -30 | -10
3. 當前剩餘 50 | 70
... | ...
4. 設定餘額 50 ? 70 //該先設定哪個好呢?
5. 後設定的生效了
--------------
6. 剩餘餘額 70
上面 ... 的地方描述了我們 add_some_latency 實現的延時狀況,現實世界經常發生延遲情況。所以最後的剩餘餘額就由最後設定餘額的那個附屬卡決定。
解決辦法
我們通過兩種方法來解決這個問題:
1.共用記憶體的解決方案
2.通過通訊的解決方案
所有的解決方案都是簡單的封裝了一下 SimpleAccount 來實現保護機制。
共用記憶體的解決方案
又叫 “通過共用記憶體來通訊”。
這種方案暗示了使用鎖機制來預防同時訪問和修改共用資源。鎖告訴其它處理常式這個資源已經被一個處理常式佔用了,因此別的處理常式需要排隊直到當前處理常式處理完畢。
讓我們來看看 LockingAccount 是怎麼實現的:
複製代碼 代碼如下:
type LockingAccount struct {
lock sync.Mutex
account *SimpleAccount
}
//封裝一下 SimpleAccount
func NewLockingAccount(balance int) *LockingAccount {
return &LockingAccount{account: NewSimpleAccount(balance)}
}
func (acc *LockingAccount) Deposit(amount uint) {
acc.lock.Lock()
defer acc.lock.Unlock()
acc.account.Deposit(amount)
}
func (acc *LockingAccount) Withdraw(amount uint) {
acc.lock.Lock()
defer acc.lock.Unlock()
acc.account.Withdraw(amount)
}
func (acc *LockingAccount) Balance() int {
acc.lock.Lock()
defer acc.lock.Unlock()
return acc.account.Balance()
}
直接明了!注意 lock sync.Lock,lock.Lock(),lock.Unlock()。
這樣每次一個附屬卡訪問銀行帳號(即共用資源),這個附屬卡會自動獲得鎖直到最後操作完畢。
我們的 LockingAccount 像下面這樣使用:
複製代碼 代碼如下:
func main() {
balance := 80
b := NewBank(bank.NewLockingAccount(balance))
fmt.Println("初始化餘額", b.Balance())
done := make(chan bool)
go func() { b.Withdraw(30, "馬伊琍"); done <- true }()
go func() { b.Withdraw(10, "姚笛"); done <- true }()
//等待 goroutine 執行完成
<-done
<-done
fmt.Println("-----------------")
fmt.Println("剩餘餘額", b.Balance())
}
輸出的結果是:
複製代碼 代碼如下:
初始化餘額 80
[-] 30 馬伊琍
[-] 10 姚笛
-----------------
剩餘餘額 40
現在結果正確了!
在這個例子中第一個處理常式加鎖後獨享共用資源,其它處理常式只能等待它執行完成。
我們接著看一下執行時的情況,假設馬伊琍先拿到了鎖:
複製代碼 代碼如下:
處理過程
________________
_馬伊琍_|__姚笛__
加鎖 ><
得到餘額 80 |
取錢 -30 |
當前餘額 50 |
... |
設定餘額 50 |
解除鎖 <>
|
當前餘額 50
|
加鎖 ><
得到餘額 | 50
取錢 | -10
當前餘額 | 40
| ...
設定餘額 | 40
解除鎖 <>
________________
剩餘餘額 40
現在我們的處理常式在訪問共用資源時相繼的產生了正確的結果。
通過通訊的解決方案
又叫 “通過通訊來共用記憶體”。
現在帳號被命名為 ConcurrentAccount,像下面這樣來實現:
複製代碼 代碼如下:
type ConcurrentAccount struct {
account *SimpleAccount
deposits chan uint
withdrawals chan uint
balances chan chan int
}
func NewConcurrentAccount(amount int) *ConcurrentAccount{
acc := &ConcurrentAccount{
account : &SimpleAccount{balance: amount},
deposits: make(chan uint),
withdrawals: make(chan uint),
balances: make(chan chan int),
}
acc.listen()
return acc
}
func (acc *ConcurrentAccount) Balance() int {
ch := make(chan int)
acc.balances <- ch
return <-ch
}
func (acc *ConcurrentAccount) Deposit(amount uint) {
acc.deposits <- amount
}
func (acc *ConcurrentAccount) Withdraw(amount uint) {
acc.withdrawals <- amount
}
func (acc *ConcurrentAccount) listen() {
go func() {
for {
select {
case amnt := <-acc.deposits:
acc.account.Deposit(amnt)
case amnt := <-acc.withdrawals:
acc.account.Withdraw(amnt)
case ch := <-acc.balances:
ch <- acc.account.Balance()
}
}
}()
}
ConcurrentAccount 同樣封裝了 SimpleAccount ,然後增加了通訊通道
調用代碼和加鎖版本的一樣,這裡就不寫了,唯一不一樣的就是初始化銀行帳號的時候:
複製代碼 代碼如下:
b := NewBank(bank.NewConcurrentAccount(balance))
運行產生的結果和加鎖版本一樣:
複製代碼 代碼如下:
初始化餘額 80
[-] 30 馬伊琍
[-] 10 姚笛
-----------------
剩餘餘額 40
讓我們來深入瞭解一下細節。
通過通訊來共用記憶體是如何工作的
一些基本注意點:
共用資源被封裝在一個控制流程程中。
結果就是資源成為了非共用狀態。沒有處理常式能夠直接存取或者修改資源。你可以看到訪問和修改資源的方法實際上並沒有執行任何改變。
複製代碼 代碼如下:
func (acc *ConcurrentAccount) Balance() int {
ch := make(chan int)
acc.balances <- ch
balance := <-ch
return balance
}
func (acc *ConcurrentAccount) Deposit(amount uint) {
acc.deposits <- amount
}
func (acc *ConcurrentAccount) Withdraw(amount uint) {
acc.withdrawals <- amount
}
訪問和修改是通過訊息和控制流程程通訊。
在控制流程程中任何訪問和修改的動作都是相繼發生的。
當控制流程程接收到訪問或者修改的請求後會立即執行相關動作。讓我們仔細看看這個流程:
複製代碼 代碼如下:
func (acc *ConcurrentAccount) listen() {
// 執行控制流程程
go func() {
for {
select {
case amnt := <-acc.deposits:
acc.account.Deposit(amnt)
case amnt := <-acc.withdrawals:
acc.account.Withdraw(amnt)
case ch := <-acc.balances:
ch <- acc.account.Balance()
}
}
}()
}
select 不斷地從各個通道中取出訊息,每個通道都跟它們所要執行的操作相一致。
重要的一點是:在 select 聲明內部的一切都是相繼執行的(在同一個處理常式中排隊執行)。一次只有一個事件(在通道中接受或者發送)發生,這樣就保證了同步訪問共用資源。
領會這個有一點繞。
讓我們用例子來看看 Balance() 的執行情況:
複製代碼 代碼如下:
一張附屬卡的流程 | 控制流程程
----------------------------------------------
1. b.Balance() |
2. ch -> [acc.balances]-> ch
3. <-ch | balance = acc.account.Balance()
4. return balance <-[ch]<- balance
5 |
這兩個流程都幹了點什麼呢?
附屬卡的流程
1.調用 b.Balance()
2.建立通道 ch,將 ch 通道塞入通道 acc.balances 中與控制流程程通訊,這樣控制流程程也可以通過 ch 來返回餘額
3.等待 <-ch 來取得要接受的餘額
4.接受餘額
5.繼續
控制流程程
1.空閑或者處理
2.通過 acc.balances 通道裡面的 ch 通道來接受餘額請求
3.取得真正的餘額值
4.將餘額值發送到 ch 通道
5.準備處理下一個請求
控制流程程每次只處理一個 事件。這也就是為什麼除了描述出來的這些以外,第2-4步沒有別的操作執行。
總結
這篇部落格描述了問題以及問題的解決辦法,但那時沒有深入去探究不同解決辦法的優缺點。
其實這篇文章的例子更適合用 mutex,因為這樣代碼更加清晰。
最後,請毫無顧忌的指出我的錯誤!