簡述python非同步i/o庫 —— asyncio

weiwenhao發表於2019-03-04

python的asyncio庫以協程為基礎,event_loop作為協程的驅動和排程模型。該模型是一個單執行緒的非同步模型,類似於node.js。下圖我所理解的該模型

事件迴圈通過select()來監聽是否存在就緒的事件,如果存在就把事件對應的callback新增到一個task list中。然後從task list頭部中取出一個task執行。在單執行緒中不斷的註冊事件,執行事件,從而實現了我們的event_loop模型。

event_loop中執行的task並不是函式

如果我們把上圖當成一個web伺服器,左邊的一個task當成一次http請求需要執行的完整任務。如果我們每一次run_task()都執行完一個完整的任務,再去run下一個task。 那這跟普通的序列伺服器並沒有區別。在併發環境下造成的使用者體驗非常差。

具體怎麼差你可以腦補一下,畢竟我們現在是使用單執行緒方式實現的web伺服器

所以task如果對應一個完整的http請求那麼其不可能是一個函式,因為函式需要從頭執行到尾佔用著整個執行緒。那你覺得task是什麼呢?

如果你不知道答案的話可以看一看我的另一篇文章 簡述python的yield和yield from

沒錯,task是一個generator,或者可以叫做可中斷的函式。task的程式碼依舊是從上寫到下來處理一個http請求。也就是我們所說的同步的程式碼組織。

