瘋狂Python講義學習筆記(含習題)之併發編程(多線程)

單線程時,如果程序在執行某行代碼時遇到阻塞,程序將會停滯在該處。單線程程序只有一個順序執行流,而多線程程序則可以包含多個順序執行流,這些順序執行流之間互不干擾。

一、線程概述

一個操縱系統支持同時運行多個任務,一個任務就是一個程序,每個運行的程序就是一個進程,每個進程包含多個順序執行流,每一個順序執行流就是一個線程。

(一)線程和進程

每個運行中的任務對應一個進程(Process),進程是處於運行過程中的程序,並且具有一定的獨立功能。進程是系統進行資源分配和調度的一個獨立單位。

可以將一個程序當成一個提供服務的機構,比如餐廳,操作系統就是餐廳的CEO,負責對餐廳的資源進行分配和調度,每個餐廳都會有多個服務員爲顧客服務(個別比較小的餐廳也可能只有一個服務員),他們爲顧客提供的服務大致是相同的,這裏的餐廳就是一個進程,而每個服務員就是一個線程,每個服務員都可以爲不同的顧客提供服務,服務員爲客戶提供服務的過程就是一個順序執行流,多個服務員之間相互不會影響,各自根據規範流程爲各自的客戶服務。

進程的特徵:

● 獨立性:進程是系統中獨立存在的實體,它可以擁有自己獨立的資源,每一個進程都擁有自己的私有的地址空間。在沒有經過進程本身允許的情況下,一個用戶進程不可以直接訪問其他進程的地址空間。這就好比每一間餐廳都有自己的特色,有的餐廳提供中餐,有的餐廳提供西餐,如果兩家餐廳之間沒有經過協商達成共識,那麼中餐廳不能給他的顧客提供西餐廳的服務。

● 動態性:進程與程序的區別在於,程序只是一個靜態的指令集合,而進程是一個正在系統中活動的指令集合。在進程中加入了時間的概念。進程具有自己的生命週期和各種不同的狀態,在程序中是沒有這些概念的。還是用餐廳做比喻,如果餐廳沒有服務員,那麼這家餐廳只是具備提供點餐服務這個功能,但顧客並不能從餐廳得到服務。而有了服務員以後就可以實現點餐服務了,而每一個服務員的狀態又不盡相同,並不是每時每刻都是所有的服務員都在工作的。

● 併發性:多個進程可以再單個處理器上併發執行,多個進程之間不會相互影響。也就是說,幾個服務員可以同時服務不同的顧客,而不必等待一個服務員服務結束之後,另一個服務員才能爲其他顧客提供服務。他們之間的工作不會相互影響。

※ 併發(Concurrency)和並行(Parallel)是不同的,並行指同一時刻有多條指令在多個處理器上同時執行,併發指同一個時刻只能有一條指令執行,但多個進程指令被快速輪換執行,是的宏觀上具有多個進程同時執行的效果。

 

多進程策略有:共用式的多任務操作策略(Windows3.1和Mac OS 9)以及搶佔式多任務操作策略(Windows NT、Windows 2000以及UNIX/Linux)

 

線程:(Thread)也被稱爲輕量級進程(Lightweight Process),線程是進程的執行單元。線程在程序中是獨立的、併發的執行流。當進程被初始化之後,主線程就被創建了。

對於大多數程序而言,有一個主線程就夠了。線程是進程的組成部分,一個進程可以有多個線程,一個線程必須有一個父進程。線程可以擁有自己的堆棧、自己的程序計數器和自己的局部變量,但不擁有系統資源,它與父進程的其他線程共享進程所擁有的全部資源。

線程可以完成一定的任務,可以與其他線程共享父進程中的共享變量及部分環境,相互之間協同來完成進程所要完成的任務。

線程的運行是搶佔式的,當前運行的線程在任何時候都可能被掛起,以便另外一個線程可以運行。

一個線程可以創建和撤銷另一個線程,同一個進程中的多個線程之間可以併發運行。

一個程序運行之後至少有一個進程,而一個進程至少要包含一個主線程。

※操作系統可以同時執行多個任務,每一個任務就是一個進程,進程可以同時執行多個任務,每一個任務就是一個線程。

  輪詢調度 資源管理 內部約束
進程 由操作系統負責管理 有獨立的堆棧、內存管理空間 由於獨立性,進程之間無約束,一個process die之後,不影響其他process
線程 有程序解釋器來負責管理 共享同一進程的資源 共享資源,互相約束,一個線程die之後,整個進程可能崩潰

(二)多線程的優勢

1. 進程在執行過程中擁有獨立的內存單元,多個線程共享這些內存,從而極大地提高了程序的運行效率。

2. 多個線程共享同一個進程的虛擬空間,利用這些共享的數據,線程之間很容易實現通信。

3. 創建進程需要分配獨立的內存空間,並分配大量的相關資源,創建線程則簡單得多,因此,使用多線程來實現併發比使用多進程來實現併發的性能要高得多。

 

二、線程的創建和啓動

Python提供了_thread和threading兩個模塊來支持多線程,其中_thread提供低級別的、原始的線程支持,以及一個簡單的鎖,一般不建議使用_thread模塊。

Python創建線程的兩種方式:

1. 使用threading模塊的Thread類的構造器創建線程。

2. 繼承threading模塊的Thread類創建線程類。

(一)調用Thread類的構造器創建線程

__init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, deamon=None)

● group:指定該線程所屬的線程組。目前該參數還未實現,因此它只能設爲None。

● target:指定該線程要調度的目標方法。

● args:指定一個元組,以位置參數的形式爲target指定的函數傳入參數。元組的第一個元素傳個target函數的第一個參數,元組的第二個元素傳個target函數的第二個參數……以此類推。

● kwargs:指定一個字典,以關鍵字參數的形式爲target指定的函數傳入參數。

● deamon:指定所構造的線程是否爲後代線程。

通過Thread類的構造器創建並啓動多線程的步驟如下:

① 調用Thread類的構造器創建線程對象。在創建線程對象時,target參數指定的函數將作爲線程執行體。

②調用線程對象的start()方法啓動該線程。

import threading


# 定義個普通的acton方法,該方法準備作爲線程執行體
def action(max):
    for i in range(max):
        # 調用threading模塊的current_thread()函數獲取當前線程
        # 調用線程對象的getName()方法獲取當前線程的名字
        print(threading.current_thread().getName() + " " + str(i))


# 下面是主程序(也就是主線程的線程執行體)
for i in range(100):
    # 調用threading模塊的current_thread()函數獲取當前線程
    print(threading.current_thread().getName() + " " + str(i))
    if i == 20:
        # 創建並啓動第一個線程
        t1 = threading.Thread(target=action, args=(100,))
        t1.start()
        # 創建並啓動第二個線程
        t2 = threading.Thread(target=action, args=(100,))
        t2.start()
print('主線程執行完成!')

執行結果:

……
MainThread 93
MainThread 94
Thread-1 78
MainThread 95
Thread-2 47
Thread-2 48
Thread-1 79
Thread-2 49
……

從執行結果可以看出,程序共創建了三個線程:MainThread、Thread-1、Thread-2,這三個線程的執行沒有先後順序,他們以併發方式執行:Thread-1執行一段時間,然後可能Thread-2或MainThread獲得CPU執行一段時間,接下來又haunted其他線程執行。

※ 實際上,多線程就是讓多個函數能併發執行,讓普通用戶感覺到多個函數似乎同時在執行。

程序可以通過setName(name)方法爲線程設置名字,也可以通過getName()方法返回指定線程的名字,這兩個方法可以通過name屬性來代替。在默認情況下,主線程的名字爲MainThread、用戶啓動的多個線程的名字一次爲Thread-1、Thread-2等。

(二)繼承Thread類創建線程

步驟:

① 定義Thread類的子類,並重寫該類的run()方法。run()方法的方法體就代表了線程需要完成的任務,因此把run()方法稱爲線程執行體。

② 創建Thread子類的實例,即創建線程對象。

③ 調用線程對象的strar()方法來啓動線程。

import threading


# 通過繼承threading.Thread類來創建線程類
class FKThread(threading.Thread):

    def __init__(self):
        threading.Thread.__init__(self)
        self.i = 0

    # 重寫run()方法作爲線程執行體
    def run(self):
        while self.i < 100:
            # 調用threading模塊的current_thread()函數獲取當前線程
            # 調用線程對象的getName()方法獲取當前線程的名字
            print(threading.current_thread().getName() + " " + str(self.i))
            self.i += 1


# 下面是主程序(也就是主線程的線程執行體)
for i in range(100):
    # 調用threading模塊的current_thread()函數獲取當前線程
    print(threading.current_thread().getName() + " " + str(i))
    if i == 20:
        # 創建並啓動第一個線程
        ft1 = FKThread()
        ft1.start()
        # 創建並啓動第二個線程
        ft2 = FKThread()
        ft2.start()
print('主線程執行完成!')

以上代碼的執行效果,同第一個程序的執行效果一樣。

 

三、線程的生命週期

線程並非一啓動就進入執行狀態,也不是一直處於執行狀態,在線程的生命週期中,要經過新建(New)、就緒(Ready)、運行(Running)、阻塞(Blocked)和死亡(Dead)5中狀態。

