goroutine背后的系統(tǒng)知識

Go語言從誕生到普及已經(jīng)三年了特咆,先行者大都是Web開發(fā)的背景,也有了一些普及型的書籍录粱,可系統(tǒng)開發(fā)背景的人在學(xué)習(xí)這些書籍的時候腻格,總有語焉不詳?shù)母杏X,網(wǎng)上也有若干流傳甚廣的文章啥繁,可其中或多或少總有些與事實不符的技術(shù)描述菜职。希望這篇文章能為比較缺少系統(tǒng)編程背景的Web開發(fā)人員介紹一下goroutine背后的系統(tǒng)知識。

1. 操作系統(tǒng)與運行庫
2. 并發(fā)與并行 (Concurrency and Parallelism)
3. 線程的調(diào)度
4. 并發(fā)編程框架
5. goroutine

1. 操作系統(tǒng)與運行庫

對于普通的電腦用戶來說旗闽,能理解應(yīng)用程序是運行在操作系統(tǒng)之上就足夠了酬核,可對于開發(fā)者,我們還需要了解我們寫的程序是如何在操作系統(tǒng)之上運行起來的适室,操作系統(tǒng)如何為應(yīng)用程序提供服務(wù)嫡意,這樣我們才能分清楚哪些服務(wù)是操作系統(tǒng)提供的,而哪些服務(wù)是由我們所使用的語言的運行庫提供的捣辆。

除了內(nèi)存管理蔬螟、文件管理、進(jìn)程管理汽畴、外設(shè)管理等等內(nèi)部模塊以外旧巾,操作系統(tǒng)還提供了許多外部接口供應(yīng)用程序使用,這些接口就是所謂的“系統(tǒng)調(diào)用”忍些。從DOS時代開始鲁猩,系統(tǒng)調(diào)用就是通過軟中斷的形式來提供,也就是著名的INT 21坐昙,程序把需要調(diào)用的功能編號放入AH寄存器绳匀,把參數(shù)放入其他指定的寄存器,然后調(diào)用INT 21炸客,中斷返回后疾棵,程序從指定的寄存器(通常是AL)里取得返回值。這樣的做法一直到奔騰2也就是P6出來之前都沒有變痹仙,譬如windows通過INT 2E提供系統(tǒng)調(diào)用是尔,Linux則是INT 80,只不過后來的寄存器比以前大一些开仰,而且可能再多一層跳轉(zhuǎn)表查詢拟枚。后來薪铜,Intel和AMD分別提供了效率更高的SYSENTER/SYSEXIT和SYSCALL/SYSRET指令來代替之前的中斷方式,略過了耗時的特權(quán)級別檢查以及寄存器壓棧出棧的操作恩溅,直接完成從RING 3代碼段到RING 0代碼段的轉(zhuǎn)換隔箍。

系統(tǒng)調(diào)用都提供什么功能呢?用操作系統(tǒng)的名字加上對應(yīng)的中斷編號到谷歌上一查就可以得到完整的列表 (Windows, Linux)脚乡,這個列表就是操作系統(tǒng)和應(yīng)用程序之間溝通的協(xié)議蜒滩,如果需要超出此協(xié)議的功能,我們就只能在自己的代碼里去實現(xiàn)奶稠,譬如俯艰,對于內(nèi)存管理,操作系統(tǒng)只提供進(jìn)程級別的內(nèi)存段的管理锌订,譬如Windows的virtualmemory系列竹握,或是Linux的brk,操作系統(tǒng)不會去在乎應(yīng)用程序如何為新建對象分配內(nèi)存辆飘,或是如何做垃圾回收啦辐,這些都需要應(yīng)用程序自己去實現(xiàn)。如果超出此協(xié)議的功能無法自己實現(xiàn)劈猪,那我們就說該操作系統(tǒng)不支持該功能昧甘,舉個例子,Linux在2.6之前是不支持多線程的战得,無論如何在程序里模擬,我們都無法做出多個可以同時運行的并符合POSIX 1003.1c語義標(biāo)準(zhǔn)的調(diào)度單元庸推。

可是常侦,我們寫程序并不需要去調(diào)用中斷或是SYSCALL指令,這是因為操作系統(tǒng)提供了一層封裝贬媒,在Windows上,它是NTDLL.DLL,也就是常說的Native API珠洗,我們不但不需要去直接調(diào)用INT 2E或SYSCALL衔肢,準(zhǔn)確的說,我們不能直接去調(diào)用INT 2E或SYSCALL脖含,因為Windows并沒有公開其調(diào)用規(guī)范罪塔,直接使用INT 2E或SYSCALL無法保證未來的兼容性。在Linux上則沒有這個問題养葵,系統(tǒng)調(diào)用的列表都是公開的征堪,而且Linus非常看重兼容性关拒,不會去做任何更改佃蚜,glibc里甚至專門提供了syscall(2)來方便用戶直接用編號調(diào)用庸娱,不過,為了解決glibc和內(nèi)核之間不同版本兼容性帶來的麻煩谐算,以及為了提高某些調(diào)用的效率(譬如_NR gettimeofday)熟尉,Linux上還是對部分系統(tǒng)調(diào)用做了一層封裝,就是VDSO (早期叫linux-gate.so)洲脂。

