Go:它是什么,?除非你一直生活在石頭下,否則你可能已經(jīng)聽說過 Golang,。Golang 或 Go 是 Google 在21世紀(jì)初開發(fā)的一種編程語言,。其最有趣的特性之一是它通過使用 goroutines 來支持并發(fā),這些 goroutines 就像輕量級(jí)的線程,。Goroutines 比實(shí)際的線程要便宜得多,,甚至每秒可以調(diào)度數(shù)百萬的 goroutines。 但 Go 是如何實(shí)現(xiàn)這一令人難以置信的壯舉的呢,?它是如何提供這種能力的,?讓我們看看 Golang 調(diào)度器在背后是如何工作的。 前提條件在我們深入探討之前,,有一些前提條件我必須談?wù)?。如果你已?jīng)對(duì)它們非常熟悉,可以跳過它們,。 系統(tǒng)調(diào)用系統(tǒng)調(diào)用是向內(nèi)核的接口,。就像 web 服務(wù)為用戶暴露一個(gè) REST API 接口一樣。 系統(tǒng)調(diào)用為 Linux 內(nèi)核提供了一個(gè)接口,。 為了更多地了解這些系統(tǒng)調(diào)用,,讓我們寫一點(diǎn)代碼!你可能首先問的問題是,,選擇哪種語言,? 答案有點(diǎn)復(fù)雜,但讓我們先了解如何在 C 語言中做,,然后再花一些時(shí)間思考如何在其他語言中執(zhí)行相同的操作,。 現(xiàn)在,讓我們嘗試
輸出是相當(dāng)容易預(yù)測的,,如下所示:
所有的語言在內(nèi)部都調(diào)用相同的系統(tǒng)調(diào)用,因?yàn)槟阒荒苁褂眠@些系統(tǒng)調(diào)用與內(nèi)核進(jìn)行交互,。例如,,看一些 NodeJS 代碼這里,你可以看到它是如何聲明文件路徑的,。他們圍繞這些系統(tǒng)調(diào)用做了很多工作,,為開發(fā)者提供了更簡單的接口,但在底層,,它們使用內(nèi)核提供的相同的系統(tǒng)調(diào)用,。 線程我相信大多數(shù)人在生活中某個(gè)時(shí)候都聽說過線程,但你真的知道它們是什么嗎,?它們是如何工作的,? 我會(huì)盡快為你解釋。 你可能已經(jīng)讀到過有多個(gè)核心的處理器,。這些核心中的每一個(gè)都像一個(gè)可以獨(dú)立工作的獨(dú)立處理單元,。 線程是基于這一點(diǎn)的抽象,我們可以在我們的程序中“創(chuàng)建”線程,,并在每個(gè)線程上運(yùn)行代碼,,然后由內(nèi)核調(diào)度在單個(gè)核心上運(yùn)行。 所以,,在任何時(shí)候,,單個(gè)核心都在運(yùn)行一個(gè)執(zhí)行線程。 我們?nèi)绾蝿?chuàng)建線程,?通過系統(tǒng)調(diào)用,! 那為什么不直接使用核心呢?為什么我們不直接寫 Goroutines正如我上面解釋的,,goroutines類似于輕量級(jí)的線程,,它們可以并發(fā)運(yùn)行并且可以在單獨(dú)的線程中運(yùn)行。 在繼續(xù)之前,沒有真正需要了解Golang,,但我認(rèn)為至少在繼續(xù)之前,,看一下一個(gè)非常簡單的goroutine的實(shí)現(xiàn)是有意義的。
輸出,,你可能猜到了,,是連續(xù)打印的單詞“HELLO”和“WORLD”。 既然用 你的直覺可能會(huì)讓你認(rèn)為這些 goroutines 只是并行運(yùn)行在 CPU 上的單獨(dú)線程,,并由操作系統(tǒng)管理,但這不是真的,。盡管現(xiàn)在每次調(diào)用都可以是單個(gè)線程,,但我們很快會(huì)發(fā)現(xiàn)這并不實(shí)際。 設(shè)計(jì)我們自己的調(diào)度器讓我們討論 Go 調(diào)度器,,就好像我們正在創(chuàng)建自己的調(diào)度器一樣。我知道這個(gè)任務(wù)可能看起來很艱巨,,但本質(zhì)上,,我們有一些工作單元,,比如說一些函數(shù),,我們需要將它們調(diào)度到有限的線程上。 所以,為了更正式地描述這一點(diǎn),,函數(shù)是單一的工作單位。它是執(zhí)行某些處理的代碼行的序列?,F(xiàn)在,,讓我們不要用像 async/await 這樣的東西來混淆自己,我們說一個(gè)函數(shù)只包含非?;镜拇a,。也許像這樣,
它應(yīng)該在線程上運(yùn)行,,這些線程在內(nèi)部在處理器的核心上進(jìn)行多路復(fù)用,。核心是有限的,但線程可以是無限的,。管理這些線程并在有限的CPU核心上運(yùn)行它們是操作系統(tǒng)調(diào)度器的工作,,所以我們不需要關(guān)心核心。我們可以簡單地將線程視為實(shí)現(xiàn)并行性的單位,。 我們確實(shí)有一套系統(tǒng)調(diào)用,,我們可以運(yùn)行它們?cè)诓僮飨到y(tǒng)上創(chuàng)建線程。系統(tǒng)調(diào)用往往是昂貴的,,所以我們需要確保我們不經(jīng)常創(chuàng)建/刪除線程,。 我們調(diào)度器的工作很簡單,將這些函數(shù)或 goroutines 運(yùn)行在線程上,。我們的調(diào)度器可以在任何時(shí)候被賦予新的函數(shù)/goroutines,,它將必須在線程上調(diào)度它們。 在下一部分,,讓我們繼續(xù)明確所有的需求,。 需求讓我們列出我們調(diào)度器中想要的一些優(yōu)先事項(xiàng),這與 Golang 所設(shè)置的優(yōu)先級(jí)相似,,這些將影響我們?cè)谠O(shè)計(jì)調(diào)度器時(shí)所做的設(shè)計(jì)決策,。 輕量級(jí) goroutines 我們希望我們的 goroutines 非常輕量級(jí)。我們希望能夠處理每秒調(diào)度的數(shù)百萬 goroutines,。這意味著我們想要做的所有事情,,如創(chuàng)建一個(gè) goroutine 或在線程上切換一個(gè) goroutine 必須非常快,。 處理系統(tǒng)調(diào)用和 I/O 系統(tǒng)調(diào)用可能難以處理,,但我們?nèi)匀幌M麨槲覀兊暮瘮?shù)提供能夠進(jìn)行系統(tǒng)調(diào)用和執(zhí)行 I/O 操作的能力。這意味著某個(gè)函數(shù)可以打開并讀取一個(gè)文件,,例如,。系統(tǒng)調(diào)用有點(diǎn)棘手,因?yàn)橐坏╅_始了系統(tǒng)調(diào)用,我們就看不到它何時(shí)結(jié)束或需要多長時(shí)間,。在某種意義上,,我們不能再控制或透明地看到函數(shù)了。當(dāng)我們開始設(shè)計(jì)我們的調(diào)度器時(shí),,這個(gè)問題會(huì)出現(xiàn),。 并行 我們當(dāng)然希望同時(shí)運(yùn)行多個(gè) goroutines。記住,,我們不需要擔(dān)心核心,,我們只需考慮如何將這些函數(shù)多路復(fù)用到線程上。 公平 我們希望一個(gè)系統(tǒng)確保運(yùn)行在其中的 goroutines 是公平的,。這意味著一個(gè) goroutine 不應(yīng)該阻塞其他 goroutines 很長時(shí)間,。公平可能有點(diǎn)難以客觀定義,但其思路是每個(gè) goroutine 都應(yīng)該在線程上獲得公平的執(zhí)行時(shí)間,。這似乎是合乎邏輯的,,因?yàn)闆]有定義某種優(yōu)先級(jí)系統(tǒng)的要求,沒有理由給予任何這些函數(shù)優(yōu)先權(quán),。 避免過度訂閱 重要的是要控制任何程序?qū)⑹褂玫木€程數(shù),。當(dāng)確保我們不會(huì)無謂地獲取操作系統(tǒng)級(jí)別的線程并阻塞機(jī)器上運(yùn)行的其他進(jìn)程時(shí),這可能會(huì)很有用,。因此,,我們應(yīng)該嘗試限制我們使用的線程數(shù)量。如果我們超出這個(gè)限制,,我們將過度訂閱,,為了對(duì)系統(tǒng)上運(yùn)行的其他進(jìn)程公平,我們會(huì)認(rèn)為這是一件很糟糕的事情,,并盡量避免它,。 第1部分:調(diào)度 Goroutines1:1 映射一個(gè)非常簡單的系統(tǒng)是我們?yōu)槊總€(gè) goroutine 創(chuàng)建一個(gè)線程。這意味著我們?cè)?goroutines 和線程之間有一個(gè)1:1的映射,。 因此,,每當(dāng)用戶嘗試創(chuàng)建一個(gè) goroutine,我們的調(diào)度器創(chuàng)建一個(gè)線程,,該線程開始執(zhí)行 goroutine,。 這導(dǎo)致了很多問題,
M:N 映射這里的邏輯步驟是使用 M:N 映射。這意味著M個(gè) goroutines 需要映射到N個(gè)線程,。因此,,用戶可以創(chuàng)建他們想要的任意數(shù)量的 goroutines,但我們對(duì)一組有限的線程有控制權(quán),,并且我們將他們的 goroutines 調(diào)度到一個(gè)線程上一段時(shí)間,。我們也可以暫停/恢復(fù)線程上運(yùn)行的 goroutines。 這樣做有點(diǎn)復(fù)雜,,因?yàn)槲覀儸F(xiàn)在需要了解如何將一個(gè) goroutine/function 映射到一個(gè)線程上,。我們可能只在每個(gè)線程上運(yùn)行一個(gè)函數(shù)很短的時(shí)間,所以我們需要做一些繁重的工作以確保我們得到正確的邏輯,。 一個(gè)好的比喻可能是餐館,。餐廳通常會(huì)有不同數(shù)量的侍者和廚師。侍者的數(shù)量取決于餐廳的客人或桌子的數(shù)量,,而廚師的數(shù)量取決于訂單的數(shù)量和烹飪所需的時(shí)間,。 在這個(gè)比喻中,我們可以把函數(shù)想象成侍者,,線程想象成廚師,。 為每個(gè)侍者提供一個(gè)廚師并不真正有意義,因?yàn)橐苍S一個(gè)廚師可以很快地烹飪食物,,也許餐廳在高峰時(shí)間有很多客人,,但在慢時(shí),也許需要更少的侍者,。所以為了正確管理這家餐廳,,你需要某種系統(tǒng)來將M個(gè)侍者分配給N個(gè)廚師。這正是我們必須在調(diào)度器中做的事情,! 為了進(jìn)一步延續(xù)這個(gè)比喻,,也許一個(gè)簡單的系統(tǒng)是有一個(gè)訂單隊(duì)列,每個(gè)訂單都有一個(gè)數(shù)字,,廚師們一個(gè)接一個(gè)地選擇數(shù)字,。這也將是我們調(diào)度器的起點(diǎn),! 一個(gè)基本的系統(tǒng) — 全局運(yùn)行隊(duì)列讓我們首先設(shè)計(jì)一個(gè)基本的系統(tǒng),并對(duì)其進(jìn)行迭代,。 我們?cè)O(shè)置一個(gè)全局的先入先出的運(yùn)行隊(duì)列,,其中包含需要運(yùn)行的一組 goroutines。每個(gè)線程從這個(gè)隊(duì)列中選擇一個(gè) goroutine 并執(zhí)行它,。當(dāng)它執(zhí)行完一個(gè) goroutine 后,,它再次選擇一個(gè)新的 goroutine 并繼續(xù)反復(fù)進(jìn)行相同的過程。 為了更好地理解,,讓我們編寫一些基本的偽代碼,,描述每個(gè)線程將執(zhí)行的內(nèi)容。
重要的是要記住,,每一個(gè)線程都在并行地運(yùn)行相同的代碼,。 這個(gè)系統(tǒng)中的一個(gè)問題是,每個(gè)線程都在訪問相同的共享變量,,即 我們需要一種方法來確保任何時(shí)候只有一個(gè)線程可以訪問運(yùn)行隊(duì)列。 互斥鎖解決這個(gè)問題的一種方法是引入一個(gè)互斥鎖,?;コ怄i只是一個(gè)鎖,每個(gè)線程在訪問隊(duì)列之前都必須獲取它,。只有線程擁有互斥鎖時(shí),,它才能執(zhí)行操作。完成后,,它可以釋放互斥鎖,,供其他線程獲取。 把它想象成一個(gè)鎖,,確保只有一個(gè)線程可以訪問全局運(yùn)行隊(duì)列,。 繼續(xù)我們的偽代碼,
現(xiàn)在,,下一個(gè)問題是每個(gè)線程都必須等待獲取一個(gè)互斥鎖來對(duì)運(yùn)行隊(duì)列執(zhí)行任何操作,。在未來,我們也可能增加暫停 goroutines,、恢復(fù) goroutines 等功能,。如果所有操作都需要互斥鎖,,那么它可能成為我們系統(tǒng)的瓶頸。 全局和本地運(yùn)行隊(duì)列為了解決單一互斥鎖的問題,,我們?yōu)槊總€(gè)線程提供了它自己的本地運(yùn)行隊(duì)列,。 每個(gè) goroutine 在創(chuàng)建時(shí)開始在全局運(yùn)行隊(duì)列中執(zhí)行,但在某個(gè)時(shí)候由不同的系統(tǒng)分配給一個(gè)本地運(yùn)行隊(duì)列,。每個(gè)線程大多與它自己的本地運(yùn)行隊(duì)列進(jìn)行交互,,選擇一個(gè) goroutine 并執(zhí)行它。這樣,,我們把大部分工作轉(zhuǎn)移到了每個(gè)線程的本地運(yùn)行隊(duì)列,。由于本地運(yùn)行隊(duì)列只被一個(gè)線程訪問,所以它甚至不需要互斥鎖,。 我們現(xiàn)在不再需要互斥鎖,,我們的代碼變得更簡單了。
我們需要明白的是,,我們?nèi)匀挥幸粋€(gè)帶有互斥鎖的全局運(yùn)行隊(duì)列,但我們可以大大減少對(duì)全局運(yùn)行隊(duì)列的調(diào)用,,因?yàn)橐粋€(gè)單獨(dú)的線程主要是從其自己的本地運(yùn)行隊(duì)列中取出 goroutines,。它可能偶爾從全局運(yùn)行隊(duì)列中獲取,比如當(dāng)其自己的本地運(yùn)行隊(duì)列為空時(shí),,但那是一個(gè)罕見的情況(如果你感興趣,,這里是找到一個(gè)正在運(yùn)行的 goroutine 執(zhí)行的代碼,你可以清楚地看到,,如果它在本地運(yùn)行隊(duì)列中找不到一個(gè) goroutine,,它將會(huì)輪詢?nèi)诌\(yùn)行隊(duì)列)。 工作竊取我們可以為我們的系統(tǒng)添加的另一個(gè)有趣的特性是“工作竊取”的概念,。每當(dāng)線程的本地運(yùn)行隊(duì)列為空時(shí),,它可以嘗試從其他本地運(yùn)行隊(duì)列中竊取工作。這也有助于平衡 goroutines 的負(fù)載,。
在這個(gè)系統(tǒng)中,,即使一個(gè)線程執(zhí)行一個(gè) goroutine 需要很長時(shí)間,它的本地運(yùn)行隊(duì)列中的其他 goroutines 也不會(huì)被餓死,。最終,,另一個(gè)線程會(huì)“竊取”這些 goroutines 并執(zhí)行它們。 處理系統(tǒng)調(diào)用如我之前所提到的,,系統(tǒng)調(diào)用可能有點(diǎn)難以處理,,因?yàn)樗鼈兛赡苄枰喈?dāng)長的時(shí)間。當(dāng)一個(gè) goroutine 執(zhí)行一個(gè)系統(tǒng)調(diào)用,,比如從一個(gè)文件中讀取數(shù)據(jù),,它可能需要很長時(shí)間才能返回,。我們甚至不知道它是否會(huì)返回,或者操作系統(tǒng)在幕后到底在做什么,。 在操作系統(tǒng)返回之前,,該線程不會(huì)被賦予任何新的工作,,基本上是處于睡眠狀態(tài),。我們可能會(huì)遇到一個(gè)情況,即分配給我們程序的所有線程都只是等待系統(tǒng)調(diào)用完成,。 我們?nèi)绾谓鉀Q這個(gè)問題呢,?在進(jìn)行系統(tǒng)調(diào)用之前,我們創(chuàng)建一個(gè)新的線程,。由于我們是在編寫語言,,以及編寫打開文件的函數(shù),我們可以在打開文件之前簡單地添加一行來創(chuàng)建一個(gè)新線程,。新線程創(chuàng)建后,,當(dāng)前線程進(jìn)入睡眠狀態(tài),直到系統(tǒng)調(diào)用完成,。 從技術(shù)上講,,這并不是過度訂閱,因?yàn)楫?dāng)前線程正在休眠,,因此不會(huì)占用操作系統(tǒng)的資源,。 但這意味著我們可能有很多我們不使用的休眠線程。 再次說,,這并不是過度訂閱,,但它為我們帶來了一個(gè)不同的問題。由于我們?yōu)槊總€(gè)線程提供資源(本地運(yùn)行隊(duì)列),,如果我們有很多線程,,我們將為每個(gè)線程分配內(nèi)存。 此外,,我們尚未深入探討的系統(tǒng)的其他部分也可能會(huì)出現(xiàn)一些問題,,例如將 goroutines 分配給本地運(yùn)行隊(duì)列的系統(tǒng)可能會(huì)將一個(gè) goroutine 分配給一個(gè)當(dāng)前被系統(tǒng)調(diào)用阻塞的線程。 此外,,工作竊取也可能變得有點(diǎn)繁瑣,。如果我們有很多線程,工作竊取將需要檢查很多本地運(yùn)行隊(duì)列,。 處理器為了解決這個(gè)問題,,我們?cè)黾恿肆硪粚映橄螅Q為處理器,。 每個(gè)線程都會(huì)獲取一個(gè)處理器,,該處理器包含執(zhí)行 Go 代碼所需的變量,。所以我們將本地運(yùn)行隊(duì)列以及我們可能擁有的任何其他變量(如緩存等)移動(dòng)到處理器中。 當(dāng)一個(gè)線程在系統(tǒng)調(diào)用上被阻塞時(shí),,它將釋放處理器,,另一個(gè)線程將獲取它并從中斷的地方繼續(xù)。 這并不顯著地改變了我們的偽代碼,,除了我們現(xiàn)在在使用本地運(yùn)行隊(duì)列時(shí)使用了一個(gè)處理器,。
第2部分:公平性搶占為了確保沒有單一的 goroutine 長時(shí)間占用一個(gè)線程,我們可以添加一個(gè)非常簡單的機(jī)制,,如果一個(gè) goroutine 的執(zhí)行時(shí)間超過了一定的時(shí)間段,,比如說10ms,那么它會(huì)被預(yù)先暫停,。然后我們將它加到運(yùn)行隊(duì)列的尾部,。 全局運(yùn)行隊(duì)列饑餓有一種可能性是,goroutines 不斷地在全局運(yùn)行隊(duì)列中被填充,,而每個(gè)處理器都繼續(xù)在其自己的本地運(yùn)行隊(duì)列上工作,。這可能導(dǎo)致全局運(yùn)行隊(duì)列中的 goroutines 基本上餓死。 這個(gè)問題的簡單解決方案是什么呢,?讓我們偶爾檢查全局運(yùn)行隊(duì)列,,而不是本地運(yùn)行隊(duì)列。 其實(shí)在代碼中理解這一點(diǎn)非常容易,。
結(jié)論這是我嘗試簡要描述 Golang 并發(fā)是如何工作的。我可能做了一些簡化,,但這大致是并發(fā)模型的方向,。歡迎查看 Go 源代碼中的 我們確實(shí)跳過了一些概念,,比如網(wǎng)絡(luò)輪詢器,但這篇文章已經(jīng)變得非常長,,我不想在這樣一個(gè)預(yù)計(jì)10-15分鐘的閱讀中塞入太多的信息,。 我選擇這個(gè)主題的原因是,我一直認(rèn)為并發(fā)和并行這樣的概念更偏理論而不是實(shí)際,,我發(fā)現(xiàn)自己難以理解它內(nèi)部是如何工作的,。在我看來,重要的是要認(rèn)識(shí)到,,歸根結(jié)底,,這些看似非常困難且像魔法一樣的底層概念只不過是代碼片段,理解它們以及導(dǎo)致它們的決策不應(yīng)該比理解任何其他代碼更難,。 |
|