你知道為什么Python這么慢?
Python 現(xiàn)在越來越火,已經(jīng)迅速擴張到包括 DevOps、數(shù)據(jù)科學(xué)、Web 開發(fā)、信息安全等各個領(lǐng)域當(dāng)中。
然而,相比起 Python 擴張的速度,Python 代碼的運行速度就顯得有點遜色了。
在代碼運行速度方面,Java、C、C++、C# 和 Python 要如何進行比較呢?并沒有一個放之四海而皆準的標準,因為具體結(jié)果很大程度上取決于運行的程序類型,而語言基準測試可以作為衡量的一個方面。
根據(jù)我這些年來進行語言基準測試的經(jīng)驗來看,Python 比很多語言運行起來都要慢。無論是使用 JIT 編譯器的 C#、Java,還是使用 AOT 編譯器的 C、C++,又或者是 JavaScript 這些解釋型語言,Python 都比它們運行得慢。
注意:對于文中的 “Python” ,一般指 CPython 這個官方的實現(xiàn)。當(dāng)然我也會在本文中提到其它語言的 Python 實現(xiàn)。
我要回答的是這個問題:對于一個類似的程序,Python 要比其它語言慢 2 到 10 倍不等,這其中的原因是什么?又有沒有改善的方法呢?
主流的說法有這些:
- “是全局解釋器鎖(GIL)的原因”
- “是因為 Python 是解釋型語言而不是編譯型語言”
- “是因為 Python 是一種動態(tài)類型的語言”
哪一個才是是影響 Python 運行效率的主要原因呢?
是全局解釋器鎖的原因嗎?
現(xiàn)在很多計算機都配備了具有多個核的 CPU ,有時甚至還會有多個處理器。為了更充分利用它們的處理能力,操作系統(tǒng)定義了一個稱為線程的低級結(jié)構(gòu)。某一個進程(例如 Chrome 瀏覽器)可以建立多個線程,在系統(tǒng)內(nèi)執(zhí)行不同的操作。在這種情況下,CPU 密集型進程就可以跨核心分擔(dān)負載了,這樣的做法可以大大提高應(yīng)用程序的運行效率。
例如在我寫這篇文章時,我的 Chrome 瀏覽器打開了 44 個線程。需要提及的是,基于 POSIX 的操作系統(tǒng)(例如 Mac OS、Linux)和 Windows 操作系統(tǒng)的線程結(jié)構(gòu)、API 都是不同的,因此操作系統(tǒng)還負責(zé)對各個線程的調(diào)度。
如果你還沒有寫過多線程執(zhí)行的代碼,你就需要了解一下線程鎖的概念了。多線程進程比單線程進程更為復(fù)雜,是因為需要使用線程鎖來確保同一個內(nèi)存地址中的數(shù)據(jù)不會被多個線程同時訪問或更改。
CPython 解釋器在創(chuàng)建變量時,首先會分配內(nèi)存,然后對該變量的引用進行計數(shù),這稱為引用計數(shù)。如果變量的引用數(shù)變?yōu)?0,這個變量就會從內(nèi)存中釋放掉。這就是在 for 循環(huán)代碼塊內(nèi)創(chuàng)建臨時變量不會增加內(nèi)存消耗的原因。
而當(dāng)多個線程內(nèi)共享一個變量時,CPython 鎖定引用計數(shù)的關(guān)鍵就在于使用了 GIL,它會謹慎地控制線程的執(zhí)行情況,無論同時存在多少個線程,解釋器每次只允許一個線程進行操作。
這會對 Python 程序的性能有什么影響?
如果你的程序只有單線程、單進程,代碼的速度和性能不會受到全局解釋器鎖的影響。
但如果你通過在單進程中使用多線程實現(xiàn)并發(fā),并且是 IO 密集型(例如網(wǎng)絡(luò) IO 或磁盤 IO)的線程,GIL 競爭的效果就很明顯了。
由 David Beazley 提供的 GIL 競爭情況圖http://dabeaz.blogspot.com/2010/01/python-gil-visualized.html
對于一個 web 應(yīng)用(例如 Django),同時還使用了 WSGI,那么對這個 web 應(yīng)用的每一個請求都運行一個單獨的 Python 解釋器,而且每個請求只有一個鎖。同時因為 Python 解釋器的啟動比較慢,某些 WSGI 實現(xiàn)還具有“守護進程模式”,可以使 Python 進程一直就緒。
其它的 Python 解釋器表現(xiàn)如何?
PyPy 也是一種帶有 GIL 的解釋器,但通常比 CPython 要快 3 倍以上。
Jython 則是一種沒有 GIL 的解釋器,這是因為 Jython 中的 Python 線程使用 Java 線程來實現(xiàn),并且由 JVM 內(nèi)存管理系統(tǒng)來進行管理。
JavaScript 在這方面又是怎樣做的呢?
所有的 Javascript 引擎使用的都是 mark-and-sweep 垃圾收集算法,而 GIL 使用的則是 CPython 的內(nèi)存管理算法。
JavaScript 沒有 GIL,而且它是單線程的,也不需要用到 GIL, JavaScript 的事件循環(huán)和 Promise/Callback 模式實現(xiàn)了以異步編程的方式代替并發(fā)。在 Python 當(dāng)中也有一個類似的 asyncio 事件循環(huán)。
是因為 Python 是解釋型語言嗎?
我經(jīng)常會聽到這個說法,但是這過于粗陋地簡化了 Python 所實際做的工作了。其實當(dāng)終端上執(zhí)行 python myscript.py
之后,CPython 會對代碼進行一系列的讀取、語法分析、解析、編譯、解釋和執(zhí)行的操作。
如果你對這一系列過程感興趣,也可以閱讀一下我之前的文章:在 6 分鐘內(nèi)修改 Python 語言 。
.pyc
文件的創(chuàng)建是這個過程的重點。在代碼編譯階段,Python 3 會將字節(jié)碼序列寫入 __pycache__/
下的文件中,而 Python 2 則會將字節(jié)碼序列寫入當(dāng)前目錄的 .pyc
文件中。對于你編寫的腳本、導(dǎo)入的所有代碼以及第三方模塊都是如此。
因此,絕大多數(shù)情況下(除非你的代碼是一次性的……),Python 都會解釋字節(jié)碼并本地執(zhí)行。與 Java、C#.NET 相比:
Java 代碼會被編譯為“中間語言”,由 Java 虛擬機讀取字節(jié)碼,并將其即時編譯為機器碼。.NET CIL 也是如此,.NET CLR(Common-Language-Runtime)將字節(jié)碼即時編譯為機器碼。
既然 Python 像 Java 和 C# 那樣都使用虛擬機或某種字節(jié)碼,為什么 Python 在基準測試中仍然比 Java 和 C# 慢得多呢?首要原因是,.NET 和 Java 都是 JIT 編譯的。
即時(JIT)編譯需要一種中間語言,以便將代碼拆分為多個塊(或多個幀)。而提前(AOT)編譯器則需要確保 CPU 在任何交互發(fā)生之前理解每一行代碼。
JIT 本身不會使執(zhí)行速度加快,因為它執(zhí)行的仍然是同樣的字節(jié)碼序列。但是 JIT 會允許在運行時進行優(yōu)化。一個優(yōu)秀的 JIT 優(yōu)化器會分析出程序的哪些部分會被多次執(zhí)行,這就是程序中的“熱點”,然后優(yōu)化器會將這些代碼替換為更有效率的版本以實現(xiàn)優(yōu)化。
這就意味著如果你的程序是多次重復(fù)相同的操作時,有可能會被優(yōu)化器優(yōu)化得更快。而且,Java 和 C# 是強類型語言,因此優(yōu)化器對代碼的判斷可以更為準確。
PyPy 使用了明顯快于 CPython 的 JIT。更詳細的結(jié)果可以在這篇性能基準測試文章中看到:哪一個 Python 版本最快?。
那為什么 CPython 不使用 JIT 呢?
JIT 也不是***的,它的一個顯著缺點就在于啟動時間。 CPython 的啟動時間已經(jīng)相對比較慢,而 PyPy 比 CPython 啟動還要慢 2 到 3 倍。Java 虛擬機啟動速度也是出了名的慢。.NET CLR 則通過在系統(tǒng)啟動時啟動來優(yōu)化體驗,而 CLR 的開發(fā)者也是在 CLR 上開發(fā)該操作系統(tǒng)。
因此如果你有個長時間運行的單一 Python 進程,JIT 就比較有意義了,因為代碼里有“熱點”可以優(yōu)化。
不過,CPython 是個通用的實現(xiàn)。設(shè)想如果使用 Python 開發(fā)命令行程序,但每次調(diào)用 CLI 時都必須等待 JIT 緩慢啟動,這種體驗就相當(dāng)不好了。
CPython 試圖用于各種使用情況。有可能實現(xiàn)將 JIT 插入到 CPython 中,但這個改進工作的進度基本處于停滯不前的狀態(tài)。
如果你想充分發(fā)揮 JIT 的優(yōu)勢,請使用 PyPy。
是因為 Python 是一種動態(tài)類型的語言嗎?
在 C、C++、Java、C#、Go 這些靜態(tài)類型語言中,必須在聲明變量時指定變量的類型。而在動態(tài)類型語言中,雖然也有類型的概念,但變量的類型是可改變的。
a = 1
a = "foo"
在上面這個示例里,Python 將變量 a
一開始存儲整數(shù)類型變量的內(nèi)存空間釋放了,并創(chuàng)建了一個新的存儲字符串類型的內(nèi)存空間,并且和原來的變量同名。
靜態(tài)類型語言這樣的設(shè)計并不是為了為難你,而是為了方便 CPU 運行而這樣設(shè)計的。因為最終都需要將所有操作都對應(yīng)為簡單的二進制操作,因此必須將對象、類型這些高級的數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換為低級數(shù)據(jù)結(jié)構(gòu)。
Python 也實現(xiàn)了這樣的轉(zhuǎn)換,但用戶看不到這些轉(zhuǎn)換,也不需要關(guān)心這些轉(zhuǎn)換。
不用必須聲明類型并不是為了使 Python 運行慢,Python 的設(shè)計是讓用戶可以讓各種東西變得動態(tài):可以在運行時更改對象上的方法,也可以在運行時動態(tài)添加底層系統(tǒng)調(diào)用到值的聲明上,幾乎可以做到任何事。
但也正是這種設(shè)計使得 Python 的優(yōu)化異常的難。
為了證明我的觀點,我使用了一個 Mac OS 上的系統(tǒng)調(diào)用跟蹤工具 DTrace。CPython 發(fā)布版本中沒有內(nèi)置 DTrace,因此必須重新對 CPython 進行編譯。以下以 Python 3.6.6 為例:
wget https://github.com/python/cpython/archive/v3.6.6.zip
unzip v3.6.6.zip
cd v3.6.6
./configure --with-dtrace
make
這樣 python.exe
將使用 DTrace 追蹤所有代碼。Paul Ross 也作過關(guān)于 DTrace 的閃電演講。你可以下載 Python 的 DTrace 啟動文件來查看函數(shù)調(diào)用、執(zhí)行時間、CPU 時間、系統(tǒng)調(diào)用,以及各種其它的內(nèi)容。
sudo dtrace -s toolkit/<tracer>.d -c ‘../cpython/python.exe script.py’
py_callflow
追蹤器顯示了程序里調(diào)用的所有函數(shù)。
那么,Python 的動態(tài)類型會讓它變慢嗎?
- 類型比較和類型轉(zhuǎn)換消耗的資源是比較多的,每次讀取、寫入或引用變量時都會檢查變量的類型
- Python 的動態(tài)程度讓它難以被優(yōu)化,因此很多 Python 的替代品能夠如此快都是為了提升速度而在靈活性方面作出了妥協(xié)
- 而 Cython 結(jié)合了 C 的靜態(tài)類型和 Python 來優(yōu)化已知類型的代碼,它可以將性能提升 84 倍。
總結(jié)
由于 Python 是一種動態(tài)、多功能的語言,因此運行起來會相對緩慢。對于不同的實際需求,可以使用各種不同的優(yōu)化或替代方案。
例如可以使用異步,引入分析工具或使用多種解釋器來優(yōu)化 Python 程序。
對于不要求啟動時間且代碼可以充分利用 JIT 的程序,可以考慮使用 PyPy。
而對于看重性能并且靜態(tài)類型變量較多的程序,不妨使用 Cython。