為什麼 Python 這麼慢?

發表於2018-10-27

Python 現在越來越火,已經迅速擴張到包括 DevOps、資料科學、Web 開發、資訊保安等各個領域當中。

然而,相比起 Python 擴張的速度,Python 程式碼的執行速度就顯得有點遜色了。

在程式碼執行速度方面,Java、C、C++、C# 和 Python 要如何進行比較呢?並沒有一個放之四海而皆準的標準,因為具體結果很大程度上取決於執行的程式型別,而語言基準測試Computer Language Benchmarks Games可以作為衡量的一個方面

根據我這些年來進行語言基準測試的經驗來看,Python 比很多語言執行起來都要慢。無論是使用 JIT 編譯器的 C#、Java,還是使用 AOT 編譯器的 C、C++,又或者是 JavaScript 這些解釋型語言,Python 都比它們執行得慢

注意:對於文中的 “Python” ,一般指 CPython 這個官方的實現。當然我也會在本文中提到其它語言的 Python 實現。

我要回答的是這個問題:對於一個類似的程式,Python 要比其它語言慢 2 到 10 倍不等,這其中的原因是什麼?又有沒有改善的方法呢?

主流的說法有這些:

  • “是全域性直譯器鎖Global Interpreter Lock(GIL)的原因”
  • “是因為 Python 是解釋型語言而不是編譯型語言”
  • “是因為 Python 是一種動態型別的語言”

哪一個才是是影響 Python 執行效率的主要原因呢?

是全域性直譯器鎖的原因嗎?

現在很多計算機都配備了具有多個核的 CPU ,有時甚至還會有多個處理器。為了更充分利用它們的處理能力,作業系統定義了一個稱為執行緒的低階結構。某一個程式(例如 Chrome 瀏覽器)可以建立多個執行緒,在系統內執行不同的操作。在這種情況下,CPU 密集型程式就可以跨核心分擔負載了,這樣的做法可以大大提高應用程式的執行效率。

例如在我寫這篇文章時,我的 Chrome 瀏覽器開啟了 44 個執行緒。需要提及的是,基於 POSIX 的作業系統(例如 Mac OS、Linux)和 Windows 作業系統的執行緒結構、API 都是不同的,因此作業系統還負責對各個執行緒的排程。

如果你還沒有寫過多執行緒執行的程式碼,你就需要了解一下執行緒鎖的概念了。多執行緒程式比單執行緒程式更為複雜,是因為需要使用執行緒鎖來確保同一個記憶體地址中的資料不會被多個執行緒同時訪問或更改。

CPython 直譯器在建立變數時,首先會分配記憶體,然後對該變數的引用進行計數,這稱為引用計數reference counting。如果變數的引用數變為 0,這個變數就會從記憶體中釋放掉。這就是在 for 迴圈程式碼塊內建立臨時變數不會增加記憶體消耗的原因。

而當多個執行緒內共享一個變數時,CPython 鎖定引用計數的關鍵就在於使用了 GIL,它會謹慎地控制執行緒的執行情況,無論同時存在多少個執行緒,直譯器每次只允許一個執行緒進行操作。

這會對 Python 程式的效能有什麼影響?

如果你的程式只有單執行緒、單程式,程式碼的速度和效能不會受到全域性直譯器鎖的影響。

但如果你通過在單程式中使用多執行緒實現併發,並且是 IO 密集型(例如網路 IO 或磁碟 IO)的執行緒,GIL 競爭的效果就很明顯了。

為什麼 Python 這麼慢?

由 David Beazley 提供的 GIL 競爭情況圖http://dabeaz.blogspot.com/2010/01/python-gil-visualized.html

對於一個 web 應用(例如 Django),同時還使用了 WSGI,那麼對這個 web 應用的每一個請求都執行一個單獨的 Python 直譯器,而且每個請求只有一個鎖。同時因為 Python 直譯器的啟動比較慢,某些 WSGI 實現還具有“守護程式模式”,可以使 Python 程式一直就緒

其它的 Python 直譯器表現如何?

PyPy 也是一種帶有 GIL 的直譯器,但通常比 CPython 要快 3 倍以上。

Jython 則是一種沒有 GIL 的直譯器,這是因為 Jython 中的 Python 執行緒使用 Java 執行緒來實現,並且由 JVM 記憶體管理系統來進行管理。

JavaScript 在這方面又是怎樣做的呢?

所有的 Javascript 引擎使用的都是 mark-and-sweep 垃圾收集演算法,而 GIL 使用的則是 CPython 的記憶體管理演算法。

JavaScript 沒有 GIL,而且它是單執行緒的,也不需要用到 GIL, JavaScript 的事件迴圈和 Promise/Callback 模式實現了以非同步程式設計的方式代替併發。在 Python 當中也有一個類似的 asyncio 事件迴圈。

是因為 Python 是解釋型語言嗎?

我經常會聽到這個說法,但是這過於粗陋地簡化了 Python 所實際做的工作了。其實當終端上執行 python myscript.py 之後,CPython 會對程式碼進行一系列的讀取、語法分析、解析、編譯、解釋和執行的操作。

如果你對這一系列過程感興趣,也可以閱讀一下我之前的文章:在 6 分鐘內修改 Python 語言

.pyc 檔案的建立是這個過程的重點。在程式碼編譯階段,Python 3 會將位元組碼序列寫入 __pycache__/ 下的檔案中,而 Python 2 則會將位元組碼序列寫入當前目錄的 .pyc 檔案中。對於你編寫的指令碼、匯入的所有程式碼以及第三方模組都是如此。

因此,絕大多數情況下(除非你的程式碼是一次性的……),Python 都會解釋位元組碼並本地執行。與 Java、C#.NET 相比:

Java 程式碼會被編譯為“中間語言”,由 Java 虛擬機器讀取位元組碼,並將其即時編譯為機器碼。.NET CIL 也是如此,.NET CLR(Common-Language-Runtime)將位元組碼即時編譯為機器碼。

既然 Python 像 Java 和 C# 那樣都使用虛擬機器或某種位元組碼,為什麼 Python 在基準測試中仍然比 Java 和 C# 慢得多呢?首要原因是,.NET 和 Java 都是 JIT 編譯的。

即時Just-in-time(JIT)編譯需要一種中間語言,以便將程式碼拆分為多個塊(或多個幀)。而提前ahead of time(AOT)編譯器則需要確保 CPU 在任何互動發生之前理解每一行程式碼。

JIT 本身不會使執行速度加快,因為它執行的仍然是同樣的位元組碼序列。但是 JIT 會允許在執行時進行優化。一個優秀的 JIT 優化器會分析出程式的哪些部分會被多次執行,這就是程式中的“熱點”,然後優化器會將這些程式碼替換為更有效率的版本以實現優化。

這就意味著如果你的程式是多次重複相同的操作時,有可能會被優化器優化得更快。而且,Java 和 C# 是強型別語言,因此優化器對程式碼的判斷可以更為準確。

PyPy 使用了明顯快於 CPython 的 JIT。更詳細的結果可以在這篇效能基準測試文章中看到:哪一個 Python 版本最快?

那為什麼 CPython 不使用 JIT 呢?

JIT 也不是完美的,它的一個顯著缺點就在於啟動時間。 CPython 的啟動時間已經相對比較慢,而 PyPy 比 CPython 啟動還要慢 2 到 3 倍。Java 虛擬機器啟動速度也是出了名的慢。.NET CLR 則通過在系統啟動時啟動來優化體驗,而 CLR 的開發者也是在 CLR 上開發該作業系統。

因此如果你有個長時間執行的單一 Python 程式,JIT 就比較有意義了,因為程式碼裡有“熱點”可以優化。

不過,CPython 是個通用的實現。設想如果使用 Python 開發命令列程式,但每次呼叫 CLI 時都必須等待 JIT 緩慢啟動,這種體驗就相當不好了。

CPython 試圖用於各種使用情況。有可能實現將 JIT 插入到 CPython 中,但這個改進工作的進度基本處於停滯不前的狀態。

如果你想充分發揮 JIT 的優勢,請使用 PyPy。

是因為 Python 是一種動態型別的語言嗎?

在 C、C++、Java、C#、Go 這些靜態型別語言中,必須在宣告變數時指定變數的型別。而在動態型別語言中,雖然也有型別的概念,但變數的型別是可改變的。

在上面這個示例裡,Python 將變數 a 一開始儲存整數型別變數的記憶體空間釋放了,並建立了一個新的儲存字串型別的記憶體空間,並且和原來的變數同名。

靜態型別語言這樣的設計並不是為了為難你,而是為了方便 CPU 執行而這樣設計的。因為最終都需要將所有操作都對應為簡單的二進位制操作,因此必須將物件、型別這些高階的資料結構轉換為低階資料結構。

Python 也實現了這樣的轉換,但使用者看不到這些轉換,也不需要關心這些轉換。

不用必須宣告型別並不是為了使 Python 執行慢,Python 的設計是讓使用者可以讓各種東西變得動態:可以在執行時更改物件上的方法,也可以在執行時動態新增底層系統呼叫到值的宣告上,幾乎可以做到任何事。

但也正是這種設計使得 Python 的優化異常的難。

為了證明我的觀點,我使用了一個 Mac OS 上的系統呼叫跟蹤工具 DTrace。CPython 釋出版本中沒有內建 DTrace,因此必須重新對 CPython 進行編譯。以下以 Python 3.6.6 為例:

這樣 python.exe 將使用 DTrace 追蹤所有程式碼。Paul Ross 也作過關於 DTrace 的閃電演講。你可以下載 Python 的 DTrace 啟動檔案來檢視函式呼叫、執行時間、CPU 時間、系統呼叫,以及各種其它的內容。

py_callflow 追蹤器顯示了程式裡呼叫的所有函式。

那麼,Python 的動態型別會讓它變慢嗎?

  • 型別比較和型別轉換消耗的資源是比較多的,每次讀取、寫入或引用變數時都會檢查變數的型別
  • Python 的動態程度讓它難以被優化,因此很多 Python 的替代品能夠如此快都是為了提升速度而在靈活性方面作出了妥協
  • Cython 結合了 C 的靜態型別和 Python 來優化已知型別的程式碼,它可以將效能提升 84 倍

總結

由於 Python 是一種動態、多功能的語言,因此執行起來會相對緩慢。對於不同的實際需求,可以使用各種不同的優化或替代方案。

例如可以使用非同步,引入分析工具或使用多種直譯器來優化 Python 程式。

對於不要求啟動時間且程式碼可以充分利用 JIT 的程式,可以考慮使用 PyPy。

而對於看重效能並且靜態型別變數較多的程式,不妨使用 Cython

延伸閱讀

Jake VDP 的優秀文章(略微過時) https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/

Dave Beazley 關於 GIL 的演講 http://www.dabeaz.com/python/GIL.pdf

JIT 編譯器的那些事 https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers/

 

相關文章