【莫煩】Threading 多執行緒教程

Amor167發表於2018-12-20

1.1 什麼是多執行緒 Threading


同時分批做同一件事情,以提高運算效率

1.2 新增執行緒 Thread


新增執行緒

本節我們來學習threading模組的一些基本操作,如獲取執行緒數,新增執行緒等。首先別忘了匯入模組:

import threading

獲取已啟用的執行緒數

threading.active_count()
# 2

檢視所有執行緒資訊

threading.enumerate()
# [<_MainThread(MainThread, started 140736011932608)>, <Thread(SockThread, started daemon 123145376751616)>]

輸出的結果是一個<_MainThread(...)>帶多個<Thread(...)>

檢視現在正在執行的執行緒

threading.current_thread()
# <_MainThread(MainThread, started 140736011932608)>

新增執行緒,threading.Thread()接收引數target代表這個執行緒要完成的任務,需自行定義

def thread_job():
    print('This is a thread of %s' % threading.current_thread())

def main():
    thread = threading.Thread(target=thread_job,)   # 定義執行緒 
    thread.start()  # 讓執行緒開始工作

完整程式與執行結果

import threading

def thread_job():
    print('This is an added Thread,number is %s'% threading.current_thread())

def main ():
    added_thread=threading.Thread(target=thread_job)
    added_thread.start()
    print(threading.active_count())
    print(threading.enumerate())
    print(threading.current_thread())

if __name__=='__main__':
    main()
"""
This is an added Thread,number is <Thread(Thread-1, started 6904)>
2
[<_MainThread(MainThread, started 12484)>, <Thread(Thread-1, started 6904)>]
<_MainThread(MainThread, started 12484)>
"""

1.3 join 功能


  • 當一個程式啟動之後,會預設產生一個主執行緒,因為執行緒是程式執行流的最小單元,當設定多執行緒時,主執行緒會建立多個子執行緒,在python中,預設情況下(其實就是setDaemon(False)),主執行緒執行完自己的任務以後,就退出了,此時子執行緒會繼續執行自己的任務,直到自己的任務結束.
  • 當我們使用setDaemon(True)方法,設定子執行緒為守護執行緒時,主執行緒一旦執行結束,則全部執行緒全部被終止執行,可能出現的情況就是,子執行緒的任務還沒有完全執行結束,就被迫停止。
  • 此時join的作用就凸顯出來了,join所完成的工作就是執行緒同步,即主執行緒任務結束之後,進入阻塞狀態,一直等待其他的子執行緒執行結束之後,主執行緒在終止。
  • join有一個timeout引數:當設定守護執行緒時,含義是主執行緒對於子執行緒等待timeout的時間將會殺死該子執行緒,最後退出程式。所以說,如果有10個子執行緒,全部的等待時間就是每個timeout的累加和。簡單的來說,就是給每個子執行緒一個timeout的時間,讓他去執行,時間一到,不管任務有沒有完成,直接殺死。沒有設定守護執行緒時,主執行緒將會等待timeout的累加和這樣的一段時間,時間一到,主執行緒結束,但是並沒有殺死子執行緒,子執行緒依然可以繼續執行,直到子執行緒全部結束,程式退出。

不加 join() 的結果

我們讓T1執行緒工作的耗時增加.

import threading
import time

def thread_job():
    print("T1 start\n")
    for i in range(10):
        time.sleep(0.1) # 任務間隔0.1s
    print("T1 finish\n")

added_thread = threading.Thread(target=thread_job, name='T1')
added_thread.start()
print("all done\n")

預想中輸出的結果是否為:

T1 start
T1 finish
all done

但實際卻是:

T1 start
all done
T1 finish

加入 join() 的結果

執行緒任務還未完成便輸出all done。如果要遵循順序,可以在啟動執行緒後對它呼叫join

added_thread.start()
added_thread.join()
print("all done\n")

使用join對控制多個執行緒的執行順序非常關鍵。舉個例子,假設我們現在再加一個執行緒T2T2的任務量較小,會比T1更快完成:

def T1_job():
    print("T1 start\n")
    for i in range(10):
        time.sleep(0.1)
    print("T1 finish\n")

def T2_job():
    print("T2 start\n")
    print("T2 finish\n")

thread_1 = threading.Thread(target=T1_job, name='T1')
thread_2 = threading.Thread(target=T2_job, name='T2')
thread_1.start() # 開啟T1
thread_2.start() # 開啟T2
print("all done\n")

輸出的”一種”結果是:

T1 start
T2 start
T2 finish
all done
T1 finish

現在T1T2都沒有join,注意這裡說”一種”是因為all done的出現完全取決於兩個執行緒的執行速度, 完全有可能T2 finish出現在all done之後。這種雜亂的執行方式是我們不能忍受的,因此要使用join加以控制。