(一) 新建和就緒狀態

當程序創建了一個Thread對象或Thread子類對象之後,該線程就處於新建狀態,當線程對象調用start()方法之後,該線程處於就緒狀態,Python解釋器會爲其創建方法調用棧和程序計數器,處於這種狀態中的線程並沒有開始運行,只是表示該線程可以運行了,至於該線程何時開始運行,取決於Python解釋器中線程調度器的調度。

啓動線程使用strat()方法,而不是run()方法!永遠不要調用線程對象的run()方法!調用start()方法來啓動線程,系統會把該run()方法當成線程執行體處理,如果直接調用run()方法,則run()方法立即就會被執行,而且在該方法返回之前其他線程無法併發執行——也就是說,如果直接調用run()方法,則系統把線程對象當成一個普通對象,而run()方法也是一個普通方法,而不是線程執行體。

import threading


# 定義準備作爲線程執行體的action函數
def action(max):
    for i in range(max):
        # 當直接調用run()方法是,Thread的name屬性返回的是該對象的名字
        # 而不是當前線程的名字
        # 使用threading.current_thread().name總是獲取當前線程的名字
        print(threading.current_thread().name + " " + str(i))


for i in range(100):
    # 調用Thread的current_thread()函數獲取當前線程
    print(threading.current_thread().name + " " + str(i))
    if i == 20:
        # 直接調用線程對象的run()方法
        # 系統會把線程對象當成普通對象,把run()方法當成普通方法
        # 所以下面兩行代碼並不會啓動兩個線程,而是一次執行兩個run()方法
        threading.Thread(target=action, args=(100,)).run()
        threading.Thread(target=action, args=(100,)).run()

 

只能對處於新建狀態的線程調用start()方法,如果程序對同一個線程重複調用start()方法,將引發RuntimeError異常。

(二)運行和阻塞狀態

處於就緒狀態的線程獲得CPU後,就開始執行run()方法的線程執行體,則該線程出於運行狀態。

如果只有一個CPU,那麼任何時刻只有一個線程處於運行狀態。如果有多個CPU,則會有多個線程並行執行,當線程數大於處理器數時,依然會出現多個線程在同一個CPU上輪換的情況。

線程並不是一直處於運行狀態的(除非線程執行體足夠短,瞬間就執行結束了),線程運行過程中需要被終端,目的是使其他線程獲得執行的機會,線程調度的細節取決於底層平臺所採用的策略。

採用搶佔式調度策略的系統會給每一個可執行的線程一個小時間段來處理任務,當時間段用完後,系統會剝奪該線程所佔用的資源,讓其他線程獲得執行的機會。在選擇下一個線程的時候,系統會考慮線程的優先級。

所有的現代桌面和服務器操作系統都採用搶佔式調度策略。

一些小型設備如手機可能採用協作式調度策略,在這樣的系統中,只有當一個線程調用了sleep()或yield()方法後纔會放棄所佔用的資源。

進入阻塞狀態的情況:

● 線程調用sleep()方法主動放棄所佔用的處理器資源。

● 線程調用了一個阻塞式I/O方法,在該方法返回之前,該線程被阻塞。

● 線程試圖獲得一個鎖對象,但該鎖對象正在被其他線程所持有。

● 線程在等待某個通知(Notify)

正在執行的線程被阻塞之後,其他線程就可以獲得執行的機會。被阻塞的線程會在合適的時候重新進入就緒狀態,而非運行狀態,阻塞解除後,線程必須重新等待線程調度器再次調度它。

解除阻塞的情況:

● 調用sleep()方法的線程經過了指定的時間。

● 線程調用的阻塞式I/O方法已經返回。

● 線程成功地獲得了試圖獲取的鎖對象。

● 線程正在等待某個通知時,其他線程發出了一個通知。

 

 

(三)線程死亡

線程結束的二種方式:

● run()方法或代表線程執行體的target函數執行完成,線程正常結束。

● 線程拋出一個未捕獲的Exception或Error。

當主線程結束時,其他線程不受任何影響,並不會隨之結束。一旦子線程啓動之後,就擁有了同主線程一樣的地位,而不受主線程影響。

線程對象的is_alive()方法可以測試線程是否死亡,當線程處於就緒、運行、阻塞三種狀態時,該方法將返回True;當線程處於新建、死亡兩種狀態時,該方法返回False。

對一個已經死亡的線程調用start()方法將引發RuntimeError異常。

(四)控制線程

(一)join線程

Thread提供了讓一個線程等待另一個線程完成的方法——join()方法。當在某個程序執行流中調用其他線程的join()方法時,調用線程將被阻塞,直到被join()方法加入的join線程執行完成。

join()方法通常由使用線程的程序調用,以將大問題劃分成許多小問題,併爲每個小問題分配一個線程,當所有小問題都得到處理後,再調用主線程來進一步操作。

import threading


# 定義action函數準備作爲線程執行體使用
def action(max):
    for i in range(max):
        print(threading.current_thread().name + " " + str(i))


# 啓動子線程
threading.Thread(target=action, args=(100,), name="新線程").start()
for i in range(100):
    if i == 20:
        jt = threading.Thread(target=action, args=(100,), name="被Join的線程")
        jt.start()
        # 主線程調用了jt線程的join()方法
        # 主線程必須等jt執行結束後纔會向下執行
        jt.join()
    print(threading.current_thread().name + " " + str(i))

執行結果:

………………
被Join的線程 96
被Join的線程 97
被Join的線程 98
被Join的線程 99
MainThread 20
MainThread 21
MainThread 22
MainThread 23
MainThread 24
………………

join(timeout=None)方法可以指定一個timeout參數,該參數指定等待被join的線程的時間最長爲timeout秒。如果在timeout秒內被join的線程還沒有執行結束,則不再等待。

(二)後臺線程

有一種線程,它在後臺運行,任務是爲其他線程提供服務,這種線程被稱爲“後臺線程(Deamon Thread)”,也被稱爲“守護線程”或“精靈線程”。例如Python解釋器的垃圾回收線程。

後臺線程的特徵:如果所有的前臺線程都死亡了,那麼後臺線程會自動死亡。

Thread對象的daemon屬性可以將指定線程設置爲後臺線程。

import threading


# 定義後臺線程的線程執行體與普通線程沒有任何區別
def action(max):
    for i in range(max):
        print(threading.current_thread().name + " " + str(i))


t = threading.Thread(target=action, args=(100,), name="後臺線程")
# 將此線程設置成後臺線程
t.daemon = True
# 啓動後臺線程
t.start()
for i in range(100):
    print(threading.current_thread().name + " " + str(i))
#-------------程序執行到此處,前臺線程(主線程)結束---------------
# 後臺線程也應該隨之結束

運行效果:

……………………
後臺線程 57
後臺線程 58
後臺線程 59
後臺線程 60
MainThread 94
MainThread 95
MainThread 96
MainThread 97
MainThread 98
MainThread 99


Process finished with exit code 0

(三)線程睡眠:sleep

如果需要讓當前正在執行的線程暫停一段時間,並進入阻塞狀態,則可以通過調用time模塊的sleep(secs)函數來實現。該函數指定一個secs參數,用於指定線程阻塞多少秒。

當線程調用sleep()進入阻塞狀態後,在其睡眠時間段內,該線程不會獲得執行機會,即使系統中沒有其他可執行線程,出於sleep()中的線程也不會執行,因此sleep()函數通常用來暫停程序的運行。

import time

for i in range(10):
    print("當前時間:%s" % time.ctime())
    # 調用sleep()函數讓當前線程暫停5s
    time.sleep(5)

執行效果:

當前時間:Thu Oct 31 23:48:35 2019
當前時間:Thu Oct 31 23:48:40 2019
當前時間:Thu Oct 31 23:48:46 2019
當前時間:Thu Oct 31 23:48:51 2019
當前時間:Thu Oct 31 23:48:56 2019
當前時間:Thu Oct 31 23:49:01 2019
當前時間:Thu Oct 31 23:49:06 2019
當前時間:Thu Oct 31 23:49:11 2019
當前時間:Thu Oct 31 23:49:16 2019
當前時間:Thu Oct 31 23:49:21 2019

Process finished with exit code 0

五、線程同步

(一)線程安全問題

一個典型的線程安全問題——銀行取錢問題。

① 用戶輸入帳戶、密碼,系統判斷用戶的帳戶、密碼是否匹配

② 用戶輸入取款金額

③ 系統判斷帳戶餘額是否大於取款金額

④ 如果餘額大於取款金額,則取款成功,否則取款失敗

具體模擬代碼如下:

class Account:
    # 定義構造器
    def __init__(self, account_no, balance):
        # 封裝帳戶編號(銀行卡號)和帳戶餘額兩個變量
        self.account_no = account_no
        self.balance = balance
import threading
import time

import Account


# 定義一個函數來模擬取錢操作
def draw(account, draw_amount):
    # 帳戶餘額大於取錢數目
    if account.balance >= draw_amount:
        # 吐出鈔票
        print(threading.current_thread().name + "取錢成功!吐出鈔票:" +
              str(draw_amount))
        time.sleep(0.001)
        # 修改餘額
        account.balance -= draw_amount
        print("\t餘額爲:" + str(account.balance))
    else:
        print(threading.current_thread().name + "取錢失敗!餘額不足!")


