如果必須選擇 Go 的一項偉大功能,那么它必須是內置的并發模型。它不僅支持并發,而且使它變得更好。 Go Concurrency Model (goroutines) 之于并發,就像 Docker 之于虛擬化。
什么是并發(Concurrency)?
在計算機編程中,并發(Concurrency)是計算機同時處理多個事物的能力。例如,如果您在瀏覽器中上網,可能會同時發生很多事情。在特定情況下,您可能正在下載一些文件,同時在您滾動的頁面上聽一些音樂。因此瀏覽器需要同時處理很多事情。如果瀏覽器無法立即處理它們,您需要等待所有下載完成,然后您才能再次開始瀏覽互聯網。那會令人沮喪。
一般來說 PC 可能只有一個 CPU 內核來完成所有的處理和計算。一個 CPU 內核同一時間只能處理一件事。當我們談論并發(concurrency)時,我們一次只做一件事,但可以將 CPU 時間分配給需要處理的事情。因此,我們會感覺同時發生了多種事情,但事實上一次只發生了一件事情。
我們通過圖表來了解上述討論的案例: CPU 通過 Web 瀏覽器如何"同時"處理多個事情。
所以從上圖可以看出,單核處理器幾乎是根據每個任務的優先級來劃分工作負載的,例如,在頁面滾動時,聽音樂的優先級可能較低,因此有時您的音樂會因低優先級而停止互聯網速度,但您仍然可以滾動頁面。
什么是并行(parallelism)?
但是問題來了,如果 CPU 有多個內核呢?如果一個處理器有多個處理器,則稱為多核處理器。多核處理器能夠同時處理多項事情。 在之前的網頁瀏覽示例中,我們的單核處理器必須在不同的事物之間分配 CPU 時間。使用多核處理器,我們可以在不同的內核中同時運行不同的東西。讓我們使用下圖來評估。
并行運行不同事物的概念稱為并行(parallelism)性。當我們的 CPU 有多個內核時,我們可以使用不同的 CPU 內核同時做多事情。因此,我們可以說我們可以很快完成一項工作(包括很多東西),但事實并非如此。我們后面在討論這一點。
并發 (concurrency) vs 并行 (parallelism)
Go 建議只在一個內核上使用 goroutines,但我們可以修改 Go 程序以在不同的處理器內核上運行 goroutines。現在,將 goroutines 視為 Go 函數,因為它們就是,但還有更多。
并發性和并行性之間有幾個區別。并發一個人在同一時間處理多個事情,多個事情之間回切換;而并行是多人同時處理多個事情,每個人處理其中的一件。但并行并不總是比并發更有利,我們在下一片文章來討論這個問題。
此時,您的腦海中可能會有很多問題,您可能已經有了并發的想法,但您可能想知道 Go 如何實現它以及如何使用它。要了解 Go 的并發架構以及如何在代碼中使用它,以及何時在應用程序中使用它,我們需要了解什么是計算機進程。
計算機進程(process)是什么?
當您使用 C、java 或 Go 等語言編寫計算機程序時,它只是一個文本文件。但是由于計算機只能理解由 0 和 1 組成的二進制指令,因此需要將該代碼編譯為機器語言。這就是編譯器的用武之地。在 python 和 javascript 等腳本語言中,解釋器做同樣的事情。
當一個編譯好的程序被送到操作系統(OS) 處理時,操作系統(os) 會分配不同的東西,比如內存地址空間(進程的堆和棧所在的位置)、程序計數器、PID(進程 ID)和其他非常重要的東西。一個進程至少有一個線程稱為主線程,而主線程可以創建多個其他線程。當主線程執行完畢后,進程退出。
所以我們理解進程是一個容器,它已經編譯了代碼、內存、不同的操作系統資源和其他可以提供給線程的東西。簡而言之,進程就是內存中的一個程序。但是什么是線程,它們的工作是什么?
什么是線程(thread)?
線程是進程內的輕量級進程。線程是一段代碼的實際執行者。線程可以訪問進程提供的內存、操作系統資源和其他東西。
在執行代碼時,線程在內存區域內存儲變量(數據)稱為堆棧,其中臨時空間變量保存臨時空間。堆棧在運行時創建,通常具有固定大小,最好為 1-2 MB。而一個線程的堆棧只能由該線程使用,不會與其他線程共享。堆是進程的一個屬性,可供任何線程使用。堆是一個共享內存空間,來自一個線程的數據也可以被其他線程訪問。
現在我們大致了解了進程和線程。但是它們有什么用呢?
當啟動 Web 瀏覽器時,必須有一些代碼指示操作系統執行某些操作。這意味著我們正在創建一個進程。該進程可能會要求操作系統為新選項卡創建另一個進程。當瀏覽器選項卡打開并且同時您正在做日常工作,該選項卡進程將開始為不同的活動(如頁面滾動、下載、聽音樂等)創建不同的線程,正如我們在之前的圖表中看到的那樣。
下面是 macOS 平臺上 Chrome 瀏覽器應用程序的屏幕截圖
上面的屏幕截圖顯示 Google Chrome 瀏覽器對打開的標簽頁和內部服務使用不同的進程。由于每個進程至少有一個線程,我們可以看到一個谷歌瀏覽器進程,在這種情況下,有超過 3 個線程。
在之前的話題中,我們談到了處理多件事或做多件事。這里的事物是由線程執行的活動。因此,當在并發或并行模式下發生多件事情時,會有多個線程串聯或并行運行,也就是多線程。
在多線程中,在一個進程中產生多個線程,內存泄漏的線程會耗盡其他線程的資源并使進程無響應。在使用瀏覽器或任何其他程序時,您可能已經多次看到這種情況。您可能已經使用活動監視器或任務管理器來查看無響應的進程并殺死它。
線程調度
當多個線程串行或并行運行時,由于多個線程可能共享一些數據,因此線程需要協同工作,以便一次只有一個線程可以訪問特定數據。以某種順序執行多個線程稱為調度。操作系統線程由內核調度,一些線程由編程語言的運行時環境管理,如 JRE。當多個線程試圖同時訪問相同的數據導致數據被更改或導致意外結果時,就會發生競爭條件。
在設計并發 Go 程序時,我們需要注意競爭條件,我們將在下一片文章中討論。
Go 中的并發(concuenry)
最后,我們將討論 Go 如何實現并發。像java這樣的傳統語言有一個線程類,可以用來在當前進程中創建多個線程。由于 Go 沒有傳統的 OOP 語法,它提供了 go 關鍵字來創建 goroutine。當 go 關鍵字放在函數調用之前,它就變成了 goroutines。
我們將在下一片文章中討論 goroutines,但簡而言之,goroutines 的行為類似于線程,但在技術上;它是對線程的抽象。
當我們運行 Go 程序時,Go runtime 將在一個核心上創建幾個線程,所有 goroutine 都在該核心上復用(產生)。在任何時候,一個線程將執行一個 goroutine,如果該 goroutine 被阻塞,那么它將被替換為另一個將在該線程上執行的 goroutine。這就像線程調度,但由 Go runtime 處理,而且速度要快得多。
在大多數情況下,建議在一個內核上運行所有 goroutines,但是如果您需要在系統的可用 CPU 內核之間劃分 goroutines,您可以使用 GOMAXPROCS 環境變量或使用函數 runtime.GOMAXPROCS(n) 調用運行時其中 n 是要使用的內核數。但是有時您可能會覺得設置 GOMAXPROCS > 1 會使您的程序變慢。這確實取決于程序的性質,但您可以在互聯網上找到問題的解決方案或解釋。實際上,當程序使用多核、操作系統線程和進程時,在通道上通信比在計算上花費更多時間的程序會遇到性能下降。
Go 有一個 M:N 調度器,它也可以使用多個處理器。在任何時候,都需要在 N 個操作系統線程上調度 M 個 goroutine,這些線程最多在 GOMAXPROCS 個處理器上運行。在任何時候,每個內核最多只能運行一個線程。但是調度程序可以根據需要創建更多線程,但這很少發生。如果你的程序沒有啟動任何額外的 goroutines,那么無論你允許它使用多少個內核,它自然只會在一個線程中運行。
線程(threads) vs goroutines
正如我們之前看到的,線程和 goroutines 之間存在明顯的區別,但下面的區別將闡明為什么線程比 goroutines 更昂貴,以及為什么 goroutines 是實現應用程序中最高級別并發的關鍵解決方案。
thread | goroutines |
---|---|
操作系統線程由內核管理并具有硬件依賴性。 | goroutines 由 go runtime管理,沒有硬件依賴。 |
OS 線程通常具有 1-2MB 的固定堆棧大小 | 在較新版本的 go 中,goroutines 通常具有 8KB(自 Go 1.4 以來為 2KB)的堆棧大小 |
堆棧大小在編譯時確定,不能增長 | go 的堆棧大小在運行時進行管理,可以通過分配和釋放堆存儲增加到 1GB |
線程之間沒有簡單的通信媒介。線程間通信之間存在巨大的延遲。 | goroutine 使用通道以低延遲與其他 goroutine 通信 |
線程具有標識。有 TID 標識進程中的每個線程。 | goroutine 沒有任何身份。 go 實現了這一點,因為 go 沒有 TLS(線程本地存儲Thread Local Storage)。 |
線程具有顯著的設置和拆卸成本,因為線程必須從操作系統請求大量資源并在完成后返回 | goroutine 由 go runtime 創建和銷毀。與線程相比,這些操作非常便宜,因為 go runtime 已經為 goroutine 維護了線程池。在這種情況下,操作系統不知道 goroutines。 |
線程是預先調度的。由于調度程序需要保存/恢復超過 50 個寄存器和狀態,因此線程之間的切換成本很高。當線程之間快速切換時,這可能非常重要。 | goroutine 是協同調度的。當發生 goroutine 切換時,只需要保存或恢復 3 個寄存器。 |
以上是一些重要的區別,但如果你深入研究,你會發現 Go 并發模型的驚人世界。為了突出 Go 并發強度的一些優勢,假設您有一個 Web 服務器,每分鐘處理 1000 個請求。如果您必須同時運行每個請求,則意味著您需要創建 1000 個線程或將它們劃分到不同的進程中。這就是 Apache 服務器管理傳入請求的方式。如果 OS 線程每個線程消耗 1MB 堆棧大小,則意味著您將耗盡 1GB RAM 用于該流量。 Apache 提供了 ThreadStackSize 指令來管理每個線程的堆棧大小,但您仍然不知道是否因此而遇到問題。
而在 goroutine 的情況下,由于堆棧大小可以動態增長,您可以毫無問題地生成 1000 個 goroutine。由于 goroutine 以 8KB(自 Go 1.4 以來為 2KB)的堆棧空間開始,它們中的大多數通常不會增長得比這更大。但是,如果存在需要更多內存的遞歸操作,Go 可以將堆棧大小增加到 1GB,我認為這幾乎不會發生,除了 for {}, 這顯然是一個錯誤。
此外,與我們之前看到的線程相比,goroutines 之間的快速切換是可能的并且更高效。由于一個 goroutine 一次在一個線程上運行并且 goroutine 是協作調度的,因此在當前 goroutine 被阻塞之前不會調度另一個 goroutine。如果該線程塊中的任何 Goroutine 說等待用戶輸入,那么另一個 goroutine 將被安排在它的位置。
goroutine 可以在遇到以下條件之一時阻塞
- network input
- sleeping
- channel operation
- blocking on primitives in the sync package
如果 goroutine 沒有在這些條件之一上阻塞,它可以使多路復用的線程餓死,殺死進程中的其他 goroutine。雖然有一些補救措施,但如果確實如此,那么它就被認為是糟糕的編程。
Channels 在goroutine 間共享數據時,將發揮重要作用,下一章我們將討論這個問題。這將防止競爭條件和對共享數據的不當訪問,而不應在多線程的情況下訪問共享內存。
更多參考
- 本文翻譯自 在 Go 中實現并發
- Jaana Dogan 有一篇關于 Go 調度器的文章,名為 Go 的工作竊取調度器,閱讀它以了解 Go 的運行時如何管理 goroutine
- Rob Pike 發表了一篇關于 GoLang 并發的精彩演講,題目是 并發不是并行