【譯】goroutine調(diào)度器,二:Go Scheduler

[toc]

原文:Scheduling In Go : Part II - Go Scheduler

前言

這是本系列的第二篇文章,本文重點(diǎn)介紹Go中的調(diào)度機(jī)制

簡(jiǎn)介

在本系列的第一篇文章中,主要介紹了操作系統(tǒng)的系統(tǒng)調(diào)度的部分內(nèi)容,我認(rèn)為那部分內(nèi)容對(duì)于理解Go調(diào)度器的語義非常重要。在本篇文章中,我會(huì)從語義層面解釋Go調(diào)度器是如何工作的,并重點(diǎn)介紹它的高級(jí)特性。Go調(diào)度器是個(gè)非常復(fù)雜的系統(tǒng),可以不用過分關(guān)注小的細(xì)節(jié),重要的是有一個(gè)好的調(diào)度器如何工作以及如何表現(xiàn)的概念,這有助于你在工程化開發(fā)過程中進(jìn)行更好的決策。

你的程序啟動(dòng)

當(dāng)你的Go程序啟動(dòng)時(shí),它會(huì)為主機(jī)上每個(gè)有標(biāo)識(shí)的虛擬內(nèi)核分配一個(gè)邏輯處理器(P),如果你的處理器運(yùn)行在一個(gè)可以執(zhí)行多硬件線程的物理內(nèi)核上(超線程),那么每個(gè)硬件線程會(huì)變?yōu)樘摂M內(nèi)核供你的Go程序使用。為了更好的理解這點(diǎn),請(qǐng)看下一我的MacBook Pro的系統(tǒng)報(bào)告。

圖1

94_figure1.png

你可以看見,我有一個(gè)四核的處理器,報(bào)告中無法體現(xiàn)每個(gè)核的硬件線程數(shù)。英特爾酷睿i7處理器具有超線程功能,因此每個(gè)核擁有2個(gè)硬件線程,對(duì)于Go程序而言,意味著有8個(gè)虛擬核心可以并行執(zhí)行OS線程。

為了測(cè)試這點(diǎn),參考下面這段代碼

代碼1

package main

import (
    "fmt"
    "runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

在我的本地運(yùn)行這段代碼,運(yùn)行結(jié)果輸出的是8,也就是說任何在我這臺(tái)機(jī)器上運(yùn)行的Go程序都會(huì)分配8個(gè)P。

每個(gè)P會(huì)被分配一個(gè)OS線程(M),這個(gè)‘M’代表的是機(jī)器(machine)。這個(gè)線程依然由操作系統(tǒng)進(jìn)行管理,也會(huì)如上一篇文章所述,操作系統(tǒng)會(huì)負(fù)責(zé)將線程放到核心上執(zhí)行。這意味著當(dāng)我的機(jī)器運(yùn)行Go程序的時(shí)候,我有8個(gè)線程可以工作,每個(gè)線程會(huì)分配一個(gè)獨(dú)立的P。

在Go程序的運(yùn)行過程中,每個(gè)Go程序會(huì)初始化一個(gè)Goroutine(G),Goroutine本質(zhì)上是協(xié)程(Coroutine),因?yàn)槲覀冇玫氖荊o語言,所以把字母C替換成了G,于是得到了一個(gè)詞Goroutine。你可以把Goroutine理解為應(yīng)用程序級(jí)別的線程,它和OS線程有很多相似之處。正如OS線程在內(nèi)核上進(jìn)行上線文切換一樣,Goroutines在M上進(jìn)行上下文切換。

最后一個(gè)難題是運(yùn)行隊(duì)列(run queue)。在Go的調(diào)度器中有兩種運(yùn)行隊(duì)列:全局運(yùn)行隊(duì)列(GRQ)和本地運(yùn)行隊(duì)列(LRQ)。每個(gè)P都有一個(gè)LRQ,用于管理分配在這個(gè)P上的上下文中執(zhí)行的Goroutine。這些Goroutine輪流在這個(gè)P分配的M上進(jìn)行上下文切換。GRQ用來管理尚未分配給P的Goroutine,有一個(gè)過程是把GRQ上的goroutine移動(dòng)到LRQ上,這個(gè)我們稍后討論。

