Go 1.5的併發特性與案例

banq發表於2015-07-18
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"
}

<p class="indent">


協程是使用"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)

<p class="indent">

第一個協程會立即返回通道中的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)
}
<p class="indent">

這是一個通用的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)
}
<p class="indent">

但是縮的問題很顯然降低了併發效能,是併發設計的最大敵人,在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)
}
<p class="indent">


上面這段程式碼建立了比較可靠的系統從而避免了併發競爭,但是我們會帶來另外一個安全問題: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")
	}
})
<p class="indent">

這裡提升了事件迴圈,等待不能超過10秒,等待超過timeout時間,會返回一個訊息給User告訴它們請求已經接受,可能會花點時間處理,請耐心等候即可,使用這種方法我們降低了DoS攻擊可能,一個真正健壯的能夠併發處理轉賬且沒有使用任何鎖的系統誕生了。

相關文章