【Python】談談Python多線程

本文環境: 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》)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章