圖二描述了全部上面提到的組件。

圖二

94_figure2.png

協(xié)同調(diào)度

在第一篇文章中我們討論過,操作系統(tǒng)調(diào)度是一個(gè)搶占式調(diào)度,本質(zhì)上你無法預(yù)測(cè)調(diào)度程序在什么時(shí)間執(zhí)行了什么操作,內(nèi)核做決策的時(shí)候一切都是不可控的,運(yùn)行在操作系統(tǒng)上的應(yīng)用程序無法控制內(nèi)核中發(fā)生的事情,除非使用了比如“atomic”和“mutex”這類同步原語。

Go調(diào)度器是Go程序運(yùn)行的一部分,并內(nèi)置在你的應(yīng)用程序中,這就說明Go調(diào)度器是運(yùn)行在內(nèi)核之上的用戶空間。Go調(diào)度器的當(dāng)前實(shí)現(xiàn)不是搶占式,而是協(xié)同調(diào)度,也就意味著,調(diào)度程序需要在代碼中明確定義用戶空間事件,并在安全的地方進(jìn)行調(diào)度決策。

這種設(shè)計(jì)的亮點(diǎn)在于看起來調(diào)度器是搶占式的,你無法預(yù)測(cè)Go調(diào)度器要做什么,因?yàn)檫@個(gè)調(diào)度決策是并不是由開發(fā)者決定的,而是在Go運(yùn)行過程中進(jìn)行的。因此以搶占式調(diào)度器的方式去思考就變的很重要,因?yàn)樗哂胁淮_定性,這不是一件容易的事。

Goroutine的狀態(tài)

與線程一樣,Goroutine也擁有三個(gè)狀態(tài),可以是三種狀態(tài)中的一種:Waiting,Runnable或者Executing

Waiting:這個(gè)狀態(tài)表示Goroutine目前已經(jīng)停止并等待一些東西再繼續(xù)運(yùn)行。這可能是在等待系統(tǒng)的操作(系統(tǒng)調(diào)用)或同步調(diào)用(原子操作和互斥操作)等。這些類型的延遲是性能不佳的根本原因。

Runnable:這個(gè)狀態(tài)表示,Goroutine需要在M上的時(shí)間片來運(yùn)行指令,如果同時(shí)有很多Goroutine需要時(shí)間片,那么這個(gè)等待時(shí)間就會(huì)變長(zhǎng),同樣的,每個(gè)Goroutine獲取的每個(gè)時(shí)間片就更短。這種類型的延遲也是性能不佳的原因。

Executing:這個(gè)狀態(tài)表示,Goroutine已被置于M并正在執(zhí)行其指令,與應(yīng)用程序相關(guān)的工作即將完,這是每個(gè)人都期望的結(jié)果。

上下文切換

