從根上理解高性能、高并發(fā)(五):深入操作系統(tǒng),理解高并發(fā)中的協(xié)程
Posted on 2021-01-18 14:51 Jack Jiang 閱讀(185) 評論(0) 編輯 收藏本文原題“程序員應如何理解高并發(fā)中的協(xié)程”,轉載請聯(lián)系作者。
1、系列文章引言
1.1 文章目的
作為即時通訊技術的開發(fā)者來說,高性能、高并發(fā)相關的技術概念早就了然與胸,什么線程池、零拷貝、多路復用、事件驅動、epoll等等名詞信手拈來,又或許你對具有這些技術特征的技術框架比如:Java的Netty、Php的workman、Go的gnet等熟練掌握。但真正到了面視或者技術實踐過程中遇到無法釋懷的疑惑時,方知自已所掌握的不過是皮毛。
返璞歸真、回歸本質,這些技術特征背后的底層原理到底是什么?如何能通俗易懂、毫不費力真正透徹理解這些技術背后的原理,正是《從根上理解高性能、高并發(fā)》系列文章所要分享的。
1.2 文章源起
我整理了相當多有關IM、消息推送等即時通訊技術相關的資源和文章,從最開始的開源IM框架MobileIMSDK,到網絡編程經典巨著《TCP/IP詳解》的在線版本,再到IM開發(fā)綱領性文章《新手入門一篇就夠:從零開發(fā)移動端IM》,以及網絡編程由淺到深的《網絡編程懶人入門》、《腦殘式網絡編程入門》、《高性能網絡編程》、《不為人知的網絡編程》系列文章。
越往知識的深處走,越覺得對即時通訊技術了解的太少。于是后來,為了讓開發(fā)者門更好地從基礎電信技術的角度理解網絡(尤其移動網絡)特性,我跨專業(yè)收集整理了《IM開發(fā)者的零基礎通信技術入門》系列高階文章。這系列文章已然是普通即時通訊開發(fā)者的網絡通信技術知識邊界,加上之前這些網絡編程資料,解決網絡通信方面的知識盲點基本夠用了。
對于即時通訊IM這種系統(tǒng)的開發(fā)來說,網絡通信知識確實非常重要,但回歸到技術本質,實現(xiàn)網絡通信本身的這些技術特征:包括上面提到的線程池、零拷貝、多路復用、事件驅動等等,它們的本質是什么?底層原理又是怎樣?這就是整理本系列文章的目的,希望對你有用。
1.3 文章目錄
《從根上理解高性能、高并發(fā)(一):深入計算機底層,理解線程與線程池》
《從根上理解高性能、高并發(fā)(二):深入操作系統(tǒng),理解I/O與零拷貝技術》
《從根上理解高性能、高并發(fā)(三):深入操作系統(tǒng),徹底理解I/O多路復用》
《從根上理解高性能、高并發(fā)(四):深入操作系統(tǒng),徹底理解同步與異步》
《從根上理解高性能、高并發(fā)(五):深入操作系統(tǒng),理解高并發(fā)中的協(xié)程》(* 本文)
《從根上理解高性能、高并發(fā)(六):高并發(fā)高性能服務器到底是如何實現(xiàn)的 (稍后發(fā)布..)》
1.4 本篇概述
接上篇《深入操作系統(tǒng),徹底理解同步與異步》,本篇是高性能、高并發(fā)系列的第5篇文章。
協(xié)程是高性能高并發(fā)編程中不可或缺的技術,包括即時通訊(IM系統(tǒng))在內的互聯(lián)網產品應用產品中應用廣泛,比如號稱支撐微信海量用戶的后臺框架就是基于協(xié)程打造的(詳見《開源libco庫:單機千萬連接、支撐微信8億用戶的后臺框架基石》)。而且越來越多的現(xiàn)代編程語言都將協(xié)程視為最重要的語言技術特征,已知的包括:Go、Python、Kotlin等。
因此了解和掌握協(xié)程技術對于很多程序員(尤其海量網絡通信應用的后端程序員)來說是相當有必要的,本文正是為你解惑協(xié)程技術原理而寫。
本文已同步發(fā)布于“即時通訊技術圈”公眾號,歡迎關注。公眾號上的鏈接是:點此進入。
2、本文作者
應作者要求,不提供真名,也不提供個人照片。
本文作者主要技術方向為互聯(lián)網后端、高并發(fā)高性能服務器、檢索引擎技術,網名是“碼農的荒島求生”,公眾號“碼農的荒島求生”。感謝作者的無私分享。
3、正文引言
作為程序員,想必你多多少少聽過協(xié)程這個詞,這項技術近年來越來越多的出現(xiàn)在程序員的視野當中,尤其高性能高并發(fā)領域。當你的同學、同事提到協(xié)程時如果你的大腦一片空白,對其毫無概念。。。

