如果我必須選擇 Go 的一個偉大特性,那么它必須是內置的并發模型。Go 不僅支持并發性,而且使其更好,更易于使用。Go 并發模型 (goroutine) 對并發編程的作用,就類似于 Docker 之于虛擬化的作用。
什么是并發
在計算機程序設計中,并發性指的是計算機同時處理多個任務的能力。例如,如果你在瀏覽器中上網,可能會有很多事情同時在發生。比如,你正在下載一些文件,同時滾動頁面來收聽音樂。因此瀏覽器需要同時處理這兩件事情。如果瀏覽器無法處理這些問題,則需要等到所有下載任務完成,然后才能夠重新瀏覽網站,這對于用戶來說是一件很痛苦的事情。
一臺通用的 PC 機可能只有一個 CPU 核心來完成所有的任務,一個 CPU 核心可以一次處理一件事。當我們討論并發性的時候,指的的是我們將 CPU 的時間片分配給需要處理的事情。因此,我們感覺到有很多事情在同時發生。
讓我們來看一個CPU管理web瀏覽器如何讓處理我們示例圖表中的內容。
從上圖中,你可以看到一個單核處理器根據每個任務的優先級來劃分工作負載。例如,當頁面滾動的時候,聽音樂的優先級可能很低,因此有時你的音樂會因為網速低而停止,但是你仍然可以滾動頁面。單個處理器通過某種切換時間片調度任務執行的策略,讓用戶感受到多個任務在同時執行。

什么是并行
接下來問題來了,如果我們CPU有多個內核呢?實際上現代的CPU上都是多核架構,如果一個CPU有多個處理核心,我們就把它叫做“多核處理器”。你可能在購買電腦或者智能手機的時候聽說過這個詞,例如我目前工作的筆記本就是2核心的 ,相對于目前比較先進的個人電腦 CPU 來說,是很 LOW的了。而且
商用服務器一般達到了 64 核心的處理能力,多個處理其能夠同時處理多個任務。在前面 web 瀏覽器的示例中,我們的單核處理器必須將 CPU 時間分配給不同的任務對象。使用多核處理器,我們可以在不同的核中同時運行不同的任務,可以看到下圖。

同時運行多個任務的概念我們稱之為并行。當我們的 CPU 有多個內核時,我們可以使用不同的 CPU 內核來同時執行多個任務,因此我們可以很快的去完成一項包括很多任務的工作。

并發 vs 并行
Go 建議只在一個內核上使用 goruntines,但是我們可以修改 Go 程序,以遍在不同的處理器內核上運行 goruntines。
并發和并行之前有幾個區別。并發是交替的處理多個事情,并行則是同時處理多個事情。那是不是并行一定會被并發更有益呢?也不一定,我們會在后續的播客里面討論到這一點
現在,可能會有很多問題在你的腦海里飛舞,你可能已經建立的并行和并發的想法,但你可能想知道如何使用 Go 的并發體系去實現它,在這之前我們先來了解一下計算機進程。
什么是計算機進程?
當你用 C、JAVA 或 Go 等語言編寫一個計算機程序時,它只是一個文本文件。但是由于計算機是只理解 0和1組成的二進制指令,所以需要將該代碼翻譯成機器語言。這就是編譯器的用武之地。在像 Python 和 js這樣的腳本語言中,解釋器做同樣的事情。 當編譯后的程序被發送到操作系統進行處理時,操作系統會分配不同的東西,比如內存地址空間(進程的堆和堆棧位于其中)、程序計數器、進程id (PID) 和其他關鍵的東西。進程至少又一個稱之為主線程的線程,而主線程可以創建多個其他線程。當主線程執行完成時,進程退出。
所以我們可以理解進程就是一個容器,它編譯了代碼,內存、不同的操作系統資源和其他可以提供給線程的東西。簡而言之,進程就是內存中的一個程序。
什么是計算機線程?
線程是一段代碼的實際執行者。線程可以訪問進程提供的內存、操作系統資源和其他東西。執行程序代碼是,內存區域內的線程存儲變量(數據)被稱為堆棧,其中暫存的變量占用堆棧空間,堆棧是在運行時創建的,通常具有固定大小,最好是1-2MB,線程堆棧只能由改線程使用,并且不會與其他線程共享。堆是進程的屬性,任何線程都可以使用它,堆是一個共享的內存空間,一個線程中的數據也可以被其他線程訪問。
現在,我們對進程和線程有了一個大致的了解。但是它們有什么用呢?
當你啟動Web瀏覽器的時候,必須有一些調用os進程操作的代碼。這意味著我們正在創建一個進程,一個進程可能會操作 os 為新選項卡創建另一個進程。當瀏覽器選項卡打開并且您在執行日常工作的時候,該選項卡將開始為不容的活動(如頁面滾動,下載,聽音樂等)創建不同的線程,就像我們前面的兩個進程處理任務圖里看到的那樣。 以下是 macOS 上Chrome瀏覽器應用程序任務圖
該圖顯示了 google Chrome 瀏覽器對打開的標簽頁和內部服務使用的不同進程。由于每個進程都至少又一個線程,因此我們可以看到線程數是大于進程數的。

