Go 語言碎碎念:併發

免責聲明

讀者不會從這篇文章看到:

  1. Goroutine 的默認棧空間大小是多少,協程和線程有什麼區別。

  2. 某 API 調用之異步與同步,阻塞與非阻塞。

  3. select 或 runtime.gopark() 的源碼解析。

  4. Go 和 Java 比哪個快?

Go 的背景

Go 語言初創團隊三劍客分別是:Ken Thompson, Rob Pike 和 Robert Griesemer。據 Ken Thompsen 所言,當初發明 Go 是因爲 [KThom]:

When the three of us got started, it was pure research. The three of us got together and decided that we hated C++.

10 年後,Go 語言增長的勢頭非常好——但 C++ 也是。Go 發佈兩年後,C++ 正式推出了 C++11 標準,並在此後規律地每三年出一個新版本,開發者數量再次回升。這部分工作不容小覷——C++ 標準從來沒有被某個大公司把持過,C++ 也沒有公司爲其做商業推廣。看看隔壁 Sun 如何對待 Java,C++ 標委會更像是用愛發電。

而 Rob Pike 作爲知名嘴炮,多次在公開場合冷嘲熱諷 C++ 的複雜 [Sys],強調 Go 自有另一套注重簡約的語言哲學。那他們是什麼樣的關係,爲什麼聽着是競爭對手,但實際應用領域看着不搭邊?這段話被我掏出來咀嚼了很多次,看了 Rob Pike 的一些表述和其他的一些資料,我隱約察覺到這句話有其語境,那就是解決 Google 的問題 [Go?]。

Google 的海量規模,會遇到前所未有的問題,也會遇到已知問題被放大到難以忍受的程度。對於前所未有的問題,Google 的那三篇最有名的分佈式論文就是非常好的例子:Google File System, MapReduce 和 BigTable,有興趣的同學可以自行去查閱。

舉例幾點已知的問題:

  1. C++ 的編譯耗時,這是當初 Go 想解決的一大痛點。C++ 爲了兼容 C 以提高易用度(他倆都誕生在 Bell Lab),包括其編譯模型。Google 內部的 C++ 代碼量級本身相當可觀,加上 Google 內部代碼倉庫的組織方式,再加上用 70 年代的 C 編譯模型來編譯,屬於凌遲。

  2. 語言本身的複雜度,C++ 必讀書目中 Scott Meyers 的兩本 Effective C++ 加起來將近百條款項,大幾百頁,而這纔剛剛入門。Go 語言一共只有不到 30 個關鍵字 [Expr],中文社區用“大道至簡” 來描述其語言設計哲學。

  3. 編寫併發程序。C++ 直到 C++11 纔有內存模型(memory model)以及線程的概念,而 Go 團隊在初期就認爲語言要內置對併發的支持。

Go 的提出有其特定背景,“知其然,知其所以然” 非常重要,如果你是一個 C++ 開發者,也請不要照搬 Google 的 C++ Style Guide,那是他們內部的需要!“我們討厭 C++” 更多是一句吐槽,Go 無意也無法替代某某語言,Go 甚至沒有編程語言甚至編程實踐創新的念頭,它的初衷就是爲了解決眼前的問題 [GoPL]。所以,事後兩個語言都活得好好的,C++ 開發者繼續在性能敏感領域圈地自萌,而 Go 開發者裏來了很多之前寫 Python 卻覺得太慢的朋友。

Go 的併發模式

在中文互聯網上,介紹 Go 幾乎總是離不開 “Go 協程” 這個概念。我之前寫有一篇文章來討論這個問題 [COE]:我不認爲這是個好的說法。goroutine 的機制是,多路複用操作系統線程,並在 OS 線程上調度協程。實現上,goroutine 可以對應爲用戶態線程;但從其整套 Go 併發設計來看,goroutine 是一個比協程或 OS 線程抽象層次更高的概念。相較之下,協程則是太原始而底層的一個概念了,這方面可以參考 C++20 coroutine,其語義基本和最早的協程一致 [Nano]。此外,訴諸權威的話,Rob Pike 介紹 goroutine 的原話是 “multiplexing coroutines on OS threads”[NotPar]。