# 創建一個帳戶
acct = Account.Account("1234567", 1000)
# 使用兩個線程模擬同一個帳戶取錢
threading.Thread(name='甲', target=draw, args=(acct, 800)).start()
threading.Thread(name='已', target=draw, args=(acct, 800)).start()

運行結果:

甲取錢成功!吐出鈔票:800
已取錢成功!吐出鈔票:800
	餘額爲:200	餘額爲:-600


Process finished with exit code 0

(二)同步鎖(Lock)

關於銀行取錢問題的錯誤原因是因爲run()方法的方法體不具備線程安全性——程序中有兩個併發線程在修改Account對象;爲了解決這個問題,Python的threading模塊引入了鎖(Lock)。threading模塊提供了Lock和Rlock兩個類,它們都提供瞭如下兩個方法來加鎖和釋放鎖。

● acquire(blocking=True, timeout=-1):請求對Lock或Rlock加鎖,其中timeout參數指定加鎖多少秒。

● release():釋放鎖。

Lock和RLock區別:

● Lock:基本的鎖對象,每次只能鎖定一次,其餘的鎖請求,需等待鎖釋放後才能獲取。

● RLock:代表可重入鎖(Reentrant Lock)。對於可重入鎖,在同一個線程中可以對它進行多次鎖定,也可以多次釋放。如果使用RLock,則acquire()和release()方法必須成對出現。

RLock鎖具有可重入性,同一線程可以對已被加鎖的RLock鎖再次加鎖,RLock對象會維持一個計數器來追蹤acquire()方法的嵌套調用,線程在每次調用acquire()加鎖後,都必須顯式調用release()方法來釋放鎖。所以,一段被鎖保護的方法可以調用另一個被相同鎖保護的方法。

Lock是控制多個線程對共享資源進行訪問的工具。鎖提供了對共享資源的獨佔訪問,每次只能有一個線程對Lock對象加鎖,線程在開始訪問共享資源之前應先請求獲得Lock對象。當對共享資源訪問完成後,程序釋放對Lock對象的鎖定。

RLock代碼格式:

class X:
    # 定義需要保證線程安全的方法
    self.lock.acquire()
    try:
        # 需要保證線程安全的代碼
        # ……方法體
    # 使用finally塊來保證釋放鎖
    finally:
        # 修改完成,釋放鎖
        self.lock.release()

使用RLock對象來控制線程安全,當加鎖和釋放鎖出現在不同的作用範圍內時,通常建議使用finally塊來確保在必要時釋放鎖。

線程安全類的特徵:

● 該類的對象可以被多個線程安全地訪問。

● 每個線程在調用該對象的任意方法之後,都將得到正確的結果。

● 每個線程在調用該對象的任意方法之後,該對象都依然保持合理的狀態。

將Account類改爲如下形式,它就是線程安全的。

import threading
import time


class Account:
    # 定義構造器
    def __init__(self, account_no, balance):
        # 封裝帳戶編號(銀行卡號)和帳戶餘額兩個變量
        self.account_no = account_no
        self._balance = balance
        self.lock = threading.RLock()

        # 因爲帳戶餘額不允許隨便修改,所以只爲self._balance提供gtetter方法
        def getBalance(slef):
            return self._balance

        # 提供一個線程安全的draw()方法來完成取錢操作
        def draw(self, draw_amount):
            # 加鎖
            self.lock.acquire()
            try:
                # 帳戶餘額大於取錢數目
                if self._balance >= draw_amount:
                    # 吐出鈔票
                    print(threading.current_thread().name + "取錢成功!吐出鈔票:" +
                          str(draw_amount))
                    time.sleep(0.001)
                    # 修改餘額
                    self._balance -= draw_amount
                    print("\t餘額爲:" + str(self._balance))
                else:
                    print(threading.current_thread().name + "取錢失敗!餘額不足!")
            finally:
                # 修改完成,釋放鎖
                self.lock.release()

以上代碼中,在對象初始化的時候定義了一個RLock對象。在程序中實現draw()方法時,進入該方法開始執行後立即請求對RLock對象加鎖,當執行完draw()犯法的取錢邏輯後,程序使用finally來確保釋放鎖。

程序中RLock對象作爲同步鎖,線程每次開始執行draw()方法修改self._blance時,都必須先對RLock對象加鎖。當該線程完成對self._balance的修改,將要推出draw()方法時,則釋放對RLock對象的鎖定。完成“加鎖→修改→釋放鎖”的安全訪問邏輯。

併發線程在任意時刻只能有一個線程可以進入修改共享資源的代碼區(也被稱爲臨界區),從而保證了線程安全。

以下代碼創建並啓動了兩個取錢線程:

import threading

import Account


# 定義一個函數來模擬取錢操作
def draw(account, draw_amount):
    # 直接調用account對象的draw()方法來執行取錢操作
    account.draw(draw_amount)


# 創建一個帳戶
acct = Account.Account("1234567", 1000)
# 使用兩個線程模擬同一個帳戶取錢
threading.Thread(name='甲', target=draw, args=(acct, 800)).start()
threading.Thread(name='已', target=draw, args=(acct, 800)).start()

 

※領域驅動設計(Domain Driven Dsign,DDD),這種方式認爲每個類都應該是完備的領域對象,例如Account代表用戶賬戶,應該提供用戶賬戶的相關方法;通過draw()方法來執行取錢操作(實際上還應該提供transfer()等方法來完成轉賬等操作),而不是直接將setBalance()方法暴露出來任人操作,這樣才能更好地保證Account對象的完整性和一致性。

可變類的線程安全是一降低程序的運行效率爲代價的,爲了減少線程安全所帶來的負面影響,程序可以採用如下策略。

● 不要對線程安全類的所有方法都進行同步,只對那些會改變競爭資源(競爭資源也就是同享資源)的方法進行同步。

● 如果可變類有兩種運行環境:單線程環境和多線程環境,則應該爲可變類提供兩種版本,即線程安全版本和線程不安全版本。

(三)死鎖

當兩個線程相互等待對方釋放同步監視器時就會發生死鎖。

Python解釋器沒有監測,也沒有採取措施來處理死鎖情況,所以在進行多線程編程時應該採取措施避免出現死鎖。

一個死鎖的例子:

import threading
import time


class A:

    def __init__(self):
        self.lock = threading.RLock()

    def foo(self, b):
        try:
            self.lock.acquire()
            print("當前線程名:" + threading.current_thread().name +
                  " 進入了A實例的foo()方法")
            time.sleep(0.2)
            print("當前線程名:" + threading.current_thread().name +
                  " 企圖調用B實例的last()方法")
            b.last()
        finally:
            self.lock.release()

    def last(self):
        try:
            self.lock.acquire()
            print("進入了A類的last()方法內部")
        finally:
            self.lock.release()


class B:

    def __init__(self):
        self.lock = threading.RLock()

    def bar(self, a):
        try:
            self.lock.acquire()
            print("當前線程名:" + threading.current_thread().name +
                  " 進入了B實例的bar()方法")
            time.sleep(0.2)
            print("當前線程名:" + threading.current_thread().name +
                  " 企圖調用A實例的last()方法")
            a.last()
        finally:
            self.lock.release()

    def last(self):
        try:
            self.lock.acquire()
            print("進入了B類的last()方法內部!")
        finally:
            self.lock.release()


a = A()
b = B()


def init():
    threading.current_thread().name = "主線程"
    # 調用a對象的foo()方法
    a.foo(b)


def action():
    threading.current_thread().name = "副線程"
    # 調用b對象的bar()方法
    b.bar(a)
    print("進入了副線程之後")


# 以action爲target啓動新線程
threading.Thread(target=action).start()
# 調用init()函數
init()

代碼輸入如下信息後停住了,並且程序永遠不會結束。

當前線程名:副線程 進入了B實例的bar()方法
當前線程名:主線程 進入了A實例的foo()方法
當前線程名:主線程 企圖調用B實例的last()方法
當前線程名:副線程 企圖調用A實例的last()方法

代碼解析:

A類和B類都是線程安全類,以上代碼中有兩個線程執行,副線程執行體是action函數,主線程線程執行體是init()函數(主程序調用init()函數)。在action中B對象調用bar()方法,進入bar()方法之前,該線程對B對象的Lock加鎖,並讓副線程暫停0.2秒,此次CPU切換到另一個線程,讓A對象執行foo()方法,進入foo()方法之前,該線程對A對象執行foo()方法,進入foo()方法之前,該線程對A對象的Lock加鎖,主線程也暫停0.2秒。接下來副線程會先醒過來,繼續向下執行,企圖調用A對象的last()方法——在執行該方法之前,必須先對A對象的Lock加鎖,而此時主線程正保持着A對象的Lock鎖定,所以副線程被阻塞。然後主線程也醒過來,繼續向下執行,企圖調用B對象的last()方法,同樣在執行該方法之前,必須先對B對象的Lock加鎖,而此時副線程並沒有釋放B對象的Lock鎖定,於是主線程也被阻塞,於是就出現了主線程保持着A對象的鎖,對待對B對象加鎖,而副線程保持着B對象的鎖,等待對A對象加鎖,兩個線程互相等待對方先釋放鎖,於是就出現了死鎖。

