為啥Python運(yùn)行速度這么慢 ?
作者:Anthony Shaw 是 Python 軟件基金會(huì)成員和 Apache 基金會(huì)成員。
近來(lái) Python 可謂人氣驟升。這門編程語(yǔ)言用于開(kāi)發(fā)運(yùn)維(DevOps)、數(shù)據(jù)科學(xué)、網(wǎng)站開(kāi)發(fā)和安全。
然而,它沒(méi)有因速度而贏得任何獎(jiǎng)牌。
Java 在速度方面與C、C++、C#或 Python 相比如何?答案很大程度上取決于你運(yùn)行的應(yīng)用程序的類型。沒(méi)有哪個(gè)基準(zhǔn)測(cè)試程序盡善盡美,不過(guò) The Computer Language Benchmarks Game(計(jì)算機(jī)語(yǔ)言基準(zhǔn)測(cè)試游戲)是個(gè)不錯(cuò)的起點(diǎn)。
十多年來(lái),我一直提到計(jì)算機(jī)語(yǔ)言基準(zhǔn)測(cè)試游戲;與 Java、C#、Go、JavaScript 和 C++ 等其他語(yǔ)言相比,Python 是速度最慢的語(yǔ)言之一。除了 JavaScript 等解釋語(yǔ)言外,這還包括 JIT(C#和 Java)以及 AOT(C和C++)編譯器。
注意:我說(shuō)“Python”時(shí),其實(shí)指這種語(yǔ)言的參考實(shí)現(xiàn):CPython。我會(huì)在本文中提到其他運(yùn)行時(shí)環(huán)境。
我想回答這個(gè)問(wèn)題:Python 運(yùn)行完成類似的應(yīng)用程序比另一種語(yǔ)言慢 2 倍至 10 倍時(shí),為什么它這么慢,我們能不能讓它更快些?
下面是幾種常見(jiàn)的說(shuō)法:
-
“它是 GIL(全局解釋器鎖)”
-
“這是由于它是解釋的,而非編譯”
-
“這是由于它是一種動(dòng)態(tài)類型語(yǔ)言”
那么,到底上述哪個(gè)原因?qū)π阅軒?lái)的影響***?
“它是 GIL”
現(xiàn)代計(jì)算機(jī)搭載擁有多個(gè)內(nèi)核的 CPU,有時(shí)搭載多個(gè)處理器。為了利用所有這些額外的處理能力,操作系統(tǒng)定義了一種名為線程的低級(jí)結(jié)構(gòu):一個(gè)進(jìn)程(比如 Chrome 瀏覽器)可能生成多個(gè)線程,并擁有針對(duì)內(nèi)部系統(tǒng)的指令。這樣一來(lái),如果某個(gè)進(jìn)程特別耗費(fèi) CPU 資源,該負(fù)載可以在諸多核心之間分擔(dān),這實(shí)際上讓大多數(shù)應(yīng)用程序更快地完成任務(wù)。
我在寫這篇文章時(shí),我的 Chrome 瀏覽器有 44 個(gè)線程開(kāi)著。請(qǐng)記住這點(diǎn):線程的結(jié)構(gòu)和 API 在基于 POSIX 的操作系統(tǒng)(比如 Mac OS 和 Linux)與 Windows OS 之間是不同的。操作系統(tǒng)還處理線程的調(diào)度。
如果你之前沒(méi)有從事過(guò)多線程編程,需要盡快熟悉的一個(gè)概念就是鎖(lock)。與單線程進(jìn)程不同,當(dāng)你需要確保改變內(nèi)存中的變量時(shí),多個(gè)線程并不同時(shí)試圖訪問(wèn)/改變同樣的內(nèi)存地址。
CPython 創(chuàng)建變量時(shí),它會(huì)分配內(nèi)存,然后計(jì)算該變量的引用有多少,這個(gè)概念名為引用計(jì)數(shù)(reference counting)。如果引用數(shù)為0,那么它從系統(tǒng)釋放這部分內(nèi)存。這就是為什么在某個(gè)代碼段(比如 for 循環(huán)的范圍)內(nèi)創(chuàng)建一個(gè)“臨時(shí)”變量不會(huì)搞砸應(yīng)用程序的內(nèi)存消耗。
當(dāng)變量在多個(gè)線程內(nèi)共享時(shí),就出現(xiàn)了這個(gè)難題:CPython 如何鎖定引用計(jì)數(shù)。有一個(gè)“全局解釋器鎖”,它小心地控制線程執(zhí)行。解釋器一次只能執(zhí)行一個(gè)操作,無(wú)論它有多少線程。
這對(duì) Python 應(yīng)用程序的性能來(lái)說(shuō)意味著什么?
如果你有單線程、單個(gè)解釋器的應(yīng)用程序,這對(duì)速度不會(huì)有影響。刪除 GIL 根本不會(huì)影響你代碼的性能。
如果你想通過(guò)使用線程機(jī)制在單個(gè)解釋器(Python 進(jìn)程)內(nèi)實(shí)現(xiàn)并發(fā)功能,而且線程是 IO 密集型(比如網(wǎng)絡(luò) IO 或磁盤 IO),你會(huì)看到 GIL 爭(zhēng)奪的后果。
上圖來(lái)自大衛(wèi)·比茲利(David Beazley)撰寫的《GIL 可視化》文章:
http://dabeaz.blogspot.com/2010/01/python-gil-visualized.html
如果你有 Web 應(yīng)用程序(比如 Django),又在使用 WSGI,那么針對(duì) Web 應(yīng)用程序的每個(gè)請(qǐng)求都是一個(gè)單獨(dú)的 Python 解釋器,所以每個(gè)請(qǐng)求只有一個(gè)鎖。由于 Python 解釋器啟動(dòng)緩慢,一些 WSGI 實(shí)現(xiàn)擁有“守護(hù)進(jìn)程模式”,這可以讓一個(gè)或多個(gè) Python 進(jìn)程為你保持活躍狀態(tài)。
其他 Python 運(yùn)行時(shí)環(huán)境怎么樣?
PyPy 有一個(gè) GIL,它通常比 CPython 快 3 倍。
Jython 之所以沒(méi)有 GIL,是由于 Jython 中的 Python 線程由 Java 線程表示,受益于 JVM 內(nèi)存管理系統(tǒng)。
JavaScript 如何執(zhí)行此任務(wù)?
好吧,首先所有 Javascript 引擎都使用標(biāo)記-清除(mark-and-sweep)垃圾收集機(jī)制。如上所述,GIL 的主要需求是 CPython 的內(nèi)存管理算法。
JavaScript 沒(méi)有 GIL,但它也是單線程的,所以它不需要內(nèi)存管理算法。JavaScript 的事件循環(huán)和承諾回調(diào)(Promise/Callback)模式是實(shí)現(xiàn)異步編程以代替并發(fā)的方法。Python 與 asyncio 事件循環(huán)有相似之處。
“這是由于它一種解釋語(yǔ)言”
我常聽(tīng)到這個(gè)觀點(diǎn),但覺(jué)得這過(guò)于簡(jiǎn)化了 CPython 的實(shí)際工作方式。如果你在終端上編寫了 python myscript.py,那么 CPython 會(huì)啟動(dòng)讀取、分析、解析、編譯、解釋和執(zhí)行代碼的一長(zhǎng)串操作。
如果你對(duì)這個(gè)過(guò)程的機(jī)理頗感興趣,我之前寫過(guò)一篇文章:《6 分鐘內(nèi)修改 Python 語(yǔ)言》(https://hackernoon.com/modifying-the-python-language-in-7-minutes-b94b0a99ce14)。
這個(gè)過(guò)程的一個(gè)重要節(jié)點(diǎn)是創(chuàng)建 .pyc 文件;在編譯階段,字節(jié)碼序列寫入到 Python 3 中__pycache__/里面的一個(gè)文件或 Python 2 中的同一個(gè)目錄。這不僅適用于你的腳本,還適用于導(dǎo)入的所有代碼,包括第三方模塊。
所以在大部分時(shí)間(除非你編寫的是只運(yùn)行一次的代碼?),Python 解釋字節(jié)碼,并在本地執(zhí)行。相比之下 Java 和C#.NET:
Java 編譯成一種“中間語(yǔ)言”,Java 虛擬機(jī)讀取字節(jié)碼,并即時(shí)編譯成機(jī)器碼。.NET CIL 也一樣,.NET 公共語(yǔ)言運(yùn)行時(shí)環(huán)境(CLR)使用即時(shí)編譯,將編譯后代碼編譯成機(jī)器碼。
那么,既然都使用虛擬機(jī)和某種字節(jié)碼,為什么 Python 在基準(zhǔn)測(cè)試中比 Java 和 C# 都要慢得多呢?首先,.NET 和 Java 是 JIT 編譯型的。
JIT 或即時(shí)編譯需要一種中間語(yǔ)言,以便將代碼拆分成塊(或幀)。提前(AOT)編譯器旨在確保 CPU 在任何交互發(fā)生之前能理解每一行代碼。
JIT 本身不會(huì)使執(zhí)行變得更快,因?yàn)樗匀粓?zhí)行相同的字節(jié)碼序列。然而,JIT 讓代碼在運(yùn)行時(shí)能夠加以優(yōu)化。一個(gè)好的 JIT 優(yōu)化器會(huì)看到應(yīng)用程序的哪些部分在頻繁執(zhí)行,這些代碼稱之為“熱點(diǎn)代碼”(hot spot)。然后,它會(huì)對(duì)這些代碼進(jìn)行優(yōu)化,其辦法是把它們換成更高效的版本。
這就意味著當(dāng)你的應(yīng)用程序一次又一次地執(zhí)行相同的操作時(shí),運(yùn)行速度可以顯著加快。另外記住一點(diǎn):Java 和 C# 是強(qiáng)類型語(yǔ)言,因此優(yōu)化器可以對(duì)代碼做出多得多的假設(shè)。
PyPy 有 JIT,如上所述,其速度比 CPython 快得多。這篇性能基準(zhǔn)測(cè)試文章作了更詳細(xì)的介紹:《哪個(gè) Python 版本的速度最快?》(https://hackernoon.com/which-is-the-fastest-version-of-python-2ae7c61a6b2b)。
那么,CPython 為什么不使用 JIT 呢?
JIT 存在幾個(gè)缺點(diǎn):缺點(diǎn)之一是啟動(dòng)時(shí)間。CPython 的啟動(dòng)時(shí)間已經(jīng)比較慢了,PyPy 的啟動(dòng)時(shí)間比 CPython 還要慢 2 倍至 3 倍。眾所周知,Java 虛擬機(jī)的啟動(dòng)速度很慢。.NET CLR 通過(guò)系統(tǒng)開(kāi)啟時(shí)啟動(dòng)解決了這個(gè)問(wèn)題,但 CLR 的開(kāi)發(fā)人員還開(kāi)發(fā)了操作系統(tǒng),CLR 在它上面運(yùn)行。
如果你有一個(gè) Python 進(jìn)程長(zhǎng)時(shí)間運(yùn)行,代碼因含有“熱點(diǎn)代碼”而可以優(yōu)化,那么 JIT 大有意義。
然而,CPython 是一種通用實(shí)現(xiàn)。所以,如果你在使用 Python 開(kāi)發(fā)命令行應(yīng)用程序,每次調(diào)用 CLI 都得等待 JIT 啟動(dòng)會(huì)慢得要命。
CPython 不得不試圖滿足盡可能多的用例(use case)。之前有人試過(guò)將 JIT 插入到 CPython 中,但這個(gè)項(xiàng)目基本上擱淺了。
如果你想獲得 JIT 的好處,又有適合它的工作負(fù)載,不妨使用 PyPy。
“這是由于它是一種動(dòng)態(tài)類型語(yǔ)言”
在“靜態(tài)類型”語(yǔ)言中,你在聲明變量時(shí)必須指定變量的類型。這樣的語(yǔ)言包括C、C++、Java、C#和 Go。
在動(dòng)態(tài)類型語(yǔ)言中,仍然存在類型這個(gè)概念,但變量的類型是動(dòng)態(tài)的。
在這個(gè)示例中,Python 創(chuàng)建了一個(gè)有相同名稱、str 類型的第二個(gè)變量,并釋放為a的***個(gè)實(shí)例創(chuàng)建的內(nèi)存。
靜態(tài)類型語(yǔ)言不是為了給你添堵而設(shè)計(jì)的,它們是兼顧 CPU 的運(yùn)行方式設(shè)計(jì)的。如果一切最終需要等同于簡(jiǎn)單的二進(jìn)制操作,你就得將對(duì)象和類型轉(zhuǎn)換成低級(jí)數(shù)據(jù)結(jié)構(gòu)。
Python 為你這么做這項(xiàng)工作,你永遠(yuǎn)看不到,也不需要操心。
不必聲明類型不是導(dǎo)致 Python 速度慢的原因,Python 語(yǔ)言的設(shè)計(jì)使你能夠讓幾乎一切都是動(dòng)態(tài)的。你可以通過(guò)猴子補(bǔ)?。╩onkey-patch),加入對(duì)運(yùn)行時(shí)聲明的值進(jìn)行低級(jí)系統(tǒng)調(diào)用的代碼。幾乎一切都有可能。
正是這種設(shè)計(jì)使得優(yōu)化 Python 異常困難。
為了說(shuō)明我的觀點(diǎn),我將使用可在 Mac OS 中使用的一種名為 Dtrace 的系統(tǒng)調(diào)用跟蹤工具。CPython 發(fā)行版并未內(nèi)置 DTrace,所以你得重新編譯 CPython。我使用 3.6.6 進(jìn)行演示。
現(xiàn)在 python.exe 將在整個(gè)代碼中使用 Dtrace 跟蹤器。保羅·羅斯(Paul Ross)寫了一篇關(guān)于 Dtrace 的雜談(https://github.com/paulross/dtrace-py#the-lightning-talk)。你可以下載 Python 的 DTrace 啟動(dòng)文件(https://github.com/paulross/dtrace-py/tree/master/toolkit)來(lái)測(cè)量函數(shù)調(diào)用、執(zhí)行時(shí)間、CPU 時(shí)間、系統(tǒng)調(diào)用和各種好玩的指標(biāo)。比如
py_callflow 跟蹤器顯示你應(yīng)用程序中的所有函數(shù)調(diào)用。
那么,Python 的動(dòng)態(tài)類型會(huì)讓它變慢嗎?
-
比較和轉(zhuǎn)換類型的開(kāi)銷很大,每次讀取、寫入或引用一個(gè)變量,都要檢查類型。
-
很難優(yōu)化一種***動(dòng)態(tài)性的語(yǔ)言。Python 的許多替代語(yǔ)言之所以快得多,原因在于它們?yōu)榱诵阅茉陟`活性方面作出了犧牲。
-
Cython 結(jié)合了C-Static 類型和 Python 來(lái)優(yōu)化類型已知的代碼,可以將性能提升 84 倍。
結(jié)論
Python 之所以速度慢,主要是由于動(dòng)態(tài)性和多功能性。它可用作解決各種問(wèn)題的工具,Python 有更優(yōu)化、速度更快的幾個(gè)替代方案。
然而,有一些方法可以優(yōu)化你的 Python 應(yīng)用程序,比如通過(guò)充分利用異步、深入了解分析工具以及考慮使用多個(gè)解釋器。
對(duì)于啟動(dòng)時(shí)間不重要、代碼會(huì)受益于 JIT 的應(yīng)用程序來(lái)說(shuō),不妨考慮 PyPy。
對(duì)于性能至關(guān)重要,又有更多靜態(tài)類型變量的部分代碼而言,不妨考慮使用 Cython。