Python學習之路37-使用asyncio包處理併發

VPointer發表於2018-08-24

《流暢的Python》筆記。

本篇主要討論asyncio包,這個包使用事件迴圈驅動的協程實現併發。

1. 前言

本篇主要介紹如果使用asyncio包將上一篇中執行緒版的“國旗下載”程式改為協程版本,通過非同步非阻塞來實現併發。

說實話,我在讀這部分內容的時候是懵逼的,書中阻塞非阻塞、同步非同步的概念和我之前的理解有很大差異。之前一直以為同步就意味著阻塞,非同步就意味著非阻塞。但其實,阻塞非阻塞與同步非同步並沒有本質的聯絡。

同步(Synchronizing)非同步(Asynchronizing)是對指令而言的,也就是程式(理解成“函式”會更好一些)。以含有I/O操作的函式為例(被呼叫方),如果這個函式要等到I/O操作結束,獲取了資料,才返回到呼叫方,這就叫同步(絕大部分函式都同步);反之,不等I/O執行完畢就返回到呼叫方,獲取的資料以其他方式轉給呼叫方,這就叫非同步。

阻塞(Blocking)非阻塞(Non-Blocking)是對程式執行緒而言(為了簡潔,只以“執行緒”為例)。因為某些原因(比如I/O),執行緒被掛起(被移出CPU),這就叫阻塞;反之,即使因為這些原因,執行緒依然不被掛起(不被移出CPU),這就叫非阻塞。

可見,這兩組概念一共可以組成四種不同情況:同步阻塞(常見),同步非阻塞(不常見),非同步阻塞(不常見),非同步非阻塞(常見)。

仍以上述I/O函式為例:

  • 如果這個函式的I/O請求已發出,只是單純地在等伺服器發回資料,執行緒也只是單純地在等這個函式返回結果,CPU將會把這個執行緒掛起,這就叫做同步阻塞
  • 如果這個函式中呼叫的是一個執行復雜計算的子函式,此時,函式依然在等結果沒有返回,但執行緒並不是沒有執行,不會被CPU掛起,這就叫做同步非阻塞(“CPU以輪詢的方式檢視I/O是否結束”更能說明這種情況,但這已是很古老的方式了);
  • 如果這個函式在I/O請求沒得到結果之前就返回了,但執行緒依然在等這個結果(在函式體之外等待使用這個資料),這就叫非同步阻塞
  • 如果這個函式在沒得到結果之前返回了,執行緒繼續執行其他函式,這就叫做非同步非阻塞。更具體一點,這種情況對應的是使用回撥實現非同步非阻塞的情況;而Python中還有一種情況,也是本篇要講的,就是使用協程實現非同步非阻塞:協程在得到結果前依然不返回,但執行緒並沒有等待,而是去執行其他協程。協程看起來就像同步一樣。

由於之前並沒有遇到程式碼世界中的同步非阻塞非同步阻塞這兩種情況,所以我也不確定上述這兩種情況的例子是否準確,歡迎大佬留言指導。但這四種情況在現實生活中就很常見了,下面舉個在某處看到的例子:

  • 老張把一普通水壺接上水放火上,眼睛直勾勾盯著等水開,不幹其他事,這叫同步阻塞
  • 老張依然用一普通水壺燒水,但把水壺放火上後去客廳看電視,時不時回來看水燒好了沒有,這叫同步非阻塞
  • 老張用一能響的水壺燒水,沒盯著看,但也沒幹其他事,只是在那兒發愣。水燒好後,壺可勁兒的響,老張一驚,取走水壺,這叫非同步阻塞
  • 老張用一能響的水壺燒水,把壺放火上後去客廳看電視,等壺響了再去拿壺,這叫非同步非阻塞

從這四個例子可以看出,阻不阻塞是對老張而言的,在計算機中對應的就是程式執行緒;同步非同步是對水壺而言的,在計算機中對應的就是函式。

有了上述概念後,我們接下來將使用asyncio包,將之前下載國旗的程式改為協程版本。

2. 非同步