可是臣樱,我們寫程序也很少直接調(diào)用NTDLL或者VDSO,而是通過更上一層的封裝腮考,這一層處理了參數(shù)準(zhǔn)備和返回值格式轉(zhuǎn)換雇毫、以及出錯處理和錯誤代碼轉(zhuǎn)換,這就是我們所使用語言的運行庫踩蔚,對于C語言棚放,Linux上是glibc,Windows上是kernel32(或調(diào)用msvcrt)馅闽,對于其他語言飘蚯,譬如Java,則是JRE福也,這些“其他語言”的運行庫通常最終還是調(diào)用glibc或kernel32局骤。

“運行庫”這個詞其實不止包括用于和編譯后的目標(biāo)執(zhí)行程序進(jìn)行鏈接的庫文件,也包括了腳本語言或字節(jié)碼解釋型語言的運行環(huán)境暴凑,譬如Python峦甩,C#的CLR,Java的JRE现喳。

對系統(tǒng)調(diào)用的封裝只是運行庫的很小一部分功能凯傲,運行庫通常還提供了諸如字符串處理、數(shù)學(xué)計算嗦篱、常用數(shù)據(jù)結(jié)構(gòu)容器等等不需要操作系統(tǒng)支持的功能冰单,同時,運行庫也會對操作系統(tǒng)支持的功能提供更易用更高級的封裝灸促,譬如帶緩存和格式的IO诫欠、線程池。

所以浴栽,在我們說“某某語言新增了某某功能”的時候荒叼,通常是這么幾種可能:
1. 支持新的語義或語法,從而便于我們描述和解決問題吃度。譬如Java的泛型甩挫、Annotation、lambda表達(dá)式椿每。
2. 提供了新的工具或類庫伊者,減少了我們開發(fā)的代碼量英遭。譬如Python 2.7的argparse
3. 對系統(tǒng)調(diào)用有了更良好更全面的封裝,使我們可以做到以前在這個語言環(huán)境里做不到或很難做到的事情亦渗。譬如Java NIO

但任何一門語言挖诸,包括其運行庫和運行環(huán)境,都不可能創(chuàng)造出操作系統(tǒng)不支持的功能法精,Go語言也是這樣多律,不管它的特性描述看起來多么炫麗,那必然都是其他語言也可以做到的搂蜓,只不過Go提供了更方便更清晰的語義和支持狼荞,提高了開發(fā)的效率。

2. 并發(fā)與并行 (Concurrency and Parallelism)

并發(fā)是指程序的邏輯結(jié)構(gòu)帮碰。非并發(fā)的程序就是一根竹竿捅到底相味,只有一個邏輯控制流,也就是順序執(zhí)行的(Sequential)程序殉挽,在任何時刻丰涉,程序只會處在這個邏輯控制流的某個位置。而如果某個程序有多個獨立的邏輯控制流斯碌,也就是可以同時處理(deal)多件事情一死,我們就說這個程序是并發(fā)的。這里的“同時”傻唾,并不一定要是真正在時鐘的某一時刻(那是運行狀態(tài)而不是邏輯結(jié)構(gòu))投慈,而是指:如果把這些邏輯控制流畫成時序流程圖,它們在時間線上是可以重疊的策吠。

并行是指程序的運行狀態(tài)逛裤。如果一個程序在某一時刻被多個CPU流水線同時進(jìn)行處理,那么我們就說這個程序是以并行的形式在運行猴抹。(嚴(yán)格意義上講,我們不能說某程序是“并行”的锁荔,因為“并行”不是描述程序本身蟀给,而是描述程序的運行狀態(tài),但這篇小文里就不那么咬文嚼字阳堕,以下說到“并行”的時候跋理,就是指代“以并行的形式運行”)顯然,并行一定是需要硬件支持的恬总。

而且不難理解:

1. 并發(fā)是并行的必要條件前普,如果一個程序本身就不是并發(fā)的,也就是只有一個邏輯控制流壹堰,那么我們不可能讓其被并行處理拭卿。

2. 并發(fā)不是并行的充分條件骡湖,一個并發(fā)的程序,如果只被一個CPU流水線進(jìn)行處理(通過分時)峻厚,那么它就不是并行的响蕴。

