1. 程式人生 > >為啥 Python 執行速度這麼慢 ?

為啥 Python 執行速度這麼慢 ?

作者:Anthony Shaw 是 Python 軟體基金會成員和 Apache 基金會成員。

近來Python可謂人氣驟升。這門程式語言用於開發運維(DevOps)、資料科學、網站開發和安全。

然而,它沒有因速度而贏得任何獎牌。

Java在速度方面與C、C++、C#或Python相比如何?答案很大程度上取決於你執行的應用程式的型別。沒有哪個基準測試程式盡善盡美,不過The Computer Language Benchmarks Game(計算機語言基準測試遊戲)是個不錯的起點。

十多年來,我一直提到計算機語言基準測試遊戲;與Java、C#、Go、JavaScript和C++等其他語言相比,Python是速度最慢的語言之一。除了JavaScript等解釋語言外,這還包括JIT(C#和Java)以及AOT(C和C++)編譯器。

注意:我說“Python”時,其實指這種語言的參考實現:CPython。我會在本文中提到其他執行時環境。

我想回答這個問題:Python執行完成類似的應用程式比另一種語言慢2倍至10倍時,為什麼它這麼慢,我們能不能讓它更快些?

下面是幾種常見的說法:

  • “它是GIL(全域性直譯器鎖)”

  • “這是由於它是解釋的,而非編譯”

  • “這是由於它是一種動態型別語言”

那麼,到底上述哪個原因對效能帶來的影響最大?

“它是GIL”

現代計算機搭載擁有多個核心的CPU,有時搭載多個處理器。為了利用所有這些額外的處理能力,作業系統定義了一種名為執行緒的低階結構:一個程序(比如Chrome瀏覽器)可能生成多個執行緒,並擁有針對內部系統的指令。這樣一來,如果某個程序特別耗費CPU資源,該負載可以在諸多核心之間分擔,這實際上讓大多數應用程式更快地完成任務。

我在寫這篇文章時,我的Chrome瀏覽器有44個執行緒開著。請記住這點:執行緒的結構和API在基於POSIX的作業系統(比如Mac OS和Linux)與Windows OS之間是不同的。作業系統還處理執行緒的排程。

如果你之前沒有從事過多執行緒程式設計,需要儘快熟悉的一個概念就是鎖(lock)。與單執行緒程序不同,當你需要確保改變記憶體中的變數時,多個執行緒並不同時試圖訪問/改變同樣的記憶體地址。

CPython建立變數時,它會分配記憶體,然後計算該變數的引用有多少,這個概念名為引用計數(reference counting)。如果引用數為0,那麼它從系統釋放這部分記憶體。這就是為什麼在某個程式碼段(比如for迴圈的範圍)內建立一個“臨時”變數不會搞砸應用程式的記憶體消耗。

當變數在多個執行緒內共享時,就出現了這個難題:CPython如何鎖定引用計數。有一個“全域性直譯器鎖”,它小心地控制執行緒執行。直譯器一次只能執行一個操作,無論它有多少執行緒。

這對Python應用程式的效能來說意味著什麼?

如果你有單執行緒、單個直譯器的應用程式,這對速度不會有影響。刪除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,但它也是單執行緒的,所以它不需要記憶體管理演算法。JavaScript的事件迴圈和承諾回撥(Promise/Callback)模式是實現非同步程式設計以代替併發的方法。Python與asyncio事件迴圈有相似之處。

“這是由於它一種解釋語言”

我常聽到這個觀點,但覺得這過於簡化了CPython的實際工作方式。如果你在終端上編寫了python myscript.py,那麼CPython會啟動讀取、分析、解析、編譯、解釋和執行程式碼的一長串操作。

