非同步程式設計 101: 是什麼、小試Python asyncio

liaochangjiang發表於2019-04-27

什麼是非同步程式設計?

注:本文說的同時是一個直觀上感覺的概念,只是為了簡化,不是嚴格意義上的同一時刻。

同步程式碼(synchrnous code)我們都很熟悉,就是執行完一個步驟再執行下一個。要在同步程式碼裡面實現"同時"執行多個任務,最簡單也是最直觀地方式就是執行多個 threads 或者多個 processes。這個層次的『同時執行』多個任務,是作業系統協助完成的。 也就是作業系統的任務排程系統來決定什麼時候執行這個任務,什麼時候切換任務,你自己,作為一個應用層的程式設計師,是沒辦法進行干預的。

我相信你也已經聽說了什麼關於 thread 和 process 的抱怨:process 太重,thread 又要牽涉到很多頭條的鎖問題。尤其是對於一個 Python 開發者來說,由於GIL(全域性直譯器鎖)的存在,多執行緒無法真正使用多核,如果你用多執行緒來執行計算型任務,速度會更慢。

非同步程式設計與之不同的是,值使用一個程式,不使用 threads,但是也能實現"同時"執行多個任務(這裡的任務其實就是函式)。

這些函式有一個非常 nice 的 feature:必要的可以暫停,把執行的權利交給其他函式。等到時機恰當,又可以恢復之前的狀態繼續執行。這聽上去是不是有點像程式呢?可以暫停,可以恢復執行。只不過程式的排程是作業系統完成的,這些函式的排程是程式自己(或者說程式設計師你自己)完成的。這也就意味著這將省去了很多計算機的資源,因為程式的排程必然需要大量 syscall,而 syscall 是很昂貴的。

非同步程式設計注意事項

有一點是需要格外注意的,非同步程式碼裡面不能用任何會 block 的函式!也就是說你的程式碼裡面不應該出現下面這些:

  • time.sleep()
  • 會阻塞的 socket
  • requests.get()
  • 會阻塞的資料庫呼叫

為什麼呢?在用 thread 或 process 的時候,程式碼阻塞了有作業系統來幫你排程,所以才不會出現『一處阻塞,處處傻等』的情況。

但是現在,對於作業系統來說,你的程式就是一個普通的程式,他並不知道你分了哪些不同的任務,一切都要靠你自己了。如果你的程式碼裡出現了阻塞的呼叫,那麼其他部分確實就是傻傻地等著。(等下判斷一下這會不會出錯)。

小試Python asyncio

Python 版本支援情況

  • asyncio 模組在 Python3.4 時釋出。
  • async 和 await 關鍵字最早在 Python3.5中引入。
  • Python3.3之前不支援。

開始動手敲程式碼

同步版本

就是一個簡單的訪問百度首頁100次,然後列印狀態碼。

import time
import requests

def visit_sync():
    start = time.time()
    for _ in range(100):
        r = requests.get(URL)
        print(r.status_code)
    end = time.time()
    print("visit_sync tasks %.2f seconds" % (end - start))

if __name__ == '__main__':
    visit_sync()
複製程式碼

執行一下,發現使用了6.64秒。

非同步程式設計 101: 是什麼、小試Python asyncio

非同步版本

import time
import asyncio
import aiohttp

async def fetch_async(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            status_code = resp.status
            print(status_code)


async def visit_async():
    start = time.time()
    tasks = []
    for _ in range(100):
        tasks.append(fetch_async(URL))
    await asyncio.gather(*tasks)
    end = time.time()
    print("visit_async tasks %.2f seconds" % (end - start))


if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(visit_async())
複製程式碼

有幾點說明一下:

  • 網路訪問的部分變了,前面用的是 requests.get(),這裡用的是 aiohttp(不是標準庫需要自己安裝)。
  • 呼叫函式的方式變了,前面通過visit_sync()就可以直接執行,非同步程式碼中不能直接visit_async(),這會提示你一個 warning:

非同步程式設計 101: 是什麼、小試Python asyncio

如果列印一下visit_async()返回值的型別可以看到,這是一個coroutine(協程)。

非同步程式設計 101: 是什麼、小試Python asyncio

正常的姿勢是呼叫await visit_async(),就想程式碼中await asyncio.gather(*tasks)一樣。但是比較麻煩的一點是await只有在以關鍵字async定義的函式裡面使用,而我們的if __name__ == "__main__"裡面沒有函式,所以可以把這個 coroutine傳給一個 eventloop。

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

執行之後發現,耗時0.34秒,效率提升20多倍。(關於如何有逼格地分析非同步效率,可以參考前面寫過的一篇文章。)

非同步程式設計 101: 是什麼、小試Python asyncio

總結一下

事實上,這篇文章已經引出了非同步程式設計中一個重要的概念:協程。『非同步程式設計101』系列文章後面還會花很多篇幅說一說一下協程。

協程"同時"執行多個任務的基礎是函式可以暫停(後面我們會講到這一點是如何實現的,Python 中是通過 yield)。上面的程式碼中使用到了asyncio的 event_loop,它做的事情,本質上來說就是當函式暫停時,切換到下一個任務,當時機恰當(這個例子中是請求完成了)恢復函式讓他繼續執行(這有點像作業系統了)。

這相比使用多執行緒或多程式,把排程地任務交給作業系統,在效能上有極大的優勢,因為不需要大量的 syscall。同時又解決了多執行緒資料共享帶來的鎖的問題。而且作為一個應用程式開發者,你應該是要比作業系統更懂,哪些時候進行任務切換。

我個人覺得,新時代的程式設計師,有兩點技能是非常重要的:非同步程式設計的能力和利用多核系統的能力。

覺得不錯點個 star?

非同步程式設計 101: 是什麼、小試Python asyncio

我的公眾號:全棧不存在的

非同步程式設計 101: 是什麼、小試Python asyncio

相關文章