爲什麼Python這麼慢?

 2018-08-14 15:00:04.0

Python語言近年來人氣爆棚。它廣泛應用於網絡開發運營,數據科學,網絡開發,以及網絡安全問題中。

然而,Python在速度上完全沒有優勢可言。

在速度上,Java如何同C,C++,C#或者Python相比較?答案几乎完全取決於要運行的應用。在這個問題上,沒有完美的評判標準,然而The Computer Language Benchmarks Game 是一個不錯的方法。

鏈接:

http://benchmarksgame.alioth.debian.org

基於我對The Computer Language Benchmarks Game超過十年的觀察,相比於Java,C#,Go,JavaScript, C++等,Python是最慢的語言之一。其中包括了 JIT (C#, Java) 和 AOT (C, C++)編譯器,以及解釋型語言,例如JavaScript。

動態編譯:

https://en.wikipedia.org/wiki/Just-in-time_compilation

靜態編譯:

https://en.wikipedia.org/wiki/Ahead-of-time_compilation

注意:當我提到「Python」時,我指的是CPython這個官方的解釋器。我也將在本文中提及其他的解釋器。

我想要回答這樣一個問題:當運行同一個程序時,爲什麼Python會 比其他語言慢2到10倍?爲什麼我們無法將它變得更快?

以下是最主要的原因:

  • 「它是GIL(Global Interpreter Lock全局解釋器鎖)」

  • 「它是解釋型語言而非編譯語言」

  • 「它是動態類型語言」

那麼以上哪種原因對性能影響最大呢?

「它是全局解釋器鎖」

現代計算機的CPU通常是多核的,並且有些擁有多個處理器。爲了充分利用多餘的處理能力,操作系統定義了一種低級的結構叫做線程:一個進程(例如Chrome瀏覽器)可以產生多個線程並且指導內部系統。

如果一個進程是CPU密集型,那麼其負載可以被多核同時處理,從而有效提高大多數應用的速度。

當我寫這篇文章時,我的Chrome瀏覽器同時擁有44個線程。注意,基於POSIX(比如MacOS和Linux)和Windows操作系統相比,線程的結構和API是不同的。操作系統也會處理線程的調度問題。

如果你之前沒有做過多線程編程,你需要快速熟悉鎖的概念。區別於單線程進程,你需要確保當內存中的變量被修改時,多線程不會同時試圖訪問或者改變同一個存儲地址。

當CPython創建變量時,它會預先分配存儲空間,然後計算當前變量的引用數目。這個概念被稱爲引用計數。如果引用計數爲零,那麼它將從系統中釋放對應存儲區域。

這就是爲什麼在CPython中創造「臨時」變量不會使應用佔用大量的存儲空間——尤其是當應用中使用了for循環這一類可能大量創建「臨時」變量的結構時。

當存在多個線程調用變量時,CPython如何鎖住引用計數成爲了一個挑戰。而「全局解釋鎖」應運而生,它能夠謹慎控制線程的執行。無論有多少的線程,解釋器每次只能執行一個操作。

這對Python的性能意味着什麼呢?

如果你的應用基於單線程、單解釋器,那麼討論速度這一點就毫無意義,因爲去掉GIL並不會影響代碼性能。

如果你想使用線程在單解釋器(Python 進程)中實現併發,並且你的線程爲IO密集型(例如網絡IO或磁盤IO),你就會看到GIL爭用的結果。

該圖來自David Beazley的GIL可視化

如果你有一個網絡應用(例如Django)並且使用WSGI,那麼每一個對於你的網絡應用的請求將是一個獨立的Python解釋器,因此每個請求只有一個鎖。因爲Python解釋器啓動很慢,一些WSGI便集成了能夠使保持Python進程的「守護進程」  。

那麼其他Python解釋器的速度又如何呢?

PyPy擁有GIL,通常比CPython快至少三倍。

Jython沒有GIL,因爲在Jython中Python線程是用Java線程表示的,這得益於JVM內存管理系統。

 JavaScript是如何做到這一點的呢?

首先,所有的Javascript引擎使用標記加清除的垃圾收集系統,而之前提到GIL的基本訴求是CPython的存儲管理算法。

JavaScript沒有GIL,但因爲它是單線程的,所以也並不需要GIL。

JavaScript通過事件循環和承諾/回調模式來實現異步編程的併發。Python有與異步事件循環相似的過程。

 「因爲它是解釋型語言

我經常聽到這句話。我覺得這只是對於CPython實際運行方式的一種簡單解釋。如果你在終端中輸入python myscript.py,那麼CPython將對這段代碼開始一系列的讀取,詞法分析,解析,編譯,解釋和運行。

這個過程中的重要步驟是在編譯階段創建一個.pyc 文件,這個字節碼序列將被寫入Python3下__pycache__/ 路徑中的一個文件(對於Python2,文件路徑相同)。這個步驟不僅僅應用於腳本文件,也應用於所有導入的代碼,包括第三方模塊。

所以大多時候(除非你寫的代碼只運行一次),Python是在解釋字節碼並且本地執行。下面我們將Java和C#.NET相比較:

Java編譯成一門「中間語言」,然後Java虛擬機讀取字節代碼並即時編譯爲機器代碼。.NET的通用中間語言(CIL)是一樣的,它的通用語言運行時間(CLR)也採用即時編譯的方法轉化爲機器代碼。

那麼,如果Python用的是和Java和C#一樣的虛擬機和某種字節代碼,爲什麼在基準測試中它卻慢得多?首先,.NET和Java是採用JIT編譯的。

JIT,又稱即時編譯,需要一種中間語言來把代碼進行分塊(或者叫數據幀)。預編譯(AOT, Ahead of Time)器的設計保證了CPU能夠在交互之前理解代碼中的每一行。

JIT本身不會使執行速度更快,因爲它仍然執行相同的字節碼序列。但是,JIT允許在運行時進行優化。好的JIT優化器可以檢測哪些部分執行次數比較多,這些部分被稱爲「熱點」。然後,它將用更高效的代碼替換它們,完成優化。

這就意味着當計算機應用程序需要重複做一件事情的時候,它就會更加地快。另外,我們要知道Java和C#是強類型語言(變量需要預定義),因此優化器可以對代碼做更多的假設。

PyPy使用即時編譯器,並且前文也有提到它比CPython更快。這篇關於基準測試的文章介紹得更爲詳細——什麼版本的Python最快?

鏈接:

https://hackernoon.com/which-is-the-fastest-version-of-python-2ae7c61a6b2b 

那麼,爲什麼CPython不使用即時編譯器呢?

JIT存在一些缺點:其中一個是啓動時間。CPython啓動時間已經相對較慢,PyPy比CPython還要慢2-3倍。衆所周知,Java虛擬機的啓動速度很慢。爲了解決這個問題,.NET CLR在系統啓動的時候就開始運行,但CLR的開發人員還開發了專門運行CLR的操作系統來加快它。

如果你有一個運行時間很長的Python進程,並且其代碼可以被優化(因爲它包含前文所述的「熱點」),那麼JIT就能夠起到很大作用。

但是,CPython適用於各類應用。因此,如果你使用Python開發命令行應用程序,每次調用CLI時都必須等待JIT啓動,這將非常緩慢。

CPython必須儘量多地嘗試不同的案例以保證通用性,而把JIT插入到CPython中可能會讓這個項目停滯不前。

如果你想要藉助JIT的力量,而且你的工作量還比較大,那麼使用PyPy吧。

「因爲它是一個動態類型語言」

在靜態類型語言中,定義變量時必須聲明類型。C, C++, Java, C#, Go都是這種語言。

在動態類型語言中,類型的概念依舊存在,但是這個變量的類型是動態變化的。

a = 1a = "foo"

在上面這個例子中,Python創建第二個變量的時候用了同樣的名字,但是變量類型是str(字符型),這樣就對先前在內存中給a分配的空間進行了釋放和再分配。

靜態類型語言的這種設計並不是爲了麻煩大家——它們是按照CPU的運行方式設計的。如果最終需要將所有內容都轉化爲簡單的二進制操作,那就必須將對象和類型轉換爲低級數據結構。

Python自動完成了這個過程,我們看不見,也沒必要看見。

不必聲明類型不是使Python變慢的原因。Python語言的設計使我們幾乎可以創建任何動態變量。我們可以在運行時替換對象中的方法,也可以胡亂地把低級系統調用賦給一個值。幾乎怎麼修改都可以。

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

爲了闡明我的觀點,我將使用一個MacOS中的應用。它是一個名爲Dtrace的系統調用跟蹤工具。CPython發行版沒有內置DTrace,因此你必須重新編譯CPython。以下演示中使用3.6.6版本。

wget https://github.com/python/cpython/archive/v3.6.6.zipunzip v3.6.6.zipcd v3.6.6./configure --with-dtracemake

現在python.exe將在整條代碼中使用Dtrace跟蹤器。Paul Ross就Dtrace做了一篇很棒的短演講。 你可以下載Python的DTrace啓動文件來測試函數調用、執行時間、CPU時間、系統調用等各種有意思的事情。例如:

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

DTrace啓動文件:

https://github.com/paulross/dtrace-py/tree/master/toolkit

演講鏈接:

https://github.com/paulross/dtrace-py#the-lightning-talk

py_callflow跟蹤器顯示應用程序中的所有函數調用

因此,是Python的動態類型讓它變慢的嗎?

  • 比較和轉換類型是耗時的,因爲每次讀取、寫入變量或引用變量類型時都會進行檢查

  • 很難優化一種如此動態的語言。其他語言之所以那麼快是因爲他們犧牲了一定的靈活性,從而提高了性能。

  • 瞭解一下Cython,它結合了C-Static類型和Python來優化已知類型的代碼,可以提供84倍速度的性能提升。

結論

Python的緩慢主要是由於它動態和多用途的特點。它可以用於解決幾乎所有問題,但是更加優化而快捷的替代方案可能存在。

但是,有一些方法可以通過利用異步計算,理解分析工具,以及考慮使用多個解釋器來優化Python應用程序。

對於有些啓動時間相對不重要,並且即時編譯器(JIT)可以提高效率的應用,可以考慮使用PyPy。

對於性能優先並且有更多靜態變量的代碼部分,請考慮使用Cython。

文章來源:機器之心