那么這篇文章正是為你量身打造的。
話不多說,今天的主題就是作為程序員,你應該如何徹底理解協(xié)程。
4、普通的函數
我們先來看一個普通的函數,這個函數非常簡單:
def func():
print("a")
print("b")
print("c")
這是一個簡單的普通函數,當我們調用這個函數時會發(fā)生什么?
- 1)調用func;
- 2)func開始執(zhí)行,直到return;
- 3)func執(zhí)行完成,返回函數A。
是不是很簡單,函數func執(zhí)行直到返回,并打印出:
a
b
c
So easy,有沒有,有沒有!

很好!
注意這段代碼是用python寫的,但本篇關于協(xié)程的討論適用于任何一門語言,因為協(xié)程并不是某種語言特有的。而我們只不過恰好使用了python來用作示例,因其足夠簡單。
那么協(xié)程是什么呢?
5、從普通函數到協(xié)程
接下來,我們就要從普通函數過渡到協(xié)程了。和普通函數只有一個返回點不同,協(xié)程可以有多個返回點。
這是什么意思呢?
void func() {
print("a")
暫停并返回
print("b")
暫停并返回
print("c")
}
普通函數下,只有當執(zhí)行完print("c")這句話后函數才會返回,但是在協(xié)程下當執(zhí)行完print("a")后func就會因“暫停并返回”這段代碼返回到調用函數。
有的同學可能會一臉懵逼,這有什么神奇的嗎?
我寫一個return也能返回,就像這樣:
void func() {
print("a")
return
print("b")
暫停并返回
print("c")
}
直接寫一個return語句確實也能返回,但這樣寫的話return后面的代碼都不會被執(zhí)行到了。
協(xié)程之所以神奇就神奇在當我們從協(xié)程返回后還能繼續(xù)調用該協(xié)程,并且是從該協(xié)程的上一個返回點后繼續(xù)執(zhí)行。
就好比孫悟空說一聲“定”,函數就被暫停了:
void func() {
print("a")
定
print("b")
定
print("c")
}
這時我們就可以返回到調用函數,當調用函數什么時候想起該協(xié)程后可以再次調用該協(xié)程,該協(xié)程會從上一個返回點繼續(xù)執(zhí)行。
Amazing,有沒有,集中注意力,千萬不要翻車。