把 Go 的併發特性排在本系列文章的第一位來聊,我認爲是相當程度上是衆望所歸的。據傳,併發編程的範式可以對應到進程間通信(inter-process communication)的兩大套路:一是共享內存(shared memory),二是消息傳遞(message passing)。當然,接下來我們會看到這兩個套路更多是不同的問題建模方法,互爲補充,而非互斥的關係。

共享內存是最普遍的模式,它更多地源自 Dijkstra 於 60 年代發佈的一系列關於併發編程的文章,這些文章奠定了計算機科學中的併發概念;1989 年 Andrew D. Birrell 的 technical report[SRC] 對這個範式做了總結。

消息傳遞則追溯到 Hoare 於 1976 年發佈的論文 Communicating Sequential Processes[CSP](以下把這篇論文簡稱 CSP76,論文提出的模型稱爲 CSP)。

併發的世界

當下,一臺 X86-64 架構的服務器可能擁有百倍於 20 年前的可用內存,但內存容量增長的速度依舊遠遠落後於人類軟件需求的增長速度,CPU 的單核心性能進步也遇到瓶頸 [Sutter]。這是提高程序併發性能的根本需求:程序員不能再依賴新的硬件來解決性能問題。併發編程因而不再停留於學術論文或實驗室中,而成爲工業界的熱門話題。

C++ 一直是 Google 內部研發的主力語言,但在 C11/C++11 標準前,這兩個編程語言是沒有 “線程” 概念的,也沒有內存模型。要進行併發編程,只能依賴第三方庫或編譯器內置函數(compiler intrinsic)。而這個第三方庫,通常就是 POSIX 提供的線程及相關同步設施。然而,很多時候,直接使用系統接口來編寫併發代碼是困難的。

直接使用 OS 線程的問題是,其粒度較大,而且屬於 OS 實現,應用程序對其的控制力有限;既然 OS 線程的粒度相對固定,那就很難能直接映射到應用程序多樣的、更細粒度的併發工作負載。爲此,程序員們可謂想盡了辦法。比如,最常見的模式是線程池,Java 標準庫對此有很好的支持;C++ 社區還有在努力引入協程(通常比 goroutine 更底層),比如 boost fiber,騰訊的 libco,最新的標準 C++20 提供語言級的協程支持。

而系統提供的同步原語(synchronization primitive)大部分直接來自經典計算機論文,比如 mutex[Mutex]、semaphore[Sem] 來自 Dijkstra,monitor(condition variable)來自 Hoare[Monitor]。這些同步原語的設計背景多是操作系統實現,而對於編寫應用程序顯得過於底層,從而阻礙程序員表達併發的程序結構或模式。

以上更多從性能角度描述了對併發的需求,很符合一些程序員的認識。然而,Go 團隊一直強調支持併發的出發點,是支持併發的軟件設計方式,因爲併發更符合世界運轉的實際方式,性能的考慮完全是次要的。併發的程序結構(模式)描述的是控制流的組織,考慮以下最有名或常用的幾個模式。

  1. Map-Reduce:

  2. Fork-Join:

  3. Pipeline:

對於一些常見的併發問題,程序員使用這樣的模式去對問題建模,是非常符合直覺的;但要用同步原語來實現,卻是非常反直覺的。實現這些模式本身,仍然是實現程序的控制流而非業務;再要用同步原語來實現,代碼只會更加骯髒 。出於以上現實的需要,Go 語言團隊認爲編程語言本身需要提供表達併發模式的基礎設施。他們的選擇是 Hoare 的 CSP,玩消息傳遞流派。實際上,Go 團隊對於 CSP 的偏好,要追溯至上世紀 80 年代了,這方面的歷史可以參考 Russ Cox 的博客 [RCox]。

