[轉載] goroutine背後的系統知識

原文: http://www.sizeofvoid.net/goroutine-under-the-hood/html

文章寫的很是好, 對內部原理解釋的很是清楚, 是我喜歡的風格, 感謝做者的精彩文章.linux

==================================================ios

Go語言從誕生到普及已經三年了,先行者大都是Web開發的背景,也有了一些普及型的書籍,可系統開發背景的人在學習這些書籍的時候,總有語焉不詳的感受,網上也有若干流傳甚廣的文章,可其中或多或少總有些與事實不符的技術描述。但願這篇文章能爲比較缺乏系統編程背景的Web開發人員介紹一下goroutine背後的系統知識。golang

1. 操做系統與運行庫
2. 併發與並行 (Concurrency and Parallelism)
3. 線程的調度
4. 併發編程框架
5. goroutine編程

1. 操做系統與運行庫c#

對於普通的電腦用戶來講,能理解應用程序是運行在操做系統之上就足夠了,可對於開發者,咱們還須要瞭解咱們寫的程序是如何在操做系統之上運行起來的,操做系統如何爲應用程序提供服務,這樣咱們才能分清楚哪些服務是操做系統提供的,而哪些服務是由咱們所使用的語言的運行庫提供的。windows

除了內存管理、文件管理、進程管理、外設管理等等內部模塊之外,操做系統還提供了許多外部接口供應用程序使用,這些接口就是所謂的「系統調用」。從DOS時代開始,系統調用就是經過軟中斷的形式來提供,也就是著名的INT 21,程序把須要調用的功能編號放入AH寄存器,把參數放入其餘指定的寄存器,而後調用INT 21,中斷返回後,程序從指定的寄存器(一般是AL)裏取得返回值。這樣的作法一直到奔騰2也就是P6出來以前都沒有變,譬如windows經過INT 2E提供系統調用,Linux則是INT 80,只不事後來的寄存器比之前大一些,並且可能再多一層跳轉表查詢。後來,Intel和AMD分別提供了效率更高的SYSENTER/SYSEXIT和SYSCALL/SYSRET指令來代替以前的中斷方式,略過了耗時的特權級別檢查以及寄存器壓棧出棧的操做,直接完成從RING 3代碼段到RING 0代碼段的轉換。api

系統調用都提供什麼功能呢?用操做系統的名字加上對應的中斷編號到谷歌上一查就能夠獲得完整的列表 (WindowsLinux),這個列表就是操做系統和應用程序之間溝通的協議,若是須要超出此協議的功能,咱們就只能在本身的代碼裏去實現,譬如,對於內存管理,操做系統只提供進程級別的內存段的管理,譬如Windows的virtualmemory系列,或是Linux的brk,操做系統不會去在意應用程序如何爲新建對象分配內存,或是如何作垃圾回收,這些都須要應用程序本身去實現。若是超出此協議的功能沒法本身實現,那咱們就說該操做系統不支持該功能,舉個例子,Linux在2.6以前是不支持多線程的,不管如何在程序裏模擬,咱們都沒法作出多個能夠同時運行的並符合POSIX 1003.1c語義標準的調度單元。緩存

但是,咱們寫程序並不須要去調用中斷或是SYSCALL指令,這是由於操做系統提供了一層封裝,在Windows上,它是NTDLL.DLL,也就是常說的Native API,咱們不但不須要去直接調用INT 2E或SYSCALL,準確的說,咱們不能直接去調用INT 2E或SYSCALL,由於Windows並無公開其調用規範,直接使用INT 2E或SYSCALL沒法保證將來的兼容性。在Linux上則沒有這個問題,系統調用的列表都是公開的,並且Linus很是看重兼容性,不會去作任何更改,glibc裏甚至專門提供了syscall(2)來方便用戶直接用編號調用,不過,爲了解決glibc和內核之間不一樣版本兼容性帶來的麻煩,以及爲了提升某些調用的效率(譬如__NR_ gettimeofday),Linux上仍是對部分系統調用作了一層封裝,就是VDSO (早期叫linux-gate.so)。網絡