如果你對這個過程的機理頗感興趣,我之前寫過一篇文章:《6分鐘內修改Python語言》(https://hackernoon.com/modifying-the-python-language-in-7-minutes-b94b0a99ce14)。

這個過程的一個重要節點是建立.pyc檔案;在編譯階段,位元組碼序列寫入到Python 3中__pycache__/裡面的一個檔案或Python 2中的同一個目錄。這不僅適用於你的指令碼,還適用於匯入的所有程式碼,包括第三方模組。

所以在大部分時間(除非你編寫的是隻執行一次的程式碼?),Python解釋位元組碼,並在本地執行。相比之下Java和C#.NET:

Java編譯成一種“中間語言”,Java虛擬機器讀取位元組碼,並即時編譯成機器碼。.NET CIL也一樣,.NET公共語言執行時環境(CLR)使用即時編譯,將編譯後代碼編譯成機器碼。

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

JIT或即時編譯需要一種中間語言,以便將程式碼拆分成塊(或幀)。提前(AOT)編譯器旨在確保CPU在任何交互發生之前能理解每一行程式碼。

JIT本身不會使執行變得更快,因為它仍然執行相同的位元組碼序列。然而,JIT讓程式碼在執行時能夠加以優化。一個好的JIT優化器會看到應用程式的哪些部分在頻繁執行,這些程式碼稱之為“熱點程式碼”(hot spot)。然後,它會對這些程式碼進行優化,其辦法是把它們換成更高效的版本。

這就意味著當你的應用程式一次又一次地執行相同的操作時,執行速度可以顯著加快。另外記住一點:Java和C#是強型別語言,因此優化器可以對程式碼做出多得多的假設。

PyPy有JIT,如上所述,其速度比CPython快得多。這篇效能基準測試文章作了更詳細的介紹:《哪個Python版本的速度最快?》(https://hackernoon.com/which-is-the-fastest-version-of-python-2ae7c61a6b2b)。

那麼,CPython為什麼不使用JIT呢?

JIT存在幾個缺點:缺點之一是啟動時間。CPython的啟動時間已經比較慢了,PyPy的啟動時間比CPython還要慢2倍至3倍。眾所周知,Java虛擬機器的啟動速度很慢。.NET CLR通過系統開啟時啟動解決了這個問題,但CLR的開發人員還開發了作業系統,CLR在它上面執行。

如果你有一個Python程序長時間執行,程式碼因含有“熱點程式碼”而可以優化,那麼JIT大有意義。

然而,CPython是一種通用實現。所以,如果你在使用Python開發命令列應用程式,每次呼叫CLI都得等待JIT啟動會慢得要命。

CPython不得不試圖滿足儘可能多的用例(use case)。之前有人試過將JIT插入到CPython中,但這個專案基本上擱淺了。

如果你想獲得JIT的好處,又有適合它的工作負載,不妨使用PyPy。

“這是由於它是一種動態型別語言”

在“靜態型別”語言中,你在宣告變數時必須指定變數的型別。這樣的語言包括C、C++、Java、C#和Go。

在動態型別語言中,仍然存在型別這個概念,但變數的型別是動態的。

a = 1
a = "foo"

在這個示例中,Python建立了一個有相同名稱、str型別的第二個變數,並釋放為a的第一個例項建立的記憶體。

靜態型別語言不是為了給你添堵而設計的,它們是兼顧CPU的執行方式設計的。如果一切最終需要等同於簡單的二進位制操作,你就得將物件和型別轉換成低階資料結構。

Python為你這麼做這項工作,你永遠看不到,也不需要操心。

不必宣告型別不是導致Python速度慢的原因,Python語言的設計使你能夠讓幾乎一切都是動態的。你可以通過猴子補丁(monkey-patch),加入對執行時宣告的值進行低階系統呼叫的程式碼。幾乎一切都有可能。

正是這種設計使得優化Python異常困難。

為了說明我的觀點,我將使用可在Mac OS中使用的一種名為Dtrace的系統呼叫跟蹤工具。CPython發行版並未內建DTrace,所以你得重新編譯CPython。我使用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的雜談(https://github.com/paulross/dtrace-py#the-lightning-talk)。你可以下載Python的DTrace啟動檔案(https://github.com/paulross/dtrace-py/tree/master/toolkit)來測量函式呼叫、執行時間、CPU時間、系統呼叫和各種好玩的指標。比如

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

py_callflow跟蹤器顯示你應用程式中的所有函式呼叫。

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

  • 比較和轉換型別的開銷很大,每次讀取、寫入或引用一個變數,都要檢查型別。

  • 很難優化一種極具動態性的語言。Python的許多替代語言之所以快得多,原因在於它們為了效能在靈活性方面作出了犧牲。

  • Cython 結合了C-Static型別和Python來優化型別已知的程式碼,可以將效能提升84倍。

結論

Python之所以速度慢,主要是由於動態性和多功能性。它可用作解決各種問題的工具,Python有更優化、速度更快的幾個替代方案。

然而,有一些方法可以優化你的Python應用程式,比如通過充分利用非同步、深入瞭解分析工具以及考慮使用多個直譯器。

對於啟動時間不重要、程式碼會受益於JIT的應用程式來說,不妨考慮PyPy。

對於效能至關重要,又有更多靜態型別變數的部分程式碼而言,不妨考慮使用Cython。