Go調(diào)度器需要明確定義的用戶空間事件,并在代碼中的安全點(diǎn)進(jìn)行上下文切換,這些事件和安全點(diǎn)在方法調(diào)用中體現(xiàn)。函數(shù)調(diào)用對(duì)Go調(diào)度器的運(yùn)行狀況至關(guān)重要。今天(使用Go 1.11或更低版本),如果你運(yùn)行一段沒有任何方法調(diào)用的tight loop,那么將會(huì)因?yàn)檎{(diào)度器和垃圾回收器而出現(xiàn)延遲,函數(shù)調(diào)用在合理的時(shí)間范圍內(nèi)發(fā)生是至關(guān)重要的。

注意:在Go的1.12版本有一項(xiàng)提議被采納,Go的調(diào)度器允許在執(zhí)行tight loop時(shí)應(yīng)用非協(xié)作搶占的技術(shù)。

Go程序中有四類事件可能觸發(fā)調(diào)度決策。

  • 使用關(guān)鍵字go
  • 垃圾回收
  • 系統(tǒng)調(diào)用
  • 同步和編排治理

使用關(guān)鍵詞go

關(guān)鍵字go是你創(chuàng)建Goroutine的方式。一旦創(chuàng)建了新的Goroutine,調(diào)度器就有可能進(jìn)行一次調(diào)度決策。

垃圾回收

由于GC在運(yùn)行過程中會(huì)使用自己的Goroutine,這些Goroutine都需要M上的時(shí)間片,因此這會(huì)導(dǎo)致GC產(chǎn)生大量的調(diào)度混亂。但是調(diào)度器非常了解Goroutine在做什么,會(huì)作出明智的調(diào)度決策,其中一項(xiàng)是決策是,當(dāng)一個(gè)Goroutine要接觸一個(gè)沒有被使用的堆時(shí),會(huì)對(duì)它進(jìn)行一次上下文切換(PS:原文是“One smart decision is context-switching a Goroutine that wants to touch the heap with those that don’t touch the heap during GC”,不知道這么理解對(duì)不對(duì))。當(dāng)GC運(yùn)行時(shí),會(huì)有許多決策。

系統(tǒng)調(diào)用

Goroutine進(jìn)行系統(tǒng)調(diào)用會(huì)導(dǎo)致Goroutine阻塞M,有時(shí)調(diào)度器會(huì)進(jìn)行上線文切換,將這個(gè)Goroutine換下,并將一個(gè)新的Goroutine換到這個(gè)M上。然而有的時(shí)候,需要新的M來繼續(xù)執(zhí)行在P中排隊(duì)的Goroutine,這是如何工作的的將在下一章詳細(xì)解釋。

同步和編排治理

如果調(diào)用了原子操作,互斥或者通道,會(huì)導(dǎo)致Goroutine阻塞,調(diào)度器會(huì)上線文切換一個(gè)新的Goroutine運(yùn)行,一旦這個(gè)Goroutine可以再次運(yùn)行了,它會(huì)重新排隊(duì)并最終通過上下文切換回到M上。

異步系統(tǒng)調(diào)用

當(dāng)你運(yùn)行的操作系統(tǒng)能夠異步執(zhí)行系統(tǒng)調(diào)用的時(shí)候,可以使用稱為網(wǎng)絡(luò)輪詢器("network poller")""的東西來更有效地處理系統(tǒng)調(diào)用。這是通過在這些相應(yīng)的OS中使用kqueue(MacOS),epoll(Linux)或iocp(Windows)來實(shí)現(xiàn)的。

基于網(wǎng)絡(luò)的系統(tǒng)調(diào)用可以使用操作系統(tǒng)的異步處理,今天我們使用的很多操作系統(tǒng)都可以實(shí)現(xiàn)。network poller這個(gè)名字的由來正是因?yàn)樗饕脕硖幚砭W(wǎng)絡(luò)請(qǐng)求。通過使用network poller來進(jìn)行網(wǎng)絡(luò)系統(tǒng)調(diào)用,調(diào)度器就可以防止Goroutine在進(jìn)行系統(tǒng)調(diào)用時(shí)阻塞M。這有助于保持M執(zhí)行P的LRQ中的Goroutine時(shí)可用,且無需創(chuàng)建新的M,這也有助于減少OS上的調(diào)度負(fù)載。

查看其工作原理的最佳方法是運(yùn)行示例。

圖3

94_figure3.png

圖3顯示了基本調(diào)度圖。Goroutine-1正在M上執(zhí)行,還有3個(gè)Goroutine在LRQ上等待獲取M時(shí)間片。網(wǎng)絡(luò)輪詢器此時(shí)空閑。

圖4

94_figure4.png