避免死鎖常見方法:

● 避免多次鎖定:儘量避免同一個線程對多個Lock進行鎖定。

● 具有相同的加鎖順序:如果多個線程需要對多個Lock進行鎖定,則應該保證它們以相同的順序請求加鎖。如以上代碼中,主線程先對A對象的Lock加鎖,再對B對象的Lock加鎖;而副線程則先對B對象的Lock加鎖,再對A對象的Lock加鎖。這種加鎖順序很容易形成嵌套鎖定,進而導致死鎖。如果讓主線程、副線程都按照相同的順序加鎖,就可以避免這個問題。

● 使用定時鎖:程序在調用acquire()方法加鎖時,指定timeout參數,設置指定時間自動釋放對Lock的鎖定。

● 死鎖檢測:死鎖檢測是一種依靠算法機制來實現的死鎖預防機制,它主要針對那些不可能實現按序加鎖,也不能使用定時鎖的場景。

 

六、線程通信

通常情況下程序無法準確控制線程的輪換執行,如果有需要,Python可通線程通信來保證線程協調運行。

(一)使用Condition實現線程通信

使用Condition可以讓那些已經得到Lock對象卻無法繼續執行的線程釋放Lock對象,Condition對象也可以喚醒其他處於等待狀態的線程。

組合使用Condition對象與Lock對象可以爲每個對象提供多個等待集(wait-set)。也就是說,Condition對象總是需要有對應的Lock對象。

Condition構造器:

__init__(self, lock=None)

程序在創建Condition時可通過lock參數出入要綁定的Lock對象,如果不指定lock參數,在創建Condition時它會自動創建一個與之綁定的Lock對象。

Condition類方法:

● acquire([timeout])/release():調用Condition關聯的Lock的acquire()或release()方法。

● wait([timeout]):導致當前線程進入Condition的等待池通知並釋放鎖,知道其他線程調用該Condition的notify()或notify_all()方法來喚醒該線程。在調用該wait()方法時可傳入一個timeout參數,指定該線程最多等待多少秒。

● notify():喚醒在該Condition等待池中的單個線程並通知它,收到通知的線程將自動調用acquire()方法嘗試加鎖。如果所有線程都在該Condition等待池中等待,則會選擇喚醒其中一個線程,選擇是任意性的。

● notify_all():喚醒在該Condition等待池中等待的所有線程並通知它們。

Condition可以理解爲更加高級的的鎖,能處理一些複雜的同步問題。

threading.Condition()可以創建一把資源鎖(默認是RLock)。

import threading
import time


def TestA():
    cond.acquire()
    print('魯班:有個殘血,追嗎?')
    cond.wait()
    print('魯班:好的!')
    cond.notify()
    cond.release()


def TestB():
    time.sleep(2)
    cond.acquire()
    print('亞瑟:等我一起追。')
    cond.notify()
    cond.wait()
    print('亞瑟:我到了,一起追!')


cond = threading.Condition()
testA = threading.Thread(target=TestA)
testB = threading.Thread(target=TestB)
testA.start()
testB.start()
testA.join()
testB.join()

再來看一個特殊的例子,假設現在要求存款者和取錢者不斷地重複存款、取錢動作,但是要求每當存款者將錢存入指定賬戶後,取錢者立即取出這筆錢。不允許存款者連續兩次存錢,也不允許取錢者連續兩次取錢。

可以通過一個旗標來標識賬戶中是否已有存款,當旗標爲False時,表明帳戶中沒有存款,存款者線程可以向下執行,直到存完錢後將旗標置爲True,同時調用Condition的notify()方法喚醒其他線程,如果旗標爲True,就調用Condition的wait()方法讓該線程等待。對我們前面的存取款的例子進行修改後得到如下代碼:

import threading


class Account:
    # 定義構造器
    def __init__(self, account_no, balance):
        # 封裝帳戶編號(銀行卡號)和帳戶餘額兩個變量
        self.account_no = account_no
        self._balance = balance
        self.cond = threading.Condition()
        # 旗標,代表是否已經存錢
        self._flag = False

    # 因爲帳戶餘額不允許隨便修改,所以只爲self._balance提供gtetter方法
    def getBalance(self):
        return self._balance

    # 提供一個線程安全的draw()方法來完成取錢操作
    def draw(self, draw_amount):
        # 加鎖
        self.cond.acquire()
        try:
            # 如果旗標爲False,表明帳戶中還沒有人存錢進去,取錢方法被阻塞
            if not self._flag:
                self.cond.wait()
            else:
                # 執行取錢操作
                print(threading.current_thread().name + " 取錢:" +
                      str(draw_amount))
                self._balance -= draw_amount
                print("帳戶餘額爲:" + str(self._balance))
                # 取錢結束,將旗標置爲False
                self._flag = False
                # 喚醒其他線程
                self.cond.notify_all()
        finally:
            # 修改完成,釋放鎖
            self.cond.release()

    def deposit(self, deposit_amount):
        # 加鎖,相當於調用Condition綁定的Lock的acquire()
        self.cond.acquire()
        try:
            # 如果self._flag爲True,表明帳戶已有人存入錢,存款方法被阻塞
            if self._flag:
                self.cond.wait()
            else:
                print(threading.current_thread().name + " 存款:" +
                      str(deposit_amount))
                self._balance += deposit_amount
                print("帳戶餘額爲:" + str(self._balance))
                # 將表明帳戶中是否已有存款的旗標設爲True
                self._flag = True
                # 喚醒其他線程
                self.cond.notify_all()
        # 使用finally釋放鎖
        finally:
            self.cond.release()
import threading

import Account


# 定義一個函數,模擬重複max次執行取錢操作
def draw_many(account, draw_amount, max):
    for i in range(max):
        account.draw(draw_amount)


# 定義一個函數,模擬重複max次存款操作
def deposit_many(account, deposit_amount, max):
    for i in range(max):
        account.deposit(deposit_amount)


# 創建一個帳戶
acct = Account.Account("123456", 0)
# 創建並啓動一個“取錢”線程
threading.Thread(name="取錢者", target=draw_many, args=(acct, 800, 100)).start()
# 創建並啓動一個“存錢”線程
threading.Thread(
    name="存錢者甲", target=deposit_many, args=(acct, 800, 100)).start()
threading.Thread(
    name="存錢者已", target=deposit_many, args=(acct, 800, 100)).start()
threading.Thread(
    name="存錢者丙", target=deposit_many, args=(acct, 800, 100)).start()

(二)使用隊列(Queue)控制線程通信

在queue模塊下提供了幾個阻塞隊列,這些隊列主要用於實現線程通信。

queue模塊下主要提供了三個類,代表三種隊列:

● queue.Queue(maxsize=0):代表FIFO(先進先出)的常規隊列,maxsize可以限制隊列的大小,如果隊列的大小達到隊列上限,就會加鎖,再次加入元素時會先被阻塞,知道隊列中的元素被消費。如果將maxsize設置爲0或負數,則該隊列的大小就是無限制的。

● queue.LifoQueue(maxsize=0):代表LIFO(後進先出)的隊列,與Queue的quiet就是出隊列的順序不同。

● PriorityQueue(maxsize=0):代表優先級隊列,優先級最小的元素先出隊列。

以上三個隊列的屬性和方法基本相同,都提供如下屬性和方法:

● Queue.qsize():返回隊列的實際大小,也就是該隊列中包含幾個元素。

● Queue.empty():判斷隊列是否爲空。

● Queue.full():判斷隊列是否已滿。

● Queue.put(item, block=True, timeout=None):向隊列中放入元素。如果隊列已滿,且block參數爲True(阻塞),當前線程被阻塞,timeout指定阻塞時間,如果將timeout設置爲None,則代表一直阻塞,知道該隊列的元素被消費;如果隊列已滿,且block參數爲False(不阻塞),則直接一發queue.FULL異常。

● Queue.put_nowait(item):向隊列中放入元素,不阻塞。相當於在上一個方法中將block參數設置爲False。

● Queue.get(item, block=True, timeout=None):從隊列中取出元素(消費元素)。如果隊列已空,且block參數爲True(阻塞),當前線程被阻塞,timeout指定阻塞時間,如果將timeout設置爲None,則代表一致阻塞,直到有元素被放入隊列中;如果隊列已空,且block參數設置爲False(不阻塞),則直接引發queue.EMPTY異常。

● Queue.get_nowait(item):從隊列中取出元素,不阻塞。相當於在上一個方法中將block參數設置爲False。

import queue

# 定義一個長度爲2的阻塞隊列
bq = queue.Queue(2)
bq.put("Python")
bq.put("Python")
print("********************")
bq.put("Python")  # 阻塞線程
print("*********************")

運行以上程序,會發現程序會在試圖放入第三個元素時被阻塞,我們可以利用這個特性來實現線程通信,具體代碼如下:

import threading
import time
import queue


