[Python進階]Python的GIL

GIL(Global Interpreter Lock)

Python 的 multi thread 讓人不明所以,為什麼我的電腦明明有四個 CPU,用 multi thread 性能卻沒有增長呢?我們先看一個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from threading import Thread
import time

def CountDown(n):
while n > 0:
n -= 1

n = 100000000

# 單一執行緒
start = time.time()
CountDown(n)
print('耗時: %s' % (time.time() - start))

# 兩個執行緒
start = time.time()
t1 = Thread(target=CountDown, args=[n // 2])
t2 = Thread(target=CountDown, args=[n // 2])
t1.start()
t2.start()
t1.join()
t2.join()

print('耗時: %s' % (time.time() - start))
# 輸出
# 耗時: 4.894810676574707
# 耗時: 4.441636085510254

CountDown 是個 CPU heavy 的函數,但是奇怪的是,明明用了兩個 Threads,耗時卻差不多,都是 4 秒左右。這是怎麼回事?難道我的電腦只有一顆 CPU 在工作嗎?其實不是。如果換成 C++ 寫,速度提升馬上有感。所以問題不是出在電腦,而是 Python 的 multi thread 沒有真正進行併行運算。

然而,Python 的 Thread 確實是實實在在的 Thread。在 Linux 系統中,它封裝了 Pthread (POSIX Thread);在 Windows 系統中,它封裝了 Windows Thread。由於 Python 的 Thread 只是做封裝,所以完全受作業系統管理,包括協調執行時間、資源管理等等。

Python 用了 multi thread 卻沒有性能提升的原因正是因為 GIL。在整個 Python 的 Process 中,只允許同時跑一個 Thread,其他的會被 Lock。因此,本質上,Python 的 Thread 只是“輪流”執行。

為什麼有 GIL?

我們知道 Python 是基於 C 實現的,之所以有 GIL 就和 CPython 有關。CPython 使用 Reference Count 管理記憶體,這用來記錄有多少pointer指向這塊記憶體。當 Reference Count = 0 時,就會釋放記憶體。我們來看個例子:

1
2
3
4
5
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

這裡的 3 是因為 a, b 和 sys.getrefcount(a) 的參數都指向同一塊記憶體,所以總共是 3。

如果兩個 Python 的 Threads 同時引用了 a,同時進行 reference count += 1,就會觸發 race condition,最終 reference count 只會加 1。因此,為了避免這樣的風險,Python 設定了一個 GIL。但是,這並不意味著 Python 是天然不用擔心 race condition 的語言。Python 的一行程式可能代表著多行的 bytecode,例如 Python 的 n += 1,其 bytecode 表示為:

1
2
LOAD_CONST 1 (1)
INPLACE_ADD

若在 INPLACE_ADD 前,GIL 被釋放了,而其他 Thread 跑了 LOAD_CONST 1 (1),同樣會造成 race condition。我們來看個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import threading

n = 0
def foo():
global n
n += 1

threads = []
for i in range(100):
t = threading.Thread(target=foo)
threads.append(t)

for t in threads:
t.start()

for t in threads:
t.join()

print(n)

所以說,GIL 的目的是為了方便 CPython 的開發者,不用顧慮變數記憶體的分配,而不是為了 Python 應用的開發者。我們還是需要使用 lock 等工具,例如下面這個例子:

1
2
3
4
5
6
7
8
import threading

n = 0
lock = threading.Lock()
def foo():
global n
with lock:
n += 1

可以繞過 GIL 嗎?

  • Python 的 GIL 是 CPython 上的限制,如果要繞過,可以透過 JPython(Java 實現的 Python)等其他實現。
  • 把 CPU heavy 的程式碼放在別的語言(例如 C++)實現,然後提供 Python 調用的 API。

總結

  • 我們探究了 Python GIL 的原理,它的目的是為了避免記憶體回收的 race condition。
  • 雖然 GIL 使得 CPython 更容易開發,但卻沒有真正的 multi threads。不過,我們可以透過其他語言的實作,把 CPU heavy 的程式碼交給 Python 去呼叫,這樣依然可以充分利用 CPU 性能。