Python?asyncio的一個坑
我們先從一個常見的Python編程錯誤開始說起,我已經(jīng)見過非常多的程序員犯過這種錯誤了:
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的異常結(jié)構(gòu)開始說起。Python中的異?;愑袃蓚€,最基礎(chǔ)的是BaseException,第二個是Exception(繼承BaseException)。這兩者有什么區(qū)別呢?
Exception代表大部分我們經(jīng)常會在業(yè)務(wù)邏輯中處理到的異常,也包括一部分運行出錯例如NameError、AttributeError等等。但是并不是所有的異常都是Exception類的子類,少數(shù)幾個異常是繼承于BaseException的:
- GeneratorExit
- SystemExit
- KeyboardInterrupt
第一個代表生成器被close()方法關(guān)閉,第二個代表系統(tǒng)退出(例如使用sys.exit),第三個代表程序被Ctrl+C中斷。之所以它們并不繼承于Exception,是因為:它們一般情況下絕不應(yīng)當(dāng)被捕獲,或者被捕獲之后應(yīng)當(dāng)立即reraise(通過不帶參數(shù)的raise語句)。
如果寫出上面那樣的語句,就可能會出現(xiàn)程序無法退出的情況:從外部發(fā)送SIGTERM信號到程序,觸發(fā)了SystemExit,然而SystemExit被捕獲然后忽略了,這樣程序就沒有正常退出,而是繼續(xù)執(zhí)行下去。像SystemExit、KeyboardInterrupt、GeneratorExit這樣的異常,因為沒有固定的拋出位置,所以如果亂捕獲的話非常危險,很可能產(chǎn)生隱含的bug,而且測試中會很難發(fā)現(xiàn)。這就是為什么Python官方文檔上會強調(diào),如果使用無參數(shù)的except,一定要配合raise重新將異常拋出。而正確的忽略執(zhí)行異常的方法應(yīng)該是:
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有什么聯(lián)系呢?
在asyncio當(dāng)中,一個異步過程可以通過asyncio.Task作為一個獨立執(zhí)行的單元啟動,這個Task對象有一個cancel()方法,可以將它從中途強制停止。類似的,異步生成器也可以通過aclose()方法強制結(jié)束。當(dāng)一個異步過程或者異步生成器被從外部強制中止的時候,會從當(dāng)前的await或者yield語句拋出asyncio.CancelledError。
問題就出在這個CancelledError上!
asyncio也許是為了偷懶,也許是為了和concurrent一致,這個異常實際上是concurrent.futures.CancelledError。它的基類是Exception,而不是BaseException。要知道,在concurrent庫當(dāng)中,CancelledError是不會拋到已經(jīng)開始了的子過程中的,它只會從future對象里拋出;而asyncio中,當(dāng)使用了cancel()方法的時候,這個異常會從Task的當(dāng)前堆棧位置拋出來。
這個事情就尷尬了,如果前面的do_not_raise是個異步方法,用 except Exception來捕獲了用戶自定義方法中的異常,那CancelledError也會被捕獲到。結(jié)果就是CancelledError被錯誤地忽略掉,導(dǎo)致cancel()方法沒有成功終止掉一個Task。
更尷尬的事情在于這個CancelledError的拋出機制。asyncio內(nèi)部使用了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()這里,會發(fā)生什么呢?實際上異常會從最內(nèi)層的func2開始拋出,從func2拋出到func1,再到func3的await t1,所以可以看到兩次traceback打印。
這就是異步方法中await的異常代理機制,它像同步調(diào)用一樣,有完整的堆棧,并且異常從最內(nèi)層拋出。這本身是一個很好的設(shè)計,很方便調(diào)試,但是一旦CancelledError拋出,你是無法確定它具體從哪條語句拋出的,這樣在寫異步邏輯的時候,實際上必須假設(shè)所有的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不被錯誤捕獲。
從這個結(jié)果上來看,CancelledError從一開始就不應(yīng)該繼承自Exception,它應(yīng)該是一個BaseException,這樣就可以減少很多異步編程中的錯誤。
并不是自己不調(diào)用cancel()就不會出現(xiàn)這樣的問題。一些會觸發(fā)cancel()過程的常見例子包括:
asyncio.wait_for在執(zhí)行超時的時候會自動cancel內(nèi)部的過程,這是一個很常用的實現(xiàn)超時邏輯的方法
aiohttp的handler,如果沒有處理完成之前用戶就關(guān)閉了HTTP連接(比如強制點了瀏覽器的停止按鈕),會對handler的異步過程調(diào)用cancel()
……
還有更尷尬的事情,許多時候我們不得不捕獲CancelledError。剛才的一段代碼,我故意沒有提,讀者們是否發(fā)現(xiàn)問題了呢?
t1.cancel()
try:
await t1
except CancelledError:
pass
在asyncio中,cancel()方法并不會立即結(jié)束一個異步Task,它只會拋出CancelledError,但是異步過程有機會使用except或者finally,在退出之前執(zhí)行一些清理過程。這里的await的本意也是等待t1完全退出再繼續(xù)。但是t1會拋出CancelledError,所以捕獲這個異常,不讓它再拋出。(而且如果不這么做,asyncio會打印一行warning,表示一個異步Task失敗沒有被處理)
那么問題就來了:如果func3()在執(zhí)行到這里的時候,又被外部代碼cancel()了呢?下面的except CancelledError就會變成問題,它會錯誤捕獲外部的CancelledError。另外,t1也會再次被cancel一遍(沒錯,await一個Task的時候,如果await所在過程被cancel,Task也會被cancel,需要使用asyncio.shield來規(guī)避)
正確的寫法應(yīng)該是:
t1.cancel()
await asyncio.wait([t1])
try:
await t1
except CancelledError:
pass
asyncio.wait等待Task執(zhí)行結(jié)束,但并不收集結(jié)果,因此內(nèi)層的CancelledError不會在這里拋出來,而且如果此時取消func3,CancelledError并不會被忽略。第二個await t1時,t1可以保證已經(jīng)結(jié)束,這里內(nèi)部沒有其他異步等待過程,因此CancelledError不會拋出在這里。也可以用t1.exception()之類代替。
到此這篇關(guān)于Python asyncio的一個坑的文章就介紹到這了,更多相關(guān)Python asyncio的一個坑內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
對pandas的算術(shù)運算和數(shù)據(jù)對齊實例詳解
今天小編就為大家分享一篇對pandas的算術(shù)運算和數(shù)據(jù)對齊實例詳解,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-12-12
使用Python高效獲取網(wǎng)絡(luò)數(shù)據(jù)的操作指南
網(wǎng)絡(luò)爬蟲是一種自動化程序,用于訪問和提取網(wǎng)站上的數(shù)據(jù),Python是進行網(wǎng)絡(luò)爬蟲開發(fā)的理想語言,擁有豐富的庫和工具,使得編寫和維護爬蟲變得簡單高效,本文將詳細介紹如何使用Python進行網(wǎng)絡(luò)爬蟲開發(fā),包括基本概念、常用庫、數(shù)據(jù)提取方法、反爬措施應(yīng)對以及實際案例2025-03-03
pytorch之torch.nn.Identity()的作用及解釋
這篇文章主要介紹了pytorch之torch.nn.Identity()的作用及解釋,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-08-08
深入了解和應(yīng)用Python 裝飾器 @decorator
在編程過程中,經(jīng)常遇到這樣的場景:登錄校驗,權(quán)限校驗,日志記錄等,這些功能代碼在各個環(huán)節(jié)都可能需要,但又十分雷同,通過裝飾器來抽象、剝離這部分代碼可以很好解決這類場景,這篇文章主要介紹了Python的裝飾器 @decorator,探討了使用的方式,需要的朋友可以參考下2019-04-04
python中pandas nlargest()的詳細用法小結(jié)
df.nlargest()是一個DataFrame的方法,用于返回DataFrame中最大的n個值所在的行,通過調(diào)用nlargest()方法,我們返回了分數(shù)最高的三個行,并按照降序排列,本文結(jié)合實例代碼給大家介紹的非常詳細,需要的朋友參考下吧2023-10-10
python內(nèi)置函數(shù)delattr()與dict()舉例詳解
這篇文章主要介紹了關(guān)于python內(nèi)置函數(shù)delattr()與dict()的相關(guān)資料,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-08-08
python中的單引號雙引號區(qū)別知識點總結(jié)
在本篇文章中小編給大家整理了關(guān)于python中的單引號雙引號有什么區(qū)別的相關(guān)知識點以及實例代碼,需要的朋友們參考下。2019-06-06

