29. 多執行緒程式設計

星光映梦發表於2024-11-09

一、什麼是執行緒

  執行緒(thread)它們是同一個程序下執行的,並共享相同的下上文。執行緒包括開始、執行順序和結束三部分。它有一個指令指標,用於記錄當前執行的上下文。當其它執行緒執行時,它可以被搶佔(中斷)和臨時掛起(也稱為睡眠)—— 這種做法叫做讓步(yielding)。

  當一個程式執行時,預設有一個執行緒,這個執行緒我們稱之為 主執行緒。多工也就可以理解為讓你的程式碼在執行過程中額外建立一些執行緒,讓這些執行緒去執行程式碼。

多執行緒的執行順序是不確定的,這是因為執行程式碼的時候,當前的執行環境可能不同以及資源的分配可能不同,導致作業系統在計算接下來應該呼叫哪個程式的時候得到了不一樣的答案,因此順序不確定;

二、執行緒的生命週期

  要想實現多執行緒,必須在主執行緒中建立新的執行緒物件。Python 中使用 threading 模組或者 Thread 子類來表示執行緒,在它的一個完整的生命週期中通常要經過如下的五種狀態:

  • 建立:當一個 Thread 類或及其子類的物件被宣告並建立時,新生的執行緒就處於新建狀態;
  • 就緒:處於新建的執行緒被 start() 後,將進入執行緒佇列等待 CPU 時間片,此時它已具備了執行的條件,只是沒分配到 CPU 資源;
  • 執行:當就緒的執行緒被排程並獲得 CPU 資源時,便進入執行狀態,run() 方法定義了執行緒的操作和功能;
  • 阻塞:在某種特殊情況下,被人為掛起或執行輸入輸出操作時,讓出 CPU 並臨時中止自己的執行,進入阻塞狀態;
  • 退出:執行緒完成了它的全部或執行緒被提前強制性中止或出現異常導致結束;

執行緒的生命週期

三、執行緒的建立

【1】、使用 threading 模組

  如果我們想要執行一個單獨的任務,那麼就需要建立一個新的執行緒。如果我們想在一個程式中有多個任務一起執行,那麼就需要在程式中建立多個 Thread 物件即可。

  在 Python 中,我們可以使用 threading 模組中的 Thread 類建立一個物件。這個物件表示一個執行緒,但它不會真正建立出來一個執行緒。而當我們呼叫 start() 方法時,才會真正建立一個新的子執行緒,並開始執行的。至於這個執行緒去執行哪裡的程式碼,要看在用 Thread 建立物件的時候給 target 傳遞的是哪個函式的引用,即將來執行緒就會執行 target 引數指向的那個函式。target 指向的那個函式程式碼執行完之後,意味著這個子執行緒結束;

  建立 Thread 物件時,target 引數指明執行緒將來去哪裡執行程式碼,而 args 引數執行執行緒去執行程式碼時所攜帶的資料,並且 args 引數是一個元組。如果我們想給指定的引數傳遞資料,我們可以給 kwargs 引數傳遞一個字典。

  程式碼執行到最後,雖然主執行緒沒有了程式碼,但是它依然會等待所有的子執行緒結束之後,它才會真正的結束,原因是:主執行緒有個特殊的功能,用來對子執行緒產生的垃圾進行回收處理。當主執行緒結束之後,才意味著整個程式真正的結束;

import time

# 1.匯入threading模組
from threading import Thread

def task(name):
    print(f"{name}開始執行")
    time.sleep(3)
    print(f"{name}執行結束")

# 2.使用threading模組中Thread建立一個物件
t1 = Thread(target=task, args=("執行緒1",))
t2 = Thread(target=task, kwargs={"name": "執行緒2"})
# 3.呼叫這個例項物件的start()方法讓這個執行緒開始執行
t1.start()
t2.start()

print("主執行緒執行了!")

一個程式中,可以有多個執行緒,執行相同的程式碼。但是,每個執行緒執行功能每個執行緒的功能,互不影響,僅僅是做的事情相同一樣而已;

