首頁 > 軟體

Python非同步併發機制詳解,讓你的程式碼運行效率就像搭上了火箭!

2021-04-22 17:43:58

探究低層建築:asyncio

Python由於全局鎖(GIL)的存在,一直無法發揮多核的優勢,其效能一直飽受詬病。不過,在IO密集型的網路程式設計各種,非同步處理比同步處理能夠提升非常之高的速度。而相對於其他語言,Python還有一個很明顯的優勢,那就是它的庫很多啊!!!

Python3版本引入了async/await特性,其特點是:當執行過程中遇到IO請求的時候,可以將CPU資源出讓,運行其他的任務;待IO完成之後,繼續執行之前的任務。協程切換與執行緒切換比較類似,但協程切換更輕,不需要作業系統參與(沒有棧切換操作,也沒有使用者態與核心態切換)。

同步/非同步

在介紹協程之前,我還是再說一下同步和非同步的概念,如果對這兩個概念都混淆不清的話,下面的更不用說了。

==同步:序列。非同步:並行。==不要被字面意思所迷惑。

同步是指完成事務的邏輯,先執行第一個事務,如果阻塞了,會一直等待,直到這個事務完成,再執行第二個事務,順序執行。。。

非同步是和同步相對的,非同步是指在處理呼叫這個事務的之後,不會等待這個事務的處理結果,直接處理第二個事務去了,通過狀態、通知、回撥來通知呼叫者處理結果。

我再簡單的介紹一下協程:

瞭解一下協程

協程,英文Coroutines,是一種比執行緒更加輕量級的存在。正如一個程序可以擁有多個執行緒一樣,一個執行緒也可以擁有多個協程。

子程式,或者稱為函數,在所有語言中都是層級呼叫,比如A呼叫B,B在執行過程中又呼叫了C,C執行完畢返回,B執行完畢返回,最後是A執行完畢。

所以子程式呼叫是通過棧實現的,一個執行緒就是執行一個子程式。

子程式呼叫總是一個入口,一次返回,呼叫順序是明確的。而協程的呼叫和子程式不同。

協程看上去也是子程式,但執行過程中,在子程式內部可中斷,然後轉而執行別的子程式,在適當的時候再返回來接著執行。

注意,在一個子程式中中斷,去執行其他子程式,不是函數呼叫,有點類似CPU的中斷。比如子程式A、B:

假設由協程執行,在執行A的過程中,可以隨時中斷,去執行B,B也可能在執行過程中中斷再去執行A,結果可能是:

1 x 2 y 3 z

但是在A中是沒有呼叫B的,所以協程的呼叫比函數呼叫理解起來要難一些。

相對於執行緒,協程的優勢

最大的優勢就是協程極高的執行效率。因為子程式切換不是執行緒切換,而是由程式自身控制,因此,沒有執行緒切換的開銷,和多執行緒比,執行緒數量越多,協程的效能優勢就越明顯

第二大優勢就是不需要多執行緒的鎖機制,因為只有一個執行緒,也不存在同時寫變數衝突,在協程中控制共享資源不加鎖,只需要判斷狀態就好了,所以執行效率比多執行緒高很多。

因為協程是一個執行緒執行,那怎麼利用多核CPU呢?最簡單的方法是多程序+協程,既充分利用多核,又充分發揮協程的高效率,可獲得極高的效能。

同步程式碼轉非同步程式碼

以下為一段同步程式碼:

import time

def hello():

time.sleep(1)

def run():

for i in range(5):

hello()

print('Hello World:%s' % time.time()) # 任何偉大的程式碼都是從Hello World 開始的!

run()

以下是一段非同步程式碼:

import time

import asyncio

# 定義非同步函數

async def hello():

asyncio.sleep(1)

print('Hello World:%s' % time.time())

def run():

for i in range(5):

loop.run_until_complete(hello())

loop = asyncio.get_event_loop()

run()

通過asyncio講解協程

通過async def來定義一個協程函數,通過await來執行一個協程物件。協程物件、協程函數的概念如下所示:

async def func_1(): # 1. 定義了一個協程函數

pass

async def func_2(): # 2. 注意要在函數內部呼叫協程函數,自身也必須定義為協程

# 3. func_1()呼叫產生了一個協程物件,通過await來執行這個協程。如果不加await,

# 直接以func_1()方式呼叫,則func_1中程式碼並不會執行。

await func_1()

async def 用來定義非同步函數,其內部有非同步操作。每個執行緒有一個事件迴圈,主執行緒呼叫asyncio.get_event_loop()時會創建事件迴圈,你需要把非同步的任務丟給這個迴圈的run_until_complete()方法,事件迴圈會安排協同程式的執行。