但是有所不同的是,在task中,我們遇到i/o操作時,我們就把i/o操作交給selector(稍後我們解析一下selector,並且把該i/o操作準備完畢後需要執行的回撥也告訴selector。然後我們使用yield儲存並中斷該函式。

此時執行緒的控制權回到event_loop手中。event_loop首先看一下selector中是否存在就緒的資料,存在的話就把對應的回撥放到task list的尾部(如圖),然後從頭部繼續run_task()。

你可能想問上面中斷的task什麼時候才能繼續執行呢?我前一句說過了,event_loop每一次迴圈都會檢測selector中是否存在就緒的i/o操作,如果存在就緒的i/o操作,我們對應就把callback放到task的尾部,當event_loop執行到這個task時。我們就能回到我們剛剛中斷的函式繼續執行啦,而且此時我們需要的i/o操作得到的資料也已經準備好了。

這種操作如果你站在函式的角度會有種神奇的感覺,在函式眼裡,自己需要get遙遠伺服器的一些資料,於是調動get(),然後瞬間就得到了遙遠伺服器的資料。沒錯在函式的眼裡就是瞬間得到,這感覺就彷彿是穿越到了未來一樣。

你可能又想問,為什麼把callback放到task,然後run一下就回到原有的函式執行位置了?

這我也不知道,我並沒有深追asyncio的程式碼,這對於我來說有些複雜。但如果是我的話,我只要在callback中設定一個變數gen指向我們的generator就行了,然後只要在callback中gen.send(res_data),我們就能回到中斷處繼續執行了。如果你有興趣的話可以自己使用debug來追一下程式碼。

不過我更推薦你閱讀一下這篇博文 深入理解 Python 非同步程式設計(上)

這裡還有幾個問題。

比如我們在task中需要執行一個1+2+3+到2000萬這樣一個操作,這個操作耗時有些長,而且不屬於i/o操作,沒法交給selector去排程,此時我們需要自己yield,讓其他的task能有機會來使用我們唯一的執行緒。這樣就又有一個新的問題。yield後,我們什麼時候再次來執行這個被中斷的函式呢?

問題程式碼示例

import asyncio

def print_sum():
    sum = 0
    count = 0
    for a in range(20000000):
        sum += a
        count += 1
        if count > 1000000:
            count = 0
            yield
    print('1+到2000萬的和是{}'.format(sum))

@asyncio.coroutine
def init():
    yield from print_sum()

loop = asyncio.get_event_loop()
loop.run_until_complete(init())
loop.run_forever()複製程式碼

我想我們可以這樣,把這個中斷的task直接加入到task list的尾部,然後繼續event_loop,這樣讓其他task有機會執行,並且處理起來更加的簡單。 asyncio庫也確實是這樣做的。

但是asyncio還提供了更好的做法,我們可以再啟動一個執行緒來執行這種cpu密集型運算


再來看看另外一個問題。如果在一個凌晨三點半,你task list此時是空的,那麼你的event_loop怎麼運作?繼續不停的loop等待新的http請求進來? no,我們不允許如此浪費cpu的資源。asyncio庫也不允許。

首先看兩行event_loop中的程式碼片段,也就是上圖中右上角部分的select(timeout)部分

    event_list = self._selector.select(timeout)
    self._process_events(event_list)複製程式碼

補充一點,作為一臺web伺服器,我們總是需要socket()、bind()、listen()、來建立一個監聽描述符sockfd,用來監聽到來的http請求,與http請求完成三路握手。然後通過accept()操作來得到一個已連線描述符connectfd。

這裡的兩個檔案描述符,此時都存在於我們的系統中,其中sockfd繼續用來執行監聽http請求操作。已經連線了的客戶端我們則通過connectfd來與其通訊。一般都是一個sockfd對多個connectfd。

更多的細節推薦閱讀 ——《unix網路程式設計卷一》中的關於socket程式設計的幾章

asyncio對於網路i/o使用了 selector模組,selector模組的底層則是由 epoll()來實現。也就是一個同步的i/o複用系統呼叫(你定會驚訝於asyncio的竟然使用了同步i/o來實現?我們在下一節來解讀一下epoll函式)

這裡你可以去讀一下python手冊中的selector模組,看看這個模組的作用

epoll()函式有個timeout引數,用來控制該函式是否阻塞,阻塞多久。對映到高層就是我們上面的selector.select(timeout)中的timeout。原來我們的event_loop中的存在一個timeout。這樣凌晨三點半我們如何處理event_loop我想你已經心裡有數了吧。

asyncio的實現和你想的差不多。如果task list is not None那麼我們的timeout=0也就是非阻塞的。解釋一下就是,我們呼叫selector.select(timeout = 0 ),該函式會馬上返回結果,我們對結果做一個上面講過的處理,也就是self._process_events(event_list)。然後我們繼續run task。

如果我們的task list is None, 那麼我們則把timeout=None。也就是設定成阻塞操作。此時我們的程式碼或者說執行緒會阻塞在selector.select(timeout = 0)處,換句話說就是等待該函式的返回。當然這樣做的前提是,你往selector中註冊了需要等待的socket描述符。


還有一些其他的問題,比如非同步mysql是如何在asyncio的基礎上實現的,這可能需要去閱讀aiomysql庫了。

你也許發現,我們一旦使用了event_loop實現單執行緒非同步伺服器,我們寫的所有程式碼就都不是我們來控制執行了,程式碼的執行權全部交給了event_loop,event_loop在適當的時間run task。讀過廖雪峰python教程的小夥伴一定看過這句話

這就是非同步程式設計的一個原則:一旦決定使用非同步,則系統每一層都必須是非同步,“開弓沒有回頭箭”。

這就是非同步程式設計。


你也許對asyncio的作用,或者使用,或者程式碼實現有著很多的疑問,我也是如此。但是很抱歉,我並不怎麼熟悉python,也沒有使用asyncio做過專案,只是出於好奇所以我對python的非同步i/o進行了一個瞭解。

我是一個紙上談兵的門外漢,到最後我也沒能看清asyncio庫的具體實現。我接下來的計劃中並不打算對asyncio庫進行更多的研究,但是我又不甘心這兩天對asyncio庫的研究付諸東流。所以我留下這篇博文,算是對自己的一個交待!希望下次能夠有機會,能夠更加了解python和asyncio的前提下,再寫一篇深入解析python—asyncio的博文。

相關文章