摘要:事件循環是異步編程的底層基石。對事件集合進行輪詢,調用回調函數等一輪事件循環結束,循環往復。協程直接利用代碼的執行位置來表示狀態,而回調則是維護了一堆數據結構來處理狀態。時代的協程技術主要是,另一個比較小眾。
引言 1.1. 存儲器山Coding Crush Python開發工程師
主要負責豈安科技業務風險情報系統redq。
存儲器山是 Randal Bryant 在《深入理解計算機系統》一書中提出的概念。
基于成本、效率的考量,計算機存儲器被設計成多級金字塔結構,塔頂是速度最快、成本最高的 CPU 內部的寄存器(一般幾 KB)與高速緩存,塔底是成本最低、速度最慢的廣域網云存儲(如百度云免費 2T )
存儲器山的指導意義在于揭示了良好設計程序的必要條件是需要有優秀的局部性:
時間局部性:相同時間內,訪問同一地址次數越多,則時間局部性表現越佳;
空間局部性:下一次訪問的存儲器地址與上一次的訪問過的存儲器地址位置鄰近;
我們將一個普通的 2.6GHz 的 CPU 的延遲時間放大到人能體驗的尺度上(數據來自微信公眾號 駒說碼事):在存儲器頂層執行單條寄存器指令的時間為1秒鐘;從第五層磁盤讀 1MB 數據卻需要一年半;ping 不同的城域網主機,網絡包需要走 12.5 年。
如果程序發送了一個 HTTP 包后便阻塞在同步等待響應的過程上,計算機不得不傻等 12 年后的那個響應再處理別的事情,低下的硬件利用率必然導致低下的程序效率。
從以上數據可以看出,內存數據讀寫、磁盤尋道讀寫、網卡讀寫等操作都是 I/O 操作,同步程序的瓶頸在于漫長的 I/O 等待,想要提高程序效率必須減少 I/O 等待時間,從提高程序的局部性著手。
同步編程的改進方式有多進程、多線程,但對于 c10k 問題都不是良好的解決方案,多進程的方式存在操作系統可調度進程數量上限較低,進程間上下文切換時間過長,進程間通信較為復雜。
而 Python 的多線程方式,由于存在眾所周知的 GIL 鎖,性能提升并不穩定,僅能滿足成百上千規模的 I/O 密集型任務,多線程還有一個缺點是由操作系統進行搶占式調度存在競態條件,可能需要引入了鎖與隊列等保障原子性操作的工具。
1.4. 異步編程說到異步非阻塞調用,目前的代名詞都是 epoll 與 kqueue,select/poll 由于效率問題基本已被取代。
epoll 是04年 Linux2.6 引入內核的一種 I/O 事件通知機制,它的作用是將大量的文件描述符托管給內核,內核將最底層的 I/O 狀態變化封裝成讀寫事件,這樣就避免了由程序員去主動輪詢狀態變化的重復工作,程序員將回調函數注冊到 epoll 的狀態上,當檢測到相對應文件描述符產生狀態變化時,就進行函數回調。
事件循環是異步編程的底層基石。
上圖是簡單的EventLoop的實現原理,
用戶創建了兩個socket連接,將系統返回的兩個文件描述符fd3、fd4通過系統調用在epoll上注冊讀寫事件;
當網卡解析到一個tcp包時,內核根據五元組找到相應到文件描述符,自動觸發其對應的就緒事件狀態,并將該文件描述符添加到就緒鏈表中。
程序調用epoll.poll(),返回可讀寫的事件集合。
對事件集合進行輪詢,調用回調函數等
一輪事件循環結束,循環往復。
epoll 并非銀彈,從圖中可以觀察到,如果用戶關注的層次很低,直接操作epoll去構造維護事件的循環,從底層到高層的業務邏輯需要層層回調,造成callback hell,并且可讀性較差。所以,這個繁瑣的注冊回調與回調的過程得以封裝,并抽象成EventLoop。EventLoop屏蔽了進行epoll系統調用的具體操作。對于用戶來說,將不同的I/O狀態考量為事件的觸發,只需關注更高層次下不同事件的回調行為。諸如libev, libevent之類的使用C編寫的高性能異步事件庫已經取代這部分瑣碎的工作。
在Python框架里一般會見到的這幾種事件循環:
libevent/libev: Gevent(greenlet+前期libevent,后期libev)使用的網絡庫,廣泛應用;
tornado: tornado框架自己實現的IOLOOP;
picoev: meinheld(greenlet+picoev)使用的網絡庫,小巧輕量,相較于libevent在數據結構和事件檢測模型上做了改進,所以速度更快。但從github看起來已經年久失修,用的人不多。
uvloop: Python3時代的新起之秀。Guido操刀打造了asyncio庫,asyncio可以配置可插拔的event loop,但需要滿足相關的API要求,uvloop繼承自libuv,將一些低層的結構體和函數用Python對象包裝。目前Sanic框架基于這個庫
EventLoop簡化了不同平臺上的事件處理,但是處理事件觸發時的回調依然很麻煩,響應式的異步程序編寫對程序員的心智是一項不小的麻煩。
因此,協程被引入來替代回調以簡化問題。協程模型主要在在以下方面優于回調模型:
以近似同步代碼的編程模式取代異步回調模式,真實的業務邏輯往往是同步線性推演的,因此,這種同步式的代碼寫起來更加容易。底層的回調依然是callback hell,但這部分臟活累活已經轉交給編譯器與解釋器去完成,程序員不易出錯。
異常處理更加健全,可以復用語言內的錯誤處理機制,回調方式。而傳統異步回調模式需要自己判定成功失敗,錯誤處理行為復雜化。
上下文管理簡單化,回調方式代碼上下文管理嚴重依賴閉包,不同的回調函數之間相互耦合,割裂了相同的上下文處理邏輯。協程直接利用代碼的執行位置來表示狀態,而回調則是維護了一堆數據結構來處理狀態。
方便處理并發行為,協程的開銷成本很低,每一個協程僅有一個輕巧的用戶態棧空間。
04年,event-driven 的 nginx 誕生并快速傳播,06年以后從俄語區國家擴散到全球。同時期,EventLoop 變得具象化與多元化,相繼在不同的編程語言實現。
近十年以來,后端領域內古老的子例程與事件循環得到結合,協程(協作式子例程)快速發展,并也革新與誕生了一些語言,比如 golang 的 goroutine,luajit 的 coroutine,Python 的 gevent,erlang 的 process,scala 的 actor 等。
就不同語言中面向并發設計的協程實現而言,Scala 與 Erlang 的 Actor 模型、Golang 中的 goroutine 都較 Python 更為成熟,不同的協程使用通信來共享內存,優化了競態、沖突、不一致性等問題。然而,根本的理念沒有區別,都是在用戶態通過事件循環驅動實現調度。
由于歷史包袱較少,后端語言上的各種異步技術除 Python Twisted 外基本也沒有 callback hell 的存在。其他的方案都已經將 callback hell 的過程進行封裝,交給庫代碼、編譯器、解釋器去解決。
有了協程,有了事件循環庫,傳統的 C10K 問題已經不是挑戰并已經上升到了 C1M 問題。
2. GeventPython2 時代的協程技術主要是 Gevent,另一個 meinheld 比較小眾。Gevent 有褒有貶,負面觀點認為它的實現不夠 Pythonic,脫離解釋器獨自實現了黑盒的調度器,monkey patch 讓不了解的用戶產生混淆。正面觀點認為正是這樣才得以屏蔽所有的細節,簡化使用難度。
Gevent 基于 Greenlet 與 Libev,greenlet 是一種微線程或者協程,在調度粒度上比 PY3 的協程更大。greenlet 存在于線程容器中,其行為類似線程,有自己獨立的棧空間,不同的 greenlet 的切換類似操作系統層的線程切換。
greenlet.hub 也是一個繼承于原生 greenlet 的對象,也是其他 greenlet 的父節點,它主要負責任務調度。當一個 greenlet 協程執行完部分例程后到達斷點,通過 greenlet.switch() 向上轉交控制權給 hub 對象,hub 執行上下文切換的操作:從寄存器、高速緩存中備份當前 greenlet 的棧內容到內存中,并將原來備份的另一個 greenlet 棧數據恢復到寄存器中。
hub 對象內封裝了一個 loop 對象,loop 負責封裝 libev 的相關操作并向上提供接口,所有 greenlet 在通過 loop 驅動的 hub 下被調度。
3. 從yield到async/await 3.1. 生成器的進化在 Python2.2 中,第一次引入了生成器,生成器實現了一種惰性、多次取值的方法,此時還是通過 next 構造生成迭代鏈或 next 進行多次取值。
直到在 Python2.5 中,yield 關鍵字被加入到語法中,這時,生成器有了記憶功能,下一次從生成器中取值可以恢復到生成器上次 yield 執行的位置。
之前的生成器都是關于如何構造迭代器,在 Python2.5 中生成器還加入了 send 方法,與 yield 搭配使用。
我們發現,此時,生成器不僅僅可以 yield 暫停到一個狀態,還可以往它停止的位置通過 send 方法傳入一個值改變其狀態。
舉一個簡單的示例,主要熟悉 yield 與 send 與外界的交互流程:
def jump_range(up_to): step = 0 while step < up_to: jump = yield step print("jump", jump) if jump is None: jump = 1 step += jump print("step", step) if __name__ == "__main__": iterator = jump_range(10) print(next(iterator)) # 0 print(iterator.send(4)) # jump4; step4; 4 print(next(iterator)) # jump None; step5; 5 print(iterator.send(-1)) # jump -1; step4; 4
在 Python3.3 中,生成器又引入了 yield from 關鍵字,yield from 實現了在生成器內調用另外生成器的功能,可以輕易的重構生成器,比如將多個生成器連接在一起執行。
def gen_3(): yield 3 def gen_234(): yield 2 yield from gen_3() yield 4 def main(): yield 1 yield from gen_234() yield 5 for element in main(): print(element) # 1,2,3,4,5
從圖中可以看出 yield from 的特點。使用 itertools.chain 可以以生成器為最小組合子進行鏈式組合,使用 itertools.cycle 可以對多帶帶一個生成器首尾相接,構造一個循環鏈。
使用 yield from 時可以在生成器中從其他生成器 yield 一個值,這樣不同的生成器之間可以互相通信,這樣構造出的生成鏈更加復雜,但生成鏈最小組合子的粒度卻精細至單個 yield 對象。
有了Python3.3中引入的yield from 這項工具,Python3.4 中新加入了asyncio庫,并提供了一個默認的event loop。Python3.4有了足夠的基礎工具進行異步并發編程。
并發編程同時執行多條獨立的邏輯流,每個協程都有獨立的棧空間,即使它們是都工作在同個線程中的。以下是一個示例代碼:
import asyncio import aiohttp @asyncio.coroutine def fetch_page(session, url): response = yield from session.get(url) if response.status == 200: text = yield from response.text() print(text) loop = asyncio.get_event_loop() session = aiohttp.ClientSession(loop=loop) tasks = [ asyncio.ensure_future( fetch_page(session, "http://bigsec.com/products/redq/")), asyncio.ensure_future( fetch_page(session, "http://bigsec.com/products/warden/")) ] loop.run_until_complete(asyncio.wait(tasks)) session.close() loop.close()
在 Python3.4 中,asyncio.coroutine 裝飾器是用來將函數轉換為協程的語法,這也是 Python 第一次提供的生成器協程 。只有通過該裝飾器,生成器才能實現協程接口。使用協程時,你需要使用 yield from 關鍵字將一個 asyncio.Future 對象向下傳遞給事件循環,當這個 Future 對象還未就緒時,該協程就暫時掛起以處理其他任務。一旦 Future 對象完成,事件循環將會偵測到狀態變化,會將 Future 對象的結果通過 send 方法方法返回給生成器協程,然后生成器恢復工作。
在以上的示例代碼中,首先實例化一個 eventloop,并將其傳遞給 aiohttp.ClientSession 使用,這樣 session 就不用創建自己的事件循環。
此處顯式的創建了兩個任務,只有當 fetch_page 取得 api.bigsec.com 兩個 url 的數據并打印完成后,所有任務才能結束,然后關閉 session 與 loop,釋放連接資源。
當代碼運行到 response = yield from session.get(url)處,fetch_page 協程被掛起,隱式的將一個 Future 對象傳遞給事件循環,只有當 session.get() 完成后,該任務才算完成。
session.get() 內部也是協程,其數據傳輸位于在存儲器山最慢的網絡層。當 session.get 完成時,取得了一個 response 對象,再傳遞給原來的 fetch_page 生成器協程,恢復其工作狀態。
為了提高速度,此處 get 方法將取得 http header 與 body 分解成兩次任務,減少一次性傳輸的數據量。response.text() 即是異步請求 http body。
使用 dis 庫查看 fetch_page 協程的字節碼,GET_YIELD_FROM_ITER 是 yield from 的操作碼:
In [4]: import dis In [5]: dis.dis(fetch_page) 0 LOAD_FAST 0 (session) 2 LOAD_ATTR 0 (get) 4 LOAD_FAST 1 (url) 6 CALL_FUNCTION 1 8 GET_YIELD_FROM_ITER 10 LOAD_CONST 0 (None) 12 YIELD_FROM 14 STORE_FAST 2 (response) 16 LOAD_FAST 2 (response) 18 LOAD_ATTR 1 (status) 20 LOAD_CONST 1 (200) 22 COMPARE_OP 2 (==) 24 POP_JUMP_IF_FALSE 48 26 LOAD_FAST 2 (response) 28 LOAD_ATTR 2 (text) 30 CALL_FUNCTION 0 32 GET_YIELD_FROM_ITER 34 LOAD_CONST 0 (None) 36 YIELD_FROM 38 STORE_FAST 3 (text) 40 LOAD_GLOBAL 3 (print) 42 LOAD_FAST 3 (text) 44 CALL_FUNCTION 1 46 POP_TOP >> 48 LOAD_CONST 0 (None) 50 RETURN_VALUE3.3. async與 await關鍵字
Python3.5 中引入了這兩個關鍵字用以取代 asyncio.coroutine 與 yield from,從語義上定義了原生協程關鍵字,避免了使用者對生成器協程與生成器的混淆。這個階段(3.0-3.4)使用 Python 的人不多,因此歷史包袱不重,可以進行一些較大的革新。
await 的行為類似 yield from,但是它們異步等待的對象并不一致,yield from 等待的是一個生成器對象,而await接收的是定義了__await__方法的 awaitable 對象。
在 Python 中,協程也是 awaitable 對象,collections.abc.Coroutine 對象繼承自 collections.abc.Awaitable。
因此,將上一小節的示例代碼改寫成:
import asyncio import aiohttp async def fetch_page(session, url): response = await session.get(url) if response.status == 200: text = await response.text() print(text) loop = asyncio.get_event_loop() session = aiohttp.ClientSession(loop=loop) tasks = [ asyncio.ensure_future( fetch_page(session, "http://bigsec.com/products/redq/")), asyncio.ensure_future( fetch_page(session, "http://bigsec.com/products/warden/")) ] loop.run_until_complete(asyncio.wait(tasks)) session.close() loop.close()
從 Python 語言發展的角度來說,async/await 并非是多么偉大的改進,只是引進了其他語言中成熟的語義,協程的基石還是在于 eventloop 庫的發展,以及生成器的完善。從結構原理而言,asyncio 實質擔當的角色是一個異步框架,async/await 是為異步框架提供的 API,因為使用者目前并不能脫離 asyncio 或其他異步庫使用 async/await 編寫協程代碼。即使用戶可以避免顯式地實例化事件循環,比如支持 asyncio/await 語法的協程網絡庫 curio,但是脫離了 eventloop 如心臟般的驅動作用,async/await 關鍵字本身也毫無作用。
4. async/await的使用 4.1. Future不用回調方法編寫異步代碼后,為了獲取異步調用的結果,引入一個 Future 未來對象。Future 封裝了與 loop 的交互行為,add_done_callback 方法向 epoll 注冊回調函數,當 result 屬性得到返回值后,會運行之前注冊的回調函數,向上傳遞給 coroutine。但是,每一個角色各有自己的職責,用 Future 向生成器 send result 以恢復工作狀態并不合適,Future 對象本身的生存周期比較短,每一次注冊回調、產生事件、觸發回調過程后工作已經完成。所以這里又需要在生成器協程與 Future 對象中引入一個新的對象 Task,對生成器協程進行狀態管理。
4.2. TaskTask,顧名思義,是維護生成器協程狀態處理執行邏輯的的任務,Task 內的_step 方法負責生成器協程與 EventLoop 交互過程的狀態遷移:向協程 send 一個值,恢復其工作狀態,協程運行到斷點后,得到新的未來對象,再處理 future 與 loop 的回調注冊過程。
4.3. Loop事件循環的工作方式與用戶設想存在一些偏差,理所當然的認知應是每個線程都可以有一個獨立的 loop。但是在運行中,在主線程中才能通過 asyncio.get_event_loop() 創建一個新的 loop,而在其他線程時,使用 get_event_loop() 卻會拋錯,正確的做法應該是 asyncio.set_event_loop() 進行當前線程與 loop 的顯式綁定。由于 loop 的運作行為并不受 Python 代碼的控制,所以無法穩定的將協程拓展到多線程中運行。
協程在工作時,并不了解是哪個 loop 在對其調度,即使調用 asyncio.get_event_loop() 也不一定能獲取到真正運行的那個 loop。因此在各種庫代碼中,實例化對象時都必須顯式的傳遞當前的 loop 以進行綁定。
Python 里另一個 Future 對象是 concurrent.futures.Future,與 asyncio.Future 互不兼容,但容易產生混淆。concurrent.futures 是線程級的 Future 對象,當使用 concurrent.futures.Executor 進行多線程編程時用于在不同的 thread 之間傳遞結果。
4.4. 現階段asyncio生態發展的困難由于這兩個關鍵字在2014年發布的Python3.5中才被引入,發展歷史較短,在Python2與Python3割裂的大環境下,生態環境的建立并不完善;
對于使用者來說,希望的邏輯是引入一個庫然后調用并獲取結果,并不關心第三方庫的內部邏輯。然而使用協程編寫異步代碼時需要處理與事件循環的交互。對于異步庫來說,其對外封裝性并不能達到同步庫那么高。異步編程時,用戶通常只會選擇一個第三方庫來處理所有HTTP邏輯。但是不同的異步實現方法不一致,互不兼容,分歧阻礙了社區壯大;
異步代碼雖然快,但不能阻塞,一旦阻塞整個程序失效。使用多線程或多進程的方式將調度權交給操作系統,未免不是一種自我保護;
其實說了這么多,個人覺得 asyncio 雖然更加優雅,卻實際使用上并不是像表面看起來的那么美好。首先,它不是特別的快(據說比 gevent 快一倍),卻引入了更多的復雜性,而且從錯誤信息 debug 更加困難。其次,這套解決方案并不成熟,最近 3.4、3.5、3.6 的三個版本,協程也有各種的細節變化,也變得越來越復雜,程序員必須隨時關注語言的變化才能同步。令人疑惑的是為什么 Python 一定要堅持用生成器來實現協程,最后又將生成器與協程進行新老劃斷,細節卻未得到屏蔽?以目前的成熟度來看,當你寫協程代碼時,必須先去理解協程、生成器的區別,future 對象與 task 對象的職能,loop 的作用。總之,目前在生產環境中使用 asyncio 技術棧來解決問題并不穩定,這個生態還需要持久的發展才能成熟。
作為程序員,在一門語言上深入同樣可以帶來知識的廣度。不同語言有不同的性格,合適的工具解決合適的問題,而以一名 Python 程序員的視角來看,大可不必堅持寄希望于 asyncio 解決 Python 的性能問題,把在縱向上搞懂 asyncio 和這一套協程細節所需的時間拿來橫向學習 Golang,尋求更合適更簡單的解決方案,代碼也可以上線了。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/44525.html
這篇文章摘自我的博客, 歡迎大家沒事去逛逛~ 背景 這幾個月我開發了公司里的一個restful webservice,起初技術選型的時候是采用了flask框架。雖然flask是一個同步的框架,但是可以配合gevent或者其它方式運行在異步的容器中(測試鏈接),效果看上去也還可以,因此就采用了這種方式。 后面閱讀了tornado的源碼,也去了解了各種協程框架以及運行的原理。總感覺flask的這種同步...
這篇文章摘自我的博客, 歡迎大家沒事去逛逛~ 背景 這幾個月我開發了公司里的一個restful webservice,起初技術選型的時候是采用了flask框架。雖然flask是一個同步的框架,但是可以配合gevent或者其它方式運行在異步的容器中(測試鏈接),效果看上去也還可以,因此就采用了這種方式。 后面閱讀了tornado的源碼,也去了解了各種協程框架以及運行的原理。總感覺flask的這種同步...
摘要:并發的方式有多種,多線程,多進程,異步等。多線程和多進程之間的場景切換和通訊代價很高,不適合密集型的場景關于多線程和多進程的特點已經超出本文討論的范疇,有興趣的同學可以自行搜索深入理解。 編程中,我們經常會遇到并發這個概念,目的是讓軟件能充分利用硬件資源,提高性能。并發的方式有多種,多線程,多進程,異步IO等。多線程和多進程更多應用于CPU密集型的場景,比如科學計算的時間都耗費在CPU...
摘要:后來的對話中,面試官也表示,可能之前做的更多的是的工作,對于容器這塊不熟悉關系也不是很大。 showImg(https://segmentfault.com/img/remote/1460000018525265?w=1718&h=808); 這次給大家講講我2年前去愛奇藝面試高級運維開發崗位的經歷,希望對大家帶來一些幫助。 公眾號「Python專欄」后臺回復:自動化運維平臺,獲取整套...
摘要:徘徊和行程所用的時間使用指數分布生成,我們將時間設為分鐘數,以便顯示清楚。迭代表示各輛出租車的進程在各輛出租車上調用函數,預激協程。 前兩篇我們已經介紹了python 協程的使用和yield from 的原理,這一篇,我們用一個例子來揭示如何使用協程在單線程中管理并發活動。。 什么是離散事件仿真 Wiki上的定義是: 離散事件仿真將系統隨時間的變化抽象成一系列的離散時間點上的事件,通過...
閱讀 1508·2023-04-26 00:25
閱讀 906·2021-09-27 13:36
閱讀 930·2019-08-30 14:14
閱讀 2172·2019-08-29 17:10
閱讀 1006·2019-08-29 15:09
閱讀 1942·2019-08-28 18:21
閱讀 962·2019-08-26 13:27
閱讀 971·2019-08-26 10:58