Python並行運算——threading庫詳解(持續更新)

码头牛牛發表於2024-05-27

0. 寫在前面:程序和執行緒

博文參考:

Python的並行(持續更新)_python 並行-CSDN部落格

《Python並行程式設計 中文版》

一些相關概念請見上一篇博文。

1. 在Python中使用執行緒

1.1 多執行緒簡介

  1. 執行緒是獨立的處理流程,可以和系統的其他執行緒並行或併發地執行。

  2. 多執行緒可以共享資料和資源,利用所謂的共享記憶體空間。

  3. 每一個執行緒基本上包含3個元素:程式計數器,暫存器和棧。

  4. 執行緒的狀態大體上可以分為ready, running, blocked

  5. 多執行緒程式設計一般使用共享內容空間進行執行緒間的通訊,這就使管理內容空間成為多執行緒程式設計的重點和難點。

  6. 執行緒的典型應用是應用軟體的並行化。

  7. 相比於程序,使用執行緒的優勢主要是效能

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 引數,單位為秒,表示等待執行緒終止的最長時間。如果 timeoutNone,則會無限期等待。
  • 返回值: 沒有返回值。

為什麼需要 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

  1. 通常一個服務程序中有多個執行緒服務,負責不同的操作,所以對於執行緒的命名是很重要的;

  2. Python中每一個執行緒在被 Thread 被建立時都有一個預設的名字(可以修改);

舉例

  • 下面我們只對first_funcsecond_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步:

  1. 定義一個 Thread 類的子類;

  2. 重寫 __init__(self, [,args]) 方法;

  3. 重寫 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 ()進行執行緒同步 :

  1. 併發執行緒中,多個執行緒對共享記憶體進行操作,並且至少有一個可以改變資料。這種情況下如果沒有同步機制,那麼多個執行緒之間就會產生競爭,從而導致程式碼無效或出錯。

  2. 解決多執行緒競爭問題的最簡單的方法就是用鎖 (Lock)。當一個執行緒需要訪問共享記憶體時,它必須先獲得 Lock 之後才能訪問;當該執行緒對共享資源使用完成後,必須釋放 Lock,然後其他執行緒在拿到 Lock 進行訪問資源。因此,為了避免多執行緒競爭的出現,必須保證:同一時刻只能允許一個執行緒訪問共享記憶體。

  3. 在實際使用中,該方法經常會導致一種 死鎖 現象,原因是不同執行緒互相拿著對方需要的 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()對執行緒進行同步

  1. 為了保證 “只有拿到鎖的執行緒才能釋放鎖”,那麼應該使用 RLock() 物件;

  2. 和 Lock()一樣,RLock()也有acquire()release()兩種方法;

  3. 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()方法包含RLockadder()remover()方法也包含RLock,就是說無論是呼叫Box還是adder()或者remover(),每個執行緒的每一步都有拿到資源、釋放資源的過程。

1.2.7 使用資訊量Semaphore()對執行緒進行同步

  1. 訊號量的定義: 訊號量是一個內部資料,用於標明當前的共享資源可以有多少併發讀取。

  2. 訊號量是由作業系統管理的一種抽象資料型別,用於多執行緒中同步對共享資源的使用;

  3. 訊號量是一個內部資料,用於表明當前共享資源可以有多少併發讀取;

  4. 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")  # 列印程式結束的資訊。

相關文章