之前我們使用執行緒實現了併發下載資料,它是同步阻塞的,因為一到I/O操作,執行緒就被阻塞,然後調入新的執行緒。現在,我們將實現一個非同步非阻塞版本。但從上述介紹知道,非同步有兩種方式:回撥和協程。本文並不會實現回撥版本的“下載國旗”,提出回撥只是為了和協程進行比較。

2.1 回撥

舉個例子說明回撥。在呼叫函式A時除了傳入必要的引數外,還傳入一個引數:函式B。A中有一些費時的操作,比如I/O,A在沒得到結果之前就返回,而將等待結果以及進行後續處理的事情交給函式B。這個過程就是回撥,函式B就稱為回撥函式

這種程式設計方式不太符合人的思維習慣,程式碼也不易於理解,情況一複雜,就很可能遇到**“回撥地獄”**:多層巢狀回撥。下面是一個JavaScript中使用回撥的例子,它巢狀了3層:

// 程式碼2.1
api_call1(request1, function (response1){  // 多麼痛的領悟
    var request2 = step1(response1);  // 第一步
    api_call2(request2, function (response2){
        var request3 = step2(response2);  // 第二步
        api_call3(request3, function (response3){
            step(response3);  // 第三步
        })
    })
})
複製程式碼

api_call1api_call2api_call3都是庫函式,用於非同步獲取結果。JavaScript中常用匿名函式作為回撥函式。下面我們使用Python來實現上述程式碼,上述三個匿名函式分別命名為stage1stage2stage3

# 程式碼2.2
def stage1(response1):
    request2 = step1(response1)
    api_call2(request2, stage2)

def stage2(response2):
    request3 = step2(response2)
    api_call3(request3, stage3)

def stage3(response3):
    step3(response3)

api_call1(request1, stage1)  # 程式碼從這裡開始執行
複製程式碼

可見,即使用Python寫,也不容易理解,這要是再多巢狀幾層,不逼瘋已經不錯了。而且,如果要在stage2中使用request2,還得使用閉包,這就又變成了巢狀定義函式的情況。並且上述程式碼還沒有考慮丟擲異常的情況:在基於回撥的API中,這個問題的解決辦法是為每個非同步呼叫註冊兩個回撥,一個用於處理操作成功時返回的結果,一個用於處理錯誤。可以看出,一旦涉及錯誤處理,回撥將更可怕。

2.2 協程

現在我們用協程來改寫上述程式碼:

# 程式碼2.3
import asyncio

@asyncio.coroutine
def three_stages(request1):
    response1 = yield from api_call1(request1)
    request2 = step1(response1)
    response2 = yield from api_call2(request2)
    request3 = step2(response2)
    response3 = yield from api_call3(request3)
    step3(response3)

loop = asyncio.get_event_loop()
loop.create_task(three_stages(request1))
複製程式碼

與前面兩個版本的回撥相比,這個版本的程式碼將3個步驟依次寫在同一函式中,易於理解,這樣看起來是不是也更像同步函式?如果要處理異常,只需要相應的yield from語句處新增try/except即可。

但也別急著把這稱為“協程天堂”,因為:

  • 不能使用常規函式,必須使用協程,而且要習慣yield from語句;
  • 不能直接呼叫協程。即,不能像直接呼叫api_call1(request1)那樣直接呼叫three_stages(request1),必須使用事件迴圈(上面的loop)來驅動協程。

但不管怎樣,程式碼讀起來和寫起來比回撥簡單多了,尤其是巢狀回撥。

小技巧:讀協程的程式碼時,為了便於理解程式碼的意思,可以直接將yield from關鍵字忽略掉。

2.3 下載國旗批量版

下面我們開始實現協程版本的“下載國旗”。

為了將其改為協程版本,我們不能使用之前的requests包,因為它會阻塞執行緒,改為使用aiohttp包。為了儘量保持程式碼的簡潔,這裡不處理異常。下方是完整的程式碼,程式碼中我們使用了新語法。以下程式碼的基本思路是:在一個單執行緒程式中使用主迴圈一次啟用佇列中的協程,各個協程向前執行幾步,然後把控制權讓給主迴圈,主迴圈再啟用佇列中的下一個協程

# 程式碼2.4
import aiohttp, os, sys, time, asyncio   # 程式碼中請勿這麼寫,這裡只是為了減少行數