但是,咱們寫程序也不多直接調用NTDLL或者VDSO,而是經過更上一層的封裝,這一層處理了參數準備和返回值格式轉換、以及出錯處理和錯誤代碼轉換,這就是咱們所使用語言的運行庫,對於C語言,Linux上是glibc,Windows上是kernel32(或調用msvcrt),對於其餘語言,譬如Java,則是JRE,這些「其餘語言」的運行庫一般最終仍是調用glibc或kernel32。

「運行庫」這個詞其實不止包括用於和編譯後的目標執行程序進行連接的庫文件,也包括了腳本語言或字節碼解釋型語言的運行環境,譬如Python,C#的CLR,Java的JRE。

對系統調用的封裝只是運行庫的很小一部分功能,運行庫一般還提供了諸如字符串處理、數學計算、經常使用數據結構容器等等不須要操做系統支持的功能,同時,運行庫也會對操做系統支持的功能提供更易用更高級的封裝,譬如帶緩存和格式的IO、線程池。

因此,在咱們說「某某語言新增了某某功能」的時候,一般是這麼幾種可能:
1. 支持新的語義或語法,從而便於咱們描述和解決問題。譬如Java的泛型、Annotation、lambda表達式。
2. 提供了新的工具或類庫,減小了咱們開發的代碼量。譬如Python 2.7的argparse
3. 對系統調用有了更良好更全面的封裝,使咱們能夠作到之前在這個語言環境裏作不到或很難作到的事情。譬如Java NIO

但任何一門語言,包括其運行庫和運行環境,都不可能創造出操做系統不支持的功能,Go語言也是這樣,無論它的特性描述看起來多麼炫麗,那必然都是其餘語言也能夠作到的,只不過Go提供了更方便更清晰的語義和支持,提升了開發的效率。

2. 併發與並行 (Concurrency and Parallelism)

併發是指程序的邏輯結構。非併發的程序就是一根竹竿捅到底,只有一個邏輯控制流,也就是順序執行的(Sequential)程序,在任什麼時候刻,程序只會處在這個邏輯控制流的某個位置。而若是某個程序有多個獨立的邏輯控制流,也就是能夠同時處理(deal)多件事情,咱們就說這個程序是併發的。這裏的「同時」,並不必定要是真正在時鐘的某一時刻(那是運行狀態而不是邏輯結構),而是指:若是把這些邏輯控制流畫成時序流程圖,它們在時間線上是能夠重疊的。

並行是指程序的運行狀態。若是一個程序在某一時刻被多個CPU流水線同時進行處理,那麼咱們就說這個程序是以並行的形式在運行。(嚴格意義上講,咱們不能說某程序是「並行」的,由於「並行」不是描述程序自己,而是描述程序的運行狀態,但這篇小文裏就不那麼咬文嚼字,如下說到「並行」的時候,就是指代「以並行的形式運行」)顯然,並行必定是須要硬件支持的。

並且不難理解:

1. 併發是並行的必要條件,若是一個程序自己就不是併發的,也就是隻有一個邏輯控制流,那麼咱們不可能讓其被並行處理。

2. 併發不是並行的充分條件,一個併發的程序,若是隻被一個CPU流水線進行處理(經過分時),那麼它就不是並行的。

3. 併發只是更符合現實問題本質的表達方式,併發的最初目的是簡化代碼邏輯,而不是使程序運行的更快;

這幾段略微抽象,咱們能夠用一個最簡單的例子來把這些概念實例化:用C語言寫一個最簡單的HelloWorld,它就是非併發的,若是咱們創建多個線程,每一個線程裏打印一個HelloWorld,那麼這個程序就是併發的,若是這個程序運行在老式的單核CPU上,那麼這個併發程序還不是並行的,若是咱們用多核多CPU且支持多任務的操做系統來運行它,那麼這個併發程序就是並行的。

還有一個略微複雜的例子,更能說明併發不必定能夠並行,並且併發不是爲了效率,就是Go語言例子裏計算素數的sieve.go。咱們從小到大針對每個因子啓動一個代碼片斷,若是當前驗證的數能被當前因子除盡,則該數不是素數,若是不能,則把該數發送給下一個因子的代碼片斷,直到最後一個因子也沒法除盡,則該數爲素數,咱們再啓動一個它的代碼片斷,用於驗證更大的數字。這是符合咱們計算素數的邏輯的,並且每一個因子的代碼處理片斷都是相同的,因此程序很是的簡潔,但它沒法被並行,由於每一個片斷都依賴於前一個片斷的處理結果和輸出。

