天天看點

面試并發異步程式設計之争:協程(asyncio)到底需不需要加鎖?

作者:獨一無二的Python君
面試并發異步程式設計之争:協程(asyncio)到底需不需要加鎖?

協程與線程向來焦孟不離,但事實上是,線程更被我們所熟知,在Python程式設計領域,單核同時間内隻能有一個線程運作,這并不是什麼缺陷,這實際上是符合客觀邏輯的,單核處理器本來就沒法同時處理兩件事情,要同時進行多件事情本來就需要正在運作的讓出處理器,然後才能去處理另一件事情,左手畫方右手畫圓在現實中本來就不成立,隻不過這個讓出的過程是線程排程器主動搶占的。

線程安全

系統的線程排程器是假設不同的線程是毫無關系的,是以它平均地配置設定時間片讓處理器一視同仁,雨露均沾。但是Python受限于GIL全局解釋器鎖,任何Python線程執行前,必須先獲得GIL鎖,然後,每執行100條位元組碼,解釋器就自動釋放GIL鎖,讓别的線程有機會執行。這個GIL全局解釋器鎖實際上把所有線程的執行代碼都給上了鎖,是以,多線程在Python中隻能交替執行,即使多個線程跑在8核處理上,也隻能用到1個核。

但其實,這并不是事情的全貌,就算隻能用單核處理任務,多個線程之前也并不是完全獨立的,它們會操作同一個資源。于是,大家又發明了同步鎖,使得一段時間内隻有一個線程可以操作這個資源,其他線程隻能等待:

import threading  
  
balance = 0  
  
def change_it_without_lock(n):  
    global balance  
    # 不加鎖的話 最後的值不是0  
    # 線程共享資料危險在于 多個線程同時改同一個變量  
    # 如果每個線程按順序執行,那麼值會是0, 但是線程時系統排程,又不确定性,交替進行  
    # 沒鎖的話,同時修改變量  
    # 是以加鎖是為了同時隻有一個線程再修改,别的線程表一定不能改  
    for i in range(1000000):  
        balance = balance + n  
        balance = balance - n  
  
def change_it_with_lock(n):  
    global balance  
    if lock.acquire():  
        try:  
            for i in range(1000000):  
                balance = balance + n  
                balance = balance - n  
        # 這裡的finally 防止中途出錯了,也能釋放鎖  
        finally:  
            lock.release()  
  
threads = [  
    threading.Thread(target=change_it_with_lock, args=(8, )),  
    threading.Thread(target=change_it_with_lock, args=(10, ))  
]  
  
lock = threading.Lock()  
  
[t.start() for t in threads]  
[t.join() for t in threads]  
  
print(balance)
複制代碼           

這種異步程式設計方式被廣大開發者所認可,線程并不安全,線程操作共享資源需要加鎖。然而人們很快發現,這種處理方式是在畫蛇添足,處理器本來同一時間就隻能有一個線程在運作。是線程排程器搶占劃分時間片給其他線程跑,而現在,多了把鎖,其他線程又說我拿不到鎖,我得拿到鎖才能操作。

就像以前的公共電話亭,本來就隻能一個人打電話,現在電話亭上加了把鎖,還是隻能一個人打電話,而有沒有鎖,有什麼差別呢?是以,問題到底出在哪兒?

事實上,在所有線程互相獨立且不會操作同一資源的模式下,搶占式的線程排程器是非常不錯的選擇,因為它可以保證所有的線程都可以被分到時間片不被垃圾代碼所拖累。而如果操作同一資源,搶占式的線程就不那麼讓人愉快了。

協程

過了一段時間,人們發現經常需要異步操作共享資源的情況下,主動讓出時間片的協程模式比線程搶占式配置設定的效率要好,也更簡單。

從實際開發角度看,與線程相比,這種主動讓出型的排程方式更為高效。一方面,它讓調用者自己來決定什麼時候讓出,比作業系統的搶占式排程所需要的時間代價要小很多。後者為了能恢複現場會在切換線程時儲存相當多的狀态,并且會非常頻繁地進行切換。另一方面,協程本身可以做成使用者态,每個協程的體積比線程要小得多,是以一個程序可以容納數量相當可觀的協程任務。