POP20_CC = ("CN IN US ID BR PK NG BD RU JP MX PH VN ET EG DE IR TR CD FR").split()
BASE_URL = "http://flupy.org/data/flags"
DEST_DIR = "downloads/"

def save_flag(img, filename):
    path = os.path.join(DEST_DIR, filename)
    with open(path, "wb") as fp: 
        fp.write(img)

def show(text):
    print(text, end=" ")
    sys.stdout.flush()

async def get_flag(cc):   # aiohttp只支援TCP和UDP請求
    url = "{}/{cc}/{cc}.gif".format(BASE_URL, cc=cc.lower())
    async with aiohttp.ClientSession() as session: # <1> 開啟一個會話
        async with session.get(url) as resp:   # 傳送請求
            image = await resp.read()   # 讀取請求
    return image

async def download_one(cc):
    image = await get_flag(cc)
    show(cc)
    save_flag(image, cc.lower() + ".gif")
    return cc

def download_many(cc_list):
    loop = asyncio.get_event_loop()   # 獲取事件迴圈
    to_do = [download_one(cc) for cc in sorted(cc_list)]  # 生成協程列表
    wait_coro = asyncio.wait(to_do)   # 將協程包裝成Task類,wait_coro並不是執行結果!而是協程!
    res, _ = loop.run_until_complete(wait_coro) # 驅動每個協程執行
    loop.close()   # 迴圈結束
    return len(res)

def main(download_many):
    t0 = time.time()
    count = download_many(POP20_CC)
    elapsed = time.time() - t0
    msg = "\n{} flags downloaded in {:.2f}s"
    print(msg.format(count, elapsed))

if __name__ == "__main__":
    main(download_many)

# 結果:
VN TR FR DE IN ID RU NG CN EG BR MX PH CD IR PK ET JP BD US 
20 flags downloaded in 1.27s
複製程式碼

解釋:

①這裡使用了新的語法async/await。再Python3.5之前,如果想定義一個協程只能延用函式的定義方式def,然後在定義體裡面使用yieldyield from。如果想把一個函式更明確地宣告為協程(或者說非同步函式),還可以使用asyncio中的coroutine裝飾器,但這麼做是不是挺麻煩的?從Python3.5起,可以明確**使用async來定義協程(非同步函式)**和非同步生成器。使用async則可以省略掉@asyncio.coroutine裝飾器;在用async修飾的協程的定義體中可以使用yield關鍵字,但不能使用yield from,它必須被替換為await,即使yield from後面只是一個普通的生成器;從由async修飾的協程或生成器中獲取資料時,必須使用await

②如果要使用@asyncio.coroutine裝飾器明確宣告協程,那麼在協程定義體內部只能使用yield from,不能使用yield,因為使用到yield的地方已經在asyncio中全部封裝成了函式或者方法。最新版的@asyncio.coroutine也可以裝飾async修飾的協程,這種情況下coroutine不做任何事,只是原封不動的返回被裝飾的協程。

③ <1>處的程式碼之所以改用async with(非同步上下文管理器),是因為新版asyncio並不支援書中的舊語法yield from aiohttp.request("GET", url)。關於async/awaitasync with/async for的相關內容將在後續文章中介紹,這裡只需要知道async對應於@asyncio.coroutineawait對應於yield from即可。

④我們將get_flag改成了協程版本,並使用aiohttp來實現非同步請求;download_one函式也隨之變成了協程版本。

download_many只是一個普通函式,它要驅動協程執行。在這個函式中,我們通過asyncio.get_event_loop()建立事件迴圈(實質就是一個執行緒)來驅動協程的執行。接著生成含20個download_one協程的協程列表to_do,隨後再呼叫asyncio.wait(to_do)將這個協程列表包裝成一個wait協程,取名為wait_corowait協程會將to_do中所有的協程包裝成Task物件(Future的子類),再形成列表。最後,我們通過loop.run_until_complete(wait_coro)驅動協程wait_coro執行。整個的驅動鏈是這樣的:loop.run_until_complete驅動協程wait_corowait_coro再在內部驅動20個協程。