只不過孫大圣使用的口訣“定”字,在編程語言中一般叫做yield(其它語言中可能會有不同的實現(xiàn),但本質都是一樣的)。
需要注意的是:當普通函數返回后,進程的地址空間中不會再保存該函數運行時的任何信息,而協(xié)程返回后,函數的運行時信息是需要保存下來的。
接下來,我們就用實際的代碼看一看協(xié)程。
6、“Talk is cheap,show me the code”
下面我們使用一個真實的例子來講解,語言采用python,不熟悉的同學不用擔心,這里不會有理解上的門檻。
在python語言中,這個“定”字同樣使用關鍵詞yield。
這樣我們的func函數就變成了:
void func() {
print("a")
yield
print("b")
yield
print("c")
}
注意:這時我們的func就不再是簡簡單單的函數了,而是升級成為了協(xié)程,那么我們該怎么使用呢?
很簡單:
def A():
co =func() # 得到該協(xié)程
next(co) # 調用協(xié)程
print("in function A") # do something
next(co) # 再次調用該協(xié)程
我們看到雖然func函數沒有return語句,也就是說雖然沒有返回任何值,但是我們依然可以寫co = func()這樣的代碼,意思是說co就是我們拿到的協(xié)程了。
接下來我們調用該協(xié)程,使用next(co),運行函數A看看執(zhí)行到第3行的結果是什么:
a
顯然,和我們的預期一樣,協(xié)程func在print("a")后因執(zhí)行yield而暫停并返回函數A。
接下來是第4行,這個毫無疑問,A函數在做一些自己的事情,因此會打印:
a
in function A
接下來是重點的一行,當執(zhí)行第5行再次調用協(xié)程時該打印什么呢?
如果func是普通函數,那么會執(zhí)行func的第一行代碼,也就是打印a。
但func不是普通函數,而是協(xié)程,我們之前說過,協(xié)程會在上一個返回點繼續(xù)運行,因此這里應該執(zhí)行的是func函數第一個yield之后的代碼,也就是 print("b")。
a
in function A
b
看到了吧,協(xié)程是一個很神奇的函數,它會自己記住之前的執(zhí)行狀態(tài),當再次調用時會從上一次的返回點繼續(xù)執(zhí)行。
7、圖形化解釋
為了讓你更加徹底的理解協(xié)程,我們使用圖形化的方式再看一遍。
首先是普通的函數調用:
在該圖中:方框內表示該函數的指令序列,如果該函數不調用任何其它函數,那么應該從上到下依次執(zhí)行,但函數中可以調用其它函數,因此其執(zhí)行并不是簡單的從上到下,箭頭線表示執(zhí)行流的方向。
從上圖中我們可以看到:我們首先來到funcA函數,執(zhí)行一段時間后發(fā)現(xiàn)調用了另一個函數funcB,這時控制轉移到該函數,執(zhí)行完成后回到main函數的調用點繼續(xù)執(zhí)行。這是普通的函數調用。
接下來是協(xié)程:

在這里:我們依然首先在funcA函數中執(zhí)行,運行一段時間后調用協(xié)程,協(xié)程開始執(zhí)行,直到第一個掛起點,此后就像普通函數一樣返回funcA函數,funcA函數執(zhí)行一些代碼后再次調用該協(xié)程。
注意:協(xié)程這時就和普通函數不一樣了,協(xié)程并不是從第一條指令開始執(zhí)行而是從上一次的掛起點開始執(zhí)行,執(zhí)行一段時間后遇到第二個掛起點,這時協(xié)程再次像普通函數一樣返回funcA函數,funcA函數執(zhí)行一段時間后整個程序結束。

8、函數只是協(xié)程的一種特例
怎么樣,神奇不神奇。和普通函數不同的是,協(xié)程能知道自己上一次執(zhí)行到了哪里。
現(xiàn)在你應該明白了吧,協(xié)程會在函數被暫停運行時保存函數的運行狀態(tài),并可以從保存的狀態(tài)中恢復并繼續(xù)運行。
很熟悉的味道有沒有,這不就是操作系統(tǒng)對線程的調度嘛(見《深入計算機底層,理解線程與線程池》),線程也可以被暫停,操作系統(tǒng)保存線程運行狀態(tài)然后去調度其它線程,此后該線程再次被分配CPU時還可以繼續(xù)運行,就像沒有被暫停過一樣。
只不過線程的調度是操作系統(tǒng)實現(xiàn)的,這些對程序員都不可見,而協(xié)程是在用戶態(tài)實現(xiàn)的,對程序員可見。
這就是為什么有的人說可以把協(xié)程理解為用戶態(tài)線程的原因。
此處應該有掌聲。