【2】、自定義類繼承 Thread

  我們可以自定義一個類繼承 Thread,然後一定要實現它的 run() 方法,即定義一個 run() 方法,並且在方法中實現要執行的程式碼。當我們呼叫自己編寫的類建立出來的物件的 start() 方法時,會建立新的執行緒,並且執行緒會自動呼叫 run() 方法開始執行。

  如果除了 run() 方法之外還定義了很多其它的方法,那麼這些方法需要在 run() 方法中自己去第呼叫,執行緒它不會自動呼叫。

import time

from threading import Thread

class MyThread(Thread):

    def __init__(self, name):
        super().__init__()
        self.name = name

    def run(self):
        print(f"{self.name}開始執行")
        time.sleep(1)
        print(f"{self.name}執行結束")


t= MyThread("執行緒1")
t.start()
print("主執行緒執行")

四、執行緒的常用屬性和方法

threading.Thread.name                   # 當前執行緒例項別名,預設為Thread-N,N從1開始遞增的整數
threading.enumerate()                   # 當前程式正在執行的執行緒
threading.current_thread()              # 獲取當前執行緒
threading.Thread.start()                # 啟動執行緒例項
threading.Thread.run()                  # 如果沒有給定target引數,對這個物件呼叫start()方法時,就會執行物件中的run()方法
threading.Thread.is_alive()             # 判斷執行緒例項是否還存活
threading.Thread.join([timeout])        # 線上程a中呼叫執行緒b的join(),此時執行緒a進入阻塞狀態,直到執行緒b完全執行以後,執行緒a才結束阻塞狀態
import time
import threading

money = 100

def task(n):
    print(f"{threading.current_thread().name}開始執行")
    global money
    money *= n
    time.sleep(n)
    print(f"{threading.current_thread().name}的money: {money}")
    print(f"{threading.current_thread().name}執行結束")


## 1、例項化物件
t1 = threading.Thread(target=task, args=(1,))
t2 = threading.Thread(target=task, args=(2,))
t3 = threading.Thread(target=task, args=(3,))

print(f"當前程式中正在執行的執行緒:{threading.enumerate()}")

start_time = time.time()

# 2、開啟執行緒
t1.start()                      # 告訴作業系統幫你建立一個程序
t2.start()
t3.start()

print(f"當前程式中正在執行的執行緒:{threading.enumerate()}")

print(t2.is_alive())            # 獲取執行緒狀態
print(threading.active_count())           # 統計當前活躍的執行緒數

# 主執行緒等待子執行緒執行結束之後在繼續往後執行
t3.join()

print(f"{threading.current_thread().name} {time.time() - start_time}")
print(f"{threading.current_thread().name} money: {money}")

五、守護執行緒

5.1、什麼是守護執行緒

  守護執行緒,專門用於服務其他的執行緒。當所有非守護執行緒結束時,沒有了被守護者,守護執行緒也就沒有工作可做,當然也就沒有繼續執行的必要了,程式就會終止,同時會殺死所有的"守護執行緒",也就是說只要有任何非守護執行緒還在執行,程式就不會終止

  在一個含有執行緒的python程式中,當主執行緒的程式碼執行完之後,如果還有其他子執行緒還未執行完畢,那麼主執行緒會等待子執行緒執行完畢之後,再結束;如果有一個執行緒必須設定為無限迴圈,那麼該執行緒不結束,意味著整個python程式就不能結束,那為了能夠讓python程式正常退出,將這類無限迴圈的執行緒設定為 守護執行緒 ,當程式當中僅僅剩下守護執行緒時,python程式就能夠正常退出,不必關心這類執行緒是否執行完畢,這就是守護執行緒的意義。

import time

from threading import Thread

def task(name,n):
    print(f"{name}開始執行")
    time.sleep(n)
    print(f"{name}執行結束")