併發能夠經過如下方式作到:

1. 顯式地定義並觸發多個代碼片斷,也就是邏輯控制流,由應用程序或操做系統對它們進行調度。它們能夠是獨立無關的,也能夠是相互依賴須要交互的,譬如上面提到的素數計算,其實它也是個經典的生產者和消費者的問題:兩個邏輯控制流A和B,A產生輸出,當有了輸出後,B取得A的輸出進行處理。線程只是實現併發的其中一個手段,除此以外,運行庫或是應用程序自己也有多種手段來實現併發,這是下節的主要內容。

2. 隱式地放置多個代碼片斷,在系統事件發生時觸發執行相應的代碼片斷,也就是事件驅動的方式,譬如某個端口或管道接收到了數據(多路IO的狀況下),再譬如進程接收到了某個信號(signal)。

並行能夠在四個層面上作到:

1. 多臺機器。天然咱們就有了多個CPU流水線,譬如Hadoop集羣裏的MapReduce任務。

2. 多CPU。無論是真的多顆CPU仍是多核仍是超線程,總之咱們有了多個CPU流水線。

3. 單CPU核裏的ILP(Instruction-level parallelism),指令級並行。經過複雜的製造工藝和對指令的解析以及分支預測和亂序執行,如今的CPU能夠在單個時鐘週期內執行多條指令,從而,即便是非併發的程序,也多是以並行的形式執行。

4. 單指令多數據(Single instruction, multiple data. SIMD),爲了多媒體數據的處理,如今的CPU的指令集支持單條指令對多條數據進行操做。

其中,1牽涉到分佈式處理,包括數據的分佈和任務的同步等等,並且是基於網絡的。3和4一般是編譯器和CPU的開發人員須要考慮的。這裏咱們說的並行主要針對第2種:單臺機器內的多核CPU並行。

關於併發與並行的問題,Go語言的做者Rob Pike專門就此寫過一個幻燈片:http://talks.golang.org/2012/waza.slide

在CMU那本著名的《Computer Systems: A Programmer’s Perspective》裏的這張圖也很是直觀清晰:

3. 線程的調度

上一節主要說的是併發和並行的概念,而線程是最直觀的併發的實現,這一節咱們主要說操做系統如何讓多個線程併發的執行,固然在多CPU的時候,也就是並行的執行。咱們不討論進程,進程的意義是「隔離的執行環境」,而不是「單獨的執行序列」。

咱們首先須要理解IA-32 CPU的指令控制方式,這樣才能理解如何在多個指令序列(也就是邏輯控制流)之間進行切換。CPU經過CS:EIP寄存器的值肯定下一條指令的位置,可是CPU並不容許直接使用MOV指令來更改EIP的值,必須經過JMP系列指令、CALL/RET指令、或INT中斷指令來實現代碼的跳轉;在指令序列間切換的時候,除了更改EIP以外,咱們還要保證代碼可能會使用到的各個寄存器的值,尤爲是棧指針SS:ESP,以及EFLAGS標誌位等,都可以恢復到目標指令序列上次執行到這個位置時候的狀態。

線程是操做系統對外提供的服務,應用程序能夠經過系統調用讓操做系統啓動線程,並負責隨後的線程調度和切換。咱們先考慮單顆單核CPU,操做系統內核與應用程序實際上是也是在共享同一個CPU,當EIP在應用程序代碼段的時候,內核並無控制權,內核並非一個進程或線程,內核只是以實模式運行的,代碼段權限爲RING 0的內存中的程序,只有當產生中斷或是應用程序呼叫系統調用的時候,控制權才轉移到內核,在內核裏,全部代碼都在同一個地址空間,爲了給不一樣的線程提供服務,內核會爲每個線程創建一個內核堆棧,這是線程切換的關鍵。一般,內核會在時鐘中斷裏或系統調用返回前(考慮到性能,一般是在不頻繁發生的系統調用返回前),對整個系統的線程進行調度,計算當前線程的剩餘時間片,若是須要切換,就在「可運行」的線程隊列裏計算優先級,選出目標線程後,則保存當前線程的運行環境,並恢復目標線程的運行環境,其中最重要的,就是切換堆棧指針ESP,而後再把EIP指向目標線程上次被移出CPU時的指令。Linux內核在實現線程切換時,耍了個花槍,它並非直接JMP,而是先把ESP切換爲目標線程的內核棧,把目標線程的代碼地址壓棧,而後JMP到__switch_to(),至關於僞造了一個CALL __switch_to()指令,而後,在__switch_to()的最後使用RET指令返回,這樣就把棧裏的目標線程的代碼地址放入了EIP,接下來CPU就開始執行目標線程的代碼了,其實也就是上次停在switch_to這個宏展開的地方。