3. 并發(fā)只是更符合現(xiàn)實問題本質(zhì)的表達(dá)方式,并發(fā)的最初目的是簡化代碼邏輯惠桃,而不是使程序運行的更快浦夷;

這幾段略微抽象,我們可以用一個最簡單的例子來把這些概念實例化:用C語言寫一個最簡單的HelloWorld辜王,它就是非并發(fā)的劈狐,如果我們建立多個線程,每個線程里打印一個HelloWorld呐馆,那么這個程序就是并發(fā)的肥缔,如果這個程序運行在老式的單核CPU上,那么這個并發(fā)程序還不是并行的摹恰,如果我們用多核多CPU且支持多任務(wù)的操作系統(tǒng)來運行它辫继,那么這個并發(fā)程序就是并行的。

還有一個略微復(fù)雜的例子俗慈,更能說明并發(fā)不一定可以并行姑宽,而且并發(fā)不是為了效率,就是Go語言例子里計算素數(shù)的sieve.go闺阱。我們從小到大針對每一個因子啟動一個代碼片段炮车,如果當(dāng)前驗證的數(shù)能被當(dāng)前因子除盡,則該數(shù)不是素數(shù)酣溃,如果不能瘦穆,則把該數(shù)發(fā)送給下一個因子的代碼片段,直到最后一個因子也無法除盡赊豌,則該數(shù)為素數(shù)扛或,我們再啟動一個它的代碼片段,用于驗證更大的數(shù)字碘饼。這是符合我們計算素數(shù)的邏輯的熙兔,而且每個因子的代碼處理片段都是相同的,所以程序非常的簡潔艾恼,但它無法被并行住涉,因為每個片段都依賴于前一個片段的處理結(jié)果和輸出。

并發(fā)可以通過以下方式做到:

1. 顯式地定義并觸發(fā)多個代碼片段钠绍,也就是邏輯控制流舆声,由應(yīng)用程序或操作系統(tǒng)對它們進(jìn)行調(diào)度。它們可以是獨立無關(guān)的柳爽,也可以是相互依賴需要交互的媳握,譬如上面提到的素數(shù)計算碱屁,其實它也是個經(jīng)典的生產(chǎn)者和消費者的問題:兩個邏輯控制流A和B,A產(chǎn)生輸出毙芜,當(dāng)有了輸出后忽媒,B取得A的輸出進(jìn)行處理。線程只是實現(xiàn)并發(fā)的其中一個手段腋粥,除此之外晦雨,運行庫或是應(yīng)用程序本身也有多種手段來實現(xiàn)并發(fā),這是下節(jié)的主要內(nèi)容隘冲。

2. 隱式地放置多個代碼片段闹瞧,在系統(tǒng)事件發(fā)生時觸發(fā)執(zhí)行相應(yīng)的代碼片段,也就是事件驅(qū)動的方式展辞,譬如某個端口或管道接收到了數(shù)據(jù)(多路IO的情況下)奥邮,再譬如進(jìn)程接收到了某個信號(signal)。

并行可以在四個層面上做到:

1. 多臺機器罗珍。自然我們就有了多個CPU流水線洽腺,譬如Hadoop集群里的MapReduce任務(wù)。

2. 多CPU覆旱。不管是真的多顆CPU還是多核還是超線程蘸朋,總之我們有了多個CPU流水線。

3. 單CPU核里的ILP(Instruction-level parallelism)扣唱,指令級并行藕坯。通過復(fù)雜的制造工藝和對指令的解析以及分支預(yù)測和亂序執(zhí)行,現(xiàn)在的CPU可以在單個時鐘周期內(nèi)執(zhí)行多條指令噪沙,從而炼彪,即使是非并發(fā)的程序,也可能是以并行的形式執(zhí)行正歼。

4. 單指令多數(shù)據(jù)(Single instruction, multiple data. SIMD)辐马,為了多媒體數(shù)據(jù)的處理,現(xiàn)在的CPU的指令集支持單條指令對多條數(shù)據(jù)進(jìn)行操作局义。

其中齐疙,1牽涉到分布式處理,包括數(shù)據(jù)的分布和任務(wù)的同步等等旭咽,而且是基于網(wǎng)絡(luò)的。3和4通常是編譯器和CPU的開發(fā)人員需要考慮的赌厅。這里我們說的并行主要針對第2種:單臺機器內(nèi)的多核CPU并行穷绵。

關(guān)于并發(fā)與并行的問題,Go語言的作者Rob Pike專門就此寫過一個幻燈片:http://talks.golang.org/2012/waza.slide

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


3. 線程的調(diào)度