if __name__ == "__main__":
    t = Thread(target=task, args=("執行緒1", 3))
    t.start()
    print("主執行緒執行")

5.2、設定守護執行緒的方式

  如果要設定守護執行緒,必須線上程啟動前(呼叫start())之前進行設定。

【1】、建立執行緒物件時,將 daemon=True 作為關鍵字引數傳入

import time

from threading import Thread

def task(name,n):
    print(f"{name}開始執行")
    time.sleep(n)
    print(f"{name}執行結束")

if __name__ == "__main__":
    t = Thread(target=task, args=("守護執行緒",3), daemon=True)    # 1、例項化物件
    t.start()                                                   # 2、開啟執行緒,告訴作業系統幫你建立一個程序
    print("主執行緒執行")

【2】、將執行緒物件的 daemon 屬性設定為 True

import time

from threading import Thread

def task(name,n):
    print(f"{name}開始執行")
    time.sleep(n)
    print(f"{name}執行結束")

if __name__ == "__main__":
    t = Thread(target=task, args=("守護執行緒",3))     # 1、例項化物件
    t.daemon = True                                 # 2、將程序設定為守護程序
    t.start()                                       # 3、開啟執行緒,告訴作業系統幫你建立一個程序
    print("主執行緒執行")

執行緒會繼承當前執行緒的 daemon 的值,如果當前執行緒為守護執行緒,那麼在該執行緒中新建的執行緒預設為守護執行緒;

六、執行緒間通訊

  如果我們想讓多個執行緒間共享資料,可以透過佇列來實現。佇列 (Queue)是具有一定約束的線性表,它只能在 一端插入入隊 ,AddQ)而在 另一端刪除出隊 ,DeleteQ)。它具有 先進先出 (FIFO)的特性。,它的常用方法如下:

queue.Queue([maxsize])                            # 生成佇列,最大可以存放maxsize資料量,預設值為32767
queue.Queue.qsize()                               # 返回當前佇列包含的訊息數量
queue.Queue.put(item, block=True, timeout=None)   # 向佇列中存取資料,預設情況下,如果佇列已滿,還要放資料,程式會阻塞,直到有位置讓出來,不會報錯
queue.Queue.put_nowait(obj)                       # 向佇列中存取資料,如果佇列已滿,還要放資料,程式會丟擲異常
queue.Queue.get(block=True, timeout=None)         # 取佇列中的資料,預設情況下,如果佇列中沒有資料,還要取資料,程式會阻塞,直到有新的資料到來,不會報錯
queue.Queue.get_nowait()                          # 取佇列中的資料,如果佇列中沒有資料,還要取資料,程式會丟擲異常
queue.Queue.empty()                               # 如果佇列為空,返回True,反之返回False
queue.Queue.full()                                # 如果佇列滿了,返回True,反之返回False
from queue import Queue

names = ["Sakura","Mikoto","Shana","Akame","Kurome"]

q = Queue(3)

print("向佇列中儲存資料")
i = 0
while not q.full():
    q.put(names[i])
    i += 1

# 如果訊息佇列已滿,如果還要向佇列中儲存資料,程式會阻塞或丟擲異常
try:
    # 如果沒有設定timeout,向已滿佇列儲存資料會阻塞,直到有位置讓出來
    # 如果設定timeout,則會等待timeout秒,如果在此期間還沒有位置空出來,程式會丟擲異常
    q.put(names[i],timeout=3)
except Exception:
    print("佇列已滿,現有訊息數量:%s" % q.qsize())

try:
    # 向已滿佇列儲存資料會丟擲異常
    q.put_nowait(names[i+1])
except Exception:
    print("佇列已滿,現有訊息數量:%s" % q.qsize())

print("從佇列中讀取資料")
while not q.empty():
    data = q.get()
    print(f"讀取的資料為{data}")

# 如果訊息佇列已空,如果還要從佇列中讀取資料,程式會阻塞或丟擲異常
try:
    # 如果沒有設定timeout,向已滿佇列儲存資料會阻塞,直到有位置讓出來
    # 如果設定timeout,則會等待timeout秒,如果在此期間還沒有位置空出來,程式會丟擲異常
    q.get(timeout=3)
