0. 寫在前面:程序和執行緒
博文參考:
Python的並行(持續更新)_python 並行-CSDN部落格
《Python並行程式設計 中文版》
一些相關概念請見上一篇博文。
1. 在Python中使用執行緒
1.1 多執行緒簡介
-
執行緒是獨立的處理流程,可以和系統的其他執行緒並行或併發地執行。
-
多執行緒可以共享資料和資源,利用所謂的共享記憶體空間。
-
每一個執行緒基本上包含3個元素:程式計數器,暫存器和棧。
-
執行緒的狀態大體上可以分為
ready
,running
,blocked
。 -
多執行緒程式設計一般使用共享內容空間進行執行緒間的通訊,這就使管理內容空間成為多執行緒程式設計的重點和難點。
-
執行緒的典型應用是應用軟體的並行化。
-
相比於程序,使用執行緒的優勢主要是效能。
1.2 threading庫實現多執行緒
1.2.1 定義一個執行緒:threading.Thread()
class threading.Thread(group=None, ## 一般設定為 None ,這是為以後的一些特性預留的
target=None, ## 當執行緒啟動的時候要執行的函式
name=None, ## 執行緒的名字,預設會分配一個唯一名字 Thread-N
args=(), ## 使用 tuple 型別給 target 傳遞引數
kwargs={}) ## 使用 dict 型別給 target 傳遞引數
group
: 保留引數,通常設定為None
。這是為以後的一些特性預留的。target
: 執行緒執行的函式或可呼叫物件。name
: 執行緒的名稱。如果未指定,將生成一個預設名稱,預設為:Thread-N。args
: 傳遞給target
的引數元組(整形和浮點型資料輸入到args
裡)。kwargs
: 傳遞給target
的關鍵字引數字典(字串型資料輸入到這裡)。
例子1:threading.Thread()的一個簡單呼叫
import threading
def function(i):
print("function called by thread %i\n" % i)
return
#threads = []
for i in range(5):
t = threading.Thread(target=function, args=(i,)) ## 用 function 函式初始化一個 Thread 物件 t,並將引數 i 傳入;
#threads.append(t)
t.start() ## 執行緒被建立後不會馬上執行,需要手動呼叫 .start() 方法執行執行緒
t.join() ## 阻塞呼叫 t 執行緒的主執行緒,t 執行緒執行結束,主執行緒才會繼續執行
[Run]
function called by thread 0
function called by thread 1
function called by thread 2
function called by thread 3
function called by thread 4
function
函式的輸入只有一個int
型數值,這裡要注意的是,在使用threading.Thread()
傳參時,arg
需要傳入一個元組,所以輸入的是(i,)
,也就是說要加個逗號,
。因為type((i))
是<class 'int'>
。
例子2:函式傳入引數同時包含浮點型和字串型數值時
import threading
# 定義一個執行緒函式,接受浮點型和字串型引數
def calculate(data_float, data_string):
result = data_float * 2
print(f"Thread result for {data_float}: {result}")
print(f"Additional string data: {data_string}")
# 建立多個執行緒並啟動
threads = []
data_float = [1.5, 2.5, 3.5] # 浮點型資料
data_string = ["Hello", "World", "OpenAI"] # 字串型資料
for i in range(len(data_float)):
thread = threading.Thread(target=calculate, args=(data_float[i], data_string[i]))
threads.append(thread)
thread.start()
# 等待所有執行緒執行完成
for thread in threads:
thread.join()
print("All threads have finished execution.")
[Run]
Thread result for 1.5: 3.0
Additional string data: Hello
Thread result for 2.5: 5.0
Additional string data: World
Thread result for 3.5: 7.0
Additional string data: OpenAI
All threads have finished execution.
1.2.2 啟動執行緒和等待執行緒終止:strat()和join()方法
在 Python 的 threading
模組中,start()
和 join()
是 Thread
類的兩個非常重要的方法,它們在多執行緒程式設計中扮演著關鍵的角色。
start()方法:
- 目的:
start()
方法用於啟動執行緒。一旦呼叫此方法,執行緒將開始執行其target
函式,即在建立Thread
物件時指定的函式。 - 用法: 通常在建立
Thread
物件後立即呼叫,無需任何引數。 - 返回值: 沒有返回值。
join()方法:
- 目的:
join()
方法用於等待執行緒終止。當一個執行緒執行join()
方法時,它會等待呼叫join()
的執行緒完成其執行,然後才繼續執行。 - 用法: 在主執行緒中呼叫,等待一個或多個執行緒完成。
- 引數: 可接受一個可選的
timeout
引數,單位為秒,表示等待執行緒終止的最長時間。如果timeout
為None
,則會無限期等待。 - 返回值: 沒有返回值。
為什麼需要 start()
和 join()
start()
的必要性:
- 在多執行緒程式中,通常需要同時執行多個任務。
start()
方法是啟動這些任務的機制。沒有它,執行緒將不會執行其目標函式。
join()
的必要性:
join()
用於同步執行緒,確保主執行緒等待所有子執行緒完成。這在以下情況下非常重要:- 當子執行緒的任務是程式繼續執行的前提時(例如,它可能在設定某些資源)。
- 當你想要確保資源被正確釋放,或者子執行緒產生的結果被正確處理時。
- 當你想要避免程式在所有執行緒完成之前退出時。
假設你有一個程式,它啟動了多個執行緒來執行某些任務,然後程式需要在所有執行緒完成之前等待:
import threading
import time
def do_work():
print("Thread starting.")
time.sleep(1) # 模擬耗時操作
print("Thread finishing.")
if __name__ == "__main__":
threads = []
for i in range(3):
t = threading.Thread(target=do_work)
threads.append(t)
t.start()
for t in threads:
t.join() # 等待所有執行緒完成
print("All threads have completed.")
-
在這個例子中,如果沒有使用
join()
,主執行緒可能在子執行緒完成之前就退出了,導致程式結束,而子執行緒中的任務可能還沒有完成。透過使用join()
,我們確保了所有子執行緒在程式退出前都已經完成。 -
簡單來說就是,如果沒有
join()
,程式執行時首先會輸出三個Thread starting
,然後再輸出All threads have completed
,這時候主執行緒已經結束了,並且後面的程式此時已經開始執行了。而在1秒之後,才會輸出Thread finishing
,也就是說在這一秒內子執行緒還沒結束,程式就會一邊執行後面的程式一邊執行子執行緒...
這裡還有一個不太正確的寫法(好像網上一些博主是這麼舉例的):
import threading
import time
def do_work():
print("Thread starting.")
time.sleep(1) # 模擬耗時操作
print("Thread finishing.")
if __name__ == "__main__":
for i in range(3):
t = threading.Thread(target=do_work)
t.start()
t.join()
print("All threads have completed.")
- 這個邏輯是啟動了一個執行緒後,等待這個執行緒完成,才會啟動下一個執行緒。其實這樣的執行結果跟下面這串程式碼差不多,執行時間都是3秒左右。
在我看來上面這個程式的多執行緒用了≈沒用...
import threading
import time
def do_work():
print("Thread starting.")
time.sleep(1) # 模擬耗時操作
print("Thread finishing.")
if __name__ == "__main__":
for i in range(3):
do_work()
print("All threads have completed.")
1.2.3 確定當前執行緒 threading.current_thread().name
-
通常一個服務程序中有多個執行緒服務,負責不同的操作,所以對於執行緒的命名是很重要的;
-
Python中每一個執行緒在被
Thread
被建立時都有一個預設的名字(可以修改);
舉例
- 下面我們只對first_func和second_func函式對應的執行緒命名,third_func函式對應的執行緒採用threading的預設命名
import threading
import time
def first_func():
print(threading.current_thread().name + str(" is Starting"))
time.sleep(2)
print(threading.current_thread().name + str("is Exiting"))
return
def second_func():
print(threading.current_thread().name + str(" is Starting"))
time.sleep(2)
print(threading.current_thread().name + str("is Exiting"))
return
def third_func():
print(threading.current_thread().name + str(" is Starting"))
time.sleep(2)
print(threading.current_thread().name + str("is Exiting"))
return
if __name__ == "__main__":
t1 = threading.Thread(name="first_func", target=first_func)
t2 = threading.Thread(name="second_func", target=second_func)
t3 = threading.Thread(target=third_func)
t1.start()
t2.start()
t3.start()
t1.join()
t2.join()
t3.join()
[Run]
first_func is Starting
second_func is Starting
Thread-1 is Starting
second_funcis Exiting
first_funcis ExitingThread-1is Exiting
-
上面的程式輸出了當前的進行的執行緒名稱,並且將thrid_func對應的執行緒命名為
Thread-1
-
從上面執行結果可以看出,如果不用
name=
引數指定執行緒名稱的話,那麼執行緒名稱將使用預設值。
1.2.4 實現一個執行緒 threading:
使用 threading
模組實現一個執行緒,需要3步:
-
定義一個
Thread
類的子類; -
重寫
__init__(self, [,args])
方法; -
重寫
run(self, [,args])
方法實現一個執行緒;
舉例:
import threading
import time
class myThread(threading.Thread): ## 定義一個 threading 子類,繼承 threading.Thread 父類
def __init__(self, threadID, name, counter): ## 重寫 __init__() 方法,並新增額外的引數
threading.Thread.__init__(self) ## 初始化繼承自Thread類的屬性,使子類物件能夠正確地繼承和使用父類的屬性和方法
self.threadID = threadID ## 子類額外的屬性
self.name = name
self.counter = counter
def run(self):
print("Starting " + self.name)
#作用是首先延遲5秒,然後輸出當前時間,一共輸出self.counter次
print_time(self.name, 5,self.counter) #呼叫後面的print_time()函式
print("Exiting " + self.name)
def print_time(threadName, delay, counter):
while counter:
time.sleep(delay)
print("%s: %s" % (threadName, time.ctime(time.time())))
counter -= 1
## 建立執行緒
thread1 = myThread(1, "Thread-1", 1)
thread2 = myThread(2, "Thread-2", 2)
## 開啟執行緒
thread1.start()
thread2.start()
## .join()
thread1.join()
thread2.join()
print("Exiting Main Thread")
[Run]
Starting Thread-1
Starting Thread-2
Thread-1: Wed May 22 20:42:33 2024
Exiting Thread-1Thread-2: Wed May 22 20:42:33 2024
Thread-2: Wed May 22 20:42:38 2024
Exiting Thread-2
Exiting Main ThreadThread
1.2 5 使用互斥鎖 Lock ()進行執行緒同步 :
-
併發執行緒中,多個執行緒對共享記憶體進行操作,並且至少有一個可以改變資料。這種情況下如果沒有同步機制,那麼多個執行緒之間就會產生競爭,從而導致程式碼無效或出錯。
-
解決多執行緒競爭問題的最簡單的方法就是用鎖 (Lock)。當一個執行緒需要訪問共享記憶體時,它必須先獲得 Lock 之後才能訪問;當該執行緒對共享資源使用完成後,必須釋放 Lock,然後其他執行緒在拿到 Lock 進行訪問資源。因此,為了避免多執行緒競爭的出現,必須保證:同一時刻只能允許一個執行緒訪問共享記憶體。
-
在實際使用中,該方法經常會導致一種 死鎖 現象,原因是不同執行緒互相拿著對方需要的 Lock,導致死鎖的發生。
詳見: https://python-parallel-programmning-cookbook.readthedocs.io/zh_CN/latest/chapter2/06_Thread_synchronization_with_Lock_and_Rlock.html
舉例:
import threading
shared_resource_with_lock = 0
shared_resource_with_no_lock = 0
COUNT = 100000
shared_resource_lock = threading.Lock() ## 鎖
## 有鎖的情況
def increment_with_lock():
# shared_resource_with_lock 即最外面的 shared_resource_with_lock
# 這樣寫就不需要再透過函式的引數引入 shared_resource_with_lock 了
global shared_resource_with_lock
for _ in range(COUNT):
shared_resource_lock.acquire() ## 獲取 鎖
shared_resource_with_lock += 1
shared_resource_lock.release() ## 釋放 鎖
def decrement_with_lock():
global shared_resource_with_lock
for _ in range(COUNT):
shared_resource_lock.acquire()
shared_resource_with_lock -= 1
shared_resource_lock.release()
## 沒有鎖的情況
def increment_without_lock():
global shared_resource_with_no_lock
for _ in range(COUNT):
shared_resource_with_no_lock += 1
def decrement_without_lock():
global shared_resource_with_no_lock
for _ in range(COUNT):
shared_resource_with_no_lock -= 1
if __name__ == "__main__":
t1 = threading.Thread(target=increment_with_lock)
t2 = threading.Thread(target=decrement_with_lock)
t3 = threading.Thread(target=increment_without_lock)
t4 = threading.Thread(target=decrement_without_lock)
## 開啟執行緒
t1.start()
t2.start()
t3.start()
t4.start()
## .join()
t1.join()
t2.join()
t3.join()
t4.join()
print ("the value of shared variable with lock management is %s" % shared_resource_with_lock)
print ("the value of shared variable with race condition is %s" % shared_resource_with_no_lock)
[Run]
the value of shared variable with lock management is 0
the value of shared variable with race condition is 79714
儘管在上面的結果中,沒鎖的情況下得到的結果有時候是正確的,但是執行多次,總會出現錯誤的結果;而有鎖的情況下,執行多次,結果一定是正確的。
儘管理論上用鎖的策略可以避免多執行緒中的競爭問題,但是可能會對程式的其他方面產生負面影響。此外,鎖的策略經常會導致不必要的開銷,也會限制程式的可擴充套件性和可讀性。更重要的是,有時候需要對多程序共享的記憶體分配優先順序,使用鎖可能和這種優先順序衝突。從實踐的經驗來看,使用鎖的應用將對debug帶來不小的麻煩。所以,最好使用其他可選的方法確保同步讀取共享記憶體,避免競爭條件。
讓我們總結一下:
- 鎖有兩種狀態: locked(被某一執行緒拿到)和unlocked(可用狀態)
- 我們有兩個方法來操作鎖:
acquire()
和release()
需要遵循以下規則:
- 如果狀態是unlocked, 可以呼叫
acquire()
將狀態改為locked - 如果狀態是locked,
acquire()
會被block直到另一執行緒呼叫release()
釋放鎖 - 如果狀態是unlocked, 呼叫
release()
將導致RuntimError
異常 - 如果狀態是locked, 可以呼叫
release()
將狀態改為unlocked
1.2.6 使用遞迴鎖Rlock()對執行緒進行同步
-
為了保證 “只有拿到鎖的執行緒才能釋放鎖”,那麼應該使用 RLock() 物件;
-
和 Lock()一樣,RLock()也有
acquire()
和release()
兩種方法; -
RLock() 有三個特點:
-
誰拿到誰釋放。如果執行緒A拿到鎖,執行緒B無法釋放這個鎖,只有A可以釋放;
-
同一執行緒可以多次拿到該鎖,即可以acquire多次;
-
acquire多少次就必須release多少次,只有最後一次release才能改變RLock的狀態為unlocked;
舉例:
import threading
import time
class Box(object):
lock = threading.RLock()
def __init__(self):
self.total_items = 0
def execute(self, n):
Box.lock.acquire()
self.total_items += n
Box.lock.release()
def add(self):
Box.lock.acquire()
self.execute(1)
Box.lock.release()
def remove(self):
Box.lock.acquire()
self.execute(-1)
Box.lock.release()
def adder(box, items):
while items > 0:
print("adding 1 item in the box")
box.add()
time.sleep(1)
items -= 1
def remover(box, items):
while items > 0:
print("removing 1 item in the box")
box.remove()
time.sleep(1)
items -= 1
if __name__ == "__main__":
items = 5
print("putting %s items in the box"% items)
box = Box()
t1 = threading.Thread(target=adder, args=(box, items))
t2 = threading.Thread(target=remover, args=(box, items))
t1.start()
t2.start()
t1.join()
t2.join()
print("%s items still remain in the box " % box.total_items)
[Run]
putting 5 items in the box
adding 1 item in the box
removing 1 item in the box
adding 1 item in the box
removing 1 item in the box
removing 1 item in the box
adding 1 item in the box
removing 1 item in the box
adding 1 item in the box
adding 1 item in the box
removing 1 item in the box
0 items still remain in the box
Box
類的execute()
方法包含RLock
,adder()
和remover()
方法也包含RLock
,就是說無論是呼叫Box
還是adder()
或者remover()
,每個執行緒的每一步都有拿到資源、釋放資源的過程。
1.2.7 使用資訊量Semaphore()對執行緒進行同步
-
訊號量的定義: 訊號量是一個內部資料,用於標明當前的共享資源可以有多少併發讀取。
-
訊號量是由作業系統管理的一種抽象資料型別,用於多執行緒中同步對共享資源的使用;
-
訊號量是一個內部資料,用於表明當前共享資源可以有多少併發讀取;
-
在
Threading
中,訊號量的操作有兩個函式:acquire()
和release()
;
同樣的,在threading模組中,訊號量的操作有兩個函式,即 acquire()
和 release()
,解釋如下:
- 每當執行緒想要讀取關聯了訊號量的共享資源時,必須呼叫
acquire()
,此操作減少訊號量的內部變數, 如果此變數的值非負,那麼分配該資源的許可權。如果是負值,那麼執行緒被掛起,直到有其他的執行緒釋放資源。 - 當執行緒不再需要該共享資源,必須透過
release()
釋放。這樣,訊號量的內部變數增加,在訊號量等待佇列中排在最前面的執行緒會拿到共享資源的許可權。
雖然表面上看訊號量機制沒什麼明顯的問題,如果訊號量的等待和通知操作都是原子的,確實沒什麼問題。但如果不是,或者兩個操作有一個終止了,就會導致糟糕的情況。
-
舉個例子,假設有兩個併發的執行緒,都在等待一個訊號量,目前訊號量的內部值為1。假設第執行緒A將訊號量的值從1減到0,這時候控制權切換到了執行緒B,執行緒B將訊號量的值從0減到-1,並且在這裡被掛起等待,這時控制權回到執行緒A,訊號量已經成為了負值,於是第一個執行緒也在等待。
-
這樣的話,儘管當時的訊號量是可以讓執行緒訪問資源的,但是因為非原子操作導致了所有的執行緒都在等待狀態。
-
注:"原子"指的是原子操作,即一個不可分割的操作。在多執行緒程式設計中,如果對訊號量的等待和通知操作是原子的,意味著它們是以不可分割的方式執行的,其他執行緒無法在這些操作中插入。這樣可以確保在多執行緒環境中,對訊號量的操作是可靠的。
Semaphore()詳解:
threading.Semaphore()
可以建立一個訊號量物件,它可以控制對共享資源的訪問數量。在建立訊號量物件時,可以指定初始的許可數量。每次訪問資源時,執行緒需要獲取一個許可;當許可數量不足時,執行緒將會被阻塞,直到有足夠的許可可用。訪問資源完成後,執行緒釋放許可,使得其他執行緒可以繼續訪問資源。
- threading.Semaphore(num):
num
表示初始的許可數量(比如這個數量為1)
舉例:
下面的程式碼展示了訊號量的使用,我們有兩個執行緒, producer()
和 consumer()
,它們使用共同的資源,即item。 producer()
的任務是生產item, consumer()
的任務是消費item。
當item還沒有被生產出來, consumer()
一直等待,當item生產出來, producer()
執行緒通知消費者資源可以使用了。
import threading
import time
import random
# 建立一個訊號量semaphore,初始值為0。
# 訊號量是一種同步機制,用於控制對共享資源的訪問。
semaphore = threading.Semaphore(0)
print("init semaphore %s" % semaphore._value) # 列印初始訊號量的值。
# 消費者執行緒將執行的函式。
def consumer():
print("consumer is waiting.") # 列印資訊,表明消費者正在等待。
semaphore.acquire() # 消費者嘗試獲取訊號量,如果訊號量的值小於1,則等待。
print("consumer notify: consumed item number %s" % item) # 列印消費者消費的專案編號。
print("consumer semaphore %s" % semaphore._value) # 在消費後列印訊號量的當前值。
# 生產者執行緒將執行的函式。
def producer():
global item # 宣告item為全域性變數,以便在函式內部修改。
time.sleep(10) # 生產者執行緒暫停10秒,模擬生產過程耗時。
item = random.randint(0, 1000) # 生產者生成一個隨機的專案編號。
print("producer notify : produced item number %s" % item) # 列印生產者生產的產品編號。
semaphore.release() # 生產者釋放訊號量,增加訊號量的值,允許其他等待的執行緒繼續執行。
print("producer semaphore %s" % semaphore._value) # 在生產後列印訊號量的當前值。
# 主程式入口。
if __name__ == "__main__":
for _ in range(0, 5): # 迴圈5次,模擬生產和消費過程。
t1 = threading.Thread(target=producer) # 建立生產者執行緒。
t2 = threading.Thread(target=consumer) # 建立消費者執行緒。
t1.start() # 啟動生產者執行緒。
t2.start() # 啟動消費者執行緒。
t1.join() # 等待生產者執行緒完成。
t2.join() # 等待消費者執行緒完成。
print("program terminated") # 列印程式結束的資訊。