在圖4中,Goroutine-1想要進(jìn)行網(wǎng)絡(luò)系統(tǒng)調(diào)用,因此Goroutine-1被移動(dòng)到網(wǎng)絡(luò)輪詢器并且發(fā)起異步網(wǎng)絡(luò)系統(tǒng)調(diào)用。一旦Goroutine-1移動(dòng)到網(wǎng)絡(luò)輪詢器,M現(xiàn)在可以執(zhí)行LRQ上的其他Goroutine。在這種情況下,Goroutine-2被上下文切換到了M上。

圖5

94_figure5.png

在圖5中,異步網(wǎng)絡(luò)系統(tǒng)調(diào)用由網(wǎng)絡(luò)輪詢器完成,并且Goroutine-1被移回到P的LRQ中。一旦Goroutine-1可以上下文切換回M,這段Go負(fù)責(zé)的相關(guān)代碼可以再次執(zhí)行。這里最大的優(yōu)點(diǎn)是,要執(zhí)行網(wǎng)絡(luò)系統(tǒng)調(diào)用不需要額外的M。網(wǎng)絡(luò)輪詢器具有OS線程,它一直在處理有效的事件循環(huán)。

同步系統(tǒng)調(diào)用

當(dāng)Goroutine想要進(jìn)行無法異步完成的系統(tǒng)調(diào)用時(shí)會(huì)發(fā)生什么?在這種情況下,網(wǎng)絡(luò)輪詢器不能被使用,并且進(jìn)行系統(tǒng)調(diào)用的Goroutine將阻塞M.很不幸,我們沒有辦法防止這種情況發(fā)生。不能異步進(jìn)行的系統(tǒng)調(diào)用的一個(gè)示例是基于文件的系統(tǒng)調(diào)用。如果你正在使用CGO,調(diào)用C函數(shù)時(shí)也可能會(huì)有其他情況阻塞M.

注意:Windows操作系統(tǒng)確實(shí)能夠進(jìn)行基于文件的異步系統(tǒng)調(diào)用。從技術(shù)上講,在Windows上運(yùn)行時(shí),可以使用網(wǎng)絡(luò)輪詢器。

讓我們來看看將導(dǎo)致M阻塞的同步系統(tǒng)調(diào)用(如文件I / O)所發(fā)生的情況。

圖6

94_figure6.png

圖6再次展示了我們的基本調(diào)度圖,但是這次Goroutine-1將進(jìn)行同步系統(tǒng)調(diào)用阻塞M1。

圖7

94_figure7.png

在圖7中,調(diào)度程序能夠識(shí)別Goroutine-1已導(dǎo)致M阻塞。此時(shí),調(diào)度器將M1與P分離,同時(shí)附加仍然阻塞的Goroutine-1。然后調(diào)度器引入新的M2來為P提供服務(wù)。此時(shí),可以從LRQ中選擇Goroutine-2并且在M2上進(jìn)行上下文切換。如果由于之前的交換而已經(jīng)存在M,那么此次轉(zhuǎn)換會(huì)比新建一個(gè)M要快。

圖8

94_figure8.png

在圖8中,由Goroutine-1產(chǎn)生的阻塞系統(tǒng)調(diào)用完成。此時(shí),Goroutine-1移回LRQ并再次由P服務(wù)。M1會(huì)放在側(cè)面以備將來再次使用。

工作竊取

調(diào)度器的另一面是一個(gè)竊取工作的調(diào)度程序。這有助于在一些領(lǐng)域保持有效的調(diào)度。首先,你期望的最后一件事就是M進(jìn)入等待狀態(tài),因?yàn)橐坏┌l(fā)生這種情況,操作系統(tǒng)就會(huì)將M從核心通過上下文切換取下。這意味著P無法完成任何工作,即使Goroutine處于可運(yùn)行狀態(tài),直到M重新進(jìn)行上下文切換回核心。竊取工作也有助于平衡所有P的Goroutine,從而更好地分配工作并更有效地完成工作。

讓我們來看一個(gè)例子。