except Exception:
    print("佇列已空,現有訊息數量:%s" % q.qsize())

try:
    # 向已滿佇列儲存資料會丟擲異常
    q.get_nowait()
except Exception:
    print("佇列已空,現有訊息數量:%s" % q.qsize())
from threading  import Thread 
from queue import Queue

def produces(q):
    q.put("hello world!")

def consumer(q):
    print(q.get())

if __name__ == "__main__":
    q = Queue(3)

    p1 = Thread(target=produces,args=(q,))
    p1.start()
  
    p2 = Thread(target=consumer,args=(q,))
    p2.start()

七、互斥鎖

  多個執行緒操作同一份資料時,可能會出現資料錯亂的問題。例如,有 3 個執行緒,其中執行緒 1 和執行緒 2 修改全域性變數,執行緒 3 獲取全域性變數的值。可能會出現第 執行緒 1 剛剛將資料存放到了全域性變數中,本意是想讓執行緒 3 獲取它的資料,但是因為作業系統的排程原因導致執行緒 3 沒有被排程,而執行緒 2 被排程了,恰巧執行緒 2 也對全域性變數進行了修改。而當執行緒 3 去讀取資料時,讀取到的是執行緒 2 修改的資料,而不是執行緒修改的資料。

  針對上述問題,解決方式就是加鎖處理:將併發變成序列,犧牲效率但保證了資料的安全

  某個執行緒要更改共享資料時,先將其鎖定,此時資源的狀態為 “鎖定” ,其它執行緒不能更改;直到該執行緒釋放資源,將資源的狀態變成 “非鎖定”,其它的執行緒才能再次鎖定該資源。互斥鎖保證了每次只有一個執行緒進行寫入操作,從而保證了多執行緒情況下資料的正確性。

import time

from threading import Thread,Lock

ticket = 100
mutex = Lock()                                  # 建立一個互斥鎖物件

def task(name):
    while True:
        global ticket
        if ticket > 0:
            buy(name)
        else:
            break

def buy(name):
    mutex.acquire()                             # 加鎖
    global ticket
    if ticket > 0:
        time.sleep(0.1)
        print(f"{name}賣票,票號為:{ticket}")
        ticket -= 1
    mutex.release()                             # 釋放鎖

if __name__ == "__main__":
    t1 = Thread(target=task,args=("視窗1",))
    t2 = Thread(target=task,args=("視窗2",))
    t3 = Thread(target=task,args=("視窗3",))

    t1.start()
    t2.start()
    t3.start()

不知道為什麼大部分都是隻有一個視窗賣票,但是多執行幾次或把 ticket 改大一些會發現其它視窗也賣票;

八、死鎖問題

  不同的執行緒分別佔用對方需要的同步資源不放棄,都在等待對方放棄自己需要的同步資源,就形成了執行緒的 死鎖;出現死鎖後,不會出現異常,不會出現提示,只是所有的執行緒都處於阻塞狀態,無法繼續;

import time

from threading import Thread,Lock

mutexA = Lock()
mutexB = Lock()

class MyThread(Thread):
  
    def run(self):
        self.fun1()
        self.fun2()

    def fun1(self):
        mutexA.acquire()
        print(f"{self.name}搶到A鎖")
        time.sleep(3)
        mutexB.acquire()
        print(f"{self.name}搶到B鎖")
        mutexB.release()
        mutexA.release()
  
    def fun2(self):
        mutexB.acquire()
        print(f"{self.name}搶到B鎖")
        time.sleep(3)
        mutexA.acquire()
        print(f"{self.name}搶到A鎖")
        mutexA.release()
        mutexB.release()

if __name__ == "__main__":
    for i in range(10):
        t  = MyThread()
        t.start()

