Python黑魔法 --- 非同步IO( asyncio) 協程

發表於2017-02-03

 

python asyncio

網路模型有很多中,為了實現高併發也有很多方案,多執行緒,多程式。無論多執行緒和多程式,IO的排程更多取決於系統,而協程的方式,排程來自使用者,使用者可以在函式中yield一個狀態。使用協程可以實現高效的併發任務。Python的在3.4中引入了協程的概念,可是這個還是以生成器物件為基礎,3.5則確定了協程的語法。下面將簡單介紹asyncio的使用。實現協程的不僅僅是asyncio,tornado和gevent都實現了類似的功能。

  • event_loop 事件迴圈:程式開啟一個無限的迴圈,程式設計師會把一些函式註冊到事件迴圈上。當滿足事件發生的時候,呼叫相應的協程函式。
  • coroutine 協程:協程物件,指一個使用async關鍵字定義的函式,它的呼叫不會立即執行函式,而是會返回一個協程物件。協程物件需要註冊到事件迴圈,由事件迴圈呼叫。
  • task 任務:一個協程物件就是一個原生可以掛起的函式,任務則是對協程進一步封裝,其中包含任務的各種狀態。
  • future: 代表將來執行或沒有執行的任務的結果。它和task上沒有本質的區別
  • async/await 關鍵字:python3.5 用於定義協程的關鍵字,async定義一個協程,await用於掛起阻塞的非同步呼叫介面。

上述的概念單獨拎出來都不好懂,比較他們之間是相互聯絡,一起工作。下面看例子,再回溯上述概念,更利於理解。

定義一個協程

定義一個協程很簡單,使用async關鍵字,就像定義普通函式一樣:

通過async關鍵字定義一個協程(coroutine),協程也是一種物件。協程不能直接執行,需要把協程加入到事件迴圈(loop),由後者在適當的時候呼叫協程。asyncio.get_event_loop方法可以建立一個事件迴圈,然後使用run_until_complete將協程註冊到事件迴圈,並啟動事件迴圈。因為本例只有一個協程,於是可以看見如下輸出:

建立一個task

協程物件不能直接執行,在註冊事件迴圈的時候,其實是run_until_complete方法將協程包裝成為了一個任務(task)物件。所謂task物件是Future類的子類。儲存了協程執行後的狀態,用於未來獲取協程的結果。

可以看到輸出結果為:

建立task後,task在加入事件迴圈之前是pending狀態,因為do_some_work中沒有耗時的阻塞操作,task很快就執行完畢了。後面列印的finished狀態。

asyncio.ensure_future(coroutine) 和 loop.create_task(coroutine)都可以建立一個task,run_until_complete的引數是一個futrue物件。當傳入一個協程,其內部會自動封裝成task,task是Future的子類。isinstance(task, asyncio.Future)將會輸出True。

繫結回撥

繫結回撥,在task執行完畢的時候可以獲取執行的結果,回撥的最後一個引數是future物件,通過該物件可以獲取協程返回值。如果回撥需要多個引數,可以通過偏函式匯入。

可以看到,coroutine執行結束時候會呼叫回撥函式。並通過引數future獲取協程執行的結果。我們建立的task和回撥裡的future物件,實際上是同一個物件。

future 與 result

回撥一直是很多非同步程式設計的惡夢,程式設計師更喜歡使用同步的編寫方式寫非同步程式碼,以避免回撥的惡夢。回撥中我們使用了future物件的result方法。前面不繫結回撥的例子中,我們可以看到task有fiinished狀態。在那個時候,可以直接讀取task的result方法。

可以看到輸出的結果:

阻塞和await

使用async可以定義協程物件,使用await可以針對耗時的操作進行掛起,就像生成器裡的yield一樣,函式讓出控制權。協程遇到await,事件迴圈將會掛起該協程,執行別的協程,直到其他的協程也掛起或者執行完畢,再進行下一個協程的執行。

耗時的操作一般是一些IO操作,例如網路請求,檔案讀取等。我們使用asyncio.sleep函式來模擬IO操作。協程的目的也是讓這些IO操作非同步化。

在 sleep的時候,使用await讓出控制權。即當遇到阻塞呼叫的函式的時候,使用await方法將協程的控制權讓出,以便loop呼叫其他的協程。現在我們的例子就用耗時的阻塞操作了。

併發和並行

併發和並行一直是容易混淆的概念。併發通常指有多個任務需要同時進行,並行則是同一時刻有多個任務執行。用上課來舉例就是,併發情況下是一個老師在同一時間段輔助不同的人功課。並行則是好幾個老師分別同時輔助多個學生功課。簡而言之就是一個人同時吃三個饅頭還是三個人同時分別吃一個的情況,吃一個饅頭算一個任務。

asyncio實現併發,就需要多個協程來完成任務,每當有任務阻塞的時候就await,然後其他協程繼續工作。建立多個協程的列表,然後將這些協程註冊到事件迴圈中。

結果如下

總時間為4s左右。4s的阻塞時間,足夠前面兩個協程執行完畢。如果是同步順序的任務,那麼至少需要7s。此時我們使用了aysncio實現了併發。asyncio.wait(tasks) 也可以使用 asyncio.gather(*tasks) ,前者接受一個task列表,後者接收一堆task。

協程巢狀

使用async可以定義協程,協程用於耗時的io操作,我們也可以封裝更多的io操作過程,這樣就實現了巢狀的協程,即一個協程中await了另外一個協程,如此連線起來。

