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