python之多執行緒

sixkery發表於2018-08-16

注:本文是廖大的教程文章,本人也在學習,因為老是記不住,自己手打一邊,程式碼也是親自測試。
廖大傳送門

多程式

多個任務可以由多程式完成,也可以由一個程式內的多執行緒完成。
一個執行緒由多個程式組成,一個程式至少有一個執行緒。
由於執行緒是作業系統直接支援的單元,因此,高階語言都內建多執行緒的支援,python 也不例外,並且,python 的執行緒是真正的 Posix Thread ,不是模擬出來的執行緒。
python 的標準庫提供了兩個模組:_thread 和 threading ,_thread 是低階模組,threading 是高階模組。絕大多數的情況下,我們只用 threading 就可以了。
啟動一個執行緒就是把函式傳入並建立 Thread 例項,然後呼叫 start() 函開始執行就可以了。

import time
import threading

#執行緒執行的程式碼
def loop():
    print(`thread %s is running` % threading.current_thread().name)
    n = 0
    while n < 5:
        n += 1
        print(`thread %s >>> %s` % (threading.current_thread().name,n))
        time.sleep(1)
    print(`thread %s end` % threading.current_thread().name)

print(`thread %s is running...` % threading.current_thread().name)
t = threading.Thread(target=loop,name=`LoopTread`)
t.start()
t.join()
print(`thread %s end` % threading.current_thread().name)

執行結果

thread MainThread is running...
thread LoopTread is running
thread LoopTread >>> 1
thread LoopTread >>> 2
thread LoopTread >>> 3
thread LoopTread >>> 4
thread LoopTread >>> 5
thread LoopTread end
thread MainThread end

由於任何程式都會預設開啟一個執行緒,我們把該執行緒稱為主執行緒,主執行緒又可以開啟新的執行緒,Python 的 threading 模組有個 current_thread() 函式,它永遠返回當前執行緒的例項。主執行緒例項的名字叫 MainThread ,子執行緒的名字在建立時指定,我們用 LoopThread 命名子執行緒。名字僅僅在列印時用來顯示,完全沒有其他意義,如果不起名字 Python 就自動給執行緒命名為 Thread-1,Thread-2……

Lock

多程式和多執行緒最大的不同在於,多程式中,同一個變數,各自有一份拷貝到每個程式,互不影響,而執行緒中,所有變數都是又所有執行緒共享所有,任何一個變數都可以被任何一個執行緒修改,因此,執行緒之間共享資料最大的危險在於多執行緒同時修改同一個變數,把內容給改亂了。
舉個例子

#假定這是你的銀行存款
balance = 0

def change_it(n):
    #先存後取
    global balance
    balance += n
    balance -= n

def run_thread(n):
    for i in range(100000):
        change_it(n)

t1 = threading.Thread(target=run_thread,args=(5,))
t2 = threading.Thread(target=run_thread,args=(8,))

t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

我們定義了一個共享變數balance,初始值為0,並且啟動兩個執行緒,先存後取,理論上結果應該為0,但是,由於執行緒的排程是由作業系統決定的,當t1、t2交替執行時,只要迴圈次數足夠多,balance的結果就不一定是0了。
執行結果:

5

原因是因為高階語言的一條語句在 CPU 執行時是若干條語句,即使一個簡單的計算

balance += n

也要分兩步

  • 計算 balance + n 結果存到臨時變數中,
  • 將臨時變數的值賦給 balance

究其原因,是因為修改 balance 需要多條語句,而執行這幾條語句時,執行緒可能中斷,從而導致多個執行緒把同一個物件的內容改亂了。

兩個執行緒同時一存一取,就可能導致餘額不對,你肯定不希望你的銀行存款莫名其妙地變成了負數,所以,我們必須確保一個執行緒在修改 balance的時候,別的執行緒一定不能改。