如果使用的是 asyncio.gather建立協程物件,那麼await的返回值就是協程執行的結果。

不在main協程函式裡處理結果,直接返回await的內容,那麼最外層的run_until_complete將會返回main協程的結果。

或者返回使用asyncio.wait方式掛起協程。

也可以使用asyncio的as_completed方法

由此可見,協程的呼叫和組合十分靈活,尤其是對於結果的處理,如何返回,如何掛起,需要逐漸積累經驗和前瞻的設計。

協程停止

上面見識了協程的幾種常用的用法,都是協程圍繞著事件迴圈進行的操作。future物件有幾個狀態:

  • Pending
  • Running
  • Done
  • Cancelled

建立future的時候,task為pending,事件迴圈呼叫執行的時候當然就是running,呼叫完畢自然就是done,如果需要停止事件迴圈,就需要先把task取消。可以使用asyncio.Task獲取事件迴圈的task

啟動事件迴圈之後,馬上ctrl+c,會觸發run_until_complete的執行異常 KeyBorardInterrupt。然後通過迴圈asyncio.Task取消future。可以看到輸出如下:

True表示cannel成功,loop stop之後還需要再次開啟事件迴圈,最後在close,不然還會丟擲異常:

迴圈task,逐個cancel是一種方案,可是正如上面我們把task的列表封裝在main函式中,main函式外進行事件迴圈的呼叫。這個時候,main相當於最外出的一個task,那麼處理包裝的main函式即可。

不同執行緒的事件迴圈

很多時候,我們的事件迴圈用於註冊協程,而有的協程需要動態的新增到事件迴圈中。一個簡單的方式就是使用多執行緒。當前執行緒建立一個事件迴圈,然後在新建一個執行緒,在新執行緒中啟動事件迴圈。當前執行緒不會被block。

啟動上述程式碼之後,當前執行緒不會被block,新執行緒中會按照順序執行call_soon_threadsafe方法註冊的more_work方法,後者因為time.sleep操作是同步阻塞的,因此執行完畢more_work需要大致6 + 3

新執行緒協程

上述的例子,主執行緒中建立一個new_loop,然後在另外的子執行緒中開啟一個無限事件迴圈。主執行緒通過run_coroutine_threadsafe新註冊協程物件。這樣就能在子執行緒中進行事件迴圈的併發操作,同時主執行緒又不會被block。一共執行的時間大概在6s左右。

master-worker主從模式

對於併發任務,通常是用生成消費模型,對佇列的處理可以使用類似master-worker的方式,master主要使用者獲取佇列的msg,worker使用者處理訊息。

為了簡單起見,並且協程更適合單執行緒的方式,我們的主執行緒用來監聽佇列,子執行緒用於處理佇列。這裡使用redis的佇列。主執行緒中有一個是無限迴圈,使用者消費佇列。

給佇列新增一些資料:

可以看見輸出:

我們發起了一個耗時5s的操作,然後又發起了連個1s的操作,可以看見子執行緒併發的執行了這幾個任務,其中5s awati的時候,相繼執行了1s的兩個任務。

停止子執行緒

如果一切正常,那麼上面的例子很完美。可是,需要停止程式,直接ctrl+c,會丟擲KeyboardInterrupt錯誤,我們修改一下主迴圈:

可是實際上並不好使,雖然主執行緒try了KeyboardInterrupt異常,但是子執行緒並沒有退出,為了解決這個問題,可以設定子執行緒為守護執行緒,這樣當主執行緒結束的時候,子執行緒也隨機退出。

執行緒停止程式的時候,主執行緒退出後,子執行緒也隨機退出才了,並且停止了子執行緒的協程任務。

aiohttp

在消費佇列的時候,我們使用asyncio的sleep用於模擬耗時的io操作。以前有一個簡訊服務,需要在協程中請求遠端的簡訊api,此時需要是需要使用aiohttp進行非同步的http請求。大致程式碼如下:

server.py

/介面表示簡訊介面,/error表示請求/失敗之後的報警。

async-custoimer.py

有一個問題需要注意,我們在fetch的時候try了異常,如果沒有try這個異常,即使發生了異常,子執行緒的事件迴圈也不會退出。主執行緒也不會退出,暫時沒找到辦法可以把子執行緒的異常raise傳播到主執行緒。(如果誰找到了比較好的方式,希望可以帶帶我)。

對於redis的消費,還有一個block的方法:

使用 brpop方法,會block住task,如果主執行緒有訊息,才會消費。測試了一下,似乎brpop的方式更適合這種佇列消費的模型。

可以看到結果

協程消費

主執行緒用於監聽佇列,然後子執行緒的做事件迴圈的worker是一種方式。還有一種方式實現這種類似master-worker的方案。即把監聽佇列的無限迴圈邏輯一道協程中。程式初始化就建立若干個協程,實現類似並行的效果。

這樣做就可以多多啟動幾個worker來監聽佇列。一樣可以到達效果。

總結

上述簡單的介紹了asyncio的用法,主要是理解事件迴圈,協程和任務,future的關係。非同步程式設計不同於常見的同步程式設計,設計程式的執行流的時候,需要特別的注意。比較這和以往的編碼經驗有點不一樣。可是仔細想想,我們平時處事的時候,大腦會自然而然的實現非同步協程。比如等待煮茶的時候,可以多寫幾行程式碼。

相關程式碼檔案的Gist

參考:Threaded Asynchronous Magic and How to Wield It

相關文章