import asyncio  
  
balance = 0  
  
async def change_it_without_lock(n):  
  
    global balance  
  
    balance = balance + n  
    balance = balance - n  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(  
    asyncio.gather(change_it_without_lock(10), change_it_without_lock(8),  
                   change_it_without_lock(2), change_it_without_lock(7)))  
  
print(balance)
複制代碼           

從代碼結構上看,協程保證了編寫過程中的思維連貫性,使得函數(閉包)體本身就無縫保持了程式狀态。邏輯緊湊,可讀性高,不易寫出錯的代碼,可調試性強。

但歸根結底,單核處理器還是同時間隻能做一件事,是以同一時間點還是隻能有一個協程任務運作,它和線程的最主要差别就是,協程是主動讓出使用權,而線程是搶占使用權,即所謂的,協程是使用者态,線程是系統态。

面試并發異步程式設計之争:協程(asyncio)到底需不需要加鎖?

同時,如圖所示,協程本身就是單線程的,即不會觸發系統的全局解釋器鎖(GIL),同時也不需要系統的線程排程器參與搶占式的排程,避免了多線程的上下文切換,是以它的性能要比多線程好。

協程安全

回到并發競争帶來的安全問題上,既然同一時間隻能有一個協程任務運作,并且協程切換并不是系統态搶占式,那麼協程一定是安全的:

import asyncio  
  
balance = 0  
  
async def change_it_without_lock(n):  
  
    global balance  
  
    balance = balance + n  
    balance = balance - n  
  
    print(balance)  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(  
    asyncio.gather(change_it_without_lock(10), change_it_without_lock(8),  
                   change_it_without_lock(2), change_it_without_lock(7)))  
  
print(balance)
複制代碼           

運作結果:

0  
0  
0  
0  
0  
liuyue:as-master liuyue$
複制代碼           

看起來是這樣的,無論是執行過程中,還是最後執行結果,都保證了其狀态的一緻性。

于是,協程操作共享變量不需要加鎖的結論開始在坊間流傳。

毫無疑問,誰主張,誰舉證,上面的代碼也充分說明了這個結論的正确性,然而我們都忽略了一個客觀事實,那就是代碼中沒有“主動讓出使用權”的操作,所謂主動讓出使用權,即使用者主動觸發協程切換,那到底怎麼主動讓出使用權?使用 await 關鍵字。

await 是 Python 3.5版本開始引入了新的關鍵字,即Python3.4版本的yield from,它能做什麼?它可以在協程内部用await調用另一個協程實作異步操作,或者說的更簡單一點,它可以挂起目前協程任務,去手動異步執行另一個協程,這就是主動讓出“使用權”:

async def hello():  
    print("Hello world!")  
    r = await asyncio.sleep(1)  
    print("Hello again!")
複制代碼           

當我們執行第一句代碼print("Hello world!")之後,使用await關鍵字讓出使用權,也可以了解為把程式“暫時”挂起,此時使用權讓出以後,别的協程就可以進行執行,随後當我們讓出使用權1秒之後,當别的協程任務執行完畢,又或者别的協程任務也“主動”讓出了使用權,協程又可以切回來,繼續執行我們目前的任務,也就是第二行代碼print("Hello again!")。

了解了協程如何主動切換,讓我們繼續之前的邏輯:

import asyncio  
  
balance = 0  
  
async def change_it_without_lock(n):  
  
    global balance  
  
    balance = balance + n  
    await asyncio.sleep(1)  
    balance = balance - n  
  
    print(balance)  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(  
    asyncio.gather(change_it_without_lock(10), change_it_without_lock(8),  
                   change_it_without_lock(2), change_it_without_lock(7)))  
  
print(balance)
複制代碼           

邏輯有了些許修改,當我對全局變量balance進行加法運算後,主動釋放使用權,讓别的協程運作,随後立刻切換回來,再進行減法運算,如此往複,同時開啟四個協程任務,讓我們來看一下代碼運作結果:

17  
9  
7  
0  
0  
liuyue:mytornado liuyue$
複制代碼           

可以看到,協程運作過程中,并沒有保證“狀态一緻”,也就是一旦通過await關鍵字切換協程,變量的狀态并不會進行同步,進而導緻執行過程中變量狀态的“混亂狀态”,但是所有協程執行完畢後,變量balance的最終結果是0,意味着協程操作變量的最終一緻性是可以保證的。

為了對比,我們再用多線程試一下同樣的邏輯:

import threading  
import time  
  
balance = 0  
  
def change_it_without_lock(n):  
    global balance  
  
    for i in range(1000000):  
        balance = balance + n  
        balance = balance - n  
  
    print(balance)  
  
  
threads = [  
    threading.Thread(target=change_it_without_lock, args=(8, )),  
    threading.Thread(target=change_it_without_lock, args=(10, )),  
    threading.Thread(target=change_it_without_lock, args=(10, )),  
    threading.Thread(target=change_it_without_lock, args=(8, ))  
]  
  
[t.start() for t in threads]  
[t.join() for t in threads]  
  
print(balance)
複制代碼           

多線程邏輯執行結果:

liuyue:mytornado liuyue$ python3 "/Users/liuyue/wodfan/work/mytornado/test.py"  
28  
18  
10  
0  
8
複制代碼           

可以看到,多線程在未加鎖的情況下,連最終一緻性也無法保證,因為線程是系統态切換,雖然同時隻能有一個線程執行,但切換過程是争搶的,也就會導緻寫操作被原子性覆寫,而協程雖然在手動切換過程中也無法保證狀态一緻,但是可以保證最終一緻性呢?因為協程是使用者态,切換過程是協作的,是以寫操作不會被争搶覆寫,會被順序執行,是以肯定可以保證最終一緻性。

協程在工作狀态中,主動切換了使用權,而我們又想在執行過程中保證共享資料的強一緻性,該怎麼辦?毫無疑問,還是隻能加鎖:

import asyncio  
  
balance = 0  
  
async def change_it_with_lock(n):  
  
    async with lock:  
  
        global balance  
  
        balance = balance + n  
        await asyncio.sleep(1)  
        balance = balance - n  
  
        print(balance)  
  
  
lock = asyncio.Lock()  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(  
    asyncio.gather(change_it_with_lock(10), change_it_with_lock(8),  
                   change_it_with_lock(2), change_it_with_lock(7)))  
  
print(balance)
複制代碼           

協程加鎖執行後結果:

liuyue:mytornado liuyue$ python3 "/Users/liuyue/wodfan/work/mytornado/test.py"  
0  
0  
0  
0  
0
複制代碼           

是的,無論是結果,還是過程中,都保持了其一緻性,但是我們也付出了相應的代價,那就是任務又回到了線性同步執行,再也沒有異步的加持了。話說回來,世界上的事情本來就是這樣,本來就沒有兩全其美的解決方案,又要共享狀态,又想多協程,還想變量安全,這可能嗎?

協程是否需要加鎖

結論當然就是看使用場景,如果協程在操作共享變量的過程中,沒有主動放棄執行權(await),也就是沒有切換挂起狀态,那就不需要加鎖,執行過程本身就是安全的;可是如果在執行事務邏輯塊中主動放棄執行權了,會分兩種情況,如果在邏輯執行過程中我們需要判斷變量狀态,或者執行過程中要根據變量狀态進行一些下遊操作,則必須加鎖,如果我們不關注執行過程中的狀态,隻關注最終結果一緻性,則不需要加鎖。是的,抛開劑量談毒性,是不客觀的,給一個健康的人注射嗎啡是犯罪,但是給一個垂死的人注射嗎啡,那就是最大的道德,是以說,道德不是空泛的,脫離對象孤立存在的,同理,抛開場景談邏輯,也是不客觀的,協程也不是虛空的,脫離具體場景孤立存在的,我們應該養成具體問題具體分析的辯證唯物思想,隻有掌握了辯證的沖突思維才能更全面更靈活的看待問題,才能透過現象,把握本質。

繼續閱讀