併發可能是 Go 語言最漂亮優雅的特性。我認爲其中很重要的一個原因,就是其併發設計靈感來自於一篇自頂向下、面向高層次抽象的論文,而非面向實現的。此處談到的 “面向實現”,指的是從硬件屬性出發,向高級語言及其編程模式構建。例如 C 語言就是一門面向實現的語言,它設計初衷就想做離彙編一步之遙的高級語言,從而獲得極強的移植性。而從高層次抽象向實現擬合的語言設計,例如 SQL,它一舉將數據定義和查詢的語言,與關係數據庫的執行計劃、存儲引擎的實現解耦。

Communicating Sequential Processes

CSP76 是一篇 “很有野心的論文”(原文如此),它嘗試通過定義進程間的通信原語來解決進程間的同步問題。首先要解釋所謂的 “並行進程”:

  1. 原文確實寫的 “parallel”,但按當代眼光來看,並行更側重強調利用硬件的執行能力,而併發強調程序的組織結構;這篇論文也更多是提出處理併發問題抽象模型。我認爲在原文的語境中,“parallel” 暫時先做併發之意理解。

  2. 原文中的 “process” 並非映射到某個具體 OS 的進程實現上,如 Linux 進程或 Windows 進程,文中是一個更抽象的概念。我認爲理解爲執行流(thread of execution)就不錯。

下面介紹 Go 借鑑 CSP 的幾個要點。注意,“Go 是否實現 CSP” 是完全另一個問題,首先 CSP 已經是一套計算模型,類似 actor model[Actor],它們有特定的語義;CSP76 只是該模型的一篇論文,隨後這個模型有後續的更新 [UsingCSP]。

CSP 解決的是並行進程環境下的通信問題,它引入了一個可以開啓一組新的並行進程,並等待它們執行完畢的原語。這個原語基於 Dijkstra 的 parbegin。類似 Go 語言中的 go 關鍵字與 sync.WaitGroup 操作的合體。CSP 中對進程有特殊的限制,它們不能通過更新全局變量來通訊或同步。

論文裏稱,當時賦值操作已經被研究得相當清楚,但影響外部環境的 IO 操作卻並不明晰。CSP 將進程間通信的 IO 操作作爲編程語言內置的原語,對應到 Gochannel 的讀寫操作:<- 和 ->

進程間通信操作以兩個進程名爲參數,分別是輸出和輸入進程;且通信操作是無緩衝的,等待通信的進程會阻塞,這對應到 Go 默認的無緩衝 channel。在隨後基於 CSP 編程實踐中,人們發現以兩個進程名作爲通信參數是個錯誤的選擇,因爲它把進程的創建和組織限制在編譯時,從而難以表達更復雜或動態的程序 [RCox]。

如今 Go 語言中,channel 是作爲變量存在的,其進行讀寫的兩端不僅可以動態綁定,還可以綁定多個 goroutine。相應地,goroutine 是沒有 identity 的——從 Java 世界來的程序員們要求 Go runtime 提供 goroutine ID,但官方終不採納。

然而,誕生於 1986 年的 Erlang 其實也是一個以 CSP 爲靈感設計併發的編程語言,Erlang process 間卻是通過指定 pid 來通信的。顯然,它對於以上問題有不同的看法。如果把眼光再放遠一些,在 actor model 中,每個 actor 以 “地址” 作爲其標識符進行通信,而標識符可以“重載”,就像 IP 地址可以劃分子網[Actor]。

此外,輸入操作可以作爲在 guarded command 的條件,輸入條件爲真即意味着該通信操作當前可以無阻塞地立即進行;循環體也可以以輸入操作爲條件,循環會運行至所有消息源進程都已終結。這類似 Go 裏將 channel 作爲 range-for 中循環的對象。

顯然,Go 的併發模式很大程度上借鑑了這篇論文的成果,並使得這篇論文在發表近 40 年後再次獲得熱度。

Share By Communicating

Do not communicate by sharing memory; instead, share memory by communicating. (Effective Go)