九、什麼是遞迴鎖

  遞迴鎖可以被連續的 acquire 和 realease,但是隻能被第一個搶到這把鎖的物件執行上述操作。遞迴鎖的內部有一個計數器,每 acquire 一次計數加 1,每 realease 一次計數減 1,只要計數不為 0,那麼其它人都無法搶到這個鎖。

import time

from threading import Thread,RLock

mutexA = mutexB = RLock()

class MyThread(Thread):
  
    def run(self):
        self.fun1()
        self.fun2()

    def fun1(self):
        mutexA.acquire()
        print(f"{self.name}搶到A鎖")
        mutexB.acquire()
        print(f"{self.name}搶到B鎖")
        mutexB.release()
        mutexA.release()
  
    def fun2(self):
        mutexB.acquire()
        print(f"{self.name}搶到B鎖")
        time.sleep(3)
        mutexA.acquire()
        print(f"{self.name}搶到A鎖")
        mutexA.release()
        mutexB.release()

if __name__ == "__main__":
    for i in range(10):
        t  = MyThread()
        t.start()

十、執行緒池

  池是用來保證計算機硬體安全的情況下最大限度的利用計算機,它降低了程式的執行效率,但是保證了計算機硬體的安全,從而讓你寫的程式能夠正常執行。

  初始化 Pool 時,可以指定一個最大執行緒數,當有新的請求提交到 Pool 時,如果池還沒有滿,那麼就會建立一個新的執行緒用來執行該請求。但是如果池中的執行緒數已經達到指定的最大值,那麼該請求就會等待,直到池中有執行緒結束,才會用之前的執行緒來執行新的任務。

import time

from concurrent.futures import ThreadPoolExecutor

# 括號內可以傳數字指定執行緒數,不傳的話,預設會開設當前計算機CPU個數5倍的執行緒
# 池子造出來後,會存在一定數量的執行緒,這些執行緒不會出現重複建立和銷燬的過程
pool = ThreadPoolExecutor(5)

def task(n):
    print(n)
    time.sleep(1)
    return n*100

def call_back(n):
    print(f"call_back:{n.result()}")

t_list= []

# 池子的使用非常簡單,只需要將需要做的任務往池子中提交即可
for i in range(20):
    # 非同步提交任務的返回結果,應該透過回撥機制來獲取
    pool.submit(task,i).add_done_callback(call_back)      # 朝池子中提交任務,非同步提交

print("主執行緒執行了")

十一、Event事件

  一些程序/執行緒需要等待另外一些程序/執行緒執行完畢之後才能執行,類似於發射訊號一樣。這時,我們可以使用 Event 事件。

  事件 Event 中有一個全域性內建標誌 flag,值為 True 或者 False。使用 wait() 函式的執行緒會處於阻塞狀態,此時 flag 值為 False,直到有其他執行緒呼叫 set() 函式讓全域性標誌 flag 置為 True,其阻塞的執行緒立刻恢復執行,還可以用 is_set() 函式檢查當前的 flag 狀態。

threading.Event.set()                   # 將標誌設為True,並通知所有處於等待阻塞狀態的執行緒恢復執行狀態
threading.Event.clear()                 # 將標誌設為False
threading.Event.wait(timeout=None)      # 如果標誌為True將立即返回,否則阻塞執行緒至等待阻塞狀態,等待其他執行緒呼叫set()
threading.Event.is_set()                # 獲取內建標誌狀態,返回True或False。
import time

from threading import Thread, Event

event = Event()

def light():
    print("紅燈亮著呢")
    time.sleep(3)
    print("綠燈亮了")
    # 告訴等待紅燈的人可以走了
    event.set()

def car(name):
    print(f"{name}正在等紅燈")
    # 別人通知不要等了
    event.wait()            # 等待別人給你發訊號
    print(f"{name}開走了")

if __name__ == "__main__":
    t = Thread(target=light)
    t.start()

    for i in range(20):
        t = Thread(target=car, args=(f"小車{i}",))
        t.start()

相關文章