![]() |
VOOZH | about |
同為程式設計師的心頭好,Python 為什麼能這麼慢?
眼下 Python 異常火爆,不論是 DevOps、數據科學、Web 開發還是安全領域,都在用 Python——但是它在速度上卻沒有任何優勢。
與 C、C++、C# 或 Python 相比,Java 的速度如何?答案很大程度上依賴於你需要運行的應用種類。世上沒有完美的性能測試,但計算機語言評測遊戲(Computer Language Benchmarks Game)是個很好的測試方式:http://algs4.cs.princeton.edu/faq/。
我從十年前就開始談論計算機語言評測遊戲。與 Java、C#、Go、JavaScript、C++ 等其他語言相比,Python 是最慢的語言之一。這裡包括JIT(Just In Time)語言(如C#、Java)和 AOT(Ahead Of Time)語言(C、C++)編譯器,也有 JavaScript 這種解釋語言。
註:本文中所說的「Python」是指語言的具體實現,即 CPython。本文也會提到其他運行。
我希望回答以下問題:如果 Python 完成相同的任務要花費其他語言二至十倍的時間,那麼它為什麼慢,能不能更快一些呢?
以下是幾種常見的原因:
「因為它是GIL(全局解釋器鎖)」
「因為它是解釋語言不是編譯語言」
「因為它是動態類型語言」
究竟哪個原因對性能的影響最大?
現代計算機的 CPU 有多個核心,有時甚至有多個處理器。為了利用所有計算能力,作業系統定義了一個底層結構,叫做線程,而一個進程(例如 Chrome瀏覽器)能夠生成多個線程,通過線程來執行系統指令。這樣如果一個進程是要使用很多 CPU,那麼計算負載就會由多個核心分擔,最終使得絕大多數應用能更快地完成任務。
在撰寫本文時,我的 Chrome 瀏覽器開了 44 個線程。另外,基於 POSIX 的作業系統(如 Mac OS 和 Linux)的線程結構和 API 與 Windows 作業系統是不一樣的。作業系統還負責線程的調度。
如果你沒寫過多線程程序,那麼你應該了解一下鎖的概念。與單線程進程不同,在多線程編程中,你要確保改變內存中的變量時,多個線程不會試圖同時修改或訪問同一個內存地址。
CPython 在創建變量時會分配內存,然後用一個計數器計算對該變量的引用的次數。這個概念叫做「引用計數」。如果引用的數目為 0,那就可以將這個變量從系統中釋放掉。這樣,創建「臨時」變量(如在 for 循環的上下文環境中)不會耗光應用程式的內存。
隨之而來的問題就是,如果變量在多個線程中共享,CPython 需要對引用計數器加鎖。有一個「全局解釋器鎖」會謹慎地控制線程的執行。不管有多少個線程,解釋器一次只能執行一個操作。
這對 Python 應用的性能有什麼影響?
如果應用程式是單線程、單解釋器的,那麼這不會對速度有任何影響。去掉 GIL 也不會影響代碼的性能。
但如果想用一個解釋器(一個 Python 進程)通過線程實現並發,而且線程是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 快三倍以上。
Jython 沒有 GIL 因為 Jython 中的 Python 線程由 Java 線程表示,因此能享受到 JVM 內存管理系統的好處。
JavaScript 怎麼處理這個問題?
首先,所有 JavaScript 引擎都是用標記-清除垃圾回收算法。如前所述,對 GIL 的需求主要是由 CPython 的內存管理算法導致的。
JavaScript 沒有 GIL,但它也是單線程的,所以它根本不需要。JavaScript 的時間循環和 Promise/Callback 模式實現了異步編程,取代了並發編程。Python 也能通過 asyncio 的事件循環實現類似的模式。
這條理由我也聽過很多,我發現它過於簡化了 CPython 的實際工作原理。當你在終端上寫 python myscript.py 時,CPython 會啟動一長串操作,包括讀取、詞法分析、語法分析、編譯、解釋以及執行。
如果你對這些過程感興趣,可以看看我之前寫的文章:
6分鐘修改Python語言:
這個過程的重點就是它會在編譯階段生成.pyc文件,字節碼會寫到__pycache__/下的文件中(如果是Python 3),或者寫到與原始碼同一個目錄中(Python 2)。不僅你編寫的腳本是這樣,所有你導入的代碼都是這樣,包括第三方模塊。
因此絕大多數情況下(除非你寫的代碼只會運行一次),Python是在解釋字節碼並在本地執行。與Java和C#.NET比較一下:
Java將原始碼編譯成「中間語言」,然後Java虛擬機讀取字節碼並即時編譯成機器碼。.NET CIL也是一樣的,.NET的公共語言運行時(CLR)使用即時編譯將字節碼編譯成機器碼。
那麼,既然它們都使用虛擬機,以及某種字節碼,為什麼Python在性能測試中比Java和C#慢那麼多?
第一個原因是,.NET和Java是即時編譯的(JIT)。即時編譯,即JIT(Just-in-time),需要一種中間語言,將代碼分割成小塊(或者稱幀)。而提前編譯(Ahead of Time,簡稱AOT)是編譯器把原始碼翻譯成CPU能理解的代碼之後再執行。
JIT本身並不能讓執行更快,因為它執行的是同樣的字節碼序列。但是,JIT可以在運行時做出優化。好的GIT優化器能找到應用程式中執行最多的部分,稱為「熱點」。然後對那些字節碼進行優化,將它們替換成效率更高的代碼。
這就是說,如果你的應用程式會反覆做某件事情,那麼速度就會快很多。此外,別忘了Java和C#都是強類型語言,所以優化器可以對代碼做更多的假設。
前面說過,PyPy有個JIT,因此它比CPython要快很多。下面這篇性能測試的文章介紹得更詳細:
哪個版本的Python最快?
那麼為什麼CPython不用JIT?
JIT也有缺點:首先就是啟動速度。CPython的啟動速度已經比較慢了,而PyPy的啟動速度要比CPython慢兩到三倍。Java虛擬機的啟動速度也是出了名的慢。.NET CLR在系統啟動時啟動,因此避免了這個問題,但這要歸功於CLR和作業系統是同一撥開發者開發的。
如果你有一個Python進程需要運行很長時間,而且代碼里包含「熱點」可以被優化,那麼使用JIT就很不錯。
但是,CPython是個通用的實現。因此如果要用Python開發命令行程序,那麼每次都要等待JIT調用CLI就特別慢了。
CPython試圖滿足大部分情況下的需求。有一個在CPython中實現JIT(https://www.slideshare.net/AnthonyShaw5/pyjion-a-jit-extension-system-for-cpython)的項目,不過這個項目已經停止很久了。
如果你想要享受JIT的好處,並且要處理的任務適合JIT,那就使用PyPy。
「靜態類型」語言要求必須在變量定義時指定其類型,例如C、C++、Java、C#和Go等。
而動態類型語言中儘管也有類型的概念,但變量的類型是動態的。
a = 1
a = "foo"
在這個例子中,Python用相同的名字和str類型定義了第二個變量,同時釋放了第一個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的演講(https://github.com/paulross/dtrace-py#the-lightning-talk)。可以從這裡下載DTrace用於Python的文件(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更慢?
比較並轉換類型的代價很大。每次讀取、寫入或引用變臉時都會檢查類型
動態類型的語言很難優化。許多替代Python的語言很快的原因就是它們犧牲了便利性來交換性能。
例如Cython(http://cython.org/),它通過結合C的靜態類型和Python的方式,使得代碼中的類型已知,從而優化代碼,能夠獲得84倍的性能提升(http://notes-on-cython.readthedocs.io/en/latest/std_dev.html)
Python慢的主要原因是因為它的動態和多樣性。它能用於解決各種問題,但多數問題都有優化得更好和更快的解決方案。
但Python應用也有許多優化措施,如使用異步、理解性能測試工具,以及使用多解釋器等。
對於啟動時間不重要,而代碼可能享受到JIT的好處的應用,可以考慮使用PyPy。
對於代碼中性能很重要的部分,如果變量大多是靜態類型,可以考慮使用Cython。
擴展閱讀
1.傑克VDP的優秀文章(雖然略顯過時)https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/
明日直播預告
活動直播:
本次活動將會在集智俱樂部 B 站主頁直播:
關注B站主播「集智俱樂部」,參與更多公開直播
活動時間:
2018年7月29日(周天),早上9:00 ~ 12:00,下午2:00~5:00.
報告內容:
9:00~9:10,開場
9:10 ~ 9:50,機器學習和程式語言(Viral Shah,英文)
10:00~10:40, 用Julia進行優化建模及求解(覃含章,中文)
10:40~11:00,討論和休息
11:00~11:30,在Jetbrain平台上為Julia打造的IDE插件(千里冰封,中文)
11:40~12:10,Julia之債卷做市交易(譚磊,中文,不直播)
2:30~3:00,Julia程序的設計,利用多重派發實現的面向對象程序設計和元編程(羅秀哲,中文)
3:10~3:40,Julia性能挑戰之路(劉金國,中文)
3:40~4:00,討論和休息
4:00~4:30,Julia編程實踐(斯大衛,中文)
4:40 ~ 5:10,Julia的聚類算法性能優化(張常有,中文)