def product(bq):
    str_tuple = ("Python", "Kotlin", "Swift")
    for i in range(99999):
        print(threading.current_thread().name + "生產者準備生產元組元素!")
        time.sleep(0.2)
        # 嘗試放入元素,如果隊列已滿,則線程被阻塞
        bq.put(str_tuple[i % 3])
        print(threading.current_thread().name + "生產者生產元組元素完成!")


def consume(bq):
    while True:
        print(threading.current_thread().name + "消費者準備消費元組元素!")
        time.sleep(0.2)
        # 嘗試取出元素,如果隊列已空,則線程被阻塞
        t = bq.get()
        print(threading.current_thread().name + "消費者消費[{0}]元素完成!".format(t))


# 創建一個容量爲1的Queue
bq = queue.Queue(maxsize=1)
# 啓動sane生產者線程
threading.Thread(target=product, args=(bq,)).start()
threading.Thread(target=product, args=(bq,)).start()
threading.Thread(target=product, args=(bq,)).start()
# 啓動一個消費者線程
threading.Thread(target=consume, args=(bq,)).start()

 

運行以上程序可以看到,三個生產者線程啓動時,由於隊列長度爲1,所以,三個生產者線程無法連續放入元素,必須等待消費者線程取出一個元素後,其中的一個生產者線程才能放入一個元素。

(三)使用Event控制線程通信

Event是一種非常簡單的線程通信機制:一個線程發出一個Event,另一個線程可通過該Event被觸發。

Event本身管理一個內部旗標,程序可以通過Event的set()方法將該旗標設置爲True,也可以調用clear()方法將該旗標設置爲False。程序可以調用wait()方法來阻塞當前線程,直到Event的內部旗標被設置爲True。

Event提供瞭如下方法:

● is_set():該方法返回Event的內部旗標是否爲True。

● set():該方法將會把Event的內部旗標設置爲True,並喚醒所有出於等待狀態的線程。

● clear():該方法將Evnet的內部旗標設置爲False,通常接下來會調用wait()方法來阻塞當前線程。

● wait(timeout=None):該方法會阻塞當前線程。

用法演示:

import threading
import time

event = threading.Event()


def cal(name):
    # 等待事件,進入等待阻塞狀態
    print('{0} 啓動'.format(threading.current_thread().getName()))
    print('{0} 準備開始計算狀態'.format(name))
    event.wait()  # ①
    # 收到事件後進入運行狀態
    print('{0} 收到通知了'.format(threading.current_thread().getName()))
    print('{0} 正式開始計算!'.format(name))


# 創建並啓動兩個線程,他們都會在①號代碼處等待
threading.Thread(target=cal, args=('甲',)).start()
threading.Thread(target=cal, args=('乙',)).start()
time.sleep(2)  # ②
print("------------------------------")
# 發出事件
print('主線程發出事件')
event.set()

以上程序以cal()函數爲target,創建並啓動了兩個線程。由於cal()函數在①號代碼處調用了Event的wait()方法,因此兩個線程執行到①號代碼處都會進入阻塞狀態;即使主線程在②號代碼處被阻塞,兩個子線程也不會向下執行。直到住程序執行到最後一行時,調用了set()方法將Event的內部旗標設置爲True,並喚醒所有等待線程,這兩個子線程才能向下執行。

如果結合Event的內部旗標,同樣可以實現前面的Account的生產者—消費者效果:存錢線程(生產者)存錢之後,必須等待取錢線程(消費者)取錢之後才能繼續向下執行。

Event本身並不帶Lock對象,因此,要實現線程同步,還需要額外的Lock對象。

使用Event改寫後的Account:

import threading


class Account:
    # 定義構造器
    def __init__(self, account_no, blance):
        # 封裝帳戶編號和帳戶餘額兩個成員變量
        self.account_no = account_no
        self._blance = blance
        self.lock = threading.Lock()
        self.event = threading.Event()

    # 因爲帳戶餘額不允許隨便修改,所以值爲self._blance提供getter方法
    def getBlance(self):
        return self._blance

    # 提供一個線程安全的draw()方法來完成取錢操作
    def draw(self, draw_amount):
        # 加鎖
        self.lock.acquire()
        # 如果Event的內部旗標爲True,則表明帳戶中已有人存錢進去
        if self.event.is_set():
            # 執行取錢操作
            print(threading.current_thread().name + "取錢:" + str(draw_amount))
            self._blance -= draw_amount
            print("帳戶餘額爲:" + str(self._blance))
            # 將Event的內部旗標設置爲False
            self.event.clear()
            # 釋放鎖
            self.lock.release()
            # 阻塞當前線程
            self.event.wait()
        else:
            # 釋放鎖
            self.lock.release()
            # 阻塞當前線程
            self.event.wait()

    def deposit(self, deposit_amount):
        # 加鎖
        self.lock.acquire()
        # 如果Event的內部旗標爲False,則表明帳戶中還沒有人存錢進去
        if not self.event.is_set():
            # 執行存款操作
            print(threading.current_thread().name + "存錢:" +
                  str(deposit_amount))
            self._blance += deposit_amount
            print("帳戶餘額爲:" + str(self._blance))
            # 將Event的內部旗標設置爲True
            self.event.set()
            # 釋放鎖
            self.lock.release()
            # 阻塞當前進程
            self.event.wait()
        else:
            # 釋放鎖
            self.lock.release()
            # 阻塞當前進程
            self.event.wait()

七、線程池

由於涉及與操作系統的交互,所以系統啓動一個新線程的成本較高。使用線程池可以很好的提升性能。

線程池在系統啓動時就創建大量空閒的線程,程序只要將一個函數提交給線程池,線程池就會啓動跟一個空閒的線程來執行它。當函數執行結束後,該線程並不會死亡,而是再次回到線程池中變成空閒狀態,等待執行下一個函數。

使用線程池可以有效地控制系統中併發線程的數量。

(一)使用線程池

Python線程池的基類是concurrent.futures模塊中的Executor,該類提供兩個子類,ThreadPoolExecutor和ProcessPoolExecutor,其中ThreadPoolExecutor用於創建線程池,ProcessPoolExcutor用於創建進程池。

使用線程池/進程池來管理併發編程,只需將相應的task函數提交給線程池/進程池即可。

Executor提供如下常用方法

● submit(fn,*args, **kwargs):將fn函數提交給線程池。*args代表傳遞給fn函數的參數,**kwargs代表以關鍵字參數的形式爲fn函數傳入參數。

● map(func, *iterables, timeout=None, chunksize=1):該函數類似於全局函數map(func, *iterable),只是該函數將會啓動多個線程,以異步方式立即對iterables執行map處理。

● shutdown(wait=True):關閉線程池。

程序將task函數通過submit方法提交給線程池後,該方法會放回一個Future對象,用於獲取線程任務函數的返回值。

Future提供瞭如下方法:

● cancel():取消該Future代表的線程任務。如果該任務正在執行,不可取消,則該方法返回False;否則,程序會取消該任務,並返回True。

● cancelled():返回Future代表的線程任務是否被成功取消。

● running():如果該Futrue代表的線程任務正在執行、不可被取消,該方法返回True。

● done():如果該Future代表的線程任務被成功取消或執行完成,則該方法返回True。

● result(timeout=None):獲取該Futrue代表的線程任務最後返回的結果。如果Future代表的線程任務還未完成,該方法將會阻塞當前線程,其中timeout參數指定最多阻塞多少秒。

● exception(timeout=None):獲取該Future代表的線程任務所引發的異常。如果該任務成功完成,該方法返回None。

● add_done_callback(fn):爲該Future代表的線程任務註冊一個“回調函數”,當該任務成功完成時,程序會自動觸發該fn函數。

線程池使用完畢後應該使用shutdown()方法關閉線程池序列,調用該方法後,線程池不再接收新任務,但會將以前所有已提交任務執行完成。當線程池中的所有任務都執行完畢後,該線程池中的所有線程都會死亡。

使用線程池的步驟:

① 調用ThreadPoolExecutor類的構造器創建一個線程池。

② 定義一個普通函數作爲線程任務。

③ 調用ThreadPoolExecutor對象的submit()方法來提交線程任務。

④ 當不想提交任何任務時,調用shutdown()方法關閉線程池。

from concurrent.futures import ThreadPoolExecutor
import threading
import time


# 定義一個函數作爲線程任務的函數
def action(max):
    my_sum = 0
    for i in range(max):
        print(threading.current_thread().name + '\t' + str(i))
        my_sum += i
    return my_sum


# 創建一個包含兩個線程的線程池
pool = ThreadPoolExecutor(max_workers=2)
# 向線程池中提交一個任務,50會作爲action()函數的參數
future1 = pool.submit(action, 50)
# 向線程池中再提交一個任務,100會作爲action()函數的參數
future2 = pool.submit(action, 100)
# 判斷future1代表的任務是否結束
print(future1.done())
time.sleep(3)
# 判斷future2代表的任務是否結束
print(future2.done())
# 查看future1代表的任務返回的結果
print(future1.result())
# 查看future2代表的任務返回的結果
print(future2.result())
# 關閉線程池
pool.shutdown()