上一節(jié)主要說的是并發(fā)和并行的概念特愿,而線程是最直觀的并發(fā)的實現(xiàn)仲墨,這一節(jié)我們主要說操作系統(tǒng)如何讓多個線程并發(fā)的執(zhí)行勾缭,當(dāng)然在多CPU的時候,也就是并行的執(zhí)行目养。我們不討論進(jìn)程俩由,進(jìn)程的意義是“隔離的執(zhí)行環(huán)境”,而不是“單獨的執(zhí)行序列”癌蚁。

我們首先需要理解IA-32 CPU的指令控制方式幻梯,這樣才能理解如何在多個指令序列(也就是邏輯控制流)之間進(jìn)行切換。CPU通過CS:EIP寄存器的值確定下一條指令的位置努释,但是CPU并不允許直接使用MOV指令來更改EIP的值碘梢,必須通過JMP系列指令、CALL/RET指令伐蒂、或INT中斷指令來實現(xiàn)代碼的跳轉(zhuǎn)煞躬;在指令序列間切換的時候,除了更改EIP之外逸邦,我們還要保證代碼可能會使用到的各個寄存器的值恩沛,尤其是棧指針SS:ESP,以及EFLAGS標(biāo)志位等缕减,都能夠恢復(fù)到目標(biāo)指令序列上次執(zhí)行到這個位置時候的狀態(tài)雷客。

線程是操作系統(tǒng)對外提供的服務(wù),應(yīng)用程序可以通過系統(tǒng)調(diào)用讓操作系統(tǒng)啟動線程烛卧,并負(fù)責(zé)隨后的線程調(diào)度和切換佛纫。我們先考慮單顆單核CPU,操作系統(tǒng)內(nèi)核與應(yīng)用程序其實是也是在共享同一個CPU总放,當(dāng)EIP在應(yīng)用程序代碼段的時候呈宇,內(nèi)核并沒有控制權(quán),內(nèi)核并不是一個進(jìn)程或線程局雄,內(nèi)核只是以實模式運行的甥啄,代碼段權(quán)限為RING 0的內(nèi)存中的程序,只有當(dāng)產(chǎn)生中斷或是應(yīng)用程序呼叫系統(tǒng)調(diào)用的時候炬搭,控制權(quán)才轉(zhuǎn)移到內(nèi)核蜈漓,在內(nèi)核里,所有代碼都在同一個地址空間宫盔,為了給不同的線程提供服務(wù)融虽,內(nèi)核會為每一個線程建立一個內(nèi)核堆棧,這是線程切換的關(guān)鍵灼芭。通常有额,內(nèi)核會在時鐘中斷里或系統(tǒng)調(diào)用返回前(考慮到性能,通常是在不頻繁發(fā)生的系統(tǒng)調(diào)用返回前),對整個系統(tǒng)的線程進(jìn)行調(diào)度巍佑,計算當(dāng)前線程的剩余時間片茴迁,如果需要切換,就在“可運行”的線程隊列里計算優(yōu)先級萤衰,選出目標(biāo)線程后堕义,則保存當(dāng)前線程的運行環(huán)境,并恢復(fù)目標(biāo)線程的運行環(huán)境脆栋,其中最重要的倦卖,就是切換堆棧指針ESP,然后再把EIP指向目標(biāo)線程上次被移出CPU時的指令筹吐。Linux內(nèi)核在實現(xiàn)線程切換時糖耸,耍了個花槍,它并不是直接JMP丘薛,而是先把ESP切換為目標(biāo)線程的內(nèi)核棧嘉竟,把目標(biāo)線程的代碼地址壓棧,然后JMP到__switch_to()洋侨,相當(dāng)于偽造了一個CALL __switch_to()指令舍扰,然后,在__switch_to()的最后使用RET指令返回希坚,這樣就把棧里的目標(biāo)線程的代碼地址放入了EIP边苹,接下來CPU就開始執(zhí)行目標(biāo)線程的代碼了,其實也就是上次停在switch_to這個宏展開的地方裁僧。

這里需要補充幾點:(1) 雖然IA-32提供了TSS (Task State Segment)个束,試圖簡化操作系統(tǒng)進(jìn)行線程調(diào)度的流程,但由于其效率低下聊疲,而且并不是通用標(biāo)準(zhǔn)茬底,不利于移植,所以主流操作系統(tǒng)都沒有去利用TSS获洲。更嚴(yán)格的說阱表,其實還是用了TSS,因為只有通過TSS才能把堆棧切換到內(nèi)核堆棧指針SS0:ESP0贡珊,但除此之外的TSS的功能就完全沒有被使用了最爬。(2) 線程從用戶態(tài)進(jìn)入內(nèi)核的時候,相關(guān)的寄存器以及用戶態(tài)代碼段的EIP已經(jīng)保存了一次门岔,所以爱致,在上面所說的內(nèi)核態(tài)線程切換時,需要保存和恢復(fù)的內(nèi)容并不多寒随。(3) 以上描述的都是搶占式(preemptively)的調(diào)度方式蒜鸡,內(nèi)核以及其中的硬件驅(qū)動也會在等待外部資源可用的時候主動調(diào)用schedule()胯努,用戶態(tài)的代碼也可以通過sched_yield()系統(tǒng)調(diào)用主動發(fā)起調(diào)度,讓出CPU逢防。