這裏須要補充幾點:(1) 雖然IA-32提供了TSS (Task State Segment),試圖簡化操做系統進行線程調度的流程,但因爲其效率低下,並且並非通用標準,不利於移植,因此主流操做系統都沒有去利用TSS。更嚴格的說,其實仍是用了TSS,由於只有經過TSS才能把堆棧切換到內核堆棧指針SS0:ESP0,但除此以外的TSS的功能就徹底沒有被使用了。(2) 線程從用戶態進入內核的時候,相關的寄存器以及用戶態代碼段的EIP已經保存了一次,因此,在上面所說的內核態線程切換時,須要保存和恢復的內容並很少。(3) 以上描述的都是搶佔式(preemptively)的調度方式,內核以及其中的硬件驅動也會在等待外部資源可用的時候主動調用schedule(),用戶態的代碼也能夠經過sched_yield()系統調用主動發起調度,讓出CPU。

如今咱們一臺普通的PC或服務裏一般都有多顆CPU (physical package),每顆CPU又有多個核 (processor core),每一個核又能夠支持超線程 (two logical processors for each core),也就是邏輯處理器。每一個邏輯處理器都有本身的一套完整的寄存器,其中包括了CS:EIP和SS:ESP,從而,以操做系統和應用的角度來看,每一個邏輯處理器都是一個單獨的流水線。在多處理器的狀況下,線程切換的原理和流程其實和單處理器時是基本一致的,內核代碼只有一份,當某個CPU上發生時鐘中斷或是系統調用時,該CPU的CS:EIP和控制權又回到了內核,內核根據調度策略的結果進行線程切換。但在這個時候,若是咱們的程序用線程實現了併發,那麼操做系統可使咱們的程序在多個CPU上實現並行。

這裏也須要補充兩點:(1) 多核的場景裏,各個核之間並非徹底對等的,譬如在同一個核上的兩個超線程是共享L1/L2緩存的;在有NUMA支持的場景裏,每一個核訪問內存不一樣區域的延遲是不同的;因此,多核場景裏的線程調度又引入了「調度域」(scheduling domains)的概念,但這不影響咱們理解線程切換機制。(2) 多核的場景下,中斷髮給哪一個CPU?軟中斷(包括除以0,缺頁異常,INT指令)天然是在觸發該中斷的CPU上產生,而硬中斷則又分兩種狀況,一種是每一個CPU本身產生的中斷,譬如時鐘,這是每一個CPU處理本身的,還有一種是外部中斷,譬如IO,能夠經過APIC來指定其送給哪一個CPU;由於調度程序只能控制當前的CPU,因此,若是IO中斷沒有進行均勻的分配的話,那麼和IO相關的線程就只能在某些CPU上運行,致使CPU負載不均,進而影響整個系統的效率。

4. 併發編程框架

以上大概介紹了一個用多線程來實現併發的程序是如何被操做系統調度以及並行執行(在有多個邏輯處理器時),同時你們也能夠看到,代碼片斷或者說邏輯控制流的調度和切換其實並不神祕,理論上,咱們也能夠不依賴操做系統和其提供的線程,在本身程序的代碼段裏定義多個片斷,而後在咱們本身程序裏對其進行調度和切換。

爲了描述方便,咱們接下來把「代碼片斷」稱爲「任務」。