當程序使用Future的result()方法來獲取結果時,該方法會阻塞當前線程,如果沒有指定timeout參數,當前線程將一直處於阻塞狀態,知道Future代表的任務返回。

(二)獲取執行結果

如果程序不希望直接調用result()方法阻塞線程,則可通過調用Future的add_done_callback()方法來添加回調函數,該回調函數形如fn(future)。當線程任務完成後,程序會自動觸發該回調函數,並將對應的Future對象作爲參數傳遞給回調函數。

from concurrent.futures import ThreadPoolExecutor
import threading


# 定義一個準備作爲線程任務的函數
def action(max):
    my_sum = 0
    for i in range(max):
        print(threading.current_thread().name + '\t' + str(i))
        my_sum += i
    return my_sum


# 創建一個包含兩個線程的線程池
with ThreadPoolExecutor(max_workers=2) as pool:
    # 向線程池中提交一個任務,50會作爲action()函數的參數
    future1 = pool.submit(action, 50)
    # 向線程池中再提交一個任務,100會作爲action()函數的參數
    future2 = pool.submit(action, 100)

    def get_result(future):
        print(future.result())

    # 爲future1添加線程完成的回調函數
    future1.add_done_callback(get_result)
    # 爲future2添加線程完成的回調函數
    future2.add_done_callback(get_result)
    print("------------------------")

 Executor的map(func, *iterables, timeout=None, chunksize=1)方法會以併發方式來執行func函數,相當於啓動len(iterables)個線程,並收集每個線程的執行結果。

from concurrent.futures import ThreadPoolExecutor
import threading


# 定義一個函數作爲線程任務函數
def action(max):
    my_sum = 0
    for i in range(max):
        print(threading.current_thread().name + '\t' + str(i))
        my_sum += i
    return my_sum


# 創建一個包含4個線程的線程池
with ThreadPoolExecutor(max_workers=4) as pool:
    # 使用線程執行map計算
    # 後面的元組有3個元素,因此程序啓動3個線程來執行action函數
    results = pool.map(action, (50, 100, 150))
    print("-----------------")
    for r in results:
        print(r)

使用map()方法來啓動線程,並收集線程的執行結果,不僅具有代碼簡單的優點,而且雖然程序會以併發方式來執行action()函數,但最後收集的action()函數的執行結果,依然與傳入參數的結果保持一致,也就是說,上面results的第一個元素是action(50)的結果,第二個是action(100)的結果,第三個是action(100)的結果。

 

八、線程相關類

(一)線程局部變量

Python在threading模塊下提供了一個local()函數,該函數可以返回一個線程局部變量,通過使用線程局部變量可以很簡捷地隔離多線程訪問的競爭資源,從而簡化多線程併發訪問的編程處理。

線程局部變量的作用是爲每一個使用該變量的線程提供一個變量的副本,使每一個線程都可以獨立地改變自己的副本,而不會和其他線程的副本衝突。

演示線程局部變量的作用:

 

import threading
from concurrent.futures import ThreadPoolExecutor

# 定義線程局部變量
mydata = threading.local()


# 定義準備作爲線程執行體使用的函數
def action(max):
    for i in range(max):
        try:
            mydata.x += i
        except:
            mydata.x = i
        # 訪問mydata的x的值
        print("{0} mydata.x 的值爲:{1}".format(threading.current_thread().name,
                                            mydata.x))


# 使用線程池啓動兩個子線程
with ThreadPoolExecutor(max_workers=2) as pool:
    pool.submit(action, 10)
    pool.submit(action, 10)

執行結果:

ThreadPoolExecutor-0_0 mydata.x 的值爲:0
ThreadPoolExecutor-0_0 mydata.x 的值爲:1
ThreadPoolExecutor-0_0 mydata.x 的值爲:3
ThreadPoolExecutor-0_0 mydata.x 的值爲:6
ThreadPoolExecutor-0_0 mydata.x 的值爲:10
ThreadPoolExecutor-0_0 mydata.x 的值爲:15
ThreadPoolExecutor-0_0 mydata.x 的值爲:21
ThreadPoolExecutor-0_0 mydata.x 的值爲:28
ThreadPoolExecutor-0_0 mydata.x 的值爲:36
ThreadPoolExecutor-0_0 mydata.x 的值爲:45
ThreadPoolExecutor-0_1 mydata.x 的值爲:0
ThreadPoolExecutor-0_1 mydata.x 的值爲:1
ThreadPoolExecutor-0_1 mydata.x 的值爲:3
ThreadPoolExecutor-0_1 mydata.x 的值爲:6
ThreadPoolExecutor-0_1 mydata.x 的值爲:10
ThreadPoolExecutor-0_1 mydata.x 的值爲:15
ThreadPoolExecutor-0_1 mydata.x 的值爲:21
ThreadPoolExecutor-0_1 mydata.x 的值爲:28
ThreadPoolExecutor-0_1 mydata.x 的值爲:36
ThreadPoolExecutor-0_1 mydata.x 的值爲:45

Process finished with exit code 0

以上程序中定義了一個threading.local變量,程序將會爲每個線程各創建一個該變量的副本。如果兩個線程共享同一個mydata變量,將會看到mydata.x最後會累加到90。但由於mydata是threading.local變量,因此程序會爲每個線程都創建一個該變量的副本,所以將會看到兩個線程的mydata.x最後都累加到45.

線程局部變量和其他同步機制一樣,都是爲了解決多線程中對共享資源的訪問衝突的。

普通同步機制中,通過爲對象加鎖來實現對共享資源的安全訪問,線程局部變量將需要併發訪問的資源複製多份,從而提供了線程安全的共享對象,在編寫多線程代碼時,可以把不安全的整個變量放到線程局部變量中,或者把該對象中與線程相關的狀態放入線程局部變量中保存。

線程局部變量並不能替代同步機制,兩者面向的問題領域不同。

同步機制爲了同步多個線程對共享資源的併發訪問,是多個線程之間進行通信的有效方式。

線程局部變量爲了隔離多個線程的數據共享,從根本上避免多個線程之間對共享資源的競爭。

如果多個線程之間需要共享資源以實現線程通信,則使用同步機制;如果僅僅需要隔離多個線程之間的共享衝突,則可以使用線程局部變量。

(二)定時器

Thread.Timer子類可以用於控制指定函數在特定時間內執行一次。

from threading import Timer


def hello():
    print("hello, world!")


# 指定10s後執行hello函數
t = Timer(10.0, hello)
t.start()

Timer只能控制函數在指定時間內執行一次,如果要使用Timer控制函數多次重複執行,需要再執行下一次調度。

Timer對象的cancel()函數可以取消Timer調度。

from threading import Timer
import time

# 定義總共輸出幾次的計數器
count = 0


def print_time():
    print("當前時間:{0}".format(time.ctime()))
    global t, count
    count += 1
    # 如果count小於10,開始下一次調度
    if count < 10:
        t = Timer(1, print_time)
        t.start()


# 指定1s後執行print_time函數
t = Timer(1, print_time)
t.start()

(三)任務調度

Python的sched模塊提供了sched.scheduler類用於任務調度。

sched.scheduler(timefunc=time.monotonic, delayfunc=time.sleep)構造器支持兩個參數。

● timefunc:該參數指定生成時間戳的時間函數,默認使用time.monotonic來生成時間戳。

● delayfunc:該參數指定阻塞程序的函數,默認使用time.sleep函數來阻塞程序。

sched.scheduler調度器支持如下常用屬性和方法:

● scheduler.enterabs(time, priority, action, argument=(), kwargs={}):指定在time時間點執行action函數,argument和kwargs用於向action函數傳入參數,argument使用位置參數的形式傳入參數,kwargs使用關鍵字參數的形式傳入參數。該方法返回一個event,它可以作爲cancel()方法的參數用於取消調度。priority參數指定該任務的優先級,當在同一個時間點有多個任務需要執行時,優先級高(值越小代表優先級越高)的任務會優先執行。

● scheduler.enter(delay, priority, action, argument=(), kwargs={}):該方法與enterabs方法基本相同,只是delay參數用於指定多少秒之後執行action任務。

● scheduler.cancel(event):取消任務。如果傳入的event參數不是當前調度隊列中的event,程序將會引發ValueError異常。

● scheduler.empty():判斷當前調度器的調度隊列是否爲空。

● scheduler.run(blocking=True):運行所有需要調度的任務。如果調用該方法的blocking參數爲True,該方法將會阻塞線程,知道所有被調度的任務都執行完成。

● scheduler.queue:該只讀屬性返回該調度器的調度隊列。

import sched, time

# 定義線程調度器
s = sched.scheduler()


# 定義被調度的函數
def print_time(name='default'):
    print("{0}的時間:{1}".format(name, time.ctime()))


print("主線程,", time.ctime())
# 指定10s後執行print_time函數
s.enter(10, 1, print_time)
# 指定5s後執行print_time函數,優先級爲2
s.enter(5, 2, print_time, argument=('位置參數',))
# 指定5s後執行print_time函數,優先級爲1
s.enter(5, 1, print_time, kwargs={'name': '關鍵字參數'})
# 執行調度的任務
s.run()
print('主線程:', time.ctime())

執行結果:

主線程, Thu Nov 14 00:24:00 2019
關鍵字參數的時間:Thu Nov 14 00:24:05 2019
位置參數的時間:Thu Nov 14 00:24:05 2019
default的時間:Thu Nov 14 00:24:10 2019
主線程: Thu Nov 14 00:24:10 2019

九、多進程

(一)使用fork創建新進程

Python的os模塊提供了一個fork()犯法,可以fork出來一個子進程。

fork()方法的作用是:程序會啓動兩個進程(一個是父進程,一個是frok出來的子進程)來執行從os.fork()開始的所有代碼。fork()方法不需要參數,它有一個返回值,該返回值表明是哪個進程在執行。

● 如果fork()方法返回0,則表明是fork出來的子進程在執行。

● 如果fork()方法返回非0,則表明是父進程在執行,該方法返回fork()出來的子進程的進程ID。

import os

print('父進程({0})開始執行'.format(os.getpid()))
# 開始fork一個子進程
# 從這行代碼開始,下面的代碼都會被兩個進程執行
pid = os.fork()
print("進程進入:{0}".format(os.getpid()))
# 如果pid爲0,則表明是子進程
if pid == 0:
    print("子進程,其ID爲({0}),父進程ID爲({1})".format(os.getpid(), os.getppid()))
else:
    print('我({0})創建的子進程ID爲({1})。'.format(os.getpid(), pid))
print('進程結束:{0}'.format(os.getpid()))

 

windows不支持fork()方法,以上程序在Windows系統上運行會報錯。

在實際編程中,程序可通過fork()方法來創建一個子進程,然後通過判斷fork()方法的返回值來確定程序是否正在執行子進程,也就是把需要併發執行的任務放在if pid==0:的條件體中,這樣就可以啓動多個子進程來執行併發任務。

(二)使用multiprocessing.Process創建新進程

Python在multiprocessing模塊下提供了Process來創建新進程。

使用Process創建新進程有兩種方式:

1. 以指定函數作爲target,創建Process對象。

2. 繼承Process類,並重寫它的run()方法來創建進程類,程序創建Process子類的實例作爲進程。

Process類有如下方法和屬性:

● run():重寫該方法可以實現進程的執行體。

● start():啓動進程。

● join([timeout]):該方法類似於線程的join()方法,當前進程必須等待被join的進程執行完成才能向下執行。

● name:該屬性用於設置或訪問進程的名字。

● is_alive():判斷進程是否還活着。

● daemon:該屬性用於判斷或設置進程的後臺狀態。

● pid:返回進程的ID。

● authkey:返回進程的授權key。

● terminate():中斷該進程。

a、以指定函數作爲target創建新進程:

import multiprocessing
import os


# 定義一個普通的action函數,該函數準備作爲進程執行體
def action(max):
    for i in range(max):
        print('({0})子進程(父進程:{1}):{2:d}'.format(os.getpid(), os.getppid(), i))


if __name__ == '__main__':
    # 下面是主程序(也就是主進程)
    for i in range(100):
        print("({0})主進程:{1:d}".format(os.getpid(), i))
        if i == 20:
            # 創建並啓動第一個進程
            mp1 = multiprocessing.Process(target=action, args=(100,))
            mp1.start()
            # 創建並啓動第二個進程
            mp2 = multiprocessing.Process(target=action, args=(100,))
            mp2.start()
            mp2.join()
    print('主進程執行完成!')

※通過multiprocessing.Process來創建並啓動進程時,程序必須先判斷if __name__ == '__main__'。否則可能引發異常。

運行以上代碼,程序將啓動三個進程,一個主進程和程序啓動的兩個子進程。由於程序調用了mp2.join(),因此主進程必須等mp2進程安城後才能向下執行。

b、繼承Process類創建子進程。

繼承Process類創建子進程的步驟:

① 定義繼承Process的子類,重寫其run()方法準備作爲進程執行體

② 創建Process子類的實例。

③ 調用Process子類的實例的start()方法來啓動進程。

import multiprocessing
import os


class MyProcess(multiprocessing.Process):

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

    # 重寫run()方法作爲進程執行體
    def run(self):
        for i in range(self.max):
            print("({0})子進程(父進程:({1}):{2:d}".format(os.getpid(), os.getppid(),
                                                   i))


if __name__ == '__main__':
    # 下面是主程序(也就是主進程)
    for i in range(100):
        print("({0})主進程:{1:d}".format(os.getpid(), i))
        if i == 20:
            # 創建並啓動第一個進程
            mp1 = MyProcess(100)
            mp1.start()
            # 創建並啓動第二個進程
            mp2 = MyProcess(100)
            mp2.start()
            mp2.join()
    print('主進程執行完成!')

(三)Context和啓動進程的方式

根據平臺的支持,Python支持三種啓動進程的方式。

1. spawn:父進程會啓動一個全新的Python解釋器進程。在這種方式下,子進程只能繼承那些處理run()方法所必須的資源。典型地,那些不必要的文件描述器和handle都不會被繼承。使用這種方式來啓動進程,其效率比使用fork和forkserver方式要低得多。

Windows只支持使用spawn方式來啓動進程,因此在Windows平臺上默認使用這種方式來啓動進程。

2. fork:父進程使用os.fork()來啓動一個Python解釋器進程。在這種方式下,子進程會繼承父進程的所有資源,因此子進程基本等效於父進程。這種方式只在UNIX平臺上有效。UNIX平臺默認使用這種方式來啓動進程。

3. forkserver:如果使用這種方式來啓動進程,程序將會啓動一個服務器進程。在以後的時間內,當程序再次請求啓動新進程時,父進程都會連接到該服務器進程,請求由服務器進程來fork新進程。通過這種方式啓動的進程不需要從父進程繼承資源。這種方式只在UNIX平臺上有效。

multiprocessing模塊提供了一個set_start_method()函數用於設置啓動進程的方式——必須將這行設置代碼放在所有與多進程有關的代碼之前。

import multiprocessing
import os


def foo(q):
    print('被啓動的新進程:({0})'.format(os.getpid()))
    q.put('Python')


if __name__ == '__main__':
    # 設置使用fork方式啓動進程
    multiprocessing.set_start_method('fork')
    q = multiprocessing.Queue()
    # 創建進程
    mp = multiprocessing.Process(target=foo, args=(q,))
    # 啓動進程
    mp.start()
    # 獲取隊列中的消息
    print(q.get())
    mp.join()

還可以利用get_context()方法來獲取Context對象,調用該方法時傳入spawn、fork或forkserver字符串。

Context擁有和multiprocessing相同的API,因此程序可以通過Context來創建並啓動進程。

import multiprocessing
import os


def foo(q):
    print('被啓動的新進程:({0})'.format(os.getpid()))
    q.put('Python')


if __name__ == '__main__':
    # 設置使用fork方式啓動進程,並獲取Context對象
    ctx = multiprocessing.get_context('fork')
    # 接下來就可以使用Context對象來代替multiprocessing模塊
    q = ctx.Queue()
    # 創建進程
    mp = ctx.Process(target=foo, args=(q,))
    # 啓動進程
    mp.start()
    # 獲取隊列中的消息
    print(q.get())
    mp.join()

(四)使用進程池管理進程

程序可以通過multiprocessing模塊的Pool()函數創建進程池,進程池實際上是multiprocessing.pool.Pool類。

進程池常用方法:

● apply(func[, args[, kwds]]):將func函數提交給進程池處理。其中args代表傳遞給func的位置參數,kwds代表傳遞給func的關鍵字參數。該方法會阻塞直到func函數執行完成。

● apply_async(func[, args[, kwds[, callback[, error_callback]]]]):這是apply()方法的異步版本,該方法不會阻塞。其中callback指定func函數完成後的回調函數,error_callback指定func函數出錯後的回調函數。

● map(func, iterable[, chunksize]):類似於全局函數map(),使用新進程對iterable的每一個元素執行func函數。

● imap(func, iterable[, chunksize]):這是map()方法的延遲版本。

● imap_unordered(func, iterable[, chunksize]):功能類似於imap()方法,該方法不能保證所生成的結果(包含多個元素)與原iterable中的元素順序一致。

● starmap(func, iterable[, chunksize]):功能類似於map()方法,但該方法要求iterable的元素也是iterable對象,程序會將每一個元素解包之後作爲func函數的參數。

● close():關閉進程池。在調用該方法之後,該進程池不能再接收新任務,它會把當前進程池中的所有任務執行完成後再關閉自己。

● terminate():立即終止進程池。

● join():等待所有進程完成。

import multiprocessing
import time
import os


def action(name='default'):
    print('({0})進程正在執行,參數爲:{1}'.format(os.getpid(), name))
    time.sleep(3)


if __name__ == '__main__':
    # 創建包含4個進程的進程池
    pool = multiprocessing.Pool(processes=4)
    # 將action分3次提交給進程池
    pool.apply_async(action)
    pool.apply_async(action, args=('位置參數',))
    pool.apply_async(action, kwds={'name': '關鍵字參數'})
    pool.close()
    pool.join()

運行結果:

(25656)進程正在執行,參數爲:default
(24836)進程正在執行,參數爲:位置參數
(28448)進程正在執行,參數爲:關鍵字參數
import multiprocessing
import os