圖9

94_figure9.png

在圖9中,我們有一個(gè)多線程Go程序,其中有兩個(gè)P,每個(gè)服務(wù)于四個(gè)Goroutine,GRQ中有一個(gè)Goroutine。如果一個(gè)P的所有Goroutine很快執(zhí)行完畢會(huì)發(fā)生什么?

圖10

94_figure10.png

在圖10中,P1沒有更多的Goroutine來執(zhí)行。但是Goroutine處于可運(yùn)行狀態(tài),無論是在LRQ中還是在GRQ中。這時(shí)需要P1開始竊取工作。竊取工作的規(guī)則如下。

代碼2

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

因此,基于清單2中的這些規(guī)則,P1需要檢測(cè)在P2的LRQ上的 Goroutine并獲取它找到的一半。

圖11

94_figure11.png

在圖11中,Goroutine的一半來自P2,現(xiàn)在P1可以執(zhí)行那些Goroutine。
如果P2完成為其所有Goroutine并且P1的LRQ中沒有任何東西會(huì)發(fā)生什么?

圖12

94_figure12.png

在圖12中,P2完成了所有工作,現(xiàn)在需要竊取一些。首先,它將查看P1的LRQ,但它找不到任何Goroutine。接下來,它將查看GRQ。在那里它會(huì)找到Goroutine-9。

圖13

94_figure13.png

在圖13中,P2從GRQ竊取了Goroutine-9并開始執(zhí)行工作。所有這些偷竊工作的好處在于它允許M保持忙碌而不會(huì)空閑。這項(xiàng)工作竊取在內(nèi)部被視為旋轉(zhuǎn)操作。這種自旋還有其他好處,在JBD(一個(gè)女工程師)的一篇博客work-stealing里有很好的解釋

實(shí)例

有了相應(yīng)的機(jī)制和語義,我們來看看如何將所有這些結(jié)合在一起,以便Go調(diào)度器能夠執(zhí)行更多的工作。想象一下用C編寫的多線程應(yīng)用程序,其中程序管理兩個(gè)OS線程,它們相互傳遞消息。

圖14

94_figure14.png

在圖14中,有2個(gè)線程來回傳遞消息。線程1在Core1上進(jìn)行上下文切換,線程1將其消息發(fā)送到線程2。

注意:消息的傳遞方式并不重要。重要的是運(yùn)行過程中線程的狀態(tài)。

圖15

94_figure15.png

在圖15中,一旦線程1完成發(fā)送消息,它現(xiàn)在需要等待響應(yīng)。這將導(dǎo)致線程1在Core 1上進(jìn)行上下文切換進(jìn)入等待狀態(tài)。一旦線程2收到有關(guān)該消息的通知,它就會(huì)進(jìn)入可運(yùn)行狀態(tài)。現(xiàn)在操作系統(tǒng)可以執(zhí)行上下文切換并在Core上執(zhí)行線程2,接下來,線程2處理消息并將新消息發(fā)送回線程1。

圖16

94_figure16.png

在圖16中,T2的消息被T1接受,再次進(jìn)行上線文切換。現(xiàn)在T2從執(zhí)行狀態(tài)切換到等待狀態(tài)和T1從等待狀態(tài)切換到可運(yùn)行狀態(tài)最后回到執(zhí)行狀態(tài),允許它處理并發(fā)回新消息。

所有這些上下文切換和狀態(tài)更改都需要時(shí)間來執(zhí)行,這限制了工作的完成速度。由于每個(gè)上下文切換可能會(huì)產(chǎn)生50納秒的延遲,并且理論上硬件每納秒執(zhí)行12條指令,因此大約會(huì)有600條指令在此期間沒有執(zhí)行。由于這些線程也在不同的內(nèi)核之間切換,因緩存未命中引起額外延遲的可能性也很高。

相同的例子,這次使用Goroutine和Go調(diào)度器。

圖17

94_figure17.png

