第三條原則: 原生并發(fā),輕量高效
并發(fā)(Concurrency)是有關(guān)結(jié)構(gòu)的,而并行(Parallelism)是有關(guān)執(zhí)行的 - Rob Pike(2012)
將時(shí)鐘指針回?fù)艿?2007 年,那時(shí) Go 語(yǔ)言三位設(shè)計(jì)者 Rob Pike、Robert Griesemer 和 Ken Thompson 都在 Google 使用 C++語(yǔ)言編寫服務(wù)端代碼。當(dāng)時(shí) C++ 標(biāo)準(zhǔn)委員會(huì)正在討論下一個(gè) C++ 標(biāo)準(zhǔn)(C++0x,也就是后來(lái)的 C++11 標(biāo)準(zhǔn)),委員會(huì)在標(biāo)準(zhǔn)草案中繼續(xù)增加大量語(yǔ)言特性的行為讓 Go 的三位設(shè)計(jì)者十分不滿,尤其是帶有原子類型的新 C++ 內(nèi)存模型,給本已負(fù)擔(dān)過重的 C++類型系統(tǒng)又增加了額外負(fù)擔(dān)。三位設(shè)計(jì)者認(rèn)為 C++ 標(biāo)準(zhǔn)委員會(huì)在思路上是短視的,因?yàn)橛布芸赡茉谖磥?lái)十年內(nèi)發(fā)生重大變化,將語(yǔ)言與當(dāng)時(shí)的硬件緊密耦合起來(lái)是十分不明智的,是沒法給開發(fā)人員在編寫大規(guī)模并發(fā)程序時(shí)帶去太多幫助的。
多年來(lái),處理器生產(chǎn)廠商一直在摩爾定律的指導(dǎo)下,在提高時(shí)鐘頻率這條跑道上競(jìng)爭(zhēng),各行業(yè)對(duì)計(jì)算能力的需求推動(dòng)了處理器處理能力的提高。CPU 的功耗和節(jié)能問題,愈來(lái)愈成為人們關(guān)注的一個(gè)焦點(diǎn),CPU 僅靠提高主頻來(lái)改進(jìn)性能的做法遇到了瓶頸,由于主頻提高導(dǎo)致 CPU 的功耗和發(fā)熱量劇增,反過來(lái)制約了 CPU 性能的進(jìn)一步提高。依靠主頻的提高帶來(lái)性能的提升已無(wú)法實(shí)現(xiàn),人們開始把研究重點(diǎn)轉(zhuǎn)向通過把多個(gè)執(zhí)行內(nèi)核放進(jìn)一個(gè)處理器,每個(gè)內(nèi)核在較低的頻率下工作來(lái)降低功耗同時(shí)提高性能。
2007 年處理器領(lǐng)域已開始進(jìn)入一個(gè)全新的多核時(shí)代,處理器廠商的競(jìng)爭(zhēng)焦點(diǎn)從主頻轉(zhuǎn)向了多核,多核設(shè)計(jì)也為摩爾定律帶去了新的生命力。與傳統(tǒng)的單核 CPU 相比,多核 CPU 帶來(lái)了更強(qiáng)的并行處理能力、更高的計(jì)算密度和更低的時(shí)鐘頻率,并大大減少了散熱和功耗。Go 的設(shè)計(jì)者敏銳地把握了 CPU 向多核方向發(fā)展的這一趨勢(shì),在決定不再使用 C++ 而去創(chuàng)建一門新語(yǔ)言的時(shí)候,果斷將面向多核、原生內(nèi)置并發(fā)支持作為了新語(yǔ)言的設(shè)計(jì)原則之一。
Go 語(yǔ)言原生并發(fā)原則的落地是映射到幾個(gè)層面上的。
1) Go 語(yǔ)言自身實(shí)現(xiàn)層面支持面向多核硬件的并發(fā)執(zhí)行和調(diào)度
提到并發(fā)執(zhí)行與調(diào)度,我們首先想到的就是操作系統(tǒng)對(duì)進(jìn)程、線程的調(diào)度。操作系統(tǒng)調(diào)度器會(huì)將系統(tǒng)中的多個(gè)線程按照一定算法調(diào)度到物理 CPU 上去運(yùn)行。傳統(tǒng)的編程語(yǔ)言比如 C、C++ 等的并發(fā)實(shí)現(xiàn)實(shí)際上就是基于操作系統(tǒng)調(diào)度的,即程序負(fù)責(zé)創(chuàng)建線程(一般通過 pthread 等函數(shù)庫(kù)調(diào)用實(shí)現(xiàn)),操作系統(tǒng)負(fù)責(zé)調(diào)度。這種傳統(tǒng)支持并發(fā)的方式有諸多不足:
-
復(fù)雜
- 創(chuàng)建容易,退出難:使用 C 語(yǔ)言的開發(fā)人員都知道,創(chuàng)建一個(gè) thread(比如利用 pthread)雖然參數(shù)也不少,但好歹可以接受。但一旦涉及到 thread 的退出,就要考慮 thread 是 detached,還是需要 parent thread 去 join?是否需要在 thread 中設(shè)置 cancel point,以保證 join 時(shí)能順利退出?
- 并發(fā)單元間通信困難,易錯(cuò):多個(gè) thread 之間的通信雖然有多種機(jī)制可選,但用起來(lái)是相當(dāng)復(fù)雜;并且一旦涉及到 shared memory,就會(huì)用到各種 lock,死鎖便成為家常便飯;
- thread stack size 的設(shè)定:是使用默認(rèn)的,還是設(shè)置的大一些,或者小一些呢?
-
難于擴(kuò)展
- 一個(gè) thread 的代價(jià)已經(jīng)比進(jìn)程小了很多了,但我們依然不能大量創(chuàng)建 thread,因?yàn)槌嗣總€(gè) thread 占用的資源不小之外,操作系統(tǒng)調(diào)度切換 thread 的代價(jià)也不??;
- 對(duì)于很多網(wǎng)絡(luò)服務(wù)程序,由于不能大量創(chuàng)建 thread,就要在少量 thread 里做網(wǎng)絡(luò)多路復(fù)用,即:使用 epoll/kqueue/IoCompletionPort 這套機(jī)制,即便有 libevent、libev 這樣的第三方庫(kù)幫忙,寫起這樣的程序也是很不易的,存在大量 callback,給程序員帶來(lái)不小的心智負(fù)擔(dān)。
為此,Go 采用了用戶層輕量級(jí) thread或者說(shuō)是類 coroutine的概念來(lái)解決這些問題,Go 將之稱為"goroutine"。goroutine 占用的資源非常小,每個(gè) goroutine stack 的 size 默認(rèn)設(shè)置是 2k,goroutine 調(diào)度的切換也不用陷入(trap)操作系統(tǒng)內(nèi)核層完成,代價(jià)很低。因此,一個(gè) Go 程序中可以創(chuàng)建成千上萬(wàn)個(gè)并發(fā)的 goroutine。所有的 Go 代碼都在 goroutine 中執(zhí)行,哪怕是 go 的 runtime 也不例外。將這些 goroutines 按照一定算法放到“CPU”上執(zhí)行的程序就稱為goroutine 調(diào)度器或goroutine scheduler。
不過,一個(gè) Go 程序?qū)τ诓僮飨到y(tǒng)來(lái)說(shuō)只是一個(gè)用戶層程序,對(duì)于操作系統(tǒng)而言,它的眼中只有 thread,它甚至不知道有什么叫 Goroutine 的東西的存在。goroutine 的調(diào)度全要靠 Go 自己完成,實(shí)現(xiàn) Go 程序內(nèi) goroutine 之間“公平”的競(jìng)爭(zhēng)“CPU”資源,這個(gè)任務(wù)就落到了 Go runtime 頭上。
Go 語(yǔ)言實(shí)現(xiàn)了G-P-M 調(diào)度模型和 work stealing 算法,這個(gè)模型一直沿用至今,如下圖所示:
- G:表示 goroutine,存儲(chǔ)了 goroutine 的執(zhí)行 stack 信息、goroutine 狀態(tài)以及 goroutine 的任務(wù)函數(shù)等;另外 G 對(duì)象是可以重用的。
- P:表示邏輯 processor,P 的數(shù)量決定了系統(tǒng)內(nèi)最大可并行的 G 的數(shù)量(前提:系統(tǒng)的物理 cpu 核數(shù)>=P 的數(shù)量);P 的最大作用還是其擁有的各種 G 對(duì)象隊(duì)列、鏈表、一些 cache 和狀態(tài)。每個(gè) G 要想真正運(yùn)行起來(lái),首先需要被分配一個(gè) P(進(jìn)入到 P 的 local runq 中)。對(duì)于 G 來(lái)說(shuō),P 就是運(yùn)行它的“CPU”,可以說(shuō):G 的眼里只有 P。
- M:M 代表著真正的執(zhí)行計(jì)算資源,一般對(duì)應(yīng)的是操作系統(tǒng)的線程。從 Goroutine 調(diào)度器的視角來(lái)看,真正的“CPU”是 M,只有將 P 和 M 綁定才能讓 P 的 runq 中 G 得以真實(shí)運(yùn)行起來(lái)。這樣的 P 與 M 的關(guān)系,就好比 Linux 操作系統(tǒng)調(diào)度層面用戶線程(user thread)與核心線程(kernel thread)的對(duì)應(yīng)關(guān)系那樣(N x M)。M 在綁定有效的 P 后,進(jìn)入 schedule 循環(huán);而 schedule 循環(huán)的機(jī)制大致是從各種隊(duì)列、p 的本地隊(duì)列中獲取 G,切換到 G 的執(zhí)行棧上并執(zhí)行 G 的函數(shù),調(diào)用 goexit 做清理工作并回到 m,如此反復(fù)。M 并不保留 G 狀態(tài),這是 G 可以跨 M 調(diào)度的基礎(chǔ)。
2) Go 語(yǔ)言為開發(fā)者提供的支持并發(fā)的語(yǔ)法元素和機(jī)制
我們先來(lái)看看那些設(shè)計(jì)并誕生于單核年代的編程語(yǔ)言,諸如:C、C++、Java 在語(yǔ)法元素和機(jī)制層面是如何支持并發(fā)的。
- 執(zhí)行單元:線程;
- 創(chuàng)建和銷毀的方式:調(diào)用庫(kù)函數(shù)或調(diào)用對(duì)象方法;
- 并發(fā)線程間的通信:多基于操作系統(tǒng)提供的 IPC 機(jī)制,比如:共享內(nèi)存、Socket、Pipe 等,當(dāng)然也會(huì)使用有并發(fā)保護(hù)的全局變量。
和上述傳統(tǒng)語(yǔ)言相比,Go 為開發(fā)人員提供了語(yǔ)言層面內(nèi)置的并發(fā)語(yǔ)法元素和機(jī)制:
- 執(zhí)行單元:goroutine;
- 創(chuàng)建和銷毀方式:go+函數(shù)調(diào)用;函數(shù)退出即 goroutine 退出;
- 并發(fā) goroutine 的通信:通過語(yǔ)言內(nèi)置的 channel 傳遞消息或?qū)崿F(xiàn)同步,并通過 select 實(shí)現(xiàn)多路 channel 的并發(fā)控制。
對(duì)比來(lái)看,Go 對(duì)并發(fā)的原生支持將大大降低開發(fā)人員在開發(fā)并發(fā)程序時(shí)的心智負(fù)擔(dān)。
3) 并發(fā)原則對(duì) Go 開發(fā)者在程序結(jié)構(gòu)設(shè)計(jì)層面的影響
由于 goroutine 的開銷很?。ㄏ鄬?duì)線程),Go 官方是鼓勵(lì)大家使用 goroutine 來(lái)充分利用多核資源的。但并不是有了 goroutine 就一定能充分的利用多核資源,或者說(shuō)即便使用 Go 也不一定能設(shè)計(jì)編寫出一個(gè)好的并發(fā)程序。
為此 Rob Pike 曾有過一次關(guān)于“并發(fā)不是并行”1的主題分享,在那次分享中,這位 Go 語(yǔ)言之父圖文并茂地講解了并發(fā)(Concurrency)和并行(Parallelism)的區(qū)別。Rob Pike 認(rèn)為:
- 并發(fā)是有關(guān)結(jié)構(gòu)的,它是一種將一個(gè)程序分解成小片段并且每個(gè)小片段都可以獨(dú)立執(zhí)行的程序設(shè)計(jì)方法; 并發(fā)程序的小片段之間一般存在通信聯(lián)系并且通過通信相互協(xié)作;
- 并行是有關(guān)執(zhí)行的,它表示同時(shí)進(jìn)行一些計(jì)算任務(wù) 。
劃重點(diǎn):并發(fā)是一種程序結(jié)構(gòu)設(shè)計(jì)的方法,它使得并行成為可能。不過這依然很抽象,我們這里也借用 Rob Pike 分享中的那個(gè)“搬運(yùn)書問題”來(lái)重新詮釋一下并發(fā)的含義。搬運(yùn)書問題要求設(shè)計(jì)一個(gè)方案,使得 gopher 能更快地將一堆廢棄的語(yǔ)言手冊(cè)搬到垃圾回收?qǐng)鰺簟?/p>
最簡(jiǎn)單的方案莫過于下圖:
這個(gè)方案顯然不是并發(fā)設(shè)計(jì)方案,它沒有對(duì)問題進(jìn)行任何分解,所有事情都是由一個(gè) gopher 從頭到尾按順序完成的。但即便這樣一個(gè)并非并發(fā)的方案,我們也可以將其放到多核的硬件上并行的執(zhí)行,只是需要多建立幾個(gè) gopher 例程(procedure)的實(shí)例罷了:
但和并發(fā)方案相比,這種方案是缺乏自動(dòng)擴(kuò)展為并行的能力的。Rob Pike 在分享中給出了兩種并發(fā)方案,也就是該問題的兩種分解方案,兩種方案都是正確的,只是分解粒度的細(xì)致程度不同。
并發(fā)方案 1 將原來(lái)單一的 gopher 例程執(zhí)行拆分為 4 個(gè)執(zhí)行不同任務(wù)的 gopher 例程,每個(gè)例程更簡(jiǎn)單:
- 將書搬運(yùn)到車上(loadBooksToCart);
- 推車到垃圾焚化地點(diǎn)(moveCartToIncinerator);
- 將書從車上搬下送入焚化爐(unloadBookIntoIncinerator);
- 將空車送返(returnEmptyCart)。
理論上并發(fā)方案 1 的處理性能能達(dá)到初始方案的四倍,并且不同 gopher 例程可以在不同的處理器核上并行執(zhí)行,而無(wú)需像最初方案那樣需要建立新實(shí)例實(shí)現(xiàn)并行。
和并發(fā)方案 1 相比,并發(fā)方案 2 增加了“暫存區(qū)域”,分解的粒度更細(xì),每個(gè)部分的 gopher 例程各司其責(zé),這樣的程序在單核處理器上也是正常運(yùn)行的(在單核上可能處理能力不如非并發(fā)方案)。但隨著處理器核數(shù)的增多,并發(fā)方案可以自然地提高處理性能,提升吞吐。而非并發(fā)方案在處理器核數(shù)提升后,也僅僅能使用其中的一個(gè)核,無(wú)法自然擴(kuò)展,這一切都是程序的結(jié)構(gòu)所決定的。這也告訴我們:并發(fā)程序的結(jié)構(gòu)設(shè)計(jì)不要局限于在單核情況下處理能力的高低,而是以在多核情況下能夠充分提升多核利用率、獲得性能的自然提升為最終目的。
除此之外,并發(fā)與組合的哲學(xué)是一脈相承的,并發(fā)是一個(gè)更大的組合的概念,它在程序設(shè)計(jì)的層面對(duì)程序進(jìn)行拆解組合,再映射到程序執(zhí)行層面上:goroutines 各自執(zhí)行特定的工作,通過 channel+select 將 goroutines 組合連接起來(lái)。并發(fā)的存在鼓勵(lì)程序員在程序設(shè)計(jì)時(shí)進(jìn)行獨(dú)立計(jì)算的分解,而對(duì)并發(fā)的原生支持讓 Go 語(yǔ)言更適應(yīng)現(xiàn)代計(jì)算環(huán)境。
并發(fā)不是并行 https://talks.golang.org/2012/waza.slide ??