如果我們要確保 balance 計算正確,就要給 change_it() 上一把鎖,當某個執行緒開始執行 change_it() 時,我們說,該執行緒因為獲得了鎖,因此其他執行緒不能同時執行 change_it(),只能等待,直到鎖被釋放後,獲得該鎖以後才能改。由於鎖只有一個,無論多少執行緒,同一時刻最多隻有一個執行緒持有該鎖,所以,不會造成修改的衝突。建立一個鎖就是通過threading.Lock() 來實現:

lock = threading.Lock()
def run_thread(n):
    for i in range(100000):
        #先要獲取鎖
        lock.acquire()
        try:
            #放心改吧
            change_it(n)
        finally:
            #改完記得釋放鎖哦
            lock.release()

當多個執行緒同時執行 lock.acquire() 時,只有一個執行緒能成功地獲取鎖,然後繼續執行程式碼,其他執行緒就繼續等待直到獲得鎖為止。

獲得鎖的執行緒用完後一定要釋放鎖,否則那些苦苦等待鎖的執行緒將永遠等待下去,成為死執行緒。所以我們用 try…finally 來確保鎖一定會被釋放。

  • 鎖的好處就是確保了某段關鍵程式碼只能由一個執行緒從頭到尾完整地執行。
  • 壞處當然也很多,首先是阻止了多執行緒併發執行,包含鎖的某段程式碼實際上只能以單執行緒模式執行,效率就大大地下降了。
  • 其次,由於可以存在多個鎖,不同的執行緒持有不同的鎖,並試圖獲取對方持有的鎖時,可能會造成死鎖,導致多個執行緒全部掛起,既不能執行,也無法結束,只能靠作業系統強制終止。

多核CPU

如果你不幸擁有一個多核CPU,你肯定在想,多核應該可以同時執行多個執行緒。
如果寫一個死迴圈的話,會出現什麼情況呢?
開啟Mac OS X的Activity Monitor,或者Windows的Task Manager,都可以監控某個程式的CPU使用率。
我們可以監控到一個死迴圈執行緒會100%佔用一個CPU。
如果有兩個死迴圈執行緒,在多核CPU中,可以監控到會佔用200%的CPU,也就是佔用兩個CPU核心。
要想把N核CPU的核心全部跑滿,就必須啟動N個死迴圈執行緒。
試試用Python寫個死迴圈:

import threading, multiprocessing

def loop():
    x = 0
    while True:
        x = x ^ 1

for i in range(multiprocessing.cpu_count()):
    t = threading.Thread(target=loop)
    t.start()

啟動與CPU核心數量相同的N個執行緒,在4核CPU上可以監控到CPU佔用率僅有102%,也就是僅使用了一核。

但是用C、C++或Java來改寫相同的死迴圈,直接可以把全部核心跑滿,4核就跑到400%,8核就跑到800%,為什麼Python不行呢?

因為Python的執行緒雖然是真正的執行緒,但直譯器執行程式碼時,有一個GIL鎖:Global Interpreter Lock,任何Python執行緒執行前,必須先獲得GIL鎖,然後,每執行100條位元組碼,直譯器就自動釋放GIL鎖,讓別的執行緒有機會執行。這個GIL全域性鎖實際上把所有執行緒的執行程式碼都給上了鎖,所以,多執行緒在Python中只能交替執行,即使100個執行緒跑在100核CPU上,也只能用到1個核。

GIL是Python直譯器設計的歷史遺留問題,通常我們用的直譯器是官方實現的CPython,要真正利用多核,除非重寫一個不帶GIL的直譯器。

所以,在Python中,可以使用多執行緒,但不要指望能有效利用多核。如果一定要通過多執行緒利用多核,那隻能通過C擴充套件來實現,不過這樣就失去了Python簡單易用的特點。

不過,也不用過於擔心,Python雖然不能利用多執行緒實現多核任務,但可以通過多程式實現多核任務。多個Python程式有各自獨立的GIL鎖,互不影響。


相關文章