一般情況下,無法在一個非協程函數中阻塞地呼叫另一個協程。但你可以通過asyncio.ensure_future()來非同步執行這個協程:

在一些框架中,會將某些函數定義為協程(即通過async修飾),這些函數都是在某個地方通過create_task,或者ensure_future來進行排程的。

協程鎖:協程之間也可能會有資源共享衝突。要防止資源共享衝突產生的資料一致性問題,需要使用asyncio.Lock。asyncio.Lock也遵從上下文管理協議。

協程睡眠:協程函數在執行中會佔用本執行緒的全部CPU時間,除非遇到IO切換出去。因此,如果你在函數中使用sleep(),在多執行緒中,一個執行緒進入sleep狀態,作業系統會切換到其它執行緒執行,整個程式仍然是可響應的(除了該執行緒,它必須等待睡眠狀態結束);而對協程來說,同一loop中的其它協程都不會得到執行,因為這個sleep會佔用本執行緒的全部執行時間,直到協程執行完畢。

上面的問題引出一個推論,也就是如果一個協程確實需要睡眠(比如某種定時任務),必須使用asyncio.sleep()

如果我們要通過asyncio來遠端呼叫一個服務,應該如何封裝呢?假設你使用的底層通訊的API是傳送和接收分離的(一般比較靠近底層的API都是這樣設計的),那麼你會面臨這樣的問題:當你通過非同步請求(比如send)發出API request後,伺服器的響應可能是通過on_message這樣的API來接收的。如何讓程式在呼叫send之後,就能得到(形式上)返回結果,然後根據返回結果繼續執行呢?

from typing import Dict

# 全局事件登錄檔。鍵為外發請求的track_id,該track_id需要伺服器在響應請求時傳回。

# 值為另一個dict,儲存著對應的asyncio.Event和網路請求的返回結果。這裡也可以使用list。

# 在強調效能的場合下,使用List[event: asyncio.Event, result: object]更好。

_events: Dict[str, Dict] = {}

# 定義阻塞呼叫的協程

async def sync_call(request):

event = asyncio.Event()

track_id = str(uuid.uuid4())

_events[track_id] = {

"events": event,

"result": None

}

# 傳送網路請求,以下僅為示例。具體網路請求要根據業務具體場景來替換。這一步一般是立即返回,

# 伺服器並沒有來得及準備好response

await aiohttp.request(...)

# L1: 阻塞地等待事件結果。當框架(或者你的網路例程)收到伺服器返回結果時,根據track_id

# 找到對應的event,觸發之

await event.wait()

# 獲取結果,並做清理

response = _events[track_id].get("result")

_events.pop(track_id)

return response

# 在框架(或者你的網路例程)的訊息接收處,比如on_message函數體中:

async def on_message(response):

# 如果伺服器不傳回track_id,則整個機制無法生效

track_id = response.get("track_id")

waited = _events.get(track_id)

if waited:

waited["result"] = response

waited["event"].set() # !這裡喚醒在L1處等待執行的

不能再深挖了,畢竟大家都是第一次接觸這個模組兒。

必須要再深挖,這裡麵包含了太多的後端設計思想,是一個很重要的模組兒。

但是不是在這篇裡面深挖,過幾天會再出一篇關於asyncio的底層原理的部落格,歡迎大家關注。

所以,程式碼到底怎麼寫?!!!

我相信,看了這麼久,還是沒有幾個人知道這玩意兒到底要怎麼寫程式碼。說實話,換我看了這麼多我也不知道啊。

沒事兒啊,重在理解嘛,是吧。

運行協程:

呼叫協程函數,協程並不會開始運行,只是返回一個協程物件,還會引發一條警告。

要讓這個協程物件運行的話,有兩種方式:

接下來就比較抽象了,需要一定的基礎了。

回撥

假如協程是一個 IO 的讀操作,我們希望知道它什麼時候結束運行,以便下一步資料的處理。這一需求可以通過往 future 添加回調來實現。

這兩個協程是併發運行的,所以等待的時間不是 1 + 3 = 4 秒,而是以耗時較長的那個協程為準。

關閉迴圈

loop 只要不關閉,就還可以再運行。但是如果關閉了,就不能再運行了。建議呼叫 loop.close,以徹底清理 loop 物件防止誤用。

這一篇就先到這裡啦,至於asyncio再往底層走,這週會更新的啦,能看到這裡的小夥伴不容易,需要多大的毅力啊。不準備收藏一下嗎?一次看這麼多,怕是很難一次性消化掉吧。

近期有很多朋友通過私信諮詢有關Python學習問題。為便於交流,點選藍色自己加入討論解答資源基地


IT145.com E-mail:sddin#qq.com