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

Python 為什麼這麼慢?

 (點擊上方快速關註並設置為星標,一起學Python)

來源:laixintao  鏈接:

https://www.kawabangga.com/posts/2979

Python 在近幾年變得異常流行,Python 語言學習成本低,寫出來很像偽代碼(甚至很像英語),可讀性高,等等有很多顯而易見的優點。被 DevOps, Data Science, Web Development 各種場景所青睞。但是這些美譽裡面從來都沒有速度。相比於其他語言,無論是 JIT 的,還是 AOT 的,Python 幾乎總是最慢的。導致 Python 的性能問題的有很多方面,本文嘗試談論一下這個話題。

  1. Python 有 GIL
  2. Python 是一種“解釋型”語言
  3. Python 是動態型別的語言

GIL

現代計算機處理器一般都會有多核,甚至有些服務器有多個處理器。所以操作系統抽象出 Thread,可以在一個行程中 spawn 出多個 Thread,讓這些 Thread 在多個核上面同時運行,發揮處理器的最大效率。(在 top 命令裡面可以看到系統中的 threads 數量)

所以很顯然,在編程時使用 Thread 來並行化運行可以提升速度。

但是 Python (有時候)不行。

Python 是不需要你手動管理記憶體的(C 語言就需要手動 malloc/free),它自帶垃圾回收程式。意思是你可以隨意申請、設置變數,Python 解釋器會自動判斷這個變數什麼時候會用不到了(比如函式退出了,函式內部變數就不用到了),然後自動釋放這部分記憶體。實現垃圾回收機制有很多種方法,Python 選擇的是取用計數+分代回收。取用計數為主。原理是每一個物件都記住有多少其他物件取用了自己,當沒有人取用自己的時候,就是垃圾了。

但是在多執行緒情況下,大家一起運行,取用計數多個執行緒一起操作,怎麼保證不會發生執行緒不安全的事情呢?很顯然多個執行緒操作同一個物件需要加鎖。

這就是 GIL,只不過這個鎖的粒度太大了,整個 Python 解釋器全域性只有一個 Thread 可以運行。詳見 dabeaz 博客【鏈接:http://dabeaz.blogspot.com/2010/01/python-gil-visualized.html

綠色表示正在運行的執行緒,一次只能有一個

因為其他語言沒有 GIL,所以很多人對 GIL 誤解。比如:

“Python 一次只能運行一個執行緒,所以我寫多執行緒程式是不需要加鎖的。” 這是不對的,“一次只能運行一個執行緒”指的是 Python 解釋器一次只能運行一個執行緒的位元組碼(Python 代碼會編譯成位元組碼給Python虛擬機運行),是 opcode 層面的。一行 Python 代碼,比如 a += 1,實際上會編譯出多條 opcode:先 load 引數 a,然後 a + 1,然後儲存回引數 a。加入 load 完成還沒計算,這時候執行緒切換了,其他執行緒修改了 a 的值,然後切換回來繼續執行計算和儲存 a,那麼就會造成執行緒不安全。所以多執行緒同時操作一個變數的時候,依然需要加鎖。

“Python 一次只能運行一個執行緒,所以 Python 的多執行緒是沒有意義的。” 這麼說也不完全對。假如你要用多執行緒利用多核的性能,那 Python 確實不行。但是假如 CPU 並不是瓶頸,網絡是瓶頸,多執行緒依然是有用的。通常的編程樣式是一個執行緒處理一個網絡連接,雖然只有一個執行緒在運行,但其他執行緒都在等待網絡連接,也不算“閑著”。簡單說,CPU 密集型的任務,Python 的多執行緒確實沒啥用(甚至因為多執行緒切換的開銷還會比單執行緒慢),IO 密集型的任務,Python 的多執行緒依然可以加速。

這麼說可能比較好理解:無論你的電腦的 CPU 有多少核,對 Python 來說,它只用 1 個核。

其他的 Python Runtime 呢?Pypy 有 GIL,但是可以比 CPython 快 3x。Jython 是基於 JVM 的,JVM 沒有 GIL,所以 Jython 依然 JVM 的記憶體分配,它也沒有 GIL。