本文不會再展開談 Go 併發編程的實踐。但推薦一篇 Go 官方博客 [Pipelines]。這篇文章非常適合琢磨 Go concurrency idioms(結尾的幾條鏈接也值得研究),並可以總結出以下要點:

type token struct{}
semaphore := make(chan token, limit)
wait := func() {
    semaphore <- token{}
}
signal := func() {
    <-semaphore
}

// 最多limit個goroutine同時在process,否則阻塞在wait()
// 不需要WaitGroup控制,或chan進行內容分發
for i := range bigCollection {
    wait()
    go func(i int) {
        process(i)
        signal()
    }(i)
}

// 等待所有goroutine退出(即最後limit個)
for i := 0; i < limit; i++ {
   semaphore <- token{}
}

引用

請珍惜像博主這樣的 YouTube junkie。

[KThom] Interview with Ken Thompson, http://web.archive.org/web/20130105013259/https://www.drdobbs.com/open-source/interview-with-ken-thompson/229502480
[Sys] "Lang NEXT 2014 Panel Systems Programming in 2014 and Beyond", https://www.youtube.com/watch?v=ZQR32nTVF_4
[Go?] “爲什麼 Go 語言如此不受待見?”, https://zhihu.com/question/27867348/answer/114125733
[Expr] Expressiveness Of Go - Rob Pike, https://talks.golang.org/2010/ExpressivenessOfGo-2010.pdf
[GoPL] The Go Programming Language and Environment, https://youtu.be/YXV7sa4oM4I
[COE] goroutine, 協程, COE - Hungbiu 的文章 - 知乎, https://zhuanlan.zhihu.com/p/404452442
[Nano] “Nano-coroutines to the Rescue! (Using Coroutines TS, of Course)” - G. Nishanov, https://www.youtube.com/watch?v=j9tlJAqMV7U
[NotPar] Concurrency Is Not Parallelism - Rob Pike, https://www.youtube.com/watch?v=qmg1CF3gZQ0
[SRC] An Introduction to Programming with Threads - Andrew D. Birrell, https://www.hpl.hp.com/techreports/Compaq-DEC/SRC-RR-35.pdf
[CSP] Communicating Sequential Processes - C.A.R. Hoare, http://www.cs.ox.ac.uk/people/bill.roscoe/publications/4.pdf
[Sutter] The Free Lunch Is Over - Herb Sutter, http://www.gotw.ca/publications/concurrency-ddj.htm
[GuardedCommand] Guarded Command - E.W. Dijkstra, https://dl.acm.org/doi/pdf/10.5555/1074100.1074433#:~:text=The%20term%20guarded%20command%2C%20as,execution%20is%20controlled%20by%20B.
[Pipelines] Go Concurrency Patterns, https://go.dev/blog/pipelines
[Mutex] Solution of a Problem in Concurrent Programming Control - E.W. Dijkstra, http://rust-class.org/static/classes/class19/dijkstra.pdf
[Sem] EWD35 - E.W. Dijkstra, https://www.cs.utexas.edu/users/EWD/translations/EWD35-English.html
[Monitor] Monitors - C.A.R. Hoare, https://www.classes.cs.uchicago.edu/archive/2020/winter/33100-1/papers/hoare-monitors.pdf
[RCox] Bell Labs and CSP Threads - Russ Cox, https://swtch.com/~rsc/thread/
[ParPatterns] Structured Parallelism programming Patterns: Patterns for Efficient Computation
[Actor] Hewitt, Meijer and Szyperski: The Actor Model, https://youtu.be/7erJ1DV_Tlo
[UsingCSP] http://www.usingcsp.com/
[Pipelines] Go Concurrency Patterns: Pipelines and cancellation - Sameer Ajmani, https://go.dev/blog/pipelines
[Context] Go Concurrency Patterns: Context - Sameer Ajmani, https://go.dev/blog/context
[Rethink] Rethinking Concurrecy Patterns - Bryan C. Mills, https://www.youtube.com/watch?v=5zXAHh5tJqQ

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/zs8OsZX5H0OT5Ie2Ru08Dw