也就是說現(xiàn)在程序員可以扮演操作系統(tǒng)的角色了,你可以自己控制協(xié)程在什么時候運行,什么時候暫停,也就是說協(xié)程的調度權在你自己手上。
在協(xié)程這件事兒上,調度你說了算。
當你在協(xié)程中寫下 yield 的時候就是想要暫停該協(xié)程,當使用 next() 時就是要再次運行該協(xié)程。
現(xiàn)在你應該理解為什么說函數只是協(xié)程的一種特例了吧,函數其實只是沒有掛起點的協(xié)程而已。
9、協(xié)程的歷史
有的同學可能認為協(xié)程是一種比較新的技術,然而其實協(xié)程這種概念早在1958年就已經提出來了,要知道這時線程的概念都還沒有提出來。
到了1972年,終于有編程語言實現(xiàn)了這個概念,這兩門編程語言就是Simula 67 以及Scheme。

但協(xié)程這個概念始終沒有流行起來,甚至在1993年還有人考古一樣專門寫論文挖出協(xié)程這種古老的技術。
因為這一時期還沒有線程,如果你想在操作系統(tǒng)寫出并發(fā)程序那么你將不得不使用類似協(xié)程這樣的技術,后來線程開始出現(xiàn),操作系統(tǒng)終于開始原生支持程序的并發(fā)執(zhí)行,就這樣,協(xié)程逐漸淡出了程序員的視線。
直到近些年,隨著互聯(lián)網的發(fā)展,尤其是移動互聯(lián)網時代的到來,服務端對高并發(fā)的要求越來越高,協(xié)程再一次重回技術主流,各大編程語言都已經支持或計劃開始支持協(xié)程。
那么協(xié)程到底是如何實現(xiàn)的呢?
10、協(xié)程到底是如何實現(xiàn)的?
讓我們從問題的本質出發(fā)來思考這個問題:協(xié)程的本質是什么呢?
其實就是可以被暫停以及可以被恢復運行的函數。那么可以被暫停以及可以被恢復意味著什么呢?
看過籃球比賽的同學想必都知道(沒看過的也能知道),籃球比賽也是可以被隨時暫停的,暫停時大家需要記住球在哪一方,各自的站位是什么,等到比賽繼續(xù)的時候大家回到各自的位置,裁判哨子一響比賽繼續(xù),就像比賽沒有被暫停過一樣。

看到問題的關鍵了嗎:比賽之所以可以被暫停也可以繼續(xù)是因為比賽狀態(tài)被記錄下來了(站位、球在哪一方),這里的狀態(tài)就是計算機科學中常說的上下文(context)。
回到協(xié)程。
協(xié)程之所以可以被暫停也可以繼續(xù),那么一定要記錄下被暫停時的狀態(tài),也就是上下文,當繼續(xù)運行的時候要恢復其上下文(狀態(tài))另外:函數運行時所有的狀態(tài)信息都位于函數運行時棧中。
函數運行時棧就是我們需要保存的狀態(tài),也就是所謂的上下文。
如圖所示:

從上圖中我們可以看出:該進程中只有一個線程,棧區(qū)中有四個棧幀,main函數調用A函數,A函數調用B函數,B函數調用C函數,當C函數在運行時整個進程的狀態(tài)就如圖所示。
現(xiàn)在:我們已經知道了函數的運行時狀態(tài)就保存在棧區(qū)的棧幀中,接下來重點來了哦。
既然函數的運行時狀態(tài)保存在棧區(qū)的棧幀中,那么如果我們想暫停協(xié)程的運行就必須保存整個棧幀的數據,那么我們該將整個棧幀中的數據保存在哪里呢?
想一想這個問題:整個進程的內存區(qū)中哪一塊是專門用來長時間(進程生命周期)存儲數據的?是不是大腦又一片空白了?