wait協程最後會返回一個元組,第一個元素是完成的協程數,第二個是未完成的協程數loop.run_until_complete返回傳入的協程的返回值(實際程式碼是Future.result())。有點繞,其實就是wait_coro最後返回一個元組給run_until_completerun_until_complete再把這個值返回給呼叫方。

⑦在上一篇中,我們知道concurrent.futures中有一個Future,且通過它的result方法獲取最後執行的結果;在asyncio包中,不光有Future,還有它的子類Task,但獲取結果通常並不是呼叫result方法,而是通過yield fromawait,即yield from future獲取結果。asyncio.Future類的result方法沒有引數,不能設定超時時間;如果呼叫resultfuture還未執行完畢,它並不會阻塞去等待結果,而是丟擲asyncio.InvalidStateError異常。

2.4 下載國旗改進版

上一篇中,我們除了使用Executor.map()批量處理執行緒之外,我們還使用了concurrent.futures.as_completed()挨個迭代執行完的執行緒返回的結果。asyncio也實現了這個方法,我們將使用這個函式改寫上方的程式碼。

還有一個問題:我們往往只關注了網路I/O請求,常常忽略本地的I/O操作。執行緒版本中的save_flag函式也是會阻塞執行緒的,因為它操作了磁碟。但由於圖片太小,速度太快,我們感覺並不明顯,如果換成更高畫素的圖片,這種速度差異就會很明顯。我們將會以某種方式使其避免阻塞執行緒。下面是改寫的程式碼:

# 程式碼2.5
import asyncio, os, sys, time, aiohttp

async def download_one(cc, semaphore):
    async with semaphore:
        image = await get_flag(cc)
    loop = asyncio.get_event_loop()
    loop.run_in_executor(None, save_flag, image, cc + ".gif")
    return cc

async def download_coro(cc_list, concur_req):
    semaphore = asyncio.Semaphore(concur_req)  # 它是一個訊號量,用於控制併發量
    to_do = [download_one(cc, semaphore) for cc in sorted(cc_list)]
    to_do_iter = asyncio.as_completed(to_do)
    for future in to_do_iter:
        res = await future
        print("Downloaded", res)

def download_many(cc_list, concur_req):  # 變化不大
    loop = asyncio.get_event_loop()
    coro = download_coro(cc_list, concur_req)
    loop.run_until_complete(coro)
    loop.close()

if __name__ == "__main__":
    t0 = time.time()
    download_many(POP20_CC, 1000)  # 第二個參數列示最大併發數
    print("\nDone! Time elapsed {:.2f}s.".format(time.time() - t0))

# 結果:
Downloaded BD
Downloaded CN
-- snip --
Downloaded US

Done! Time elapsed 1.21s.
複製程式碼

上述程式碼有3個地方值得關注:

  • asyncio.as_completed()元素為協程的可迭代物件為引數,但自身並不是協程,只是一個生成器。它在內部將傳入的協程包裝成Task,然後返回一個生成器,產出協程的返回值。這個生成器按協程完成的順序生成值(先完成先產出),而不是按協程在迭代器中的順序生成值。
  • asyncio.Semaphore是個訊號量類,內部維護這一個計數器,呼叫它的acquire方法(這個方法是個協程),計數器減一;對其呼叫release方法(這個方法不是協程),計數器加一;當計數器為0時,會阻塞呼叫這個方法的協程。
  • 我們將save_flag函式放到了其他執行緒中,loop.run_in_executor()的第一個引數是Executor例項,如果為None,則使用事件迴圈的預設ThreadPoolExecutor例項。餘下的引數是可呼叫物件,以及可呼叫物件的位置引數。

3. 總結

本章開篇介紹了阻塞非阻塞、同步非同步的概念,然後介紹了非同步的兩種實現方式:回撥和協程。並通過程式碼比較了回撥和協程的實現方式。然後我們使用asyncioaiohttp兩個庫,將之前執行緒版本的下載國旗程式改為了協程版本。可惜我也是剛接觸協程不久,寫的內容不一定準確,尤其是關於asyncio的內容,這個庫之前是一點都沒接觸過。後面我會專門研究Python中的協程,以及asyncio的實現,爭取把這部分內容徹底搞懂。


迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~

Python學習之路37-使用asyncio包處理併發

相關文章