圖解 - 協程的道與術
前言
大家好,我的朋友們!
大白乾了 6 年多後端,寫過 C/C++、Python、Go,每次說到協程的時候,腦海裏就只能浮現一些關鍵字 yeild、async、go 等等。
但是對於協程這個知識點,我理解的一直比較模糊,於是決定搞清楚。
全文閱讀預計耗時 10 分鐘,少刷幾個小視頻的時間,多學點知識,想想就很划算噻!
協程概念的誕生
先拋一個粗淺的結論:協程從廣義來說是一種設計理念,我們常說的只是具體的實現。
理解好思想,技術點就很簡單了,關於協程道與術的區別:
上古神器 COBOL
協程概念的出現比線程更早,甚至可以追溯到 20 世紀 50 年代,提協程就必須要說到一門生命力極強的最早的高級編程語言 COBOL。
最開始我以爲 COBOL 這門語言早就消失在歷史長河中,但是我錯了。
COBOL 語言,是一種面向過程的高級程序設計語言,主要用於數據處理,是國際上應用最廣泛的一種高級語言。COBOL 是英文 Common Business-Oriented Language 的縮寫,原意是面向商業的通用語言。
截止到今年在全球範圍內大約有 1w 臺大型機中有 3.8w + 遺留系統中約 2000 億行代碼是由 COBOL 寫的,佔比高達 65%,同時在美國很多政府和企業機構都是基於 COBOL 打造的,影響力巨大。
時間拉回 1958 年,美國計算機科學家梅爾文 · 康威 (Melvin Conway) 就開始鑽研基於磁帶存儲的 COBOL 的編譯器優化問題,這在當時是個非常熱門的話題,不少青年才俊都撲進去了,包括圖靈獎得主唐納德 · 爾文 · 克努斯教授 (Donald Ervin Knuth) 也寫了一個優化後的編譯器。
看看這兩位的簡介,我沉默了:
梅爾文 · 康威 (Melvin Conway) 也是一位超級大佬,著名的康威定律提出者。
唐納德 · 爾文 · 克努斯是算法和程序設計技術的先驅者,1974 年的圖靈獎得主,計算機排版系統 TeX 和字型設計系統 METAFONT 的發明者,他因這些成就和大量創造性的影響深遠的著作而譽滿全球,《計算機程序設計的藝術》被《美國科學家》雜誌列爲 20 世紀最重要的 12 本物理科學類專著之一。
COBOL 編譯器的技術難題
我們都是知道高級編程語言需要藉助編譯器來生成二進制可執行文件,編譯器的基本步驟包括:讀取字符流、詞法分析、語法分析、語義分析、代碼生成器、代碼優化器等。
這種管道式的流程,上一步的輸出作爲下一步的輸入,將中間結果存儲在內存即可,這在現代計算機上毫無壓力,但是受限於軟硬件水平,在幾十年前的 COBOL 語言卻是很難的。
當時的 COBOL 程序被寫在一個磁帶上,而磁帶不支持隨機讀寫,只能順序讀,而當時的內存又不可能把整個磁帶的內容都裝進去,所以一次讀取沒編譯完就要再從頭讀。
於是,我腦補了 COBOL 編譯器和磁帶之間可能的兩種 multi-pass 形式的交互情況:
-
可能情況一
對於 COBOL 的編譯器來說,要完成詞法分析、語法分析就要從磁帶上讀取程序的源代碼,在之前的編譯器中詞法分析和語法分析是相互獨立的,這就意味着: -
詞法分析時需要將磁帶從頭到尾過一遍
-
語法分析時需要將磁帶從頭到尾過一遍
-
可能情況二
聽過磁帶的朋友們一定知道磁帶的兩個基本操作:倒帶和快進。
在完成編譯器的詞法分析和語法分析兩件事情時,需要磁帶反覆的倒帶和快進去尋找兩類分析所需的部分,類似於磁盤的尋道,磁頭需要反覆移動橫跳,並且當時的磁帶不一定支持隨機讀寫。
從一些資料可以看到,COBOL 當時編譯器各個環節相互獨立的,這種軟硬件的綜合限制導致無法實現 one-pass 編譯。
協同式解決方案
在梅爾文 · 康威的編譯器設計中將詞法分析和語法分析合作運行,而不再像其他編譯器那樣相互獨立,兩個模塊交織運行,編譯器的控制流在詞法分析和語法分析之間來回切換:
-
當詞法分析模塊基於詞素產生足夠多的詞法單元 Token 時就控制流轉給語法分析
-
當語法分析模塊處理完所有的詞法單元 Token 時將控制流轉給詞法分析模塊
-
詞法分析和語法分析各自維護自身的運行狀態,並且具備主動讓出和恢復的能力
可以看到這個方案的核心思想在於:
梅爾文 · 康威構建的這種協同工作機制,需要參與者讓出(yield)控制流時,記住自身狀態,以便在控制流返回時能從上次讓出的位置恢復(resume)執行。簡言之,
協程的全部精神就在於控制流的主動讓出和恢復
。
在 1963 年,梅爾文 · 康威也發表了一篇論文來說明自己的這種思想,雖然半個多世紀過去了,有幸我還是找到了這篇論文:
https://melconway.com/Home/pdf/compiler.pdf
懷才不遇的協程
雖然協程概念出現的時間比線程還要早,但是協程一直都沒有正是登上舞臺,真是有點懷才不遇的趕腳。
我們上學的時候,老師就講過一些軟件設計思想,其中主流語言崇尚自頂向下 top-down 的編程思想:
對要完成的任務進行分解,先對最高層次中的問題進行定義、設計、編程和測試,而將其中未解決的問題作爲一個子任務放到下一層次中去解決。
這樣逐層、逐個地進行定義、設計、編程和測試,直到所有層次上的問題均由實用程序來解決,就能設計出具有層次結構的程序。
C 語言就是典型的 top-down 思想的代表,在 main 函數作爲入口,各個模塊依次形成層次化的調用關係,同時各個模塊還有下級的子模塊,同樣有層次調用關係。
但是協程這種相互協作調度的思想和 top-down 是不合的,在協程中各個模塊之間存在很大的耦合關係,並不符合高內聚低耦合的編程思想,相比之下 top-down 使程序結構清晰、層次調度明確,代碼可讀性和維護性都很不錯。
與線程相比,協作式任務系統讓調用者自己來決定什麼時候讓出,比操作系統的搶佔式調度所需要的時間代價要小很多,後者爲了能恢復現場會在切換線程時保存相當多的狀態,並且會非常頻繁地進行切換,資源消耗更大。
綜合來說,協程完全是用戶態的行爲,由程序員自己決定什麼時候讓出控制權,保存現場和切換恢復使用的資源也非常少,同時對提高處理器效率來說也是完全符合的。
那麼不禁要問:協程看着不錯,爲啥沒成爲主流呢?
-
協程的思想和當時的主流不符合
-
搶佔式的線程可以解決大部分的問題,讓使用者感受的痛點不足
換句話說:協程能幹的線程幹得也不錯,線程乾的不好的地方,使用者暫時也不太需要,所以協程就這樣懷才不遇了。
其實,協程雖然在 x86 架構上沒有折騰出大風浪,由於搶佔式任務系統依賴於 CPU 硬件的支持,對硬件要求比較高,對於一些嵌入式設備來說,協同調度再合適不過了,所以協程在另外一個領域也施展了拳腳。
協程的雄起
我們對於 CPU 的壓榨從未停止。
對於 CPU 來說,任務分爲兩大類:計算密集型和 IO 密集型。
計算密集型已經可以最大程度發揮 CPU 的作用,但是 IO 密集型一直是提高 CPU 利用率的難點。
IO 密集型任務之痛
對於 IO 密集型任務,在搶佔式調度中也有對應的解決方案:異步 + 回調。
也就是遇到 IO 阻塞,比如下載圖片時會立即返回,等待下載完成將結果進行回調處理,交付給發起者。
就像你常去早餐店,油條還沒好,你和老闆很熟悉就先交了錢去座位玩手機了,等你的油條好了,服務員就端過去了,這就是典型的異步 + 回調。
雖然異步 + 回調在現實生活中看着也很簡單,但是在程序設計上卻很讓人頭痛,在某些場景下會讓整個程序的可讀性非常差,而且也不好寫,相反同步 IO 雖然效率低,但是很好寫,
還是以爲異步圖片下載爲例,圖片服務中臺提供了異步接口,發起者請求之後立即返回,圖片服務此時給了發起者一個唯一標識 ID,等圖片服務完成下載後把結果放到一個消息隊列,此時需要發起者不斷消費這個 MQ 才能拿到下載結果。
整個過程相比同步 IO 來說,原來整體的邏輯被拆分爲好幾個部分,各個子部分有狀態的遷移,對大部分程序員來說維護狀態簡直就是噩夢,日後必然是 bug 的高發地。
用戶態協同調度
隨着網絡技術的發展和高併發要求,對於搶佔式調度對 IO 型任務處理的低效逐漸受到重視,終於協程的機會來了。
協程將 IO 的處理權交給了程序員,遇到 IO 被阻塞時就交出控制權給其他協程,等其他協程處理完再把控制權交回來。
通過 yield 方式轉移執行權的多個協程之間並非調用者和被調用者的關係,而是彼此平等、對稱、合作的關係。
協程一直沒有佔上風的原因,除了設計思想的矛盾,還有一些其他原因,畢竟協程也不是銀彈,來看看協程有什麼問題:
-
協程無法利用多核,需要配合進程來使用纔可以在多 CPU 上發揮作用
-
線程的回調機制仍然有巨大生命力,協程無法全部替代
-
控制權需要轉移可能造成某些協程的飢餓,搶佔式更加公平
-
協程的控制權由用戶態決定可能轉移給某些惡意的代碼,搶佔式由操作系統來調度更加安全
綜上來說,協程和線程並非矛盾,協程的威力在於 IO 的處理,恰好這部分是線程的軟肋,由對立轉換爲合作才能開闢新局面。
擁抱協程的編程語言
網絡操作、文件操作、數據庫操作、消息隊列操作等重 IO 操作,是任何高級編程語言無法避開的問題,也是提高程序效率的關鍵。
像 Java、C/C++、Python 這些老牌語言也陸續開始藉助於第三方包來支持協程,來解決自身語言的不足。
像 Golang 這種新生選手,在語言層面原生支持了協程,可以說是徹底擁抱協程,這也造就了 Go 的高併發能力。
我們來分別看看它們是怎麼實現協程的,以及實現協程的關鍵點是什麼。
Python
Python 對協程的支持也經歷了多個版本,從部分支持到完善支持一直在演進:
-
Python2.x 對協程的支持比較有限,生成器 yield 實現了一部分但不完全
-
第三方庫 gevent 對協程的實現有比較好,但不是官方的
-
Python3.4 加入了 asyncio 模塊
-
在 Python3.5 中又提供了 async/await 語法層面的支持
-
Python3.6 中 asyncio 模塊更加完善和穩
-
Python3.7 開始 async/await 成爲保留關鍵字
我們以最新的 async/await 來說明 Python 的協程是如何使用的:
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 來實現圖片的併發下載功能。
-
在普通的函數 def 前面加 async 關鍵字就變成異步 / 協程函數,調用該函數並不會運行,而是返回一個協程對象,後續在 event_loop 中執行
-
await 表示等待 task 執行完成,也就是 yeild 讓出控制權,同時 asyncio 使用事件循環 event_loop 來實現整個過程,await 需要在 async 標註的函數中使用
-
event_loop 事件循環充當管理者的角色,將控制權在幾個協程函數之間切換
C++
在 C++20 引入協程框架,但是很不成熟,換句話說是給寫協程庫的大佬用的最底層的東西,用起來就很複雜門檻比較高。
C++ 作爲高性能服務器開發語言的無冕之王,各大公司也做了很多嘗試來使用協程功能,比如 boost.coroutine、微信的 libco、libgo、雲風用 C 實現的協程庫等。
說實話,C++ 協程相關的東西有點複雜,後面專門寫一下,在此不展開了。
Go
go 中的協程被稱爲 goroutine,被認爲是用戶態更輕量級的線程,協程對操作系統而言是透明的,也就是操作系統無法直接調度協程,因此必須有個中間層來接管 goroutine。
goroutine 仍然是基於線程來實現的,因爲線程纔是 CPU 調度的基本單位,在 go 語言內部維護了一組數據結構和 N 個線程,協程的代碼被放進隊列中來由線程來實現調度執行,這就是著名的 GMP 模型。
- G:Goroutine
每個 Gotoutine 對應一個 G 結構體,G 存儲 Goroutine 的運行堆棧,狀態,以及任務函數,可重用函數實體 G 需要保存到 P 的隊列或者全局隊列才能被調度執行。
- M:machine
M 是線程的抽象,代表真正執行計算的資源,在綁定有效的 P 後,進入調度執行循環,M 會從 P 的本地隊列來執行,
- P:Processor
P 是一個抽象的概念,不是物理上的 CPU 而是表示邏輯處理器。當一個 P 有任務,需要創建或者喚醒一個系統線程 M 去處理它隊列中的任務。
P 決定同時執行的任務的數量,GOMAXPROCS 限制系統線程執行用戶層面的任務的數量。
對 M 來說,P 提供了相關的執行環境,入內存分配狀態,任務隊列等。
GMP 模型運行的基本過程:
-
首先創建一個 G 對象,然後 G 被保存在 P 的本地隊列或者全局隊列
-
這時 P 會喚醒一個 M,M 尋找一個空閒的 P 將 G 移動到它自己,然後 M 執行一個調度循環:調用 G 對象 -> 執行 -> 清理線程 -> 繼續尋找 Goroutine。
-
在 M 的執行過程中,上下文切換隨時發生。當切換髮生,任務的執行現場需要被保護,這樣在下一次調度執行可以進行現場恢復。
-
M 的棧保存在 G 對象,只有現場恢復需要的寄存器 (SP,PC 等),需要被保存到 G 對象。
總結
本文通過 1960 年對 COBOL 語言編譯器的 one-pass 問題的介紹,讓大家看到了協同式程序的最早背景以及主動讓出 / 恢復的重要理念。
緊接着介紹了主流的自頂向下的軟件設計思想和協程思想的矛盾所在,並且搶佔式程序調度的蓬勃發展,以及存在的問題。
繼續介紹了關於 IO 密集型任務對於提升 CPU 效率的阻礙,搶佔式調度對於 IO 密集型問題的異步 + 回調的解決方案,以及協程的處理,展示了協程在 IO 密集型任務上處理的重大優勢。
最後說明了當前搶佔式調度 + 協程 IO 密集型處理的方案,包括 Python、C++ 和 go 的語言層面對於協程的支持和實現。
本文特別具體的內容並不多,旨在介紹協程思想及其優勢所在,對於各個語言的協程實現細節並未展開。
最後依然是感謝大家的耐心閱讀,我們下期見!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/B6HUKvV-dW7jMEszUp9MQA