和內核的實現相似,只是咱們不須要考慮中斷和系統調用,那麼,咱們的程序本質上就是一個循環,這個循環自己就是調度程序schedule(),咱們須要維護一個任務的列表,根據咱們定義的策略,先進先出或是有優先級等等,每次從列表裏挑選出一個任務,而後恢復各個寄存器的值,而且JMP到該任務上次被暫停的地方,全部這些須要保存的信息均可以做爲該任務的屬性,存放在任務列表裏。

看起來很簡單啊,但是咱們還須要解決幾個問題:

(1) 咱們運行在用戶態,是沒有中斷或系統調用這樣的機制來打斷代碼執行的,那麼,一旦咱們的schedule()代碼把控制權交給了任務的代碼,咱們下次的調度在何時發生?答案是,不會發生,只有靠任務主動調用schedule(),咱們纔有機會進行調度,因此,這裏的任務不能像線程同樣依賴內核調度從而毫無顧忌的執行,咱們的任務裏必定要顯式的調用schedule(),這就是所謂的協做式(cooperative)調度。(雖然咱們能夠經過註冊信號處理函數來模擬內核裏的時鐘中斷並取得控制權,可問題在於,信號處理函數是由內核調用的,在其結束的時候,內核從新得到控制權,隨後返回用戶態並繼續沿着信號發生時被中斷的代碼路徑執行,從而咱們沒法在信號處理函數內進行任務切換)

(2) 堆棧。和內核調度線程的原理同樣,咱們也須要爲每一個任務單獨分配堆棧,而且把其堆棧信息保存在任務屬性裏,在任務切換時也保存或恢復當前的SS:ESP。任務堆棧的空間能夠是在當前線程的堆棧上分配,也能夠是在堆上分配,但一般是在堆上分配比較好:幾乎沒有大小或任務總數的限制、堆棧大小能夠動態擴展(gcc有split stack,但太複雜了)、便於把任務切換到其餘線程。

到這裏,咱們大概知道了如何構造一個併發的編程框架,可如何讓任務能夠並行的在多個邏輯處理器上執行呢?只有內核纔有調度CPU的權限,因此,咱們仍是必須經過系統調用建立線程,才能夠實現並行。在多線程處理多任務的時候,咱們還須要考慮幾個問題:

(1) 若是某個任務發起了一個系統調用,譬如長時間等待IO,那當前線程就被內核放入了等待調度的隊列,豈不是讓其餘任務都沒有機會執行?

在單線程的狀況下,咱們只有一個解決辦法,就是使用非阻塞的IO系統調用,並讓出CPU,而後在schedule()裏統一進行輪詢,有數據時切換回該fd對應的任務;效率略低的作法是不進行統一輪詢,讓各個任務在輪到本身執行時再次用非阻塞方式進行IO,直到有數據可用。

若是咱們採用多線程來構造咱們整個的程序,那麼咱們能夠封裝系統調用的接口,當某個任務進入系統調用時,咱們就把當前線程留給它(暫時)獨享,並開啓新的線程來處理其餘任務。

(2) 任務同步。譬如咱們上節提到的生產者和消費者的例子,如何讓消費者在數據尚未被生產出來的時候進入等待,而且在數據可用時觸發消費者繼續執行呢?

在單線程的狀況下,咱們能夠定義一個結構,其中有變量用於存放交互數據自己,以及數據的當前可用狀態,以及負責讀寫此數據的兩個任務的編號。而後咱們的併發編程框架再提供read和write方法供任務調用,在read方法裏,咱們循環檢查數據是否可用,若是數據還不可用,咱們就調用schedule()讓出CPU進入等待;在write方法裏,咱們往結構裏寫入數據,更改數據可用狀態,而後返回;在schedule()裏,咱們檢查數據可用狀態,若是可用,則激活須要讀取此數據的任務,該任務繼續循環檢測數據是否可用,發現可用,讀取,更改狀態爲不可用,返回。代碼的簡單邏輯以下:

