【Go 語言社群】在 Go 語言中,如何正確的使用併發
Glyph Lefkowitz最近寫了一篇啟蒙文章,其中他詳細的說明了一些關於開發高併發軟體的挑戰,如果你開發軟體但是沒有閱讀這篇問題,那麼我建議你閱讀一篇。這是一篇非常好的文章,現代軟體工程應該擁有的豐富智慧。
從多個花絮中提取,但是如果我斗膽提出主要觀點的總結,其內容就是:搶佔式多工和一般共享狀態結合導致軟體開發過程不可管理的複雜性, 開發人員可能更喜歡保持自己的一些理智以此避免這種不可管理的複雜性。搶佔式排程對於哪些真正的並行任務是好的,但是當可變狀態通過多併發執行緒共享時,明確的多工合作更招人喜歡 。
儘管合作多工,你的程式碼仍有可能是複雜的,它只是有機會保持可管理下一定的複雜性。當控制轉移是明確一個程式碼閱讀者至少有一些可見的跡象表明事情可能脫離正軌。沒有明確標記每個新階段是潛在的地雷:“如果這個操作不是原子操作,最後出現什麼情況?”那麼在每個命令之間的空間變成無盡的空間黑洞,可怕的Heisenbugs出現
在過去的一年多,儘管在Heka上的工作(一個高效能資料、日誌和指標處理引擎)已大多數使用GO語言開發。Go的亮點之一就是語言本身有一些非常有用的併發原語。但是Go的併發效能怎麼樣,需要通過支援本地推理的鼓勵程式碼鏡頭觀察。
並非事實都是好的。所有的Goroutine訪問相同的共享記憶體空間,狀態預設可變,但是Go的排程程式不保證在上下文選擇過程中的準確性。在單核設定中,Go的執行時間進入“隱式協同工作”一類, 在Glyph中經常提到的非同步程式模型列表選擇4。 當Goroutine能夠在多核系統中並行執行,世事難料。
Go不可能保護你,但是並不意味著你不能採取措施保護自己。在寫程式碼過程中通過使用一些Go提供的原語,可最小化相關的搶佔式排程產生的異常行為。請看下面Glyph示例“賬號轉換”程式碼段中Go介面(忽略哪些不易於最終儲存定點小數的浮點數)
- func Transfer(amount float64, payer, payee *Account,
- server SomeServerType) error {
- if payer.Balance() < amount {
- return errors.New("Insufficient funds")
- }
- log.Printf("%s has sufficient funds", payer)
- payee.Deposit(amount)
- log.Printf("%s received payment", payee)
- payer.Withdraw(amount)
- log.Printf("%s made payment", payer)
- server.UpdateBalances(payer, payee) // Assume this is magic and always works.
- return nil
- }
複製程式碼
這明顯的是不安全的,如果從多個goroutine中呼叫的話,因為它們可能併發的從存款排程中得到相同的結果,然後一起請求更多的已取消呼叫的存款變數。最好是程式碼中危險部分不會被多goroutine執行。在此一種方式實現了該功能:
type transfer struct {
payer *Account
payee *Account
amount float64
}
var xferChan = make(chan *transfer)
var errChan = make(chan error)
func init() {
go transferLoop()
}
func transferLoop() {
for xfer := range xferChan {
if xfer.payer.Balance < xfer.amount {
errChan <- errors.New("Insufficient funds")
continue
}
log.Printf("%s has sufficient funds", xfer.payer)
xfer.payee.Deposit(xfer.amount)
log.Printf("%s received payment", xfer.payee)
xfer.payer.Withdraw(xfer.amount)
log.Printf("%s made payment", xfer.payer)
errChan <- nil
}
}
func Transfer(amount float64, payer, payee *Account,
server SomeServerType) error {
xfer := &transfer{
payer: payer,
payee: payee,
amount: amount,
}
xferChan <- xfer
err := <-errChan
if err == nil {
server.UpdateBalances(payer, payee) // Still magic.
}
return err
}
複製程式碼
這裡有更多程式碼,但是我們通過實現一個微不足道的事件迴圈消除併發問題。當代碼首次執行時,它啟用一個goroutine執行迴圈。轉發請求為了此目的而傳遞入一個新建立的通道。結果經由一個錯誤通道返回到迴圈外部。因為通道不是緩衝的,它們加鎖,並且通過Transfer函式無論多個併發的轉發請求怎麼進,它們都將通過單一的執行事件迴圈被持續的服務。
上面的程式碼看起來有點彆扭,也許吧. 對於這樣一個簡單的場景一個互斥鎖(mutex)也許會是一個更好的選擇,但是我正要嘗試去證明的是可以向一個go例程應用隔離狀態操作. 即使稍稍有點尷尬,但是對於大多數需求而言它的表現已經足夠好了,並且它工作起來,甚至使用了最簡單的賬號結構實現:
type Account struct {
balance float64
}
func (a *Account) Balance() float64 {
return a.balance
}
func (a *Account) Deposit(amount float64) {
log.Printf("depositing: %f", amount)
a.balance += amount
}
func (a *Account) Withdraw(amount float64) {
log.Printf("withdrawing: %f", amount)
a.balance -= amount
}
不過如此笨拙的賬戶實現看起來會有點天真. 通過不讓任何大於當前平衡的撤回操作執行,從而讓賬戶結構自身提供一些保護也許更起作用。那如果我們把撤回函式變成下面這個樣子會怎麼樣呢?
func (a *Account) Withdraw(amount float64) {
if amount > a.balance {
log.Println("Insufficient funds")
return
}
log.Printf("withdrawing: %f", amount)
a.balance -= amount
}
複製程式碼
不幸的是,這個程式碼患有和我們原來的 Transfer 實現相同的問題。併發執行或不幸的上下文切換意味著我們可能以負平衡結束。幸運的是,內部的事件迴圈理念應用在這裡同樣很好,甚至更好,因為事件迴圈 goroutine 可以與每個個人賬戶結構例項很好的耦合。這裡有一個例子說明這一點:
type Account struct {
balance float64
deltaChan chan float64
balanceChan chan float64
errChan chan error
}
func NewAccount(balance float64) (a *Account) {
a = &Account{
balance: balance,
deltaChan: make(chan float64),
balanceChan: make(chan float64),
errChan: make(chan error),
}
go a.run()
return
}
func (a *Account) Balance() float64 {
return <-a.balanceChan
}
func (a *Account) Deposit(amount float64) error {
a.deltaChan <- amount
return <-a.errChan
}
func (a *Account) Withdraw(amount float64) error {
a.deltaChan <- -amount
return <-a.errChan
}
func (a *Account) applyDelta(amount float64) error {
newBalance := a.balance + amount
if newBalance < 0 {
return errors.New("Insufficient funds")
}
a.balance = newBalance
return nil
}
func (a *Account) run() {
var delta float64
for {
select {
case delta = <-a.deltaChan:
a.errChan <- a.applyDelta(delta)
case a.balanceChan <- a.balance:
// Do nothing, we've accomplished our goal w/ the channel put.
}
}
}
這個API略有不同,Deposit 和 Withdraw 方法現在都返回了錯誤。它們並非直接處理它們的請求,而是把賬戶餘額的調整量放入 deltaChan,在 run 方法執行時的事件迴圈中訪問 deltaChan。同樣的,Balance 方法通過阻塞不斷地在事件迴圈中請求資料,直到它通過 balanceChan 接收到一個值。
須注意的要點是上述的程式碼,所有對結構內部資料值得直接訪問和修改都是有事件迴圈觸發的 *within* 程式碼來完成的。如果公共 API 呼叫表現良好並且只使用給出的渠道同資料進行互動的話, 那麼不管對公共方法進行多少併發的呼叫,我們都知道在任意給定的時間只會有它們之中的一個方法得到處理。我們的時間迴圈程式碼推理起來更加容易了很多。
該模式的核心是 Heke 的設計. 當Heka啟動時,它會讀取配置檔案並且在它自己的go例程中啟動每一個外掛. 隨著時鐘訊號、關閉通知和其它控制訊號,資料經由通道被送入外掛中. 這樣就鼓勵了外掛作者使用一種想上述事例那樣的 事件迴圈型別的架構 來實現外掛的功能.
再次,GO不會保護你自己. 寫一個同其內部資料管理和主題有爭議的條件保持鬆耦合的Heka外掛(或者任何架構)是完全可能的。但是有一些需要注意的小地方,還有Go的爭議探測器的自由應用程式,你可以編寫的程式碼其行為可以預測,甚至在搶佔式排程的門面程式碼中。
英文原文:Sane Concurrency with Go
譯文連結:http://www.oschina.net/translate/sane-concurrency-with-go