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