从并发模型看 Go 的语言设计
傳統的程序語言設計都不會將輸入輸出作為語言的核心,但 Tony Hoare 認為輸入輸出是基本的編程原語,且通信順序進程(Communicating sequential processes,CSP)的并行組合(這里可能用「并發」會更為準確)是基本的程序組織方法。Go 語言的并發設計就是基于 CSP 模型的。
在最初的 CSP 模型中,程序總由若干個可以相互通信的進程構成,其中每一個進程內部是順序執行的(這也就是 CSP 名稱的含義)。注意這里的「進程」并不一定指操作系統中的進程,也不一定是操作系統的線程,它是一個抽象的概念,代表一組計算的序列,例如 goroutine 這種在應用層調度的計算序列也算 CSP 中的「P」。與 Go 語言不同的地方在于,這個最初的設計中并沒有通信管道的概念,每個進程是直接和另一個進程進行通信的,但在 Go 語言中,goroutine 是匿名的,一個 goroutine 并沒有辦法直接將消息發給另一個 goroutine,為了實現 goroutine 之間的通信,Go 語言提供了 first class 的 channel,消息通過 channel 來從一個 goroutine 發到另一個 goroutine。而且,Go 語言也不要求 goroutine 內部是順序執行的,goroutine 內部可以創建更多的 goroutine,并發地完成工作。
下面,我們通過例子說明基于 CSP 模型是如何組織程序的。
首先來看的是一個計算階乘的例子,階乘的一個簡單的遞歸實現可以是這樣的:
而基于 CSP 組織程序,我們可以這樣做:
// 包裝一個階乘計算函數func MakeFactFunc() func(int) int { in, out := make(chan int), make(chan int) go FactCalc(in, out) return func(x int) int { in <- x return <-out }}
MakeFactFunc?就是簡單地封裝一下?FactCalc,獲取一個計算階乘的函數。主要的計算是由?FactCalc?進行的。
每一個?FactCalc?都會被作為一個獨立的 goroutine 來執行,對于第 i 個 goroutine 而言,它先從第 i - 1 個 goroutine 中讀入一個數字?n,然后,如果?n > 0,這個 goroutine 需要做 3 件事:
? 1. 向第 i + 1 個 goroutine 寫入一個?n - 1
? 2. 從第 i + 1個 goroutine 處讀回來一個數字?r
? 3. 將?n * r?寫入第 i - 1 個 goroutine
否則,則向第 i - 1 個 goroutine 處寫入一個 1。
如前所述,由于 Go 語言不支持直接向一個 goroutine 發消息,所以這里的消息收發都要基于 channel 進行。我們可以看到,一旦?FactCalc?發現自己無法完成階乘問題的計算工作,它就會創建另一個 goroutine(只會創建一次),并將子問題發送給這個 goroutine 進行處理,這會形成一個?FactCalc?goroutine 的鏈條,鏈條上的每一個 goroutine 都與前一個和后一個 goroutine 進行通信(這就是前文所說的「若干個可以相互通信的進程」)。
我們又了這樣的階乘計算器后,我們可以這么去使用它:
執行程序,我們可以看到這樣的輸出:
相比于直接使用遞歸函數調用,這個實現方式非常不直觀。下面這個圖可能能幫助理解:
這里的圓形為調用者,每一個矩形都為一個 goroutine,當我們嘗試計算?fact(3)?時,會將 3 寫入最前面的 in channel 中,數據開始從第一個 goroutine 向后流動。第一個 goroutine 會從這個 channel 中讀到這個 3,它將?3 - 1?寫入下一個 in channel 中,然后開始阻塞等待 out channel 出現第二個 goroutine 計算的結果,第二個、第三個 goroutine 的計算是類似的,等到第 4 個 goroutine 從 in channel 中讀取輸入時,它發現這是一個 0,于是直接向 out channel 寫入一個 1,此時數據開始從最后一個 goroutine 往回流動,經過第三個和第二個 goroutine 的計算后,第一個 goroutine 會獲得 2,然后將?2 * 3?輸出。
注意到這里進行階乘計算的實體并不是遞歸的函數,而是并發的 goroutine,它們之間通過 channel 進行通信, 每個 goroutine 都將計算拆分并發送給其他 goroutine 進行處理,直到計算變為 trivial 的情況。當然了,這個實現相比簡單的遞歸函數會顯得很啰嗦,我們在實際使用中也不會這么做,但這個例子說明了如何在 CSP 模型下,利用數據的流動實現我們常見的遞歸。
下面的一個例子中,我們使用篩法來計算素數。所謂素數篩,大概就是對正整數 2 ~ n 進行遍歷,然后對每一個數字都進行一次篩選,只留下是素數的部分,對于第 i 位的篩選,我們需要依賴前面已經曬出的 m 個素數,當且僅當這 m 個素數都無法整除第 i 位的數字時,這個數字可以通過這一位的篩選,也就是這樣:
上面這個實現利用了 Haskell 的惰性求值能力,但對于大多數語言而言,我們的實現都不可能這么簡潔,基于傳統的順序計算的思路,程序都會比較啰嗦,而且關鍵是很不清晰。而在 CSP 模型下,我們可以這么實現:
func PrimeFilter(prime int, in <-chan int, out chan<- int) { for { i := <-in if i%prime != 0 { out <- i } }}
func PrimeSieve(out chan<- int) { c := make(chan int) go Counter(c) for { prime := <-c out <- prime newC := make(chan int) go PrimeFilter(prime, c, newC) c = newC }}
可以看到,我們的素數篩由三個部分組成,首先,Counter?從 2 開始依次產生自然數。PrimeFilter?就是每一層素數的過濾器,每一層過濾器只持有一個輸入 channel 一個輸出 channel 和一個素數?prime,它將不斷從輸入 channel 中讀入數字,并將其中無法被?prime整除的部分輸出。PrimeSieve?則是一個完整的素數篩,它每獲得一個素數,都將素數輸出,并創建一個新一層的過濾器,因此整個過程大概是這樣的:
PrimeSieve?可以向 out channel 中依次輸出被篩出來的素數,這個過程是惰性的,直到我們從 out channel 中取出素數,下一個素數才會被計算。我們可以這樣去使用它:
執行程序,我們可以看到這樣的輸出:
從這兩個例子中我們可以看到 CSP 模型不一定是用于并行計算,至少在這兩個例子中,每一個 goroutine 在進行計算之后都在阻塞等待,同一時間事實上僅有一個活躍的 goroutine,但 Go 語言對 CSP 并發模型的支持能讓整個設計變得簡單清晰(「并發」和「并行」的區別可以參考這個視頻)。這反映到 Go 語言設計上的要點有兩個:
Goroutine 之間可以通過 channel 來進行通信,channel 是 first class value,可以被直接傳遞。在這種情況下,goroutine 之間很容易進行協作,共同完成一個計算工作。
Goroutine 十分輕量,可以在單機建立大量 goroutine 而不至于消耗過多性能。對于素數篩的例子,每計算多一個素數都需要多一個 goroutine。而階乘計算的例子,輸入參數 + 1 都需要多一個 goroutine。顯然,如果沒有系統層調度的「process」的支持,CSP 所能應用的范圍就非常局限了。
下面我們再通過另外一個例子看一下 Go 語言的其他設計點。
一個信號量有兩個操作,分別稱為 V(signal())與P(wait())。其運作方式如下:
初始化,信號標 S 一個非負數的整數值。
執行 P 操作(wait())時,信號標 S 的值將嘗試被減少。當信號標 S 非正數時,進程會阻塞等待;當信號標 S 為正數時,S 被成功減少,進程可以繼續往下執行。
執行 V 操作(signal())時,信號標 S 的值將會被增加。
在 CSP 模型下,我們可以這樣實現:
func (sem *Semaphore) Wait() { sem.dec <- struct{}{}}
func (sem *Semaphore) Signal() { sem.inc <- struct{}{}}
func MakeSemaphore(initVal int) *Semaphore { sem := Semaphore{ inc: make(chan struct{}), dec: make(chan struct{}), } go func(s int) { for { if s > 0 { select { case <-sem.inc: s = s + 1 case <-sem.dec: s = s - 1 } } else { <-sem.inc s = s + 1 } } }(initVal) return &sem}
*Semaphore?有兩個操作,分別是?Wait?和?Signal,它們分別向?dec?channel 和?incchannel 發消息。而?MakeSemaphore?中創建的 goroutine 則會根據?s int?狀態的不同選擇不同的操作,如果?s > 0,則從?inc?channel 或?dec?channel 中隨機讀取一個值,并將?s?的值進行增加/減少 1,否則,從?inc?channel 中讀取一個值,并將?s?的值增加 1。注意這里的「隨機」是非常重要的,如果?inc?和?dec?同時都有數據可讀,則實際從哪個 channel 中讀出數據是不確定的,正是因為 Go 語言的?select?是隨機的,我們才可以在這里用它來進行調度。顯然,在大多數語言中,如果要實現 channel 這樣的類型,一般是以庫的形式進行實現,而 Go 語言將其上升到了語言層面實現,這樣雖然顯的不夠純粹干凈,但這樣可以通過更方便的語法實現?select?這樣強大的功能,如果實現為庫的形式,是難以做到這個程度的。
在這個例子中,我們將基于 Go 語言實現一個極簡單的服務模板,代碼如下:
type Output struct { Rsp interface{} State interface{}}
type Handler = func(input Input) (Output, error)
type Response struct { Result interface{} Error error}
type InMessage struct { Req interface{} OutChan chan<- Response}
type Service struct { inChan chan<- InMessage}
func (service *Service) RpcCall(request interface{}) (interface{}, error) { outChan := make(chan Response) service.inChan <- InMessage{request, outChan} rsp := <-outChan if rsp.Error != nil { return nil, rsp.Error } return rsp.Result, nil}
func MakeService(handler Handler, initState interface{}) *Service { inChan := make(chan InMessage) go func(state interface{}) { for { in := <-inChan out, err := handler(Input{in.Req, state}) if err != nil { in.OutChan <- Response{nil, err} } else { state = out.State in.OutChan <- Response{out.Rsp, nil} } } }(initState) return &Service{inChan}}
這里的?Service?是一個服務模板,我們通過?MakeService?來創建它。在創建服務模板的時候,我們要求調用者傳入一個請求處理函數?handler func(input Input) (Output, error),從類型可以知道,它接受一個請求,然后進行處理,并返回響應。請求和響應中都帶有狀態,handler?可以借此保存和修改狀態,由于模板并不知道狀態是什么,因此,MakeService?還要求調用者傳入一個初始的狀態?initState。然后,MakeService?會啟動一個 goroutine,這個 goroutine 不斷從?inChan?讀入請求,并調用?handler?進行處理,最終將響應通過?outChan?發回給調用方。RpcCall?簡單封裝了一下從?inChan?輸入請求,從?outChan?讀取響應的過程。我們可以使用這個模板這樣實現一個簡單的電話本服務:
type Insert struct { Name string Phone int}
type PhoneBookService = Service
func (s *PhoneBookService) Insert(name string, phone int) { s.RpcCall(Insert{name, phone})}
func (s *PhoneBookService) Query(name string) (int, error) { phone, err := s.RpcCall(Query{"Tom"}) if err != nil { return 0, err } return phone.(int), nil}
func MakePhoneBookService() *PhoneBookService { return MakeService(func(i Input) (Output, error) { st := i.State.(map[string]int) switch req := i.Req.(type) { case Query: x, ok := st[req.Name] if !ok { return Output{nil, nil}, fmt.Errorf("%v no found", req.Name) } return Output{x, st}, nil case Insert: st[req.Name] = req.Phone return Output{nil, st}, nil default: return Output{nil, nil}, fmt.Errorf("unknonw input: %v", req) } }, make(map[string]int))}
func main() { service := MakePhoneBookService() phone, err := service.Query("Tom") if err != nil { fmt.Println("query err:", err) } else { fmt.Println("query succ:", phone) } service.Insert("Tom", 123456) phone, err = service.Query("Tom") if err != nil { fmt.Println("query err:", err) } else { fmt.Println("query succ:", phone) }}
這個電話本功能很簡單,只有?Insert?和?Query?兩種方法。Service?模板的作用是將整個 Go 語言的并發模型封裝在函數調用內,從?PhoneBookService?的實現中,我們可以發現,這里沒有任何 goroutine 的產生代碼,也沒有 channel 的使用,僅僅出現了簡單的函數調用。對于?handler?的實現,里面也是一個簡單的循環。這樣一來,具體服務的實現者就不需要接觸 Go 語言的并發模型,也可以實現簡單的服務了。
執行程序,我們可以看到如下的輸出:
在這里,我們可以注意到 Go 語言的另外兩點設計,一個是使用錯誤返回值的錯誤的處理方式,另一個是只有接口沒有泛型。
首先說錯誤處理。
Go 語言的錯誤處理方式有很大爭議,支持者認為,Go 的錯誤返回值方式讓錯誤的出現更加明確,不會擾亂讓開發者的邏輯,更清晰地表達了意圖。而反對者則認為異常拋出的缺失導致 Go 代碼的錯誤處理非常冗長,且頻繁打斷主要邏輯。顯然,這兩個觀點都有各自的道理,且在不同的語言里我們也看到了這兩種錯誤處理方式的廣泛應用,但是我認為在 Go 的并發模型的限制下,使用錯誤返回值的方式是一個合理正確的選擇。如前所述,Go 語言每當創建一個 goroutine 之后,這個 goroutine 就和創建者沒有什么關系了,它甚至不能像線程一樣直接被等待執行結束。goroutine 和 goroutine 唯一進行關聯的方式就是通過 channel 的消息傳遞。假設 Go 語言支持了拋出異常,那么,一個 goroutine 中拋出了一個沒有被捕獲的異常,這個異常將會導致什么呢?由于沒有任何實體有責任捕獲并處理這個異常,因此這里唯一正確的處理方式就是 panic 了,這個處理顯然是很不可靠的,一個 goroutine 中的異常導致整個系統的 panic 無法讓人接受。當然,有人會說,那在每個 goroutine 的最頂層都 try-catch 一下就可以了。那問題又來了,try-catch 之后呢?如果出現了一個已經被拋到頂層的異常,說明這個異常應該無法被這個 goroutine 自身處理了,應該交由其監視者來處理,例如上面的例子中,調用者就應該負責處理?Service?goroutine 中產生的錯誤。那么,在這個時候,唯一正確的做法就是將拋出的異常以錯誤值的形式通過 channel 發送給監視者,以期待上層能夠正確處理這個異常。那么這樣一來,開發者就必須頻繁混合使用兩種錯誤處理方式,這樣的開發方式是極其混亂且易錯的。所以,使用錯誤返回值的方式應該是更加合理統一的方式了。
第二點,關于泛型的問題。
Go 語言只有接口沒有泛型,這導致了很多麻煩,例如我們無法實現帶有靜態檢查的自定義容器,泛型算法也難以實現。許多 Go 語言的開發者對于泛型的看法是:你不需要這個。我承認在實際工程中泛型的使用場合遠少于接口,但是,即便從服務開發這個 Go 語言的主戰場來看,泛型的必要性也依然很高。從上面的例子中我們可以看到,代碼中大量充斥著?interface{}?和對?interface{}?的類型轉換。其原因就是我們在實現這樣一個服務模板時,我們并不知道模板的使用者需要處理怎樣的 request,返回怎樣的 response,也不知道這里的 state 是什么。由于泛型的缺失,我們的代碼相當于失去了靜態的類型檢查,將靜態的類型錯誤變為了運行時錯誤,這樣一來,Go 語言的靜態能力就缺失了很多,甚至我們可以說,Go 語言泛型的缺失使得 Go 語言在類型安全性上不如帶有泛型能力的靜態類型語言,卻比這些語言在使用上還要更啰嗦(各種類型轉換和錯誤判斷)。
Go 語言是一個原生支持并發的語言,其并發模型基于 CSP 模型。通過使用 Go 語言的并發能力,我們可以設計出非常直觀易懂的代碼。經過上面幾個例子的分析中我們可以看出,從并發模型和并發程序設計的角度來看,Go 在語言設計上的優勢在于:
擁有輕量的應用層進程 goroutine,允許開發者基于大量 goroutine 來設計并發程序
First class channel 的支持,使得 goroutine 之間能夠很輕易地相互合作
select?關鍵字的隨機能力使得開發者可以基于 channel 來對程序實現調度
使用返回值的形式處理錯誤,很好地契合了 goroutine + channel 的并發模型
而 Go 在語言設計上的劣勢在于:
泛型的缺失導致許多程序設計變得脆弱,增加代碼量且失去了安全性
總結
以上是生活随笔為你收集整理的从并发模型看 Go 的语言设计的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 腾讯云入选云原生产业联盟首批理事单位 助
- 下一篇: 中国移动携手腾讯开展5G联合创新