Python 中的全局鎖 GIL 是什麼?對多線程有什麼影響?

一文搞清楚 Multiprocess 和 Multithread 到底差在哪! 那篇文中,我們對 Multiprocess 和 Multithread 有了基本的認知。

今天我們來講一個更進階的概念:Global Interpreter Lock (GIL)!

什麼是 GIL

在深入討論 GIL 之前,有一件事需要先澄清一下。也就是這個 GIL 的機制,只存在於 CPython 直譯器中。

這裡不探討什麼是直譯器(Interpreter),有機會我們以後再來講,這裡我們可以先簡單理解為將 Python 程式碼轉換為 機器語言 的機器。

Python 的直譯器有很多種,包括 Jython 和 IronPython,但目前最主流的還是 CPython。

而 GIL 這個東西,並非 Python 專屬,而是 CPython 專屬。你完全可以使用其他直譯器來繞過 GIL 這個限制。

好啦那所以 GIL 是什麼?

我知道有看沒有懂,沒關係,我們繼續看下去!

為什麼會有 GIL

GIL 的限制讓電腦一次只能使用「一個線程」執行 Python 程式碼。因為這個限制,我們可以增加 CPython 的線程安全性 (thread-safety)。

什麼是線程安全性?還記得我們之前說過,多線程 multithread 的程式擁有共同的資料和資源(cpu、記憶體)嗎?

其實有些程式在進行多線程處理時,會因為同時訪問和修改共享的資料,而導致了意外的發生。在這種情況下,我們就會說這個程式並不是線程安全(thread safe)。

一個常見的例子就是 race condition,我們來看一個簡單的例子:

import threading

# 共享變數
shared_counter = 0

def increment_counter():
    global shared_counter
    for _ in range(1000000):  # 增加 shared_counter
        shared_counter += 1

# 創建兩個 thread
thread1 = threading.Thread(target=increment_counter)
thread2 = threading.Thread(target=increment_counter)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print("最終 counter 值:", shared_counter)

你可能會覺得,每次執行程式後,shared_counter 都會是 2000000。

但其實不是!這是因為有可能當一個 thread 在進行存取前,另一個 thread 在進行改寫,這就造成了不同的結果產生。

而 GIL 的出現就是為了防止這樣的情況發生。但記得,GIL 的出現並不會讓多線程的程式完全變為 thread-safe,它只會解決大部分情況。

想要完全達到 thread-safe,還需要其他技術比如說 synchronization 來解決上述 race condition 的問題。

GIL 如何運作

當一個 Python 程式是以多線程 multithread 的方式在運行時,只有爭取到 GIL 的線程可以運行該程式。

每個線程基本上都在做這樣的事:

  1. 爭取 GIL
  2. 執行程式碼
  3. 釋放 GIL

所以我們可以將 GIL 看作是一種「通行證」,只有取得通行證的線程才可以執行程式。而一個直譯器只會有一個 GIL,因此,就算你把兩個不同的 thread 放在不同的核上,程式在運行時依然只會有一個 thread 在運行。

在 CPython 中,會有一個內建的計數器或計時器,當達到一定的閥值時,就會強制釋放 GIL。

而 GIL 一但釋放,其他線程就會被喚醒並進行競爭,這樣的喚醒競爭的行為,就會消耗很多資源(時間)。

更扯的是,GIL 常常都是被同一個線程爭取到,因此喚醒其他線程很多時候只是白白浪費資源。這是由於 CPython 底層實作的缺陷。

因為這樣的缺陷,多線程在 Python 中其實和單線程的效率是差不多的,甚至可能更差!

所以你才可能會聽到有人說:「在 Python 中不要用 multithread,要用就用 multiprocess!」

GIL 的影響

理解了基本 GIL 運作邏輯後,我們來實際看看 GIL 對多線程到底影像到什麼程度。

CPU bound 任務

我們來看一個簡單的 CPU bound 任務,並分別在單線程與多線程上執行。

單線程
import time

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

start = time.time()
countdown(100000000)
end = time.time()

print('總時間:', end - start)

運行後我們會得到類似這樣的輸出:

總時間: 2.3528549671173096
多線程
import time
import threading

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

t1 = threading.Thread(target=countdown, args=(100000000/2,))
t2 = threading.Thread(target=countdown, args=(100000000/2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('總時間:', end - start)

運行後我們會得到類似這樣的輸出:

總時間: 2.818242073059082

我們會發現居然比單線程的還來得慢!

但是我們也已經知道為什麼了,因此也不是很驚訝。

I/O bound 任務

接著我們來看對於 I/O bound 任務的影響。一樣我們分為單線程與多線程。

單線程
import time
import requests

def request(url):
    response = requests.get(url)

start = time.time()
request('https://google.com')
request('https://google.com')
end = time.time()

print('總時間:', end - start)

執行後我們可以看到這樣的輸出:

總時間: 1.0379688739776611
多線程
import time
import threading
import requests

def request(url):
    response = requests.get(url)

t1 = threading.Thread(target=request, args=('https://google.com',))
t2 = threading.Thread(target=request, args=('https://google.com',))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('總時間:', end - start)

執行後可以看到這樣的輸出:

總時間: 0.7394030094146729

神奇的事發生了!GIL 的存在居然對 I/O bound 類型的任務沒有什麼影響!

這是因為對於 I/O bound 類型的任務,程式會因為需要等待其他系統的回應而多少出現閒置的時間。而這個閒置的時間,就讓 Python 可以去釋放 GIL,並讓其他線程 thread 有機會爭取到 GIL。

如何避免 GIL

有幾個方法可以規避掉 GIL 帶來的缺陷:

  1. 使用 multiprocess
  2. 使用其他 Interpreter
  3. 等待 Python 官方移除 GIL

使用 multiprocess

一個常見的方法就是使用 multiprocess 取代 multithread。

這是因為如果使用 multiprocess,則每一個 process 本身都會有自己的直譯器,也就是說每一個 process 都有自己的 GIL。

有興趣的人可以到 利用 multiprocess 執行 CPU bound 任務 那章中看看該如何利用 multiprocess 取代 multithread!

使用其他 Interpreter

一開始講到了,GIL 的存在只限於當你的直譯器是 CPython 時才會遇到。

你完完全全可以使用其他現有的直譯器,比如說 Jython、IronPython 或 PyPy。

然而 CPython 畢竟是當前最主流的,因此所擁有的資料庫也最多。如果你要使用到的資料庫只存在於 CPython,那這個方法可能就不適合你了。

等待 Python 官方移除 GIL

在 2023 年,Python 終於通過協議要在 CPython 中移除 GIL 機制了!詳細內容可以看這篇 PEP 703 – Making the Global Interpreter Lock Optional in CPython

但是實際什麼時候會推出目前還不知道,只能慢慢等待了。

總結

這章我們又學到了一個新的觀念啦!有沒有很開心~

我們介紹了什麼是 GIL,為什麼需要 GIL,以及它帶來的影響。

另外,我們也在文中提到了幾次 concurrent 這個詞,不知道大家清不清楚 Concurrency 和 Parallelism 之間的差異。

不知道沒關係~ 我以後會再寫其他文章專門講解!

那這章就到這裡啦~

如果有學到東西的話可以留個五星評價喔!