這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。Go語言最有用的特性是將
並發作為第一支援的語言,使用協程goroutine, 非常容易實現代碼的
並發,這使得Go成為網路類應用的重要選擇,本文以銀行轉賬為例,闡述了Go 1.5新版本中如何使用協程實現
並發。該文還指出了在Go 1.5版本之間所有協程只是運行在單個進程,並不支援多核CPU並行計算,1.5以後提升到支援多核。
Golang Security and Concurrency
下面代碼是一段協程的實現:
func hello() { println("Hello!") } // --- func main() { testchan := make(chan string) go hello() go func(chan string) { println(<-testchan) }(testchan) testchan <- "world" }
|
協程是使用"go"這個關鍵詞,可以將其作為獨立函數或匿名函數看待,這個函數是非堵塞的,因為協程會靈活進行調度。我們也會使用Channel通道,允許協程彼此傳輸變數,類似隊列管道,這就輕鬆解決了協程之間通訊的問題,當有東西發送到通道中,channel會堵塞住一直等到讀操作發生,因此,這種方式是不會有丟失訊息的風險。
在Go 1.5以前版本,所有協程預設都是運行在單進程(類似node.js),這意味著只是
並發但不是並行,因為一次只有一個協程在運行,內部調度器對它們進行調度以確保所有協程都能夠運行。
下面的代碼類比了單進程方式:
testchan := make(chan int) finite_func := func() { testchan <- 1 } infinite_func := func() { for {} testchan <- 1 } go finite_func() go infinite_func() println(<-testchan)
|
第一個協程會立即返回通道中的1數值,而第二個無限迴圈,因為單線程原因會導致程式一直掛住等待兩個協程先後完成,而如果使用兩個進程,這個程式會在第一個協程返回結果時立即就退出了。
上面示範了協程的基本知識,下面我們看看競爭條件,使用簡單線上銀行轉賬案例,每次發一個請求會導致從A賬戶轉賬鈔票到B賬戶,銀行需要轉移現金並輸出新的賬戶餘額:
type User struct { Cash int } func (u *User) sendCash(to *User, amount int) bool { if u.Cash < amount { return false } /* Delay to demonstrate the race condition */ time.Sleep(500 * time.Millisecond) u.Cash = u.Cash - amount to.Cash = to.Cash + amount return true } func main() { me := User{Cash: 500} you := User{Cash: 500} http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { me.sendCash(&you, 50) fmt.Fprintf(w, "I have $%d\n", me.Cash) fmt.Fprintf(w, "You have $%d\n", you.Cash) fmt.Fprintf(w, "Total transferred: $%d\n", (you.Cash - 500)) }) http.ListenAndServe(":8080", nil) }
|
這是一個通用的Go Web應用,定義User資料結構,sendCash是在兩個User之間轉賬的服務,這裡使用的是net/http 包,我們建立了一個簡單的Http伺服器,然後將請求路由到轉賬50元的sendCash方法,在正常操作下,代碼會如我們預料一樣運行,每次轉移50美金,一旦一個使用者的賬戶餘額達到0美金,就不能再進行轉出鈔票了,因為沒有錢了,但是,如果我們很快地發送很多請求,這個程式會繼續轉出很多錢,導致賬戶餘額為負數。
這是課本上經常談到的競爭情況race condition,在這個代碼中,賬戶餘額的檢查是與從賬戶中取錢操作分離的,我們假想一下,如果一個請求剛剛完成賬戶餘額檢查,但是還沒有取錢,也就是沒有減少賬戶餘額數值;而另外一個請求線程同時也檢查賬戶餘額,發現賬戶餘額還沒有剩為零(結果兩個請求都一起取錢,導致賬戶餘額為負數),這是典型的"check-then-act"競爭情況。這是很普遍存在的
並發bug。
那麼我們如何解決呢?我們肯定不能移除檢查操作,而是確保檢查和取錢兩個動作之間沒有任何其他動作發生,其他語言是使用鎖,當賬戶進行更新時,鎖住禁止同時有其他線程操作,確保一次只有一個進程操作,也就是排斥鎖Mutex。
使用Go語言也能實現鎖操作,如下:
type User struct { Cash int } var transferLock *sync.Mutex func (u *User) sendCash(to *User, amount int) bool { transferLock.Lock() /* Defer runs this function whenever sendCash exits */ defer transferLock.Unlock() if u.Cash < amount { return false } /* Delay to demonstrate the race condition */ time.Sleep(500 * time.Millisecond)
u.Cash = u.Cash - amount to.Cash = to.Cash + amount return true } func main() { transferLock = &sync.Mutex{} me := User{Cash: 500} you := User{Cash: 500} http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { me.sendCash(&you, 50) fmt.Fprintf(w, "I have $%d\n", me.Cash) fmt.Fprintf(w, "You have $%d\n", you.Cash) fmt.Fprintf(w, "Total transferred: $%d\n", (you.Cash - 500)) }) http.ListenAndServe(":8080", nil) }
|
但是縮的問題很顯然降低了
並發效能,是
並發設計的最大敵人,在Go中推薦使用通道Channel,我們能夠使用事件迴圈event loop機制更靈活地實現
並發,我們委託一個後台協程監聽通道,當通道中有資料時,立即進行轉賬操作,因為協程是順序地讀取通道中的資料,也就是巧妙地迴避了競爭情況,沒有必要使用任何狀態變數防止
並發競爭了。
type User struct { Cash int } type Transfer struct { Sender *User Recipient *User Amount int } func sendCashHandler (transferchan chan Transfer) { var val Transfer for { val = <-transferchan val.Sender.sendCash(val.Recipient, val.Amount) } } /* sendCash is the same */ func main() { me := User{Cash: 500} you := User{Cash: 500} transferchan := make(chan Transfer) go sendCashHandler(transferchan) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { transfer := Transfer{Sender: &me, Recipient: &you, Amount: 50} transferchan <- transfer fmt.Fprintf(w, "I have $%d\n", me.Cash) fmt.Fprintf(w, "You have $%d\n", you.Cash) fmt.Fprintf(w, "Total transferred: $%d\n", (you.Cash - 500)) }) http.ListenAndServe(":8080", nil) }
|
上面這段代碼建立了比較可靠的系統從而避免了
並發競爭,但是我們會帶來另外一個安全問題:DoS(Denial of Service服務拒絕),如果我們的轉賬操作慢下來,那麼不斷進來的請求需要等待進行轉賬操作的那個協程從通道中讀取新資料,但是這個線程忙於照顧轉賬操作,沒有閑功夫讀取通道中新資料,這個情況會導致系統容易遭受DoS攻擊,外界只要發送大量請求就能讓系統停止回應。
一些基礎機制比如buffered channel可以處理這種情況,但是buffered channel是有記憶體上限的,不足夠儲存所有請求資料,最佳化解決方案是使用Go傑出的“select”語句:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { transfer := Transfer{Sender: &me, Recipient: &you, Amount: 50} /* Attempt the transfer */ result := make(chan int) go func(transferchan chan<- Transfer, transfer Transfer, result chan<- int) { transferchan <- transfer result <- 1 }(transferchan, transfer, result) select { case <-result: fmt.Fprintf(w, "I have $%d\n", me.Cash) fmt.Fprintf(w, "You have $%d\n", you.Cash) fmt.Fprintf(w, "Total transferred: $%d\n", (you.Cash - 500)) case <-time.After(time.Second * 10): fmt.Fprintf(w, "Your request has been received, but is processing slowly") } })
|
這裡提升了事件迴圈,等待不能超過10秒,等待超過timeout時間,會返回一個訊息給User告訴它們請求已經接受,可能會花點時間處理,請耐心等候即可,使用這種方法我們降低了DoS攻擊可能,一個真正健壯的能夠
並發處理轉賬且沒有使用任何鎖的系統誕生了。