現(xiàn)在我們一臺普通的PC或服務(wù)里通常都有多顆CPU (physical package),每顆CPU又有多個核 (processor core)蒲讯,每個核又可以支持超線程 (two logical processors for each core)忘朝,也就是邏輯處理器。每個邏輯處理器都有自己的一套完整的寄存器判帮,其中包括了CS:EIP和SS:ESP局嘁,從而,以操作系統(tǒng)和應(yīng)用的角度來看晦墙,每個邏輯處理器都是一個單獨的流水線悦昵。在多處理器的情況下,線程切換的原理和流程其實和單處理器時是基本一致的晌畅,內(nèi)核代碼只有一份但指,當(dāng)某個CPU上發(fā)生時鐘中斷或是系統(tǒng)調(diào)用時,該CPU的CS:EIP和控制權(quán)又回到了內(nèi)核抗楔,內(nèi)核根據(jù)調(diào)度策略的結(jié)果進(jìn)行線程切換棋凳。但在這個時候,如果我們的程序用線程實現(xiàn)了并發(fā)连躏,那么操作系統(tǒng)可以使我們的程序在多個CPU上實現(xiàn)并行剩岳。

這里也需要補充兩點:(1) 多核的場景里,各個核之間并不是完全對等的入热,譬如在同一個核上的兩個超線程是共享L1/L2緩存的拍棕;在有NUMA支持的場景里,每個核訪問內(nèi)存不同區(qū)域的延遲是不一樣的勺良;所以绰播,多核場景里的線程調(diào)度又引入了“調(diào)度域”(scheduling domains)的概念,但這不影響我們理解線程切換機制郑气。(2) 多核的場景下幅垮,中斷發(fā)給哪個CPU?軟中斷(包括除以0尾组,缺頁異常忙芒,INT指令)自然是在觸發(fā)該中斷的CPU上產(chǎn)生,而硬中斷則又分兩種情況讳侨,一種是每個CPU自己產(chǎn)生的中斷呵萨,譬如時鐘,這是每個CPU處理自己的跨跨,還有一種是外部中斷潮峦,譬如IO囱皿,可以通過APIC來指定其送給哪個CPU;因為調(diào)度程序只能控制當(dāng)前的CPU忱嘹,所以嘱腥,如果IO中斷沒有進(jìn)行均勻的分配的話,那么和IO相關(guān)的線程就只能在某些CPU上運行拘悦,導(dǎo)致CPU負(fù)載不均齿兔,進(jìn)而影響整個系統(tǒng)的效率。

4. 并發(fā)編程框架

以上大概介紹了一個用多線程來實現(xiàn)并發(fā)的程序是如何被操作系統(tǒng)調(diào)度以及并行執(zhí)行(在有多個邏輯處理器時)础米,同時大家也可以看到分苇,代碼片段或者說邏輯控制流的調(diào)度和切換其實并不神秘,理論上屁桑,我們也可以不依賴操作系統(tǒng)和其提供的線程医寿,在自己程序的代碼段里定義多個片段,然后在我們自己程序里對其進(jìn)行調(diào)度和切換蘑斧。

為了描述方便靖秩,我們接下來把“代碼片段”稱為“任務(wù)”。

和內(nèi)核的實現(xiàn)類似乌叶,只是我們不需要考慮中斷和系統(tǒng)調(diào)用盆偿,那么,我們的程序本質(zhì)上就是一個循環(huán)准浴,這個循環(huán)本身就是調(diào)度程序schedule()事扭,我們需要維護(hù)一個任務(wù)的列表,根據(jù)我們定義的策略乐横,先進(jìn)先出或是有優(yōu)先級等等求橄,每次從列表里挑選出一個任務(wù),然后恢復(fù)各個寄存器的值葡公,并且JMP到該任務(wù)上次被暫停的地方罐农,所有這些需要保存的信息都可以作為該任務(wù)的屬性,存放在任務(wù)列表里催什。

看起來很簡單啊涵亏,可是我們還需要解決幾個問題:

(1) 我們運行在用戶態(tài),是沒有中斷或系統(tǒng)調(diào)用這樣的機制來打斷代碼執(zhí)行的蒲凶,那么气筋,一旦我們的schedule()代碼把控制權(quán)交給了任務(wù)的代碼,我們下次的調(diào)度在什么時候發(fā)生旋圆?答案是宠默,不會發(fā)生,只有靠任務(wù)主動調(diào)用schedule()灵巧,我們才有機會進(jìn)行調(diào)度搀矫,所以抹沪,這里的任務(wù)不能像線程一樣依賴內(nèi)核調(diào)度從而毫無顧忌的執(zhí)行,我們的任務(wù)里一定要顯式的調(diào)用schedule()瓤球,這就是所謂的協(xié)作式(cooperative)調(diào)度融欧。(雖然我們可以通過注冊信號處理函數(shù)來模擬內(nèi)核里的時鐘中斷并取得控制權(quán),可問題在于冰垄,信號處理函數(shù)是由內(nèi)核調(diào)用的蹬癌,在其結(jié)束的時候,內(nèi)核重新獲得控制權(quán)虹茶,隨后返回用戶態(tài)并繼續(xù)沿著信號發(fā)生時被中斷的代碼路徑執(zhí)行,從而我們無法在信號處理函數(shù)內(nèi)進(jìn)行任務(wù)切換)

(2) 堆棧隅要。和內(nèi)核調(diào)度線程的原理一樣蝴罪,我們也需要為每個任務(wù)單獨分配堆棧,并且把其堆棧信息保存在任務(wù)屬性里步清,在任務(wù)切換時也保存或恢復(fù)當(dāng)前的SS:ESP要门。任務(wù)堆棧的空間可以是在當(dāng)前線程的堆棧上分配,也可以是在堆上分配廓啊,但通常是在堆上分配比較好:幾乎沒有大小或任務(wù)總數(shù)的限制欢搜、堆棧大小可以動態(tài)擴展(gcc有split stack,但太復(fù)雜了)谴轮、便于把任務(wù)切換到其他線程炒瘟。

到這里,我們大概知道了如何構(gòu)造一個并發(fā)的編程框架第步,可如何讓任務(wù)可以并行的在多個邏輯處理器上執(zhí)行呢疮装?只有內(nèi)核才有調(diào)度CPU的權(quán)限,所以粘都,我們還是必須通過系統(tǒng)調(diào)用創(chuàng)建線程廓推,才可以實現(xiàn)并行。在多線程處理多任務(wù)的時候翩隧,我們還需要考慮幾個問題:

(1) 如果某個任務(wù)發(fā)起了一個系統(tǒng)調(diào)用樊展,譬如長時間等待IO,那當(dāng)前線程就被內(nèi)核放入了等待調(diào)度的隊列堆生,豈不是讓其他任務(wù)都沒有機會執(zhí)行专缠?

在單線程的情況下,我們只有一個解決辦法顽频,就是使用非阻塞的IO系統(tǒng)調(diào)用藤肢,并讓出CPU,然后在schedule()里統(tǒng)一進(jìn)行輪詢糯景,有數(shù)據(jù)時切換回該fd對應(yīng)的任務(wù)嘁圈;效率略低的做法是不進(jìn)行統(tǒng)一輪詢省骂,讓各個任務(wù)在輪到自己執(zhí)行時再次用非阻塞方式進(jìn)行IO,直到有數(shù)據(jù)可用最住。

如果我們采用多線程來構(gòu)造我們整個的程序钞澳,那么我們可以封裝系統(tǒng)調(diào)用的接口,當(dāng)某個任務(wù)進(jìn)入系統(tǒng)調(diào)用時涨缚,我們就把當(dāng)前線程留給它(暫時)獨享轧粟,并開啟新的線程來處理其他任務(wù)。

(2) 任務(wù)同步脓魏。譬如我們上節(jié)提到的生產(chǎn)者和消費者的例子兰吟,如何讓消費者在數(shù)據(jù)還沒有被生產(chǎn)出來的時候進(jìn)入等待,并且在數(shù)據(jù)可用時觸發(fā)消費者繼續(xù)執(zhí)行呢茂翔?

