本文首發於本人部落格,轉載請註明出處
寫在前面
- 作者電腦為 4 核架構,因此使用 4 個執行緒測試是合理的
- 本文使用的
cpython
版本為3.6.4
- 本文使用的
pypy
版本為5.9.0-beta0
,相容 Python 3.5 語法 - 本文使用的
jython
版本為2.7.0
,相容 Python 2.7 語法 - 若無特殊說明,作語言解時,
python
指 Python 語言;作直譯器解時,python
指cpython
本文使用的測速函式程式碼如下:
from __future__ import print_function
import sys
PY2 = sys.version_info[0] == 2
# 因為 Jython 不相容 Python 3 語法,此處必須 hack 掉 range 以保證都是迭代器版本
if PY2:
range = xrange # noqa
from time import time
from threading import Thread
def spawn_n_threads(n, target):
"""
啟動 n 個執行緒並行執行 target 函式
"""
threads = []
for _ in range(n):
thread = Thread(target=target)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
def test(target, number=10, spawner=spawn_n_threads):
"""
分別啟動 1, 2, 3, 4 個控制流,重複 number 次,計算執行耗時
"""
for n in (1, 2, 3, 4, ):
start_time = time()
for _ in range(number): # 執行 number 次以減少偶然誤差
spawner(n, target)
end_time = time()
print(`Time elapsed with {} branch(es): {:.6f} sec(s)`.format(n, end_time - start_time))
複製程式碼
並行?偽並行?
學過作業系統的同學都知道,執行緒是現代作業系統底層一種輕量級的多工機制。一個程式空間中可以存在多個執行緒,每個執行緒代表一條控制流,共享全域性程式空間的變數,又有自己私有的記憶體空間。
多個執行緒可以同時執行。此處的“同時”,在較早的單核架構中表現為“偽並行”,即讓執行緒以極短的時間間隔交替執行,從人的感覺上看它們就像在同時執行一樣。但由於僅有一個運算單元,當執行緒皆執行計算密集型任務時,多執行緒可能會出現 1 + 1 > 2 的反效果。
而“真正的並行”只能在多核架構上實現。對於計算密集型任務,巧妙地使用多執行緒或多程式將其分配至多個 CPU 上,通常可以成倍地縮短運算時間。
作為一門優秀的語言,python 為我們提供了操縱執行緒的庫 threading
。使用 threading
,我們可以很方便地進行並行程式設計。但下面的例子可能會讓你對“並行”的真實性產生懷疑。
假設我們有一個計算斐波那契數列的函式:
def fib():
a = b = 1
for i in range(100000):
a, b = b, a + b
複製程式碼
此處我們不記錄其結果,只是為了讓它產生一定的計算量,使運算時間開銷遠大於執行緒建立、切換的時間開銷。現在我們執行 test(fib)
,嘗試在不同數量的執行緒中執行這個函式。如果執行緒是“真並行”,時間開銷應該不會隨執行緒數大幅上漲。但執行結果卻讓我們大跌眼鏡:
# CPython,fib
Time elapsed with 1 branch(es): 1.246095 sec(s)
Time elapsed with 2 branch(es): 2.535884 sec(s)
Time elapsed with 3 branch(es): 3.837506 sec(s)
Time elapsed with 4 branch(es): 5.107638 sec(s)
複製程式碼
從結果中可以發現:時間開銷幾乎是正比於執行緒數的!這明顯和多核架構的“真並行”相矛盾。這是為什麼呢?
一切的罪魁禍首都是一個叫 GIL 的東西。
GIL
GIL 是什麼
GIL 的全名是 the Global Interpreter Lock (全域性解釋鎖),是常規 python 直譯器(當然,有些直譯器沒有)的核心部件。我們看看官方的解釋:
The Python interpreter is not fully thread-safe. In order to support multi-threaded Python programs, there’s a global lock, called the global interpreter lock or GIL, that must be held by the current thread before it can safely access Python objects.
可見,這是一個用於保護 Python 內部物件的全域性鎖(在程式空間中唯一),保障瞭直譯器的執行緒安全。
這裡用一個形象的例子來說明 GIL 的必要性(對資源搶佔問題非常熟悉的可以跳過不看):
我們把整個程式空間看做一個車間,把執行緒看成是多條不相交的流水線,把執行緒控制流中的位元組碼看作是流水線上待處理的物品。Python 直譯器是工人,整個車間僅此一名。作業系統是一隻上帝之手,會隨時把工人從一條流水線調到另一條——這種“隨時”是不由分說的,即不管處理完當前物品與否。
若沒有 GIL。假設工人正在流水線 A 處理 A1 物品,根據 A1 的需要將房間溫度(一個全域性物件)調到了 20 度。這時上帝之手發動了,工人被調到流水線 B 處理 B1 物品,根據 B1 的需要又將房間溫度調到了 50 度。這時上帝之手又發動了,工人又調回 A 繼續處理 A1。但此時 A1 暴露在了 50 度的環境中,安全問題就此產生了。
而 GIL 相當於一條鎖鏈,一旦工人開始處理某條流水線上的物品,GIL 便會將工人和該流水線鎖在一起。而被鎖住的工人只會處理該流水線上的物品。就算突然被調到另一條流水線,他也不會幹活,而是乾等至重新調回原來的流水線。這樣每個物品在被處理的過程中便總是能保證全域性環境不會突變。
GIL 保證了執行緒安全性,但很顯然也帶來了一個問題:每個時刻只有一條執行緒在執行,即使在多核架構中也是如此——畢竟,直譯器只有一個。如此一來,單程式的 Python 程式便無法利用到多核的優勢了。
驗證
為了驗證確實是 GIL 搞的鬼,我們可以用不同的直譯器再執行一次。這裡使用 pypy(有 GIL)和 jython (無 GIL)作測試:
# PyPy, fib
Time elapsed with 1 branch(es): 0.868052 sec(s)
Time elapsed with 2 branch(es): 1.706454 sec(s)
Time elapsed with 3 branch(es): 2.594260 sec(s)
Time elapsed with 4 branch(es): 3.449946 sec(s)
複製程式碼
# Jython, fib
Time elapsed with 1 branch(es): 2.984000 sec(s)
Time elapsed with 2 branch(es): 3.058000 sec(s)
Time elapsed with 3 branch(es): 4.404000 sec(s)
Time elapsed with 4 branch(es): 5.357000 sec(s)
複製程式碼
從結果可以看出,用 pypy 執行時,時間開銷和執行緒數也是幾乎成正比的;而 jython 的時間開銷則是以較為緩慢的速度增長的。jython 由於下面還有一層 JVM,單執行緒的執行速度很慢,但線上程數達到 4 時,時間開銷只有單執行緒的兩倍不到,僅僅稍遜於 cpython 的 4 執行緒執行結果(5.10 secs)。由此可見,GIL 確實是造成偽並行現象的主要因素。
如何解決?
GIL 是 Python 直譯器正確執行的保證,Python 語言本身沒有提供任何機制訪問它。但在特定場合,我們仍有辦法降低它對效率的影響。
使用多程式
執行緒間會競爭資源是因為它們共享同一個程式空間,但程式的記憶體空間是獨立的,自然也就沒有必要使用解釋鎖了。
許多人非常忌諱使用多程式,理由是程式操作(建立、切換)的時間開銷太大了,而且會佔用更多的記憶體。這種擔心其實沒有必要——除非是對併發量要求很高的應用(如伺服器),多程式增加的時空開銷其實都在可以接受的範圍中。更何況,我們可以使用程式池減少頻繁建立程式帶來的開銷。
下面新建一個 spawner
,以演示多程式帶來的效能提升:
from multiprocessing import Process
def spawn_n_processes(n, target):
threads = []
for _ in range(n):
thread = Process(target=target)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
複製程式碼
使用 cpython 執行 test(fib, spawner=spawn_n_processes)
,結果如下:
# CPython, fib, multi-processing
Time elapsed with 1 branch(es): 1.260981 sec(s)
Time elapsed with 2 branch(es): 1.343570 sec(s)
Time elapsed with 3 branch(es): 2.183770 sec(s)
Time elapsed with 4 branch(es): 2.732911 sec(s)
複製程式碼
可見這裡出現了“真正的並行”,程式效率得到了提升。
使用 C 擴充套件
GIL 並不是完全的黑箱,CPython 在直譯器層提供了控制 GIL 的開關——這就是 Py_BEGIN_ALLOW_THREADS
和 Py_END_ALLOW_THREADS
巨集。這一對巨集允許你在自定義的 C 擴充套件中釋放 GIL,從而可以重新利用多核的優勢。
沿用上面的例子,自定義的 C 擴充套件函式好比是流水線上一個特殊的物品。這個物品承諾自己不依賴全域性環境,同時也不會要求工人去改變全域性環境。同時它帶有 Py_BEGIN_ALLOW_THREADS
和 Py_END_ALLOW_THREADS
兩個機關,前者能砍斷 GIL 鎖鏈,這樣工人被排程走後不需要乾等,而是可以直接幹活;後者則將鎖鏈重新鎖上,保證操作的一致性。
這裡同樣用一個 C 擴充套件做演示。由於 C 實現的斐波那契數列計算過快,此處採用另一個計算 PI 的函式:
// cfib.c
#include <python3.6m/Python.h>
static PyObject* fib(PyObject* self, PyObject* args)
{
Py_BEGIN_ALLOW_THREADS
double n = 90000000, i;
double s = 1;
double pi = 3;
for (i = 2; i <= n * 2; i += 2) {
pi = pi + s * (4 / (i * (i + 1) * (i + 2)));
s = -s;
}
Py_END_ALLOW_THREADS
return Py_None;
}
// 模組初始化程式碼略去
複製程式碼
使用 cpython 執行 test(cfib.fib)
,結果如下:
# CPython, cfib, non-GIL
Time elapsed with 1 branch(es): 1.334247 sec(s)
Time elapsed with 2 branch(es): 1.439759 sec(s)
Time elapsed with 3 branch(es): 1.603779 sec(s)
Time elapsed with 4 branch(es): 1.689330 sec(s)
複製程式碼
若註釋掉以上兩個巨集,則結果如下:
# CPython, cfib, with-GIL
Time elapsed with 1 branch(es): 1.331415 sec(s)
Time elapsed with 2 branch(es): 2.671651 sec(s)
Time elapsed with 3 branch(es): 4.022696 sec(s)
Time elapsed with 4 branch(es): 5.337917 sec(s)
複製程式碼
可見其中的效能差異。因此當你想做一些計算密集型任務時,不妨嘗試用 C 實現,以此規避 GIL。
值得注意的是,一些著名的科學計算庫(如 numpy
)為了提升效能,其底層也是用 C 實現的,並且會在做一些執行緒安全操作(如 numpy
的陣列操作)時釋放 GIL。因此對於這些庫,我們可以放心地使用多執行緒。以下是一個例子:
import numpy
def np_example():
ones = numpy.ones(10000000)
numpy.exp(ones)
複製程式碼
用 CPython 執行 test(np_example)
結果如下:
# CPython, np_example
Time elapsed with 1 branch(es): 3.708392 sec(s)
Time elapsed with 2 branch(es): 2.462703 sec(s)
Time elapsed with 3 branch(es): 3.578331 sec(s)
Time elapsed with 4 branch(es): 4.276800 sec(s)
複製程式碼
讓執行緒做該做的事
讀到這,有同學可能會奇怪了:我在使用 python 多執行緒寫爬蟲時可從來沒有這種問題啊——用 4 個執行緒下載 4 個頁面的時間與單執行緒下載一個頁面的時間相差無幾。
這裡就要談到 GIL 的第二種釋放時機了。除了呼叫 Py_BEGIN_ALLOW_THREADS
,直譯器還會在發生阻塞 IO(如網路、檔案)時釋放 GIL。發生阻塞 IO 時,呼叫方執行緒會被掛起,無法進行任何操作,直至核心返回;IO 函式一般是原子性的,這確保了呼叫的執行緒安全性。因此在大多數阻塞 IO 發生時,直譯器沒有理由加鎖。
以爬蟲為例:當 Thread1 發起對 Page1 的請求後,Thread1 會被掛起,此時 GIL 釋放。當控制流切換至 Thread2 時,由於沒有 GIL,不必乾等,而是可以直接請求 Page2……如此一來,四個請求可以認為是幾乎同時發起的。時間開銷便與單執行緒請求一次一樣。
有人反對使用阻塞 IO,因為若想更好利用阻塞時的時間,必須使用多執行緒或程式,這樣會有很大的上下文切換開銷,而非阻塞 IO + 協程顯然是更經濟的方式。但當若干任務之間沒有偏序關係時,一個任務阻塞是可以接受的(畢竟不會影響到其他任務的執行),同時也會簡化程式的設計。而在一些通訊模型(如 Publisher-Subscriber)中,“阻塞”是必要的語義。
多個阻塞 IO 需要多條非搶佔式的控制流來承載,這些工作交給執行緒再合適不過了。
小結
- 由於 GIL 的存在,大多數情況下 Python 多執行緒無法利用多核優勢。
- C 擴充套件中可以接觸到 GIL 的開關,從而規避 GIL,重新獲得多核優勢。
- IO 阻塞時,GIL 會被釋放。