先別空白!
很顯然:這就是堆區(qū)啊(heap),我們可以將棧幀保存在堆區(qū)中,那么我們該怎么在堆區(qū)中保存數據呢?希望你還沒有暈,在堆區(qū)中開辟空間就是我們常用的C語言中的malloc或者C++中的new。
我們需要做的就是:在堆區(qū)中申請一段空間,讓后把協(xié)程的整個棧區(qū)保存下,當需要恢復協(xié)程的運行時再從堆區(qū)中copy出來恢復函數運行時狀態(tài)。
再仔細想一想,為什么我們要這么麻煩的來回copy數據呢?
實際上:我們需要做的是直接把協(xié)程的運行需要的棧幀空間直接開辟在堆區(qū)中,這樣都不用來回copy數據了,如下圖所示。

從上圖中我們可以看到:該程序中開啟了兩個協(xié)程,這兩個協(xié)程的棧區(qū)都是在堆上分配的,這樣我們就可以隨時中斷或者恢復協(xié)程的執(zhí)行了。
有的同學可能會問,那么進程地址空間最上層的棧區(qū)現(xiàn)在的作用是什么呢?
答案是:這一區(qū)域依然是用來保存函數棧幀的,只不過這些函數并不是運行在協(xié)程而是普通線程中的。
現(xiàn)在你應該看到了吧,在上圖中實際上共有3個執(zhí)行流:
- 1)一個普通線程;
- 2)兩個協(xié)程。
雖然有3個執(zhí)行流但我們創(chuàng)建了幾個線程呢?
答案是:一個線程。
現(xiàn)在你應該明白為什么要使用協(xié)程了吧:使用協(xié)程理論上我們可以開啟無數并發(fā)執(zhí)行流,只要堆區(qū)空間足夠,同時還沒有創(chuàng)建線程的開銷,所有協(xié)程的調度、切換都發(fā)生在用戶態(tài),這就是為什么協(xié)程也被稱作用戶態(tài)線程的原因所在。
掌聲在哪里?

因此:即使你創(chuàng)建了N多協(xié)程,但在操作系統(tǒng)看來依然只有一個線程,也就是說協(xié)程對操作系統(tǒng)來說是不可見的。
這也許是為什么協(xié)程這個概念比線程提出的要早的原因,可能是寫普通應用的程序員比寫操作系統(tǒng)的程序員最先遇到需要多個并行流的需求,那時可能都還沒有操作系統(tǒng)的概念,或者操作系統(tǒng)沒有并行這種需求,所以非操作系統(tǒng)程序員只能自己動手實現(xiàn)執(zhí)行流,也就是協(xié)程。
現(xiàn)在你應該對協(xié)程有一個清晰的認知了吧。

