本節繼續學習 Python 並行程式設計的另一種實現方式,也就是 Asyncio 並行程式設計。
我們知道,使用多執行緒和普通的單執行緒相比,其執行效率會有極大的提高。但不得不說,多執行緒雖然有諸多優勢,也存在一定的局限性:
-
多執行緒執行過程中容易被打斷,還可能出現多個執行緒同時競爭同一資源的情況;
-
多執行緒切換本身存在一定的損耗,執行緒數不能無線增加,因此如果
IO
操作非常頻繁,多執行緒很有可能滿足不了高效率、高品質的需求。
為了解決這些問題,Asyncio 並行程式設計應運而生。
在詳細介紹 Asyncio 之前,要先搞清楚什麼是同步,什麼是非同步。所謂同步,是指操作一個接一個地執行,下一個操作必須等上一個操作執行完成之後才能開始執行;而非同步是指不同操作間可以相互交替執行,如果其中地某個操作被堵塞,程式並不會等待,而是會找出可執行的操作繼續執行。
為了更好地區分同步和非同步,這裡舉個例子,假設公司要我們做一份報表,並以郵件的方式提交,則分別以同步和非同步的方式完成的過程如下:
-
如果按照同步的方式,應先向軟體中輸入各項資料,接下來等報表生成,再寫郵件提交;
-
如果按照非同步的方式,向軟體中輸出各項資料後,會先寫郵件,等待報表生成後,暫停寫郵件的工作去檢視生成的報表,確認無誤後在寫郵件直到傳送完畢。
了解了同步和非同步(以及它們之間的區別)之後,接下來正式開始介紹 Asyncio。
什麼是 Asyncio
事實上,Asyncio 和其他 Python 程式一樣,是單執行緒的,它只有一個主執行緒,但可以進行多個不同的任務。這裡的任務,指的就是特殊的 future 物件,我們可以把它類比成多執行緒版本裡的多個執行緒。
這些不同的任務,被一個叫做事件迴圈(Event Loop)的物件所控制。所謂事件迴圈,是指主執行緒每次將執行序列中的任務清空後,就去事件佇列中檢查是否有等待執行的任務,如果有則每次取出一個推到執行序列中執行,這個過程是迴圈往復的。
為了簡化講解這個問題,可以假設任務只有兩個狀態:,分別是預備狀態和等待狀態:
-
預備狀態是指任務目前空閒,但隨時待命準備執行;
-
等待狀態是指任務已經執行,但正在等待外部的操作完成,比如 I/O 操作。
在這種情況下,事件迴圈會維護兩個任務列表,分別對應這兩種狀態,並且選取預備狀態的一個任務(具體選取哪個任務,和其等待的時間長短、佔用的資源等等相關)使其執行,一直到這個任務把控制權交還給事件迴圈為止。
當任務把控制權交還給事件迴圈物件時,它會根據其是否完成把任務放到預備或等待狀態的列表,然後遍歷等待狀態列表的任務,檢視他們是否完成:如果完成,則將其放到預備狀態的列表;反之,則繼續放在等待狀態的列表。而原先在預備狀態列表的任務位置仍舊不變,因為它們還未執行。
這樣,當所有任務被重新放置在合適的列表後,新一輪的迴圈又開始了,事件迴圈物件繼續從預備狀態的列表中選取一個任務使其執行…如此周而復始,直到所有任務完成。
值得一提的是,對於 Asyncio 來說,它的任務在執行時不會被外部的一些因素打斷,因此 Asyncio 內的操作不會出現競爭資源(多個執行緒同時使用同一資源)的情況,也就不需要擔心執行緒安全的問題了。
如何使用Asyncio
講完了 Asyncio 的原理,下面結合具體的程式碼來看一下它的用法。還是以下載網站內容為例,用 Asyncio 的實現程式碼(省略了例外處理的一些操作)如下:
import asyncio
import aiohttp
import time
async def download_one(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
print('Read {} from {}'.format(resp.content_length, url))
async def download_all(sites):
tasks = [asyncio.ensure_future(download_one(site)) for site in sites]
await asyncio.gather(*tasks)
def main():
sites = [
'http://c.biancheng.net',
'http://c.biancheng.net/c',
'http://c.biancheng.net/python'
]
start_time = time.perf_counter()
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(download_all(sites))
finally:
loop.close()
end_time = time.perf_counter()
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
if __name__ == '__main__':
main()
執行結果為:
Read 52053 from http://c.biancheng.net
Read 30718 from http://c.biancheng.net/c
Read 34470 from http://c.biancheng.net/python
Download 3 sites in 0.12174049999999999 seconds
注意,此程式執行前,需確保已安裝好 aiohttp 模組,此模組可直接執行 pip install aiohttp 命令安裝。
上面程式中,Async 和 await 關鍵字是 Asyncio 的最新寫法,表示這個語句(函數)是非阻塞的,正好對應前面所講的事件迴圈的概念,即如果任務執行的過程需要等待,則將其放入等待狀態的列表中,然後繼續執行預備狀態列表裡的任務。
另外在主函數中,第 22-26 行程式碼表示拿到事件迴圈物件,並執行 download_all() 函數,直到其結束,最後關閉這個事件迴圈物件。
值得一提的,如果讀者使用 Python 3.7 及以上版本,則 22-26 行程式碼可以直接用 asyncio.run(download_all(sites)) 來代替。
至於 Asyncio 版本的函數 download_all(),和之前多執行緒版本有很大的區別:
-
這裡的 asyncio.ensure_future(coro) 表示對輸入的協程 coro 建立一個任務,安排它的執行,並返回此任務物件。可以看到,這裡對每一個網站的下載,都建立了一個對應的任務。
注意,Python 3.7+ 版本之後,可以使用 asyncio.create_task(coro) 等效替代 asyncio.ensure_future(coro)。
-
asyncio.gather() 表示在事件迴圈物件中執行 aws 序列的所有任務。
可以看到,其輸出結果顯示用時只有 0.12s,比之前的多執行緒版本效率更高,充分體現其優勢。
當然,除了例子中用到的這幾個函數,Asyncio 還提供了很多其他的用法,你可以檢視 Python 事件迴圈官方文件進行了解。
Asyncio有缺陷嗎?
通過以上的學習,明顯看到了 Asyncio 的強大。但是,任何一種方案都不是完美的,都存在一定的局限性,Asyncio 同樣如此。
實際工作中,想用好 Asyncio,特別是發揮其強大的功能,很多情況下必須得有相應的 Python 庫支援。前面章節在學習多執行緒程式設計中使用的是 requests 庫,但本節使用的是 aiohttp 庫,原因在於 requests 庫並不相容 Asyncio,而 aiohttp 庫相容。Asyncio 軟體庫的相容性問題,在 Python3 的早期一直是個大問題,但是隨著技術的發展,這個問題正逐步得到解決。
另外,使用 Asyncio 時,因為在任務排程方面有了更大的自主權,寫程式碼時就得更加注意,不然很容易出錯。