在后臺(tái)的Python:眾多程序員無法攻克的難題
本文轉(zhuǎn)載自公眾號(hào)“讀芯術(shù)”(ID:AI_Discovery)
先看兩個(gè)超級(jí)簡(jiǎn)單的代碼。
- for i inrange(10**7):
- x = i %5
代碼1:簡(jiǎn)單代碼
- defmain():
- for i inrange(10**7):
- x = i %5
- main()
代碼2:定義了一個(gè)主函數(shù)來運(yùn)行相同的簡(jiǎn)單代碼。
兩個(gè)代碼都執(zhí)行一個(gè)虛擬任務(wù)。取0到1000萬之間的數(shù)字(通過for循環(huán)),并計(jì)算其模(余數(shù))為5,到目前為止操作非常簡(jiǎn)單。那么,測(cè)量代碼的運(yùn)行時(shí)間是多少呢?
- import time
- start_time = time.time()
- for i inrange(10**7):
- x = i %5
- finish_time = time.time()
- print("Duration:{} msec".format((finish_time-start_time)*1000))
在代碼1中添加一個(gè)簡(jiǎn)單的計(jì)時(shí)器
- import time
- defmain():
- for i inrange(10**7):
- x = i %5
- start_time = time.time()
- main()
- finish_time = time.time()
- print("Duration:{} msec".format((finish_time-start_time)*1000))
在代碼2中添加一個(gè)簡(jiǎn)單的計(jì)時(shí)器
在兩個(gè)代碼中添加一個(gè)簡(jiǎn)單的計(jì)時(shí)器來測(cè)量各自的運(yùn)行時(shí)間。由于兩個(gè)代碼執(zhí)行相同的簡(jiǎn)單任務(wù),預(yù)計(jì)運(yùn)行時(shí)間也相同。當(dāng)然,如果運(yùn)行時(shí)間真的相同,本文就沒有存在的必要了。事實(shí)上,代碼1和代碼2的運(yùn)行時(shí)間分別為739毫秒和434毫秒,驚訝吧!
很多Python程序員并不知道這個(gè)難題,因?yàn)檫@需要深入理解Python的運(yùn)行原理。本文就將解答“運(yùn)行python代碼時(shí)會(huì)發(fā)生什么?”,重點(diǎn)介紹很流行的Python工具CPython。如果你不知道正在使用何種Python工具,那么你90%使用的是CPython。
以下是運(yùn)行源代碼時(shí)的情況:
首先,源代碼通過“詞法分析”程序被分解成標(biāo)記,例如, x=1將被分解成x, =,和1。然后,通過“句法分析”的過程,這些標(biāo)記被組織成抽象語法樹(AST),之后“編譯器”將所有內(nèi)容轉(zhuǎn)換成為一個(gè)叫做“字節(jié)碼”的抽象代碼。
在Python中,不像C、C++、Java等語言,編譯器不會(huì)獲取“源代碼”并將其轉(zhuǎn)換為“機(jī)器代碼”,理解這一點(diǎn)很重要。與之相反,編譯器可接受“源代碼”并且將其轉(zhuǎn)換為“字節(jié)碼”。解釋器的任務(wù)是獲取字節(jié)碼并以機(jī)器能夠理解的方式運(yùn)行。
在Python運(yùn)行代碼的四個(gè)步驟中,解釋器負(fù)責(zé)最繁重的工作。而其他三個(gè)步驟不會(huì)處理太多的任務(wù)。因此,任何時(shí)候想要研究Python程序的性能時(shí),應(yīng)該查看解釋步驟并尋找一些線索。
解釋器讀取字節(jié)碼并運(yùn)行其指令。如果字節(jié)碼類似于菜譜,那么指令便是菜譜中的不同步驟。如果字節(jié)碼可讀取,就可能找到關(guān)于上述謎題的一些線索。使用 dis包來查看字節(jié)碼指令。dis是一個(gè)Python包,用于分析和解碼字節(jié)碼,并以人們可以理解的方式顯示出來。dis.dis() 的輸出結(jié)構(gòu)如下:

本文不詳細(xì)介紹dis包的細(xì)節(jié),只關(guān)注Operation Named的一列。Operation name指示Python解釋器的行為。如果你非常好奇,那么名為ceval.c的文件可以回答。以上兩個(gè)代碼都運(yùn)行了dis.dis(),為了簡(jiǎn)化操作,本文突出顯示重要部分,即循環(huán)部分。下圖顯示了這兩個(gè)代碼的字節(jié)碼:
如圖所示,兩個(gè)代碼在給定的指令方面非常相似。但是,仔細(xì)觀察,會(huì)發(fā)現(xiàn)字節(jié)碼中有一些細(xì)微的(但是很重要的)差異。在代碼1中,可以看到STORE_NAME和LOAD_NAME,但是在代碼2中,可以看到STORE_FAST和LOAD_FAST。運(yùn)行時(shí)間的差異似乎是由于這兩種指令類型的不同造成的??梢圆榭碿eval.c文件來了解其中的差異。
簡(jiǎn)而言之,在代碼1中,解釋器處理變量i和x的方式與代碼2不同(注意_NAME和_FAST后綴)。代碼1中,i和x都是全局變量,而CPython將這些變量存儲(chǔ)在字典數(shù)據(jù)結(jié)構(gòu)中,這使得加載過程比存儲(chǔ)在固定大小數(shù)組中的局部變量耗時(shí)更久。與字典相比,從固定大小的數(shù)組中檢索變量要快得多。
為什么Python這么做?很簡(jiǎn)單,因?yàn)樵谥鞔a中,不知道有多少變量會(huì)出現(xiàn),但是在一個(gè)函數(shù)中變量的數(shù)量是固定的。
如果這是原因所在,來做個(gè)測(cè)試:把解釋器打亂,在代碼2(快速代碼)中將x和i變量定義為全局變量,并再次測(cè)量運(yùn)行時(shí)間。這是改變后的代碼2:
- defmain():
- global i, x
- for i inrange(10**7):
- x = i %5
- main()
代碼3與代碼2相同,只是定義了變量i和x,以查看全局變量是否是導(dǎo)致難題代碼性能變慢的原因。
運(yùn)行代碼3,用時(shí)805毫秒(代碼2用時(shí)434 毫秒)。代碼3的用時(shí)非常接近于代碼1(即739毫秒)。這正如預(yù)計(jì)的,處理全局變量比處理局部變量(固定大小的數(shù)組與字典)花費(fèi)更多的時(shí)間。
如你所見,只需要了解一點(diǎn)關(guān)于Python解釋器的工作原理,以及從dis庫(kù)中得到幫助,這個(gè)難題即可迎刃而解。