struct chan { bool ready, int data }; int read (struct chan *c) { while (1) { if (c->ready) { c->ready = false; return c->data; } else { schedule(); } } } void write (struct chan *c, int i) { while (1) { if (c->ready) { schedule(); } else { c->data = i; c->ready = true; schedule(); // optional return; } } }

很顯然,若是是多線程的話,咱們須要經過線程庫或系統調用提供的同步機制來保護對這個結構體內數據的訪問。

以上就是最簡化的一個併發框架的設計考慮,在咱們實際開發工做中遇到的併發框架可能因爲語言和運行庫的不一樣而有所不一樣,在功能和易用性上也可能各有取捨,但底層的原理都是異曲同工。

譬如,glic裏的getcontext/setcontext/swapcontext系列庫函數能夠方便的用來保存和恢復任務執行狀態;Windows提供了Fiber系列的SDK API;這兩者都不是系統調用,getcontext和setcontext的man page雖然是在section 2,但那只是SVR4時的歷史遺留問題,其實現代碼是在glibc而不是kernel;CreateFiber是在kernel32裏提供的,NTDLL裏並無對應的NtCreateFiber。

在其餘語言裏,咱們所謂的「任務」更多時候被稱爲「協程」,也就是Coroutine。譬如C++裏最經常使用的是Boost.Coroutine;Java由於有一層字節碼解釋,比較麻煩,但也有支持協程的JVM補丁,或是動態修改字節碼以支持協程的項目;PHP和Python的generator和yield其實已是協程的支持,在此之上能夠封裝出更通用的協程接口和調度;另外還有原生支持協程的Erlang等,筆者不懂,就不說了,具體可參見Wikipedia的頁面:http://en.wikipedia.org/wiki/Coroutine

因爲保存和恢復任務執行狀態須要訪問CPU寄存器,因此相關的運行庫也都會列出所支持的CPU列表。

從操做系統層面提供協程以及其並行調度的,好像只有OS X和iOS的Grand Central Dispatch,其大部分功能也是在運行庫裏實現的。

5. goroutine

Go語言經過goroutine提供了目前爲止全部(我所瞭解的)語言裏對於併發編程的最清晰最直接的支持,Go語言的文檔裏對其特性也描述的很是全面甚至超過了,在這裏,基於咱們上面的系統知識介紹,列舉一下goroutine的特性,算是小結:

(1) goroutine是Go語言運行庫的功能,不是操做系統提供的功能,goroutine不是用線程實現的。具體可參見Go語言源碼裏的pkg/runtime/proc.c

(2) goroutine就是一段代碼,一個函數入口,以及在堆上爲其分配的一個堆棧。因此它很是廉價,咱們能夠很輕鬆的建立上萬個goroutine,但它們並非被操做系統所調度執行

(3) 除了被系統調用阻塞的線程外,Go運行庫最多會啓動$GOMAXPROCS個線程來運行goroutine

(4) goroutine是協做式調度的,若是goroutine會執行很長時間,並且不是經過等待讀取或寫入channel的數據來同步的話,就須要主動調用Gosched()來讓出CPU

(5) 和全部其餘併發框架裏的協程同樣,goroutine裏所謂「無鎖」的優勢只在單線程下有效,若是$GOMAXPROCS > 1而且協程間須要通訊,Go運行庫會負責加鎖保護數據,這也是爲何sieve.go這樣的例子在多CPU多線程時反而更慢的緣由

(6) Web等服務端程序要處理的請求從本質上來說是並行處理的問題,每一個請求基本獨立,互不依賴,幾乎沒有數據交互,這不是一個併發編程的模型,而併發編程框架只是解決了其語義表述的複雜性,並非從根本上提升處理的效率,也許是併發鏈接和併發編程的英文都是concurrent吧,很容易產生「併發編程框架和coroutine能夠高效處理大量併發鏈接」的誤解。

(7) Go語言運行庫封裝了異步IO,因此能夠寫出貌似併發數不少的服務端,可即便咱們經過調整$GOMAXPROCS來充分利用多核CPU並行處理,其效率也不如咱們利用IO事件驅動設計的、按照事務類型劃分好合適比例的線程池。在響應時間上,協做式調度是硬傷。

(8) goroutine最大的價值是其實現了併發協程和實際並行執行的線程的映射以及動態擴展,隨着其運行庫的不斷髮展和完善,其性能必定會愈來愈好,尤爲是在CPU核數愈來愈多的將來,終有一天咱們會爲了代碼的簡潔和可維護性而放棄那一點點性能的差異。

相關文章
相關標籤/搜索