在單線程的情況下混蔼,我們可以定義一個結(jié)構(gòu),其中有變量用于存放交互數(shù)據(jù)本身珊燎,以及數(shù)據(jù)的當(dāng)前可用狀態(tài)惭嚣,以及負(fù)責(zé)讀寫此數(shù)據(jù)的兩個任務(wù)的編號。然后我們的并發(fā)編程框架再提供read和write方法供任務(wù)調(diào)用悔政,在read方法里晚吞,我們循環(huán)檢查數(shù)據(jù)是否可用,如果數(shù)據(jù)還不可用谋国,我們就調(diào)用schedule()讓出CPU進(jìn)入等待槽地;在write方法里,我們往結(jié)構(gòu)里寫入數(shù)據(jù)烹卒,更改數(shù)據(jù)可用狀態(tài)闷盔,然后返回;在schedule()里旅急,我們檢查數(shù)據(jù)可用狀態(tài)逢勾,如果可用,則激活需要讀取此數(shù)據(jù)的任務(wù)藐吮,該任務(wù)繼續(xù)循環(huán)檢測數(shù)據(jù)是否可用溺拱,發(fā)現(xiàn)可用,讀取谣辞,更改狀態(tài)為不可用迫摔,返回。代碼的簡單邏輯如下:

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;
        }
    }
}

很顯然泥从,如果是多線程的話句占,我們需要通過線程庫或系統(tǒng)調(diào)用提供的同步機制來保護(hù)對這個結(jié)構(gòu)體內(nèi)數(shù)據(jù)的訪問。

以上就是最簡化的一個并發(fā)框架的設(shè)計考慮躯嫉,在我們實際開發(fā)工作中遇到的并發(fā)框架可能由于語言和運行庫的不同而有所不同纱烘,在功能和易用性上也可能各有取舍杨拐,但底層的原理都是殊途同歸。

譬如擂啥,glic里的getcontext/setcontext/swapcontext系列庫函數(shù)可以方便的用來保存和恢復(fù)任務(wù)執(zhí)行狀態(tài)哄陶;Windows提供了Fiber系列的SDK API;這二者都不是系統(tǒng)調(diào)用哺壶,getcontext和setcontext的man page雖然是在section 2屋吨,但那只是SVR4時的歷史遺留問題,其實現(xiàn)代碼是在glibc而不是kernel山宾;CreateFiber是在kernel32里提供的至扰,NTDLL里并沒有對應(yīng)的NtCreateFiber。

在其他語言里资锰,我們所謂的“任務(wù)”更多時候被稱為“協(xié)程”渊胸,也就是Coroutine。譬如C++里最常用的是Boost.Coroutine台妆;Java因為有一層字節(jié)碼解釋,比較麻煩胖翰,但也有支持協(xié)程的JVM補丁接剩,或是動態(tài)修改字節(jié)碼以支持協(xié)程的項目;PHP和Python的generator和yield其實已經(jīng)是協(xié)程的支持萨咳,在此之上可以封裝出更通用的協(xié)程接口和調(diào)度懊缺;另外還有原生支持協(xié)程的Erlang等,筆者不懂培他,就不說了鹃两,具體可參見Wikipedia的頁面:http://en.wikipedia.org/wiki/Coroutine

由于保存和恢復(fù)任務(wù)執(zhí)行狀態(tài)需要訪問CPU寄存器,所以相關(guān)的運行庫也都會列出所支持的CPU列表舀凛。

從操作系統(tǒng)層面提供協(xié)程以及其并行調(diào)度的俊扳,好像只有OS X和iOS的Grand Central Dispatch,其大部分功能也是在運行庫里實現(xiàn)的猛遍。

5. goroutine

Go語言通過goroutine提供了目前為止所有(我所了解的)語言里對于并發(fā)編程的最清晰最直接的支持馋记,Go語言的文檔里對其特性也描述的非常全面甚至超過了,在這里懊烤,基于我們上面的系統(tǒng)知識介紹梯醒,列舉一下goroutine的特性,算是小結(jié):

(1) goroutine是Go語言運行庫的功能腌紧,不是操作系統(tǒng)提供的功能茸习,goroutine不是用線程實現(xiàn)的。具體可參見Go語言源碼里的pkg/runtime/proc.c

(2) goroutine就是一段代碼壁肋,一個函數(shù)入口号胚,以及在堆上為其分配的一個堆棧籽慢。所以它非常廉價,我們可以很輕松的創(chuàng)建上萬個goroutine涕刚,但它們并不是被操作系統(tǒng)所調(diào)度執(zhí)行

(3) 除了被系統(tǒng)調(diào)用阻塞的線程外嗡综,Go運行庫最多會啟動$GOMAXPROCS個線程來運行g(shù)oroutine

(4) goroutine是協(xié)作式調(diào)度的,如果goroutine會執(zhí)行很長時間杜漠,而且不是通過等待讀取或?qū)懭隿hannel的數(shù)據(jù)來同步的話极景,就需要主動調(diào)用Gosched()來讓出CPU

(5) 和所有其他并發(fā)框架里的協(xié)程一樣,goroutine里所謂“無鎖”的優(yōu)點只在單線程下有效驾茴,如果$GOMAXPROCS > 1并且協(xié)程間需要通信盼樟,Go運行庫會負(fù)責(zé)加鎖保護(hù)數(shù)據(jù),這也是為什么sieve.go這樣的例子在多CPU多線程時反而更慢的原因

