圖解|工作6年多,我還是沒有搞懂什么是協(xié)程的道與術
時間:2021-11-09 13:51:16
手機看文章
掃描二維碼
隨時隨地手機看文章
[導讀]前言大家好,我的朋友們!大白干了6年多后端,寫過C/C、Python、Go,每次說到協(xié)程的時候,腦海里就只能浮現(xiàn)一些關鍵字yeild、async、go等等。但是對于協(xié)程這個知識點,我理解的一直比較模糊,于是決定搞清楚。全文閱讀預計耗時10分鐘,少刷幾個小視頻的時間,多學點知識,想...
前言
大家好,我的朋友們!
協(xié)程概念的誕生
先拋一個粗淺的結論:協(xié)程從廣義來說是一種設計理念,我們常說的只是具體的實現(xiàn)。
上古神器COBOL
協(xié)程概念的出現(xiàn)比線程更早,甚至可以追溯到20世紀50年代,提協(xié)程就必須要說到一門生命力極強的最早的高級編程語言COBOL。
COBOL語言,是一種面向過程的高級程序設計語言,主要用于數(shù)據(jù)處理,是國際上應用最廣泛的一種高級語言。COBOL是英文Common Business-Oriented Language的縮寫,原意是面向商業(yè)的通用語言。
截止到今年在全球范圍內大約有1w臺大型機中有3.8w 遺留系統(tǒng)中約2000億行代碼是由COBOL寫的,占比高達65%,同時在美國很多政府和企業(yè)機構都是基于COBOL打造的,影響力巨大。時間拉回1958年,美國計算機科學家梅爾文·康威(Melvin Conway)就開始鉆研基于磁帶存儲的COBOL的編譯器優(yōu)化問題,這在當時是個非常熱門的話題,不少青年才俊都撲進去了,包括圖靈獎得主唐納德·爾文·克努斯教授(Donald Ervin Knuth)也寫了一個優(yōu)化后的編譯器。
梅爾文·康威(Melvin Conway)也是一位超級大佬,著名的康威定律提出者。
唐納德·爾文·克努斯是算法和程序設計技術的先驅者,1974年的圖靈獎得主,計算機排版系統(tǒng)TeX和字型設計系統(tǒng)METAFONT的發(fā)明者,他因這些成就和大量創(chuàng)造性的影響深遠的著作而譽滿全球,《計算機程序設計的藝術》被《美國科學家》雜志列為20世紀最重要的12本物理科學類專著之一。那究竟是什么問題讓這群天才們投入這么大的精力呢?快來看看!
COBOL編譯器的技術難題
我們都是知道高級編程語言需要借助編譯器來生成二進制可執(zhí)行文件,編譯器的基本步驟包括:讀取字符流、詞法分析、語法分析、語義分析、代碼生成器、代碼優(yōu)化器等。
當時的COBOL程序被寫在一個磁帶上,而磁帶不支持隨機讀寫,只能順序讀,而當時的內存又不可能把整個磁帶的內容都裝進去,所以一次讀取沒編譯完就要再從頭讀。于是,我腦補了COBOL編譯器和磁帶之間可能的兩種multi-pass形式的交互情況:
-
可能情況一
對于COBOL的編譯器來說,要完成詞法分析、語法分析就要從磁帶上讀取程序的源代碼,在之前的編譯器中詞法分析和語法分析是相互獨立的,這就意味著:
- 詞法分析時需要將磁帶從頭到尾過一遍
- 語法分析時需要將磁帶從頭到尾過一遍
-
可能情況二
聽過磁帶的朋友們一定知道磁帶的兩個基本操作:倒帶和快進。
在完成編譯器的詞法分析和語法分析兩件事情時,需要磁帶反復的倒帶和快進去尋找兩類分析所需的部分,類似于磁盤的尋道,磁頭需要反復移動橫跳,并且當時的磁帶不一定支持隨機讀寫。
協(xié)同式解決方案
在梅爾文·康威的編譯器設計中將詞法分析和語法分析合作運行,而不再像其他編譯器那樣相互獨立,兩個模塊交織運行,編譯器的控制流在詞法分析和語法分析之間來回切換:
- 當詞法分析模塊基于詞素產生足夠多的詞法單元Token時就控制流轉給語法分析
- 當語法分析模塊處理完所有的詞法單元Token時將控制流轉給詞法分析模塊
- 詞法分析和語法分析各自維護自身的運行狀態(tài),并且具備主動讓出和恢復的能力
梅爾文·康威構建的這種協(xié)同工作機制,需要參與者讓出(yield)控制流時,記住自身狀態(tài),以便在控制流返回時能從上次讓出的位置恢復(resume)執(zhí)行。簡言之,協(xié)程的全部精神就在于控制流的主動讓出和恢復。
這種協(xié)作式的任務流和計算機中斷非常像,在當時條件的限制下,由梅爾文·康威提出的這種讓出/恢復模式的協(xié)作程序被認為是最早的協(xié)程概念,并且基于這種思想可以打造新的COBOL編譯器。
在1963年,梅爾文·康威也發(fā)表了一篇論文來說明自己的這種思想,雖然半個多世紀過去了,有幸我還是找到了這篇論文:
https://melconway.com/Home/pdf/compiler.pdf
說實話這paper真是有點難,時間過于久遠,很難有共鳴,最后我放棄了,要不然我或許能搞明白之前編譯器的具體問題了。
懷才不遇的協(xié)程
雖然協(xié)程概念出現(xiàn)的時間比線程還要早,但是協(xié)程一直都沒有正是登上舞臺,真是有點懷才不遇的趕腳。
我們上學的時候,老師就講過一些軟件設計思想,其中主流語言崇尚自頂向下top-down的編程思想:
對要完成的任務進行分解,先對最高層次中的問題進行定義、設計、編程和測試,而將其中未解決的問題作為一個子任務放到下一層次中去解決。
這樣逐層、逐個地進行定義、設計、編程和測試,直到所有層次上的問題均由實用程序來解決,就能設計出具有層次結構的程序。
C語言就是典型的top-down思想的代表,在main函數(shù)作為入口,各個模塊依次形成層次化的調用關系,同時各個模塊還有下級的子模塊,同樣有層次調用關系。
但是協(xié)程這種相互協(xié)作調度的思想和top-down是不合的,在協(xié)程中各個模塊之間存在很大的耦合關系,并不符合高內聚低耦合的編程思想,相比之下top-down使程序結構清晰、層次調度明確,代碼可讀性和維護性都很不錯。
與線程相比,協(xié)作式任務系統(tǒng)讓調用者自己來決定什么時候讓出,比操作系統(tǒng)的搶占式調度所需要的時間代價要小很多,后者為了能恢復現(xiàn)場會在切換線程時保存相當多的狀態(tài),并且會非常頻繁地進行切換,資源消耗更大。
綜合來說,協(xié)程完全是用戶態(tài)的行為,由程序員自己決定什么時候讓出控制權,保存現(xiàn)場和切換恢復使用的資源也非常少,同時對提高處理器效率來說也是完全符合的。
那么不禁要問:協(xié)程看著不錯,為啥沒成為主流呢?
-
協(xié)程的思想和當時的主流不符合
-
搶占式的線程可以解決大部分的問題,讓使用者感受的痛點不足
換句話說:協(xié)程能干的線程干得也不錯,線程干的不好的地方,使用者暫時也不太需要,所以協(xié)程就這樣懷才不遇了。
其實,協(xié)程雖然在x86架構上沒有折騰出大風浪,由于搶占式任務系統(tǒng)依賴于CPU硬件的支持,對硬件要求比較高,對于一些嵌入式設備來說,協(xié)同調度再合適不過了,所以協(xié)程在另外一個領域也施展了拳腳。
協(xié)程的雄起
我們對于CPU的壓榨從未停止。
對于CPU來說,任務分為兩大類:計算密集型和IO密集型。
計算密集型已經可以最大程度發(fā)揮CPU的作用,但是IO密集型一直是提高CPU利用率的難點。
IO密集型任務之痛
對于IO密集型任務,在搶占式調度中也有對應的解決方案:異步 回調。
也就是遇到IO阻塞,比如下載圖片時會立即返回,等待下載完成將結果進行回調處理,交付給發(fā)起者。
就像你常去早餐店,油條還沒好,你和老板很熟悉就先交了錢去座位玩手機了,等你的油條好了,服務員就端過去了,這就是典型的異步 回調。
雖然異步 回調在現(xiàn)實生活中看著也很簡單,但是在程序設計上卻很讓人頭痛,在某些場景下會讓整個程序的可讀性非常差,而且也不好寫,相反同步IO雖然效率低,但是很好寫,
還是以為異步圖片下載為例,圖片服務中臺提供了異步接口,發(fā)起者請求之后立即返回,圖片服務此時給了發(fā)起者一個唯一標識ID,等圖片服務完成下載后把結果放到一個消息隊列,此時需要發(fā)起者不斷消費這個MQ才能拿到下載結果。
整個過程相比同步IO來說,原來整體的邏輯被拆分為好幾個部分,各個子部分有狀態(tài)的遷移,對大部分程序員來說維護狀態(tài)簡直就是噩夢,日后必然是bug的高發(fā)地。
用戶態(tài)協(xié)同調度
隨著網絡技術的發(fā)展和高并發(fā)要求,對于搶占式調度對IO型任務處理的低效逐漸受到重視,終于協(xié)程的機會來了。
協(xié)程將IO的處理權交給了程序員,遇到IO被阻塞時就交出控制權給其他協(xié)程,等其他協(xié)程處理完再把控制權交回來。
通過yield方式轉移執(zhí)行權的多個協(xié)程之間并非調用者和被調用者的關系,而是彼此平等、對稱、合作的關系。
協(xié)程一直沒有占上風的原因,除了設計思想的矛盾,還有一些其他原因,畢竟協(xié)程也不是銀彈,來看看協(xié)程有什么問題:
-
協(xié)程無法利用多核,需要配合進程來使用才可以在多CPU上發(fā)揮作用
-
線程的回調機制仍然有巨大生命力,協(xié)程無法全部替代
-
控制權需要轉移可能造成某些協(xié)程的饑餓,搶占式更加公平
-
協(xié)程的控制權由用戶態(tài)決定可能轉移給某些惡意的代碼,搶占式由操作系統(tǒng)來調度更加安全
綜上來說,協(xié)程和線程并非矛盾,協(xié)程的威力在于IO的處理,恰好這部分是線程的軟肋,由對立轉換為合作才能開辟新局面。
擁抱協(xié)程的編程語言
網絡操作、文件操作、數(shù)據(jù)庫操作、消息隊列操作等重IO操作,是任何高級編程語言無法避開的問題,也是提高程序效率的關鍵。
像Java、C/C 、Python這些老牌語言也陸續(xù)開始借助于第三方包來支持協(xié)程,來解決自身語言的不足。
像Golang這種新生選手,在語言層面原生支持了協(xié)程,可以說是徹底擁抱協(xié)程,這也造就了Go的高并發(fā)能力。
我們來分別看看它們是怎么實現(xiàn)協(xié)程的,以及實現(xiàn)協(xié)程的關鍵點是什么。
Python
Python對協(xié)程的支持也經歷了多個版本,從部分支持到完善支持一直在演進:
-
Python2.x對協(xié)程的支持比較有限,生成器yield實現(xiàn)了一部分但不完全
-
第三方庫gevent對協(xié)程的實現(xiàn)有比較好,但不是官方的
-
Python3.4加入了asyncio模塊
-
在Python3.5中又提供了async/await語法層面的支持
-
Python3.6中asyncio模塊更加完善和穩(wěn)
-
Python3.7開始async/await成為保留關鍵字
我們以最新的async/await來說明Python的協(xié)程是如何使用的:
import asyncio
from pathlib import Path
import logging
from urllib.request import urlopen, Request
import os
from time import time
import aiohttp
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
CODEFLEX_IMAGES_URLS = ['https://codeflex.co/wp-content/uploads/2021/01/pandas-dataframe-python-1024x512.png',
'https://codeflex.co/wp-content/uploads/2021/02/github-actions-deployment-to-eks-with-kustomize-1024x536.jpg',
'https://codeflex.co/wp-content/uploads/2021/02/boto3-s3-multipart-upload-1024x536.jpg',
'https://codeflex.co/wp-content/uploads/2018/02/kafka-cluster-architecture.jpg',
'https://codeflex.co/wp-content/uploads/2016/09/redis-cluster-topology.png']
async def download_image_async(session, dir, img_url):
download_path = dir / os.path.basename(img_url)
async with session.get(img_url) as response:
with download_path.open('wb') as f:
while True:
chunk = await response.content.read(512)
if not chunk:
break
f.write(chunk)
logger.info('Downloaded: ' img_url)
async def main():
images_dir = Path("codeflex_images")
Path("codeflex_images").mkdir(parents=False, exist_ok=True)
async with aiohttp.ClientSession() as session:
tasks = [(download_image_async(session, images_dir, img_url)) for img_url in CODEFLEX_IMAGES_URLS]
await asyncio.gather(*tasks, return_exceptions=True)
if __name__ == '__main__':
start = time()
event_loop = asyncio.get_event_loop()
try:
event_loop.run_until_complete(main())
finally:
event_loop.close()
logger.info('Download time: %s seconds', time() - start)
這段代碼展示了如何使用async/await來實現(xiàn)圖片的并發(fā)下載功能。
-
在普通的函數(shù)def前面加async關鍵字就變成異步/協(xié)程函數(shù),調用該函數(shù)并不會運行,而是返回一個協(xié)程對象,后續(xù)在event_loop中執(zhí)行
-
await表示等待task執(zhí)行完成,也就是yeild讓出控制權,同時asyncio使用事件循環(huán)event_loop來實現(xiàn)整個過程,await需要在async標注的函數(shù)中使用
-
event_loop事件循環(huán)充當管理者的角色,將控制權在幾個協(xié)程函數(shù)之間切換
C
在C 20引入協(xié)程框架,但是很不成熟,換句話說是給寫協(xié)程庫的大佬用的最底層的東西,用起來就很復雜門檻比較高。
C 作為高性能服務器開發(fā)語言的無冕之王,各大公司也做了很多嘗試來使用協(xié)程功能,比如boost.coroutine、微信的libco、libgo、云風用C實現(xiàn)的協(xié)程庫等。
說實話,C 協(xié)程相關的東西有點復雜,后面專門寫一下,在此不展開了。
Go
go中的協(xié)程被稱為goroutine,被認為是用戶態(tài)更輕量級的線程,協(xié)程對操作系統(tǒng)而言是透明的,也就是操作系統(tǒng)無法直接調度協(xié)程,因此必須有個中間層來接管goroutine。
goroutine仍然是基于線程來實現(xiàn)的,因為線程才是CPU調度的基本單位,在go語言內部維護了一組數(shù)據(jù)結構和N個線程,協(xié)程的代碼被放進隊列中來由線程來實現(xiàn)調度執(zhí)行,這就是著名的GMP模型。
-
G:Goroutine
每個Gotoutine對應一個G結構體,G存儲Goroutine的運行堆棧,狀態(tài),以及任務函數(shù),可重用函數(shù)實體G需要保存到P的隊列或者全局隊列才能被調度執(zhí)行。
-
M:machine
M是線程的抽象,代表真正執(zhí)行計算的資源,在綁定有效的P后,進入調度執(zhí)行循環(huán),M會從P的本地隊列來執(zhí)行,
-
P:Processor
P是一個抽象的概念,不是物理上的CPU而是表示邏輯處理器。當一個P有任務,需要創(chuàng)建或者喚醒一個系統(tǒng)線程M去處理它隊列中的任務。
P決定同時執(zhí)行的任務的數(shù)量,GOMAXPROCS限制系統(tǒng)線程執(zhí)行用戶層面的任務的數(shù)量。
對M來說,P提供了相關的執(zhí)行環(huán)境,入內存分配狀態(tài),任務隊列等。
GMP模型運行的基本過程:
-
首先創(chuàng)建一個G對象,然后G被保存在P的本地隊列或者全局隊列
-
這時P會喚醒一個M,M尋找一個空閑的P將G移動到它自己,然后M執(zhí)行一個調度循環(huán):調用G對象->執(zhí)行->清理線程->繼續(xù)尋找Goroutine。
-
在M的執(zhí)行過程中,上下文切換隨時發(fā)生。當切換發(fā)生,任務的執(zhí)行現(xiàn)場需要被保護,這樣在下一次調度執(zhí)行可以進行現(xiàn)場恢復。
-
M的棧保存在G對象,只有現(xiàn)場恢復需要的寄存器(SP,PC等),需要被保存到G對象。
總結
本文通過1960年對COBOL語言編譯器的one-pass問題的介紹,讓大家看到了協(xié)同式程序的最早背景以及主動讓出/恢復的重要理念。
緊接著介紹了主流的自頂向下的軟件設計思想和協(xié)程思想的矛盾所在,并且搶占式程序調度的蓬勃發(fā)展,以及存在的問題。
繼續(xù)介紹了關于IO密集型任務對于提升CPU效率的阻礙,搶占式調度對于IO密集型問題的異步 回調的解決方案,以及協(xié)程的處理,展示了協(xié)程在IO密集型任務上處理的重大優(yōu)勢。
最后說明了當前搶占式調度 協(xié)程IO密集型處理的方案,包括Python、C 和go的語言層面對于協(xié)程的支持和實現(xiàn)。
本文特別具體的內容并不多,旨在介紹協(xié)程思想及其優(yōu)勢所在,對于各個語言的協(xié)程實現(xiàn)細節(jié)并未展開。