在圖17中,有兩個(gè)Goroutine正在相互協(xié)調(diào),來回傳遞消息。G1在M1上進(jìn)行上下文切換,M1在Core 1上運(yùn)行,因此G1可以執(zhí)行操作將消息發(fā)送給G2。

圖18

94_figure18.png

在圖18中,一旦G1完成發(fā)送消息,它需要等待回復(fù),這讓G1在M1上進(jìn)行上下文切換進(jìn)入等待狀態(tài)。一旦G2收到這個(gè)消息,它將進(jìn)入可執(zhí)行狀態(tài),現(xiàn)在Go調(diào)度器可以進(jìn)行上線文切換,將G2切換到M1上執(zhí)行,此時(shí)仍然在core 1上運(yùn)行。接下來,G2處理消息并將消息發(fā)送回G1。

圖19

94_figure19.png

在圖19中,當(dāng)G2接收到由G2發(fā)送的消息時(shí),再次進(jìn)行上下文切換。現(xiàn)在G2從執(zhí)行狀態(tài)切換到等待狀態(tài),G1從等待狀態(tài)切換到可運(yùn)行狀態(tài),最后返回到執(zhí)行狀態(tài),并它處理并發(fā)回的消息。

表面上的似乎沒有什么不同。無論使用Threads還是Goroutines,都會(huì)發(fā)生相同的上下文切換和狀態(tài)更改。但是,使用Threads和Goroutines之間存在一個(gè)主要區(qū)別,乍一看可能并不明顯。

在使用Goroutines的情況下,全程使用相同的OS線程和核心。這意味著,從操作系統(tǒng)的角度來看,操作系統(tǒng)線程永遠(yuǎn)不會(huì)進(jìn)入等待狀態(tài);我們使用Threads時(shí)因?yàn)樯舷挛那袚Q造成的指令損失,在使用Goroutine時(shí)不會(huì)丟失。

從本質(zhì)上講,Go已將IO / Blocking工作轉(zhuǎn)變?yōu)椴僮飨到y(tǒng)級(jí)別的CPU-bound工作。由于所有上下文切換都是在應(yīng)用程序級(jí)別進(jìn)行的,因此在使用Threads時(shí),每個(gè)上下文切換都不會(huì)丟失600條指令(平均)。調(diào)度器還有助于提高緩存行效率和NUMA。這就是為什么我們不需要比虛擬內(nèi)核更多的線程。在Go中,隨著時(shí)間的推移,可以完成更多的工作,因?yàn)镚o調(diào)度程序嘗試使用更少的線程并在每個(gè)線程上執(zhí)行更多操作,這有助于減少操作系統(tǒng)和硬件的負(fù)載。

結(jié)論

Go調(diào)度器在設(shè)計(jì)方面充分考慮到了操作系統(tǒng)與硬件工作的復(fù)雜性,這方面確實(shí)令人驚訝。在操作系統(tǒng)級(jí)別,將IO/Blocking工作轉(zhuǎn)換為CPU-bound工作,這一點(diǎn)在充分利用CPU方面取得了巨大的成功,這也是為什么你不需要更多的虛擬內(nèi)核,你可以合理的認(rèn)為每個(gè)虛擬內(nèi)核上只需要一個(gè)操作系統(tǒng)線程,就可以完成所有工作。這樣對(duì)于網(wǎng)絡(luò)應(yīng)用程序和其他應(yīng)用程序可以不必對(duì)OS線程造成阻塞。

作為開發(fā)人員,你仍然需要了解你的應(yīng)用程序正在處理的工作類型已經(jīng)正在做什么,你不能無限的創(chuàng)建Goroutine并期望依然擁有驚人的性能。少即是多,但是通過理解這些Go-scheduler語義,您可以做出更好的工程決策。在下一篇文章中,我將探討以保守方式利用并發(fā)性以獲得更好性能的想法,同時(shí)平衡可能需要添加到代碼中的復(fù)雜因素。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容