其他語言呢?剛剛說了 JVM,Java 也是用的取用計數,但是它的的 GC 是 multithread-aware 的,實現上更複雜一些。(有朋友跟我說 Java 已經不是取用計數了,這個地方請讀者註意,附一個參考資料【鏈接:https://plumbr.io/handbook/garbage-collection-in-java】)。JavaScript 是單執行緒異步編程的樣式,所以它沒有這個問題。

作為一個解釋型的語言……

像 C/C++/Rust 這些語言直接編譯成機器碼運行,是編譯型語言;Python 的運行過程是虛擬機讀入 Python 代碼(文本),詞法分析,編譯成虛擬機認識的 opcode,然後虛擬機解釋 opcode 執行。但這其實不是最主要的原因,Python import 之後會快取編譯後的 opcode,( pyc 檔案或者 __pycache__ 檔案夾)。所以讀入、詞法分析和編譯並沒有占用太多的時間。

那麼真正的慢的是哪一步分呢?就是後面的虛擬機解釋 opcode 執行的部分。前期的編譯是將 Python 代碼編譯成解釋器可以理解的中間代碼,解釋器再將中間代碼翻譯成 CPU 可以理解的指令。相比於 AOT(提前編譯型語言,比如C)直接編譯成機器碼,肯定是慢的。

但是為什麼 Java 不慢呢?

因為 Java 有 JIT。即時編譯技術將代碼分成 frames,AOT 編譯器負責在運行時將中間代碼翻譯成 CPU 可以理解的代碼。這一部分跟 Python 的解釋器沒有太大的區別,依然是翻譯中間代碼、執行。真正快的地方是,JIT 可以在運行時做優化,比如虛擬機發現一段代碼在頻繁執行(大多數情況下我們的程式都在反覆執行一段代碼),就會開始優化,將這段代碼用更改的版本替換掉。這是僅有虛擬機語言才有的優勢,因為要收集運行時信息。像 gcc 這種 AOT編譯器,只能基於靜態分析做一些分析。

為什麼 Python 沒有 JIT 呢?

第一是 JIT 開發成本比較高,非常複雜。C# 也有很好的 JIT,因為微軟有錢。

第二是 JIT 啟動速度慢,Java 和 C# 虛擬機啟動很多。CPython 也很慢,Pypy 有 JIT,它比 CPython 還要慢 2x – 3x。長期運行的程式來說,啟動慢一些沒有什麼,畢竟運行時間長了之後代碼會變快,收益更高。但是 CPython 是通用目的的虛擬機,像命令列程式來說,啟動速度慢體驗就差很多了。

第三是 Java 和 C# 是靜態型別的虛擬機,編譯器可以做一些假設。(這麼說不知道對不對,因為 Lua 也有很好的 JIT)

動態型別

靜態型別的語言比如 C,Java,Go,需要在宣告變數的時候帶上型別。而 Python 就不用,Python 幫你決定一個變數是什麼型別,並且可以隨意改變。

動態型別為什麼慢呢?每次檢查型別和改變型別開銷太大;如此動態的型別,難以優化。

動態型別帶來好處是,寫起來非常簡單,符合直覺(維護就是另一回事了);可以在運行時修改物件的行為,Monkey Patch 非常簡單。

近幾年的語言都是靜態型別的,比如 Go,Rust。靜態型別不僅對編譯器來說更友好,對程式員來說程式也更好維護。個人認為,未來是屬於靜態型別的。

閱讀資料:

  1. Python 官方 wiki

    【鏈接:https://wiki.python.org/moin/GlobalInterpreterLock】

  2. Removing Python’s GIL: The Gilectomy

    【鏈接:https://www.youtube.com/watch?v=P3AyI_u66Bw】

  3. David Beazley 有關 GIL 的 Slides:http://www.dabeaz.com/GIL/,視頻(比較糊,畢竟2010年的)

     

    【鏈接:https://www.youtube.com/watch?v=P3AyI_u66Bw】

  4. Gilectomy的最新動態

    【鏈接:https://lwn.net/Articles/754577/

  5. https://hackernoon.com/why-is-python-so-slow-e5074b6fe55b
  6. https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/
  7. https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers/
赞(0)

分享創造快樂