11、協(xié)程技術概念小結
正文內容用了較多調侃語氣,目的是希望能輕松詼諧地助你理解協(xié)程技術概念。那么,我們從嚴肅專業(yè)知識來小結一下,到底什么是協(xié)程呢?
11.1 協(xié)程是比線程更小的執(zhí)行單元
協(xié)程是比線程更小的一種執(zhí)行單元,你可以認為是輕量級的線程。
之所以說輕:其中一方面的原因是協(xié)程所持有的棧比線程要小很多,java當中會為每個線程分配1M左右的棧空間,而協(xié)程可能只有幾十或者幾百K,棧主要用來保存函數參數、局部變量和返回地址等信息。
我們知道:而線程的調度是在操作系統(tǒng)中進行的,而協(xié)程調度則是在用戶空間進行的,是開發(fā)人員通過調用系統(tǒng)底層的執(zhí)行上下文相關api來完成的。有些語言,比如nodejs、go在語言層面支持了協(xié)程,而有些語言,比如C,需要使用第三方庫才可以擁有協(xié)程的能力(比如微信開源的Libco庫就是這樣的,見:《開源libco庫:單機千萬連接、支撐微信8億用戶的后臺框架基石》)。
由于線程是操作系統(tǒng)的最小執(zhí)行單元,因此也可以得出,協(xié)程是基于線程實現(xiàn)的,協(xié)程的創(chuàng)建、切換、銷毀都是在某個線程中來進行的。
使用協(xié)程是因為線程的切換成本比較高,而協(xié)程在這方面很有優(yōu)勢。
11.2 協(xié)程的切換到底為什么很廉價?
關于這個問題,我們回顧一下線程切換的過程:
- 1)線程在進行切換的時候,需要將CPU中的寄存器的信息存儲起來,然后讀入另外一個線程的數據,這個會花費一些時間;
- 2)CPU的高速緩存中的數據,也可能失效,需要重新加載;
- 3)線程的切換會涉及到用戶模式到內核模式的切換,據說每次模式切換都需要執(zhí)行上千條指令,很耗時。
實際上協(xié)程的切換之所以快的原因我認為主要是:
- 1)在切換的時候,寄存器需要保存和加載的數據量比較小;
- 2)高速緩存可以有效利用;
- 3)沒有用戶模式到內核模式的切換操作;
- 4)更有效率的調度,因為協(xié)程是非搶占式的,前一個協(xié)程執(zhí)行完畢或者堵塞,才會讓出CPU,而線程則一般使用了時間片的算法,會進行很多沒有必要的切換(為了盡量讓用戶感知不到某個線程卡)。
12、寫在最后
寫到這里,相信你已經理解協(xié)程到底是怎么一回事了,關于協(xié)程更系統(tǒng)的知識可以自行查閱相關資料,我就不再啰嗦了。
下一篇《從根上理解高性能、高并發(fā)(六):高并發(fā)高性能服務器到底是如何實現(xiàn)的》,敬請期待!
附錄:更多高性能、高并發(fā)文章精選
《高性能網絡編程(一):單臺服務器并發(fā)TCP連接數到底可以有多少》
《高性能網絡編程(二):上一個10年,著名的C10K并發(fā)連接問題》
《高性能網絡編程(三):下一個10年,是時候考慮C10M并發(fā)問題了》
《高性能網絡編程(四):從C10K到C10M高性能網絡應用的理論探索》
《高性能網絡編程(五):一文讀懂高性能網絡編程中的I/O模型》
《高性能網絡編程(六):一文讀懂高性能網絡編程中的線程模型》
《高性能網絡編程(七):到底什么是高并發(fā)?一文即懂!》
《以網游服務端的網絡接入層設計為例,理解實時通信的技術挑戰(zhàn)》
《知乎技術分享:知乎千萬級并發(fā)的高性能長連接網關技術實踐》
《一套海量在線用戶的移動端IM架構設計實踐分享(含詳細圖文)》
《一套原創(chuàng)分布式即時通訊(IM)系統(tǒng)理論架構方案》
《微信技術總監(jiān)談架構:微信之道——大道至簡(演講全文)》
《如何解讀《微信技術總監(jiān)談架構:微信之道——大道至簡》》
《騰訊資深架構師干貨總結:一文讀懂大型分布式系統(tǒng)設計的方方面面》
《以微博類應用場景為例,總結海量社交系統(tǒng)的架構設計步驟》
本文已同步發(fā)布于“即時通訊技術圈”公眾號。
▲ 本文在公眾號上的鏈接是:點此進入。同步發(fā)布鏈接是:http://www.52im.net/thread-3306-1-1.html
作者:Jack Jiang (點擊作者姓名進入Github)
出處:http://www.52im.net/space-uid-1.html
交流:歡迎加入即時通訊開發(fā)交流群 215891622
討論:http://www.52im.net/
Jack Jiang同時是【原創(chuàng)Java
Swing外觀工程BeautyEye】和【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
本博文
歡迎轉載,轉載請注明出處(也可前往 我的52im.net 找到我)。