(6) Web等服務(wù)端程序要處理的請求從本質(zhì)上來講是并行處理的問題锈至,每個請求基本獨立晨缴,互不依賴,幾乎沒有數(shù)據(jù)交互峡捡,這不是一個并發(fā)編程的模型击碗,而并發(fā)編程框架只是解決了其語義表述的復(fù)雜性,并不是從根本上提高處理的效率们拙,也許是并發(fā)連接和并發(fā)編程的英文都是concurrent吧稍途,很容易產(chǎn)生“并發(fā)編程框架和coroutine可以高效處理大量并發(fā)連接”的誤解。

(7) Go語言運行庫封裝了異步IO砚婆,所以可以寫出貌似并發(fā)數(shù)很多的服務(wù)端械拍,可即使我們通過調(diào)整$GOMAXPROCS來充分利用多核CPU并行處理,其效率也不如我們利用IO事件驅(qū)動設(shè)計的装盯、按照事務(wù)類型劃分好合適比例的線程池坷虑。在響應(yīng)時間上,協(xié)作式調(diào)度是硬傷埂奈。

(8) goroutine最大的價值是其實現(xiàn)了并發(fā)協(xié)程和實際并行執(zhí)行的線程的映射以及動態(tài)擴展迄损,隨著其運行庫的不斷發(fā)展和完善,其性能一定會越來越好挥转,尤其是在CPU核數(shù)越來越多的未來海蔽,終有一天我們會為了代碼的簡潔和可維護(hù)性而放棄那一點點性能的差別。

轉(zhuǎn)自: http://www.sizeofvoid.net/goroutine-under-the-hood/

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末绑谣,一起剝皮案震驚了整個濱河市党窜,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌借宵,老刑警劉巖幌衣,帶你破解...
    沈念sama閱讀 222,590評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡豁护,警方通過查閱死者的電腦和手機哼凯,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,157評論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來楚里,“玉大人断部,你說我怎么就攤上這事“喽校” “怎么了蝴光?”我有些...
    開封第一講書人閱讀 169,301評論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長达址。 經(jīng)常有香客問我蔑祟,道長,這世上最難降的妖魔是什么沉唠? 我笑而不...
    開封第一講書人閱讀 60,078評論 1 300
  • 正文 為了忘掉前任疆虚,我火速辦了婚禮,結(jié)果婚禮上满葛,老公的妹妹穿的比我還像新娘径簿。我一直安慰自己,他們只是感情好嘀韧,可當(dāng)我...
    茶點故事閱讀 69,082評論 6 398
  • 文/花漫 我一把揭開白布牍帚。 她就那樣靜靜地躺著,像睡著了一般乳蛾。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上鄙币,一...
    開封第一講書人閱讀 52,682評論 1 312
  • 那天肃叶,我揣著相機與錄音,去河邊找鬼十嘿。 笑死因惭,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的绩衷。 我是一名探鬼主播蹦魔,決...
    沈念sama閱讀 41,155評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼咳燕!你這毒婦竟也來了勿决?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 40,098評論 0 277
  • 序言:老撾萬榮一對情侶失蹤招盲,失蹤者是張志新(化名)和其女友劉穎低缩,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,638評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡咆繁,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,701評論 3 342
  • 正文 我和宋清朗相戀三年讳推,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片玩般。...
    茶點故事閱讀 40,852評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡银觅,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出坏为,到底是詐尸還是另有隱情究驴,我是刑警寧澤谋旦,帶...
    沈念sama閱讀 36,520評論 5 351
  • 正文 年R本政府宣布钦睡,位于F島的核電站搀菩,受9級特大地震影響帜羊,放射性物質(zhì)發(fā)生泄漏犹褒。R本人自食惡果不足惜政钟,卻給世界環(huán)境...
    茶點故事閱讀 42,181評論 3 335
  • 文/蒙蒙 一闲先、第九天 我趴在偏房一處隱蔽的房頂上張望撕氧。 院中可真熱鬧摧找,春花似錦核行、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,674評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至综苔,卻和暖如春惩系,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背如筛。 一陣腳步聲響...
    開封第一講書人閱讀 33,788評論 1 274
  • 我被黑心中介騙來泰國打工堡牡, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人杨刨。 一個月前我還...
    沈念sama閱讀 49,279評論 3 379
  • 正文 我出身青樓晤柄,卻偏偏與公主長得像,于是被迫代替她去往敵國和親妖胀。 傳聞我的和親對象是個殘疾皇子芥颈,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,851評論 2 361

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