Python協程技術的演化

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

引言

1.1. 儲存空間山

儲存空間山是 Randal Bryant 在《深入理解電腦系統》一書中提出的概念。

基於成本、效率的考量,電腦儲存空間被設計成多級金字塔結構,塔頂是速度最快、成本最高的 CPU 內部的寄存器(一般幾 KB)與快取,塔底是成本最低、速度最慢的廣域網路雲端儲存(如百度雲免費 2T )


儲存空間山的指導意義在於揭示了良好設計程式的必要條件是需要有優秀的局部性:

時間局部性:相同時間內,訪問同一地址次數越多,則時間局部性表現越佳;

空間局部性:下一次訪問的儲存空間地址與上一次的訪問過的儲存空間地址位置鄰近;

1.2. cpu的時間觀

操作真實延遲CPU體驗

執指0.38ns1s

讀L1緩衝0.5ns1.3s

分支錯誤修正5ns13s

讀L2緩衝7ns18.2s

加解互斥鎖25ns1min 5s

記憶體定址100ns4min 20s

環境切換/系統調用1.5us1h 5min

1Gbps網路傳輸2KB資料20us14.4h

從RAM取1M資料區塊250us7.5day

Ping單一IDC主機500us15day

從SSD讀1M資料1ms1month

從硬碟讀1M資料20ms20month

Ping不同城市主機150 ms12.5year

虛擬機器重啟4s300year

伺服器重啟5min25000year

我們將一個普通的 2.6GHz 的 CPU 的延遲時間放大到人能體驗的尺度上(資料來自公眾號 駒說碼事):在儲存空間頂層執行單條寄存器指令的時間為1秒鐘;從第五層磁碟讀 1MB 資料卻需要一年半;ping 不同的城域網主機,網路包需要走 12.5 年。

如果程式發送了一個 HTTP 包後便阻塞在同步等待響應的過程上,電腦不得不傻等 12 年後的那個響應再處理別的事情,低下的硬體利用率必然導致低下的程式效率。

1.3. 同步編程

從以上資料可以看出,記憶體資料讀寫、磁碟尋道讀寫、網卡讀寫等操作都是 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架構基於這個庫

1.5. 協程

EventLoop簡化了不同平台上的事件處理,但是處理事件觸發時的回調依然很麻煩,響應式的非同步程式編寫對程式員的心智是一項不小的麻煩。

因此,協程被引入來替代回調以簡化問題。協程模型主要在在以下方面優於回調模型:

以近似同步代碼的編程模式取代非同步回調模式,真實的商務邏輯往往是同步線性推演的,因此,這種同步式的代碼寫起來更加容易。底層的回調依然是callback hell,但這部分髒活累活已經轉交給編譯器與解譯器去完成,程式員不易出錯。

異常處理更加健全,可以複用語言內的錯誤處理機制,回調方式。而傳統非同步回調模式需要自己判定成功失敗,錯誤處理行為複雜化。

上下文管理簡單化,回調方式代碼上下文管理嚴重依賴閉包,不同的回呼函數之間相互耦合,割裂了相同的上下文處理邏輯。協程直接利用代碼的執行位置來表示狀態,而回調則是維護了一堆資料結構來處理狀態。

方便處理並發行為,協程的開銷成本很低,每一個協程僅有一個輕巧的使用者態棧空間。

1.6. EventLoop與協程的發展史

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. Gevent


Python2 時代的協程技術主要是 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 isNone:

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():

yield3

def gen_234():

yield2

yieldfrom gen_3()

yield4

def main():

yield1

yieldfrom gen_234()

yield5

for element in main():

print(element) # 1,2,3,4,5


可以看出 yield from 的特點。使用 itertools.chain 可以以產生器為最小組合子進行鏈式組合,使用 itertools.cycle 可以對單獨一個產生器首尾相接,構造一個迴圈鏈。

使用 yield from 時可以在產生器中從其他產生器 yield 一個值,這樣不同的產生器之間可以互相通訊,這樣構造出的產生鏈更加複雜,但產生鏈最小組合子的粒度卻精細至單個 yield 對象。

3.2. 短暫的asynico.coroutine 與yield from

有了Python3.3中引入的yield from 這項工具,Python3.4 中新加入了asyncio庫,並提供了一個預設的event loop。Python3.4有了足夠的基礎工具進行非同步並發編程。

並發編程同時執行多條獨立的邏輯流,每個協程都有獨立的棧空間,即使它們是都工作在同個線程中的。以下是一個範例程式碼:

import asyncio

import aiohttp

@asyncio.coroutine

def fetch_page(session, url):

response = yieldfrom session.get(url)

if response.status == 200:

text = yieldfrom 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_VALUE

3.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

asyncdef 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. Task

Task,顧名思義,是維護產生器協程狀態處理執行邏輯的的任務,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 以進行綁定。

4.3. 另一個Future

Python 裡另一個 Future 對象是 concurrent.futures.Future,與 asyncio.Future 互不相容,但容易產生混淆。concurrent.futures 是線程級的 Future 對象,當使用 concurrent.futures.Executor 進行多線程編程時用於在不同的 thread 之間傳遞結果。

4.4. 現階段asyncio生態發展的困難

由於這兩個關鍵字在2014年發布的Python3.5中才被引入,發展曆史較短,在Python2與Python3割裂的大環境下,生態環境的建立並不完善;

對於使用者來說,希望的邏輯是引入一個庫然後調用並擷取結果,並不關心第三方庫的內部邏輯。然而使用協程編寫非同步代碼時需要處理與事件迴圈的互動。對於非同步庫來說,其對外封裝性並不能達到同步庫那麼高。非同步編程時,使用者通常只會選擇一個第三方庫來處理所有HTTP邏輯。但是不同的非同步實現方法不一致,互不相容,分歧阻礙了社區壯大;

非同步代碼雖然快,但不能阻塞,一旦阻塞整個程式失效。使用多線程或多進程的方式將調度權交給作業系統,未免不是一種自我保護;

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.