歡迎光臨
每天分享高質量文章

為什麼 Python 這麼慢? | Linux 中國

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


致謝
編譯自 | 
https://hackernoon.com/why-is-python-so-slow-e5074b6fe55b
 
 作者 | Anthony Shaw
 譯者 | Hank Chow (HankChow) ???共計翻譯:20.0 篇 貢獻時間:332 天

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

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

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

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

註意:對於文中的 “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 行程一直就緒[6]

其它的 Python 解釋器表現如何?

PyPy 也是一種帶有 GIL 的解釋器[7],但通常比 CPython 要快 3 倍以上。

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

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

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

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

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

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

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

.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 版本最快?[11]

那為什麼 CPython 不使用 JIT 呢?

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

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

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

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

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

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

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

  1. a = 1

  2. a = "foo"

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

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

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

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

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

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

  1. wget https://github.com/python/cpython/archive/v3.6.6.zip

  2. unzip v3.6.6.zip

  3. cd v3.6.6

  4. ./configure --with-dtrace

  5. make

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

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

py_callflow 追蹤器顯示[14]了程式里呼叫的所有函式。

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

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

總結

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

例如可以使用異步,引入分析工具或使用多種解釋器來優化 Python 程式。

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

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

延伸閱讀

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[21] 選題:oska874 譯者:HankChow 校對:wxy

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

赞(0)

分享創造快樂