我們試試在T1啟動後,T2啟動前加上thread_1.join():

thread_1.start()
thread_1.join() # notice the difference!
thread_2.start()
print("all done\n")

輸出結果:

T1 start
T1 finish
T2 start
all done
T2 finish

可以看到,T2會等待T1結束後才開始執行。

如果我們在T2啟動後放上thread_1.join()會怎麼樣呢?

thread_1.start()
thread_2.start()
thread_1.join() # notice the difference!
print("all done\n")
``
輸出結果:
```py
T1 start
T2 start
T2 finish
T1 finish
all done

T2T1之後啟動,並且因為T2任務量小會在T1之前完成;而T1也因為加了joinall done在它完成後才顯示。

你也可以新增thread_2.join()進行嘗試,但為了規避不必要的麻煩,推薦如下這種1221的V型排布:

thread_1.start() # start T1
thread_2.start() # start T2
thread_2.join() # join for T2
thread_1.join() # join for T1
print("all done\n")

"""
T1 start
T2 start
T2 finish
T1 finish
all done
"""

1.4 儲存程式結果 Queue


程式碼實現功能,將資料列表中的資料傳入,使用四個執行緒處理,將結果儲存在Queue中,執行緒執行完後,從Queue中獲取儲存的結果

匯入執行緒,佇列的標準模組

import threading
import time
from queue import Queue

定義一個被多執行緒呼叫的函式

函式的引數是一個列表l和一個佇列q,函式的功能是,對列表的每個元素進行平方計算,將結果儲存在佇列中

def job(l,q):
    for i in range (len(l)):
        l[i] = l[i]**2
    q.put(l)   #多執行緒呼叫的函式不能用return返回值

定義一個多執行緒函式

在多執行緒函式中定義一個Queue,用來儲存返回值,代替return,定義一個多執行緒列表,初始化一個多維資料列表,用來處理:

def multithreading():
    q =Queue()    #q中存放返回值,代替return的返回值
    threads = [] #建立一個陣列作為程式列表,以把建立好的執行緒裝到threads中
    data = [[1,2,3],[3,4,5],[4,4,4],[5,5,5]]

在多執行緒函式中定義四個執行緒,啟動執行緒,將每個執行緒新增到多執行緒的列表中

for i in range(4):   #定義四個執行緒
    t = threading.Thread(target=job,args=(data[i],q)) #Thread首字母要大寫,被呼叫的job函式沒有括號,只是一個索引,引數在後面
    t.start()#開始執行緒
    threads.append(t) #把每個執行緒append到執行緒列表中

分別join四個執行緒到主執行緒

for thread in threads: #thread可以用其他符號替代 如t、i
    thread.join()

定義一個空的列表results,將四個線執行後儲存在佇列中的結果返回給空列表results

results = []
for _ in range(4):
    results.append(q.get())  #q.get()按順序從q中拿出一個值
print(results)

完整的程式碼

import threading
import time

from queue import Queue

def job(l,q):
    for i in range (len(l)):
        l[i] = l[i]**2
    q.put(l)

def multithreading():
    q =Queue()
    threads = []
    data = [[1,2,3],[3,4,5],[4,4,4],[5,5,5]]
    for i in range(4):
        t = threading.Thread(target=job,args=(data[i],q))
        t.start()
        threads.append(t)
    for thread in threads:
        thread.join()
    results = []
    for _ in range(4):
        results.append(q.get())
    print(results)

if __name___=='__main__':
    multithreading()

最後執行結果為:

[[1, 4, 9], [9, 16, 25], [16, 16, 16], [25, 25, 25]]

1.5 GIL 不一定有效率


這次我們來看看為什麼說 python 的多執行緒 threading 有時候並不是特別理想. 最主要的原因是就是, Python 的設計上, 有一個必要的環節, 就是 Global Interpreter Lock (GIL). 這個東西讓 Python 還是一次性只能處理一個東西.

我從這裡摘抄了一段對於 GIL 的解釋.

儘管Python完全支援多執行緒程式設計, 但是直譯器的C語言實現部分在完全並行執行時並不是執行緒安全的。 實際上,直譯器被一個全域性直譯器鎖保護著,它確保任何時候都只有一個Python執行緒執行。 GIL最大的問題就是Python的多執行緒程式並不能利用多核CPU的優勢 (比如一個使用了多個執行緒的計算密集型程式只會在一個單CPU上面執行)。

在討論普通的GIL之前,有一點要強調的是GIL只會影響到那些嚴重依賴CPU的程式(比如計算型的)。 如果你的程式大部分只會涉及到I/O,比如網路互動,那麼使用多執行緒就很合適, 因為它們大部分時間都在等待。實際上,你完全可以放心的建立幾千個Python執行緒, 現代作業系統執行這麼多執行緒沒有任何壓力,沒啥可擔心的。

測試 GIL

我們建立一個 job, 分別用 threading 和 一般的方式執行這段程式. 並且建立一個 list 來存放我們要處理的資料. 在 Normal 的時候, 我們這個 list 擴充套件4倍, 在 threading 的時候, 我們建立4個執行緒, 並對執行時間進行對比.

import threading
from queue import Queue
import copy
import time

def job(l, q):
    res = sum(l)
    q.put(res)

def multithreading(l):
    q = Queue()
    threads = []
    for i in range(4):
        t = threading.Thread(target=job, args=(copy.copy(l), q), name='T%i' % i)
        t.start()
        threads.append(t)
    [t.join() for t in threads]
    total = 0
    for _ in range(4):
        total += q.get()
    print(total)

def normal(l):
    total = sum(l)
    print(total)

if __name__ == '__main__':
    l = list(range(1000000))
    s_t = time.time()
    normal(l*4)
    print('normal: ',time.time()-s_t)
    s_t = time.time()
    multithreading(l)
    print('multithreading: ', time.time()-s_t)

如果你成功執行整套程式, 你大概會有這樣的輸出. 我們的運算結果沒錯, 所以程式 threading 和 Normal 執行了一樣多次的運算. 但是我們發現 threading 卻沒有快多少, 按理來說, 我們預期會要快3-4倍, 因為有建立4個執行緒, 但是並沒有. 這就是其中的 GIL 在作怪.

1999998000000
normal:  0.10034608840942383
1999998000000
multithreading:  0.08421492576599121

在這裡插入圖片描述

1.6 執行緒鎖 Lock


匯入執行緒標準模組

import threading

不使用 Lock 的情況

函式一:全域性變數A的值每次加1,迴圈10次,並列印

def job1():
    global A
    for i in range(10):
        A+=1
        print('job1',A)

函式二:全域性變數A的值每次加10,迴圈10次,並列印

def job2():
    global A
    for i in range(10):
        A+=10
        print('job2',A)

主函式:定義兩個執行緒,分別執行函式一和函式二

if __name__== '__main__':
    A=0
    t1=threading.Thread(target=job1)
    t2=threading.Thread(target=job2)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

完整程式碼:

import threading

def job1():
    global A
    for i in range(10):
        A+=1
        print('job1',A)

def job2():
    global A
    for i in range(10):
        A+=10
        print('job2',A)

if __name__== '__main__':
    lock=threading.Lock()
    A=0
    t1=threading.Thread(target=job1)
    t2=threading.Thread(target=job2)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

執行結果(在spyder編譯器下執行的列印結果):

job1job2 11
job2 21
job2 31
job2 41
job2 51
job2 61
job2 71
job2 81
job2 91
job2 101
 1
job1 102
job1 103
job1 104
job1 105
job1 106
job1 107
job1 108
job1 109
job1 110

可以看出,列印的結果非常混亂

使用 Lock 的情況

lock在不同執行緒使用同一共享記憶體時,能夠確保執行緒之間互不影響,使用lock的方法是, 在每個執行緒執行運算修改共享記憶體之前,執行lock.acquire()將共享記憶體上鎖, 確保當前執行緒執行時,記憶體不會被其他執行緒訪問,執行運算完畢後,使用lock.release()將鎖開啟, 保證其他的執行緒可以使用該共享記憶體。

函式一和函式二加鎖

def job1():
    global A,lock
    lock.acquire()
    for i in range(10):
        A+=1
        print('job1',A)
    lock.release()

def job2():
    global A,lock
    lock.acquire()
    for i in range(10):
        A+=10
        print('job2',A)
    lock.release()

主函式中定義一個Lock

if __name__== '__main__':
    lock=threading.Lock()
    A=0
    t1=threading.Thread(target=job1)
    t2=threading.Thread(target=job2)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

完整的程式碼

import threading

def job1():
    global A,lock
    lock.acquire()
    for i in range(10):
        A+=1
        print('job1',A)
    lock.release()

def job2():
    global A,lock
    lock.acquire()
    for i in range(10):
        A+=10
        print('job2',A)
    lock.release()

if __name__== '__main__':
    lock=threading.Lock()
    A=0
    t1=threading.Thread(target=job1)
    t2=threading.Thread(target=job2)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

執行結果

job1 1
job1 2
job1 3
job1 4
job1 5
job1 6
job1 7
job1 8
job1 9
job1 10
job2 20
job2 30
job2 40
job2 50
job2 60
job2 70
job2 80
job2 90
job2 100
job2 110

從列印結果來看,使用lock後,一個一個執行緒執行完。使用lock和不使用lock,最後列印輸出的結果是不同的。

相關文章