在多線程中,在一個進程中產生多個線程的情況下,具有內存泄漏的線程可能會耗盡其他現層需要的資源而導致進程無響應。使用瀏覽器或其他任何程序的時候,你可能都遇到過出現無響應進程,任務管理器提示要將其殺死的現象。
線程調度
當多個線程串行或者并行運行的時候,由于多個線程之間可能共享一些數據,因此線程之間需要協同工作,以便于一次只有一個線程可以訪問特定的數據,保證任務的安全執行。我們把以某種順序執行多個線程稱為調度,操作系統線程由內核調度,某些線程由編程語言(如:Java的運行時環境-JRE )的運行時環境管理。當多個線程試圖同時訪問同一數據導致數據被更改或導致意外結果時,我們就說發生了爭用(race condition)。
當我們設計并發的 Go 程序時,關鍵在于尋找到這種爭用的情況,并且通過合理的措施才可以爭用情況下,多線程程序的安全運行。

在 Go 中使用并發
接下來,我們來討論如何在 Go 代碼中實現并發。我們知道,在 Java, C++ 之類具有面向對象編程(OOP)特性的的語言中一般具有一個線程類,我們可以通過該類在當前進程中創建多個線程對象。由于 Go 語言沒有傳統 OOP 語法,因此它提供了 go 關鍵字來創建 goruntine。當go關鍵字放在函數調用之前時,它將成為 goruntine 并被 go 調度執行。
在后續的文章中,我們將單獨討論協程 goroutine (文中goroutine和協程是等價的概念),目前你可以將它看作是一個線程,從技術上來講,協程的行為類似于線程,它是線程的抽象,下一小節將會介紹這兩者之間的區別。
當我們運行 Go 程序時,Go 運行時將在一個內核上創建一定數量的線程。所有的 goruntine 在該內核上進行多路復用。在任意時間點,一個線程執行一個 goroutine,如果該 goroutine 被停止,則它將被換成在該線程上執行另一個 goroutine。這有點類似于內核的線程調度,但是由 Go 的運行時 (runtime) 處理,將比內核調度更快。
建議在大多數的情況下,在一個內核上運行所有的 goroutine,但是如果你需要在系統的多核內核之前調度執行 goroutine,則可以使用 GOMAXPROCS 環境變量控制,也可以使用runtime.GOMAXPROCS(n)(
https://golang.org/pkg/runtime/#GOMAXPROCS) 調節運行時環境,其中 n 就是你要使用的核心數。你可能會覺得將 GOMAXPROCS 設置成 1 使程序變慢。不過這不是絕對的,如何設置這個參數取決于你目前運行程序的性質,很有可能花在多個核之間的通信開銷要比你的運行開銷還要大,這時候操作系統線程和進程將會遇到性能下降的情況,同樣你的 Go 程序性能也就隨之下降了。 Go 有一個 M:N 調度程序,它可以調度 Go 程序在多個處理器上執行。任何時候,都需要在 GOMAXPROCS 個處理器上運行 N 個操作系統線程上再調度 M 個協程 。在任何時候,每個內核最多運行一個線程,但如果需要,調度程序可以創建更多的線程,但是這種情況很少發生。如果你的代碼里面沒有啟動任何的 goroutine,那么無論你是用多少個內核,你的程序都只會在一個線程中、一個核上運行。
線程 vs 協程
由于線程和協程之間存在著明顯的區別,下面我們將通過對比項來解釋為什么線程開銷比協程更高,以及為什么協程是我們應用程序實現高級別并發特性的關鍵所在。
以上是幾個重要的區別,推薦你去深入的研究 Go 并發模型的實現,它將會顛覆你對并發編程的理解。為了突出這個 Go 協程模型的強大,我們可以來分析一個案例。假設有一臺 web 服務器,每分鐘處理 1000 個請求。如果必須同時運行每個請求,則意味著你需要創建 1000 個線程或將它們劃分到不同的進程中。這就是經典服務器 Apache (https://www.apache.org/) 的做法,如果每個線程消耗 1MB 的堆棧大小,則意味著你將要使用 1GB 的內存用于處理改流量。當然,Apache 提供了ThreadStackSize 指令來管理每個線程的堆棧大小,但是問題仍然沒有得到根本的解決。對于 Go 寫成來說,由于堆棧大小可以動態增長,因此,你可以毫無問題的生成 1000 個 goruntine 。由于 goruntine 的初始堆棧空間可以調節,初始為8KB(更高的Go版本可能會更小),因此并不會消耗多大的內存空間。同時當某個 goruntine 里面需要進行遞歸操作。Go可以輕松的將堆棧大小調大,可以達到1GB的大小,這樣無疑是“用更低的成本去做同樣的事情”。

上面我們提到,一個線程上在一個時刻執行運行一個協程,協程與協程之前是 Go 運行時來進行協同調度的。另一個協程不會被 “被占用的線程” 調度,知道在該線程上運行著的協程被阻塞。以下情況可以阻塞一個協程:
- 網絡流輸入
- 休眠 (sleeping)
- 通道 (channel) 操作
- 阻塞同步包 (
https://golang.org/pkg/sync/) 中的一些原語觸發
我們可以思考,假設協程不在上述情況下阻塞,那么阻塞住的協程將導致它所運行在的線程阻塞,殺掉其他需要調度的協程,我們需要通過詳細謹慎的編程手段來阻止這樣的事情發生。通道和同步原語在 Go 語言并發編程中扮演的舉足輕重的角色,后面我們將通過詳細的文章來分析它們的原理以及使用上的注意事項,這里不再過多闡述。
通過這篇文章,我們了解了線程調度的概念,以及 Go 中的并發使用和協程調度模型,最后我們對線程和協程進行了詳細的對比項,希望這些對比項可以幫助你在 Go 并發編程時做出更好的決策來使得程序達到更優的性能。后續的文章,我們將給出一些實際的程序代碼來探索 Go 并發編程的奧秘,盡情期待。