# 定義一個準備作爲進程任務的函數
def action(max):
    my_sum = 0
    for i in range(max):
        print('({0})進程正在執行:{1:d}'.format(os.getpid(), i))
        my_sum += i
    return my_sum


if __name__ == '__main__':
    # 創建一個包含4個進程的進程池
    with multiprocessing.Pool(processes=4) as pool:
        # 使用進程執行map計算
        # 後面元組有3個元素,因此程序啓動3個進程來執行action函數
        results = pool.map(action, (50, 100, 150))
        print('----------------------')
        for r in results:
            print(r)

(五)進程通信

Python爲進程通信提供了兩種機制

1. Queue:一個進程向Queue中放入數據,另一個進程從Queue中讀取數據

2. Pipe:Pipe代表連接兩個進程的管道。程序在調用Pipe()函數時會產生兩個連接端,分別交給通信的兩個進程,進程即可以從該連接端讀取數據,也可以向連接端寫入數據。

a、使用Queue實現進程通信

進程的Queue與線程的Queue類似,都提供了qsize()、empty()、full()、put()、put_nowait()、get()、get_nowait()等方法。

import multiprocessing


def f(q):
    print('({0})進程開始放入數據...'.format(multiprocessing.current_process().pid))
    q.put('Python')


if __name__ == '__main__':
    # 創建進程通信的Queue
    q = multiprocessing.Queue()
    # 創建子進程
    p = multiprocessing.Process(target=f, args=(q,))
    # 啓動子進程
    p.start()
    print('({0})進程開始取出數據...'.format(multiprocessing.current_process().pid))
    # 取出數據
    print(q.get())  # Python
    p.join()

執行結果:

(14352)進程開始取出數據...
(6196)進程開始放入數據...
Python

b、使用Pipe實現進程通信

程序調用multiprocessing.Pipe()函數來創建一個管道,該函數會返回兩個PipeConnection對象,代表管道的兩個連接端。

PipeConnection對象包含如下常用方法:

● send(obj):發送一個obj給管道的另一端,另一端使用recv()方法接收。該obj必須是可pickable的(Python的序列化機制),如果該對象序列化之後超過32MB,則可能會引發ValueError異常。

● recv():接收另一端通過send()方法發送過來的數據。

● fileno():關於連接所使用的文件描述器。

● close():關閉連接

● poll([timeout]):返回連接中是否還有數據可以讀取。

● send_bytes(buffer[, offset[, size]]):發送字節數據。如果沒有指定offset、size參數,則默認發送buffer字節串的全部數據;如果指定了offset和size參數,則只發送buffer字節串中從offset開始、長度爲size的字節數據。通過該方法發送的數據,應該使用recv_bytes()或recv_bytes_into方法接收。

● recv_bytes([maxlength]):接收通過send_bytes()方法發送的數據,maxlength指定最多接收的字節數,該方法返回接收到的字節數據。

● recv_bytes_into(buffer[, offset]):功能同recv_bytes()方法類似,只是該方法將接收到的數據放在buffer中。

import multiprocessing


def f(conn):
    print('({0})進程開始發送數據...'.format(multiprocessing.current_process().pid))
    # 使用conn發送數據
    conn.send('Python')


if __name__ == '__main__':
    # 創建Pipe,該函數返回兩個PipeConnection對象
    parent_conn, child_conn = multiprocessing.Pipe()
    # 創建子進程
    p = multiprocessing.Process(target=f, args=(child_conn,))
    # 啓動子進程
    p.start()
    print('({0})進程開始接收數據...'.format(multiprocessing.current_process().pid))
    # 通過conn讀取數據
    print(parent_conn.recv())    # Python
    p.join()

運行結果:

(31524)進程開始接收數據...
(27492)進程開始發送數據...
Python

習題:

1. 啓動3個線程打印遞增的數字,控制線程l打印1,2,3,4,5(每行都打印線程名和一個數字〉,線程2打印6,7,8,9,10 ,線程3打印11,12,13,14,15; 接下來再由線程l打印16,17,18,19,20……依此類推,直到打印75。

from concurrent.futures import ThreadPoolExecutor
import threading


# 新建一個類控制線程鎖
class MyThread:

    def __init__(self):
        # 當前打印的值
        self.number = 0
        # 控制應由哪個線程執行打印任務
        self.state = 1
        # 使用condition來進行線程間通信
        self.cond = threading.Condition()

    # 打印方法,連續打印5個數字以後就退出當前線程,把執行權限交給下一個線程
    def my_print(self, thread_num):
        try:
            # 爲當前線程加鎖
            self.cond.acquire()
            # 如果當前線程不是應該執行打印任務的線程,則阻塞當前線程
            while self.state != thread_num:
                self.cond.wait()
            # 打印5個連續數值:
            for i in range(5):
                self.number += 1
                print("thread{0} : {1}".format(thread_num, self.number))
            # 每打印5個數字後,將thread_num即state值加1,控制由下一個線程來執行打印任務
            self.state = self.state % 3 + 1
            # 喚醒所有線程
            self.cond.notify_all()
        finally:
            # 釋放鎖
            self.cond.release()


# 線程執行體
def action(mt, thread_num):
    # 控制每個線程要執行MyThread對象的my_print()方法5次
    for i in range(5):
        mt.my_print(thread_num)


# 創建MyThread類對象
mt = MyThread()
# 創建一個包含三個線程的線程池
with ThreadPoolExecutor(max_workers=3) as pool:
    # 啓動3個線程
    for i in range(3):
        # 使用線程池啓動3個線程
        pool.submit(action, mt, i + 1)

2. 編寫兩個線程,其中一個線程打印1~52;另一個線程打印A~Z,打印順序是12A34B56C … 5152Z。該練習題需要利用多線程通信的知識。

import threading, queue

# 創建一個只有1個元素的隊列
bq = queue.Queue(1)
# 創建線程鎖
lock = threading.RLock()


def action1(bq):
    for i in range(1, 53, 2):
        # 向隊列中放入元素,因爲隊列只有一個元素,因此放入元素後該線程被阻塞
        bq.put(i)
        print(i, end='')
        print(i + 1, end='')


def action2(bq):
    for i in range(26):
        # 從隊列中取出元素,取出元素後,隊列爲空當前線程被阻塞
        bq.get()
        print(chr(65 + i), end='')


# 創建並啓動第一個線程
t1 = threading.Thread(target=action1, args=(bq,))
t1.start()
# 創建並啓動第二個線程
t2 = threading.Thread(target=action2, args=(bq,))
t2.start()

3. 有4個線程1,2,3,4。線程l的功能是輸出l,線程2的功能是輸出2,依此類推。現在有4個文件A,B,C,D ,初始都爲空。讓4個文件最後呈現出如下內容:

A:1 2 3 4 1 2...

B: 2 3 4 1 2 3...

C: 3 4 1 2 3 4...

D: 4 1 2 3 4 1...

from concurrent.futures import ThreadPoolExecutor
import threading
import time, os
from pathlib import Path


# 創建文件寫入類
class WriteFile:

    def __init__(self):
        # 當前線程ID
        self.current_thread_num = 1
        # 寫入文件總數
        self.write_count = 0

    # 創建函數向文件寫入數字
    def write_num(self, value):
        # 生成文件位置
        with open(self.current_file_name() + ".txt", 'a+') as f:
            f.write(value + " ")
            print(
                "ThreadNum={0} is executing. {1} is written into file: {2}.txt \n"
                .format(self.current_thread_num, value,
                        self.current_file_name()))
            self.write_count += 1
            self.current_thread_num = int(value)
            self.next_thread_num()

    # 獲取當前寫入文件的文件名
    def current_file_name(self):
        '''判斷接下來要寫入哪個文件'''
        tmp = self.write_count % 4
        name_map = {0: 'A', 1: 'B', 2: 'C', 3: 'D'}
        return name_map[tmp]

    # 獲取下一個進程的ID
    def next_thread_num(self):
        if self.write_count % 4 == 0:
            if self.current_thread_num < 3:
                self.current_thread_num += 2
            else:
                self.current_thread_num = (self.current_thread_num + 2) % 4
        else:
            if self.current_thread_num == 4:
                self.current_thread_num = 1
            else:
                self.current_thread_num += 1


wf = WriteFile()
# 創建Condition對象,用於線程間通信
wf.cond = threading.Condition()
# 如果文件已經存在,先將文件刪除
for f in ('A', 'B', 'C', 'D'):
    if Path(f + '.txt').exists():
        os.remove(f + '.txt')


# 創建線程體函數
def action(value):
    try:
        # 向每個文件寫入6個數字
        for i in range(6):
            try:
                wf.cond.acquire()
                # 保證要寫入的值必須與當前線程的id相同
                while int(value) != wf.current_thread_num:
                    wf.cond.wait()
                wf.write_num(value)
                wf.cond.notify_all()
            finally:
                wf.cond.release()
    except Exception as e:
        print("異常{0}".format(e))


# 創建一個包含4個線程的線程池
with ThreadPoolExecutor(max_workers=4) as pool:
    # 使用線程池啓動4個線程
    for i in range(4):
        pool.submit(action, str(i + 1))

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章