Python asyncio的一個坑
我們先從一個常見的Python
編程錯誤開始說起,我已經見過非常多的程序員犯過這種錯誤瞭:
def do_not_raise(user_defined_logic): try: user_defined_logic() except: logger.warning("User defined logic raises an exception", exc_info=True) # ignore
這段代碼的錯誤之處在哪裡呢?
我們從Python
的異常結構開始說起。Python
中的異常基類有兩個,最基礎的是BaseException
,第二個是Exception
(繼承BaseException
)。這兩者有什麼區別呢?
Exception
代表大部分我們經常會在業務邏輯中處理到的異常,也包括一部分運行出錯例如NameError
、AttributeError
等等。但是並不是所有的異常都是Exception
類的子類,少數幾個異常是繼承於BaseException
的:
- GeneratorExit
- SystemExit
- KeyboardInterrupt
第一個代表生成器被close()
方法關閉,第二個代表系統退出(例如使用sys.exit
),第三個代表程序被Ctrl+C
中斷。之所以它們並不繼承於Exception
,是因為:它們一般情況下絕不應當被捕獲,或者被捕獲之後應當立即reraise
(通過不帶參數的raise語句)。
如果寫出上面那樣的語句,就可能會出現程序無法退出的情況:從外部發送SIGTERM信號到程序,觸發瞭SystemExit,然而SystemExit被捕獲然後忽略瞭,這樣程序就沒有正常退出,而是繼續執行下去。像SystemExit、KeyboardInterrupt、GeneratorExit這樣的異常,因為沒有固定的拋出位置,所以如果亂捕獲的話非常危險,很可能產生隱含的bug,而且測試中會很難發現。這就是為什麼Python官方文檔上會強調,如果使用無參數的except
,一定要配合raise重新將異常拋出。而正確的忽略執行異常的方法應該是:
def do_not_raise(user_defined_logic): try: user_defined_logic() except Exception: ### <= Notice here ### logger.warning("User defined logic raises an exception", exc_info=True) # ignore
那麼說瞭這麼多,跟asyncio有什麼聯系呢?
在asyncio
當中,一個異步過程可以通過asyncio.Task
作為一個獨立執行的單元啟動,這個Task對象有一個cancel()方法,可以將它從中途強制停止。類似的,異步生成器也可以通過aclose()
方法強制結束。當一個異步過程或者異步生成器被從外部強制中止的時候,會從當前的await
或者yield
語句拋出asyncio.CancelledError
。
問題就出在這個CancelledError上!
asyncio也許是為瞭偷懶,也許是為瞭和concurrent
一致,這個異常實際上是concurrent.futures.CancelledError
。它的基類是Exception
,而不是BaseException
。要知道,在concurrent
庫當中,CancelledError
是不會拋到已經開始瞭的子過程中的,它隻會從future對象裡拋出;而asyncio中,當使用瞭cancel()方法的時候,這個異常會從Task的當前堆棧位置拋出來。
這個事情就尷尬瞭,如果前面的do_not_raise
是個異步方法,用 except Exception
來捕獲瞭用戶自定義方法中的異常,那CancelledError
也會被捕獲到。結果就是CancelledError
被錯誤地忽略掉,導致cancel()方法沒有成功終止掉一個Task。
更尷尬的事情在於這個CancelledError
的拋出機制。asyncio
內部使用瞭Python的生成器和yield from
機制,yield from可以自動代理異常,
為瞭說明這一點我們考慮下面的代碼:
import traceback import asyncio async def func1(): try: return await func2() except Exception: traceback.print_exc() raise async def func2(): try: await asyncio.sleep(2) except Exception: traceback.print_exc() raise async def func3(): t1 = asyncio.ensure_future(func1()) await asyncio.sleep(1) t1.cancel() try: await t1 except CancelledError: pass
在t1.cancel()
這裡,會發生什麼呢?實際上異常會從最內層的func2開始拋出,從func2拋出到func1,再到func3的await t1,所以可以看到兩次traceback
打印。
這就是異步方法中await的異常代理機制,它像同步調用一樣,有完整的堆棧,並且異常從最內層拋出。這本身是一個很好的設計,很方便調試,但是一旦CancelledError
拋出,你是無法確定它具體從哪條語句拋出的,這樣在寫異步邏輯的時候,實際上必須假設所有的await語句都有可能拋出CancelledError。如果在外面加上瞭前面的do_not_raise
這樣的機制,就會錯誤地忽略掉CancelledError
。
所以異步邏輯中的忽略異常必須寫成:
async def do_not_raise(user_defined_coroutine): try: await user_defined_coroutine except CancelledError: raise except Exception: logger.warning("User defined logic raises an exception", exc_info=True) # ignore
這樣才能保證CancelledError
不被錯誤捕獲。
從這個結果上來看,CancelledError
從一開始就不應該繼承自Exception
,它應該是一個BaseException
,這樣就可以減少很多異步編程中的錯誤。
並不是自己不調用cancel()就不會出現這樣的問題。一些會觸發cancel()過程的常見例子包括:
asyncio.wait_for
在執行超時的時候會自動cancel內部的過程,這是一個很常用的實現超時邏輯的方法
aiohttp的handler
,如果沒有處理完成之前用戶就關閉瞭HTTP連接(比如強制點瞭瀏覽器的停止按鈕),會對handler的異步過程調用cancel()
……
還有更尷尬的事情,許多時候我們不得不捕獲CancelledError
。剛才的一段代碼,我故意沒有提,讀者們是否發現問題瞭呢?
t1.cancel() try: await t1 except CancelledError: pass
在asyncio中,cancel()方法並不會立即結束一個異步Task,它隻會拋出CancelledError
,但是異步過程有機會使用except
或者finally,在退出之前執行一些清理過程。這裡的await的本意也是等待t1完全退出再繼續。但是t1會拋出CancelledError
,所以捕獲這個異常,不讓它再拋出。(而且如果不這麼做,asyncio
會打印一行warning
,表示一個異步Task失敗沒有被處理)
那麼問題就來瞭:如果func3()在執行到這裡的時候,又被外部代碼cancel()
瞭呢?下面的except CancelledError
就會變成問題,它會錯誤捕獲外部的CancelledError
。另外,t1也會再次被cancel一遍(沒錯,await一個Task的時候,如果await所在過程被cancel,Task也會被cancel,需要使用asyncio.shield
來規避)
正確的寫法應該是:
t1.cancel() await asyncio.wait([t1]) try: await t1 except CancelledError: pass
asyncio.wait
等待Task執行結束,但並不收集結果,因此內層的CancelledError
不會在這裡拋出來,而且如果此時取消func3,CancelledError
並不會被忽略。第二個await t1時,t1可以保證已經結束,這裡內部沒有其他異步等待過程,因此CancelledError
不會拋出在這裡。也可以用t1.exception()
之類代替。
到此這篇關於Python asyncio的一個坑的文章就介紹到這瞭,更多相關Python asyncio的一個坑內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- python asyncio 協程庫的使用
- Python協程asyncio異步編程筆記分享
- Python協程asyncio模塊的演變及高級用法
- python中Task封裝協程的知識點總結
- python協程與 asyncio 庫詳情