本文環境: Python 2.7.10 (CPython)。
- 因爲GIL的存在,Python多線程是否雞肋?
- 既然已有GIL,是否Python編程不需要關注線程安全的問題?不需要使用鎖?
- 爲什麼Python進階材料很少有講解多線程?
一、GIL簡介
首先我們看下Global Interpreter Lock(GIL)的官方介紹:
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)
簡而言之,因爲CPython的內存管理不是線程安全的,所以需要加一個全局解釋鎖來保障Python內部對象是線程安全的。
GIL的存在導致Python多線程是不完整的多線程,Python社區內部對是否保留GIL一致激烈討論,這裏我們就不在累述。
二、Python多線程是否雞肋
正如上節所說,Python的多線程是不完整的多線程。不過拋開具體應用場景談“Python多線程是否雞肋”就是耍流氓了!
1. 爲什麼需要多線程呢?
爲什麼需要多線程呢?總結一下,多線程多應用在如下場景:
-
需要運行後臺任務但不希望停止主線程的執行
- 定期打印日誌
- 圖形界面下,主循環需要等待事件
-
分散任務負載
- 高負載任務一般分計算密集型、IO密集型兩類。
2. 計算密集型 vs. IO密集型
計算密集型任務的特點是要進行大量的計算,消耗CPU資源,比如計算圓周率、對視頻進行高清解碼等等,全靠CPU的運算能力。這種計算密集型任務雖然也可以用多任務完成,但是任務越多,花在任務切換的時間就越多,CPU執行任務的效率就越低,所以,要最高效地利用CPU,計算密集型任務同時進行的數量應當等於CPU的核心數。計算密集型任務由於主要消耗CPU資源,因此,代碼運行效率至關重要。
IO密集型,涉及到網絡、磁盤IO的任務都是IO密集型任務,這類任務的特點是CPU消耗很少,任務的大部分時間都在等待IO操作完成(因爲IO的速度遠遠低於CPU和內存的速度)。對於IO密集型任務,任務越多,CPU效率越高,但也有一個限度。常見的大部分任務都是IO密集型任務,比如Web應用。IO密集型任務執行期間,99%的時間都花在IO上,花在CPU上的時間很少。
計算密集型驗證例子
Python作爲一門腳本語言,本身執行效率極低,完全不適合計算密集型任務的開發。再加上GIL的存在,需要花費大量時間用在線程間的切換,其多線程性能甚至低於單線程。以下是一個驗證例子:
順序執行的單線程(single_thread.py)
#! /usr/bin/python
from threading import Thread
import time
def my_counter():
i = 0
for _ in range(100000000):
i = i + 1
return True
def main():
thread_array = {}
start_time = time.time()
for tid in range(2):
t = Thread(target=my_counter)
t.start()
t.join()
end_time = time.time()
print("Total time: {}".format(end_time - start_time))
if __name__ == '__main__':
main()
同時執行的兩個併發線程(multi_thread.py)
#! /usr/bin/python
from threading import Thread
import time
def my_counter():
i = 0
for _ in range(100000000):
i = i + 1
return True
def main():
thread_array = {}
start_time = time.time()
for tid in range(2):
t = Thread(target=my_counter)
t.start()
thread_array[tid] = t
for i in range(2):
thread_array[i].join()
end_time = time.time()
print("Total time: {}".format(end_time - start_time))
if __name__ == '__main__':
main()
多線程執行更慢了!
經過大量測試,Python多線程下一般最多隻能佔用1.5~2核,完全無法充分利用CPU資源。
3.小結
在低計算場景(普通後臺任務、IO密集型任務)下,Python多線程還是有一點用武之地。但是計算密集型任務的話,Python多線程是真雞肋,甚至會嚴重拖後腿。
三、鎖與線程安全
既然有GIL這個語言級的鎖,那我們是不是可以不關注鎖與線程安全,直接起飛了?
且看下面這個例子
#! /usr/bin/python
import threading
i = 0
def test():
global i
for x in range(100000):
i += 1
threads = [threading.Thread(target=test) for t in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert i == 100000, i
顯然失敗了。因爲高級語言的一條語句執行時都是分爲若干條執行碼,即使一個簡單的計算:i += 1,也是分爲4個執行碼。
- load i
- load 1
- add
- store it back to i
Python解釋器默認每100個操作碼切換一個活動線程(通過從一個線程釋放GIL以便另一個線程可以使用)。當100個操作碼切換時,就會出現爭搶,從而出現線程不安全的情況。此時就需要我們加一個簡單的鎖。
#!/usr/bin/python
import threading
i = 0
i_lock = threading.Lock()
def test():
global i
i_lock.acquire()
try:
for x in range(100000):
i += 1
finally:
i_lock.release()
threads = [threading.Thread(target=test) for t in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert i == 100000, i
四、總結
相比Java那種天生面向多線程的語言不同,Python本身多線程就是不太完善的多線程。GIL的存在導致多線程CPU利用效率甚至低於單線程,卻仍然要面對鎖與線程安全的問題。同時Python語言又不像Java自帶多種線程安全的數據類型,增加了多線程編程的複雜度,所以很少有資料大篇幅講解Python多線程。
正如《Python高手之路》所言: (Python)處理好多線程是很難的,其複雜程度意味着與其他方式(異步事件\多進程)相比它是bug的更大來源,而且考慮到通常能夠獲取的好處很少,所以最好不要在多線程上浪費太多精力。
參考資料:
python中的GIL詳解
is-the-operator-thread-safe-in-python
《Python高手之路》(《The Hacker’s Guide to Python》)