Linux中國

為什麼 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 競爭的效果就很明顯了。

由 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 這些靜態類型語言中,必須在聲明變數時指定變數的類型。而在動態類型語言中,雖然也有類型的概念,但變數的類型是可改變的。

a = 1
a = "foo"

在上面這個示例里,Python 將變數 a 一開始存儲整數類型變數的內存空間釋放了,並創建了一個新的存儲字元串類型的內存空間,並且和原來的變數同名。

靜態類型語言這樣的設計並不是為了為難你,而是為了方便 CPU 運行而這樣設計的。因為最終都需要將所有操作都對應為簡單的二進位操作,因此必須將對象、類型這些高級的數據結構轉換為低級數據結構。

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

不用必須聲明類型並不是為了使 Python 運行慢,Python 的設計是讓用戶可以讓各種東西變得動態:可以在運行時更改對象上的方法,也可以在運行時動態添加底層系統調用到值的聲明上,幾乎可以做到任何事。

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

為了證明我的觀點,我使用了一個 Mac OS 上的系統調用跟蹤工具 DTrace。CPython 發布版本中沒有內置 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 也作過關於 DTrace 的閃電演講。你可以下載 Python 的 DTrace 啟動文件來查看函數調用、執行時間、CPU 時間、系統調用,以及各種其它的內容。

sudo dtrace -s toolkit/<tracer>.d -c 『../cpython/python.exe script.py』

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/

via: https://hackernoon.com/why-is-python-so-slow-e5074b6fe55b

作者:Anthony Shaw 選題:oska874 譯者:HankChow 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國