全網最適合入門的物件導向程式設計教程:59 Python 並行與併發-並行與併發和執行緒與程序
摘要:
在 Python 中,"並行"(parallelism)與"併發"(concurrency)通常用於描述程式的執行方式,而"執行緒"(thread)與"程序"(process)是實現並行和併發的兩種常見方式;執行緒是程序中的最小執行單元,多個執行緒可以共享同一程序的記憶體空間;程序是計算機中正在執行的程式的例項,每個程序都有獨立的記憶體空間和資源。
原文連結:
FreakStudio的部落格
往期推薦:
學嵌入式的你,還不會物件導向??!
全網最適合入門的物件導向程式設計教程:00 物件導向設計方法導論
全網最適合入門的物件導向程式設計教程:01 物件導向程式設計的基本概念
全網最適合入門的物件導向程式設計教程:02 類和物件的 Python 實現-使用 Python 建立類
全網最適合入門的物件導向程式設計教程:03 類和物件的 Python 實現-為自定義類新增屬性
全網最適合入門的物件導向程式設計教程:04 類和物件的Python實現-為自定義類新增方法
全網最適合入門的物件導向程式設計教程:05 類和物件的Python實現-PyCharm程式碼標籤
全網最適合入門的物件導向程式設計教程:06 類和物件的Python實現-自定義類的資料封裝
全網最適合入門的物件導向程式設計教程:07 類和物件的Python實現-型別註解
全網最適合入門的物件導向程式設計教程:08 類和物件的Python實現-@property裝飾器
全網最適合入門的物件導向程式設計教程:09 類和物件的Python實現-類之間的關係
全網最適合入門的物件導向程式設計教程:10 類和物件的Python實現-類的繼承和里氏替換原則
全網最適合入門的物件導向程式設計教程:11 類和物件的Python實現-子類呼叫父類方法
全網最適合入門的物件導向程式設計教程:12 類和物件的Python實現-Python使用logging模組輸出程式執行日誌
全網最適合入門的物件導向程式設計教程:13 類和物件的Python實現-視覺化閱讀程式碼神器Sourcetrail的安裝使用
全網最適合入門的物件導向程式設計教程:全網最適合入門的物件導向程式設計教程:14 類和物件的Python實現-類的靜態方法和類方法
全網最適合入門的物件導向程式設計教程:15 類和物件的 Python 實現-__slots__魔法方法
全網最適合入門的物件導向程式設計教程:16 類和物件的Python實現-多型、方法重寫與開閉原則
全網最適合入門的物件導向程式設計教程:17 類和物件的Python實現-鴨子型別與“file-like object“
全網最適合入門的物件導向程式設計教程:18 類和物件的Python實現-多重繼承與PyQtGraph串列埠資料繪製曲線圖
全網最適合入門的物件導向程式設計教程:19 類和物件的 Python 實現-使用 PyCharm 自動生成檔案註釋和函式註釋
全網最適合入門的物件導向程式設計教程:20 類和物件的Python實現-組合關係的實現與CSV檔案儲存
全網最適合入門的物件導向程式設計教程:21 類和物件的Python實現-多檔案的組織:模組module和包package
全網最適合入門的物件導向程式設計教程:22 類和物件的Python實現-異常和語法錯誤
全網最適合入門的物件導向程式設計教程:23 類和物件的Python實現-丟擲異常
全網最適合入門的物件導向程式設計教程:24 類和物件的Python實現-異常的捕獲與處理
全網最適合入門的物件導向程式設計教程:25 類和物件的Python實現-Python判斷輸入資料型別
全網最適合入門的物件導向程式設計教程:26 類和物件的Python實現-上下文管理器和with語句
全網最適合入門的物件導向程式設計教程:27 類和物件的Python實現-Python中異常層級與自定義異常類的實現
全網最適合入門的物件導向程式設計教程:28 類和物件的Python實現-Python程式設計原則、哲學和規範大彙總
全網最適合入門的物件導向程式設計教程:29 類和物件的Python實現-斷言與防禦性程式設計和help函式的使用
全網最適合入門的物件導向程式設計教程:30 Python的內建資料型別-object根類
全網最適合入門的物件導向程式設計教程:31 Python的內建資料型別-物件Object和型別Type
全網最適合入門的物件導向程式設計教程:32 Python的內建資料型別-類Class和例項Instance
全網最適合入門的物件導向程式設計教程:33 Python的內建資料型別-物件Object和型別Type的關係
全網最適合入門的物件導向程式設計教程:34 Python的內建資料型別-Python常用複合資料型別:元組和命名元組
全網最適合入門的物件導向程式設計教程:35 Python的內建資料型別-文件字串和__doc__屬性
全網最適合入門的物件導向程式設計教程:36 Python的內建資料型別-字典
全網最適合入門的物件導向程式設計教程:37 Python常用複合資料型別-列表和列表推導式
全網最適合入門的物件導向程式設計教程:38 Python常用複合資料型別-使用列表實現堆疊、佇列和雙端佇列
全網最適合入門的物件導向程式設計教程:39 Python常用複合資料型別-集合
全網最適合入門的物件導向程式設計教程:40 Python常用複合資料型別-列舉和enum模組的使用
全網最適合入門的物件導向程式設計教程:41 Python常用複合資料型別-佇列(FIFO、LIFO、優先順序佇列、雙端佇列和環形佇列)
全網最適合入門的物件導向程式設計教程:42 Python常用複合資料型別-collections容器資料型別
全網最適合入門的物件導向程式設計教程:43 Python常用複合資料型別-擴充套件內建資料型別
全網最適合入門的物件導向程式設計教程:44 Python內建函式與魔法方法-重寫內建型別的魔法方法
全網最適合入門的物件導向程式設計教程:45 Python實現常見資料結構-連結串列、樹、雜湊表、圖和堆
全網最適合入門的物件導向程式設計教程:46 Python函式方法與介面-函式與事件驅動框架
全網最適合入門的物件導向程式設計教程:47 Python函式方法與介面-回撥函式Callback
全網最適合入門的物件導向程式設計教程:48 Python函式方法與介面-位置引數、預設引數、可變引數和關鍵字引數
全網最適合入門的物件導向程式設計教程:49 Python函式方法與介面-函式與方法的區別和lamda匿名函式
全網最適合入門的物件導向程式設計教程:50 Python函式方法與介面-介面和抽象基類
全網最適合入門的物件導向程式設計教程:51 Python函式方法與介面-使用Zope實現介面
全網最適合入門的物件導向程式設計教程:52 Python函式方法與介面-Protocol協議與介面
全網最適合入門的物件導向程式設計教程:53 Python字串與序列化-字串與字元編碼
全網最適合入門的物件導向程式設計教程:54 Python字串與序列化-字串格式化與format方法
全網最適合入門的物件導向程式設計教程:55 Python字串與序列化-位元組序列型別和可變位元組字串
全網最適合入門的物件導向程式設計教程:56 Python字串與序列化-正規表示式和re模組應用
全網最適合入門的物件導向程式設計教程:57 Python字串與序列化-序列化與反序列化
全網最適合入門的物件導向程式設計教程:58 Python字串與序列化-序列化Web物件的定義與實現
更多精彩內容可看:
給你的 Python 加加速:一文速通 Python 平行計算
一文搞懂 CM3 微控制器除錯原理
肝了半個月,嵌入式技術棧大彙總出爐
電子計算機類比賽的“武林秘籍”
一個MicroPython的開源專案集錦:awesome-micropython,包含各個方面的Micropython工具庫
Avnet ZUBoard 1CG開發板—深度學習新選擇
SenseCraft 部署模型到Grove Vision AI V2影像處理模組
文件和程式碼獲取:
可訪問如下連結進行對文件下載:
https://github.com/leezisheng/Doc
本文件主要介紹如何使用 Python 進行物件導向程式設計,需要讀者對 Python 語法和微控制器開發具有基本瞭解。相比其他講解 Python 物件導向程式設計的部落格或書籍而言,本文件更加詳細、側重於嵌入式上位機應用,以上位機和下位機的常見串列埠資料收發、資料處理、動態圖繪製等為應用例項,同時使用 Sourcetrail 程式碼軟體對程式碼進行視覺化閱讀便於讀者理解。
相關示例程式碼獲取連結如下:https://github.com/leezisheng/Python-OOP-Demo
正文
並行與併發的基本概念
在以上的模擬感測器-主機的示例中,我們可以看到我們的程式總是在順序執行,如我們利用串列埠助手和主機 MasterClass 類進行配合,主機類傳送指令,我們透過串列埠助手輸入模擬資料傳送到主機端,但是一直沒有涉及 MasterClass 主機類和 SensorClass 感測器類的互動,即 SensorClass 的例項接收 MasterClass 的例項傳送命令,進行解析後執行指定操作。這是由於,我們之前的程式碼一直用的是單執行緒順序執行的方式,同時在 SensorClass 和 MasterClass 的父類 SerialClass 中 ReadSerial 串列埠讀取方法的實現中(dev.readline()方法),使用了阻塞式的方法,在感測器類等待命令或主機類等待資料時必須得等接收到資料後才能執行下一步操作。
如果要求 MasterClass 主機類和 SensorClass 感測器類輪流交替工作/同時工作,則需要使用 Python 中多執行緒/多程序實現多工的並行/併發。
所謂併發(concurrency)指應用能夠交替執行不同的任務,這意味著需要讓單個處理器每秒在不同任務之間進行多次切換。而並行(parallel)是指多個處理器或多核處理器同時處理多個不同的任務。二者區別,如下圖所示:
多工的切換或處理可以由多程序完成,也可以由一個程序內的多執行緒完成。
程序(process)是資源分配的最小單位,一個程式至少有一個程序,程序都有自己獨立的地址空間,記憶體,資料棧等,所以程序佔用資源多。由於程序的資源獨立,所以通訊不方便,只能使用程序間通訊(IPC)。
執行緒(thread)是程式執行的最小單位,一個程序至少有一個執行緒。執行緒共享程序中的資料,他們使用相同的地址空間,使用執行緒建立快捷,建立開銷比程序小。同一程序下的執行緒共享全域性變數、靜態變數等資料,所以執行緒通訊非常方便,但會存在資料同步與互斥的問題,如何處理好同步與互斥是編寫多執行緒程式的難點。
二者關係如下圖所示:
在講解多執行緒和多程序之前,讓我們先回顧一下之前已經實現的一些類和相互關係:
其中,DateProcess 資料處理類尚未與主機類發生聯絡,這個我們在接下來的程式碼中會進行實現。
多執行緒
多執行緒的執行機制類似於同時啟動並執行多個不同的程式。與程序不同,每個獨立的程序都擁有一個程式的入口點、一個明確的執行序列和一個出口點。然而,執行緒則無法獨立執行,它們必須依賴於一個應用程式來提供執行控制。
每個執行緒都擁有一組自己的 CPU 暫存器,這組暫存器被稱為執行緒的上下文。上下文反映了執行緒在上次執行時 CPU 暫存器的狀態。線上程的上下文中,指令指標和堆疊指標暫存器尤為關鍵。執行緒總是在程序的上下文中執行,這些地址用於標識擁有執行緒的程序地址空間中的記憶體位置。透過此種方式,執行緒能夠協同工作,實現更高效的任務處理。
我們使用多執行緒前需要匯入 Python 中的 threading 模組:
from threading import Thread
多執行緒的建立和執行
Python 中使用執行緒有兩種方式,函式或者用類來包裝執行緒物件:
- 函式方法:呼叫 thread 模組中的 Thread 函式來建立一個執行緒例項。
語法如下:
- 用類來包裝執行緒物件:使用 Threading 模組建立執行緒,直接從 threading.Thread 繼承,然後重寫 init 方法和 run 方法。
這裡,我們對 SensorClass 感測器類實現多執行緒執行,首先使其繼承於 Thread 類,在 SensorClass 的初始化方法中呼叫 Thread 的初始化方法,同時新建一個 run()方法,在 run()方法中先完成感測器的初始化和開啟,再建立一個 while 迴圈,每次迴圈都會生成一個新的資料值(模擬採集的感測器資料),並根據接收到的命令執行相應的操作。示例程式碼如下:
class SensorClass(SerialClass,Thread):
'''
感測器類,繼承自SerialClass\Thread
'''
... ...
_# 類的初始化_
def __init__(self,port:str = "COM11",id:int = 0,state:int = WORK_MODE["RESPOND_MODE"]):
try:
_# 判斷輸入埠號是否為str型別_
if type(port) is not str:
raise TypeError("InvalidPortError:",port)
_# 判斷ID號是否在0~99之間_
if id < 0 or id > 99:
_# 觸發異常後,後面的程式碼就不會再執行_
_# 當傳遞給函式或方法的引數型別不正確或者引數的值不合法時,會引發此異常。_
raise InvalidIDError("InvalidIDError:",id)
_# 呼叫父類的初始化方法,super() 函式將父類和子類連線_
super().__init__(port)
self.sensorvalue = 0
self.sensorid = id
self.sensorstate = state
print("Sensor Init")
logging.info("Sensor Init")
_# Thread的初始化方法_
Thread.__init__(self)
except TypeError:
_# 當發生異常時,輸出如下語句,提醒使用者重新輸入埠號_
print("Input error com, Please try new com number")
except InvalidIDError as e:
_# 當發生異常時,輸出如下語句,提醒使用者重新輸入ID號_
print("Input error ID, Please try id : 0~99")
print(e.args)
... ...
_# 多執行緒中用以表示執行緒活動的方法_
_# run 方法中的所有程式碼(或者在這一方法內部呼叫的程式碼)都在一個單獨的執行緒中執行。_
def run(self):
_# 宣告全域性變數,互斥鎖_
global lock
_# 初始化計數變數_
data_count = 0
_# 初始化感測器_
self.InitSensor()
_# 開啟感測器_
self.StartSensor()
while True:
_# 生成資料_
data_count = data_count + 1
_# 原始訊號_
signal = math.sin(data_count) * 10
_# 模擬噪聲_
noise = random.uniform(0, 5)
_# 最終資料_
data = int(signal + noise)
_# 獲取互斥鎖_
lock.acquire()
_# 接收命令_
cmd = self.RecvMasterCMD()
_# 根據命令進行相關操作_
if cmd == SensorClass.STOP_CMD:
_# 如果接收到停止命令,停止感測器_
self.StopSensor()
_# 輸出提示資訊_
print("Sensor stop work !!!")
return
elif cmd == SensorClass.SENDID_CMD:
_# 如果接收到傳送ID命令,傳送感測器ID號_
self.SendSensorID()
elif cmd == SensorClass.SENDVALUE_CMD:
_# 如果接收到傳送資料命令,傳送資料_
self.SendSensorValue(data)
elif cmd == SensorClass.NONE_CMD:
_# 如果沒有接收到指令_
print("Not Recv cmd!!!")
_# 釋放互斥鎖_
lock.release()
_# 延時0.5s_
time.sleep(0.5)
同時,這裡我們需要修改 SensorClass 的父類 SerialClass 中的屬性,使其不會一直阻塞在資料接收的過程中:
_# 設定timeout超時時間_
self.dev.timeout = 0.5
在主程式中,建立了一個互斥鎖和一個 SensorClass 物件,並在主執行緒中開啟了一個新的執行緒來執行 SensorClass 物件的 run()方法。在主執行緒中,程式會不斷檢查新執行緒是否已經退出了 run()方法,如果沒有退出,則獲取互斥鎖並列印一條資訊,然後釋放互斥鎖並延時 0.5 秒。當新執行緒退出了 run()方法後,主執行緒會輸出一條除錯資訊表示多執行緒執行結束。
示例程式碼如下:
if __name__ == "__main__":
# 建立一個互斥鎖
lock = Lock()
# 初始化執行緒
s_thread = SensorClass(port = "COM11",id = 0,state = SensorClass.WORK_MODE["RESPOND_MODE"])
# 開啟執行緒,start方法以併發方式執行
s_thread.start()
# run()方法只是類的一個普通方法,還是在主執行緒裡執行
# s_thread.run()
# join方法確保thread子執行緒執行完畢後才能執行下一個執行緒
# timeout表示超時時間,線上程達到超時時間後結束執行緒
# s_thread.join(timeout=5)
# 檢查s_thread執行緒是否已經退出了run方法
while s_thread.is_alive():
# 獲取互斥鎖
lock.acquire()
# 列印資訊
print("Multi threaded work,This is the main thread for creating and running")
# 釋放互斥鎖
lock.release()
# 延時0.5s
time.sleep(0.5)
# 多執行緒結束,輸出除錯資訊
print("End of multi-threaded running")
接下來我們看一下執行結果:
可以看到,每個程式都有一個執行緒,稱為主執行緒。從頭開始執行的程式碼就在這個執行緒中,s_thread 是主執行緒中建立的子執行緒,新的執行緒直到我們呼叫執行緒的 start()方法時才會開始執行。我們也可以使用 join 方法,確保 thread 子執行緒執行完畢後才能執行下一個執行緒。
執行緒同步
在多執行緒環境中,當多個執行緒同時對某一資料進行修改時,可能會產生難以預測的結果。考慮一個場景,有一個列表,其中的所有元素都初始化為 0。執行緒"set"負責從後向前遍歷列表,將其中的每個元素都修改為 1,而執行緒"print"則負責從前往後遍歷列表並列印其內容。如果在這兩個執行緒的執行過程中,執行緒"set"在修改列表的過程中被執行緒"print"打斷,那麼列印出的結果可能是列表中的元素一部分為 0,另一部分為 1,這就是所謂的資料不一致問題。
為了解決這個問題,我們引入了鎖的概念。鎖有兩種狀態:鎖定和未鎖定。當一個執行緒,如"set",需要訪問共享資料時,它必須首先嚐試獲取鎖。如果鎖已經被其他執行緒,如"print",獲取,那麼執行緒"set"將被阻塞,直到執行緒"print"釋放鎖為止。這樣,確保了每次只有一個執行緒能夠訪問共享資料。經過這樣的處理,當列印列表時,要麼全部輸出 0,要麼全部輸出 1,從而避免了出現資料不一致的尷尬情況。
Python 的 Thread 物件提供了 Lock 和 Rlock 兩種鎖機制來實現執行緒同步。這兩種物件都提供了 acquire 和 release 方法。對於需要確保每次只被一個執行緒訪問的資料或共享資源,例如列印資訊到控制檯的操作,我們可以將其放置在 acquire 和 release 方法之間。這樣,我們就可以確保在多執行緒環境下,資料的一致性和完整性得到保護。
在示例程式碼中,我們建立並傳遞給了執行緒一個互斥鎖,保證兩個執行緒可以安全地呼叫 Printf 函式輸出相關資訊。
# 獲取互斥鎖
lock.acquire()
# 列印資訊
print("Multi threaded work,This is the main thread for creating and running")
# 釋放互斥鎖
lock.release()
除了互斥鎖、遞迴鎖外,執行緒也具有其他地應用於執行緒同步的方法,如訊號量、事件、欄杆等,這裡並不做過多講解。
實際上,為了有效地管理記憶體、進行垃圾回收以及在庫中呼叫機器碼,Python 擁有一個名為全域性直譯器鎖(GIL)的工具。它是無法被關閉的,也就是說,在 Python 中的多執行緒是假的多執行緒,Python 直譯器雖然可以開啟多個執行緒,但在同一時間只有一個執行緒在直譯器中執行。GIL 問題存在於大部分人使用的 Python 實現版本(如 CPython),在一些非標準實現的版本中已經解決了這一問題,例如 IronPython 和 Jython。
執行緒間的通訊
除了使用互斥鎖、遞迴鎖等執行緒同步方法保證共享記憶體不會被兩個執行緒同時訪問以外,我們也可以使用 Queue 模組保證兩個執行緒中需要互動的資料被安全訪問,Queue 模組中提供了同步的、執行緒安全的佇列類,包括 FIFO(先入先出)佇列 Queue,LIFO(後入先出)佇列 LifoQueue,和優先順序佇列 PriorityQueue。這些佇列都實現了鎖原語,能夠在多執行緒中直接使用。可以使用佇列來實現執行緒間的同步。
執行緒池
系統啟動一個新執行緒的成本相對較高,因為它需要與作業系統進行互動。在這種情況下,採用執行緒池是一種提升效能的有效方法,尤其是在程式中需要建立大量短暫生命週期的執行緒時,更應優先考慮使用執行緒池。執行緒池在系統啟動時即預先建立了大量空閒執行緒,程式只需將函式提交給執行緒池,執行緒池就會啟動一個空閒執行緒來執行該函式。當函式執行完畢後,該執行緒並不會終止,而是返回執行緒池繼續處於空閒狀態,等待執行下一個函式。
此外,執行緒池還有助於精確控制系統中併發執行緒的數量。若系統中存在大量併發執行緒,可能導致系統效能顯著下降,甚至引發 Python 直譯器崩潰。而執行緒池透過設定最大執行緒數引數,能夠有效地防止併發執行緒數量超出系統承受能力,從而確保系統的穩定執行。
從 Python3.2 開始,標準庫為我們提供了 concurrent.futures 模組,它提供了 ThreadPoolExecutor (執行緒池)和 ProcessPoolExecutor (程序池)兩個類。
相比 threading 等模組,該模組透過 submit 返回的是一個 future 物件,它是一個未來可期的物件,它們用於“呼叫並回答”型別的互動,其中處理過程可以發生在另外一個執行緒中,並且在未來某個節點我們可以向它詢問結果。
透過它可以獲取某一個執行緒執行的狀態或某一個任務執行的狀態及返回值:
① 主執行緒可以獲取某一個執行緒(或者任務的)的狀態,以及返回值。
② 當一個執行緒完成的時候,主執行緒能夠立即知道。
使用執行緒池來執行執行緒任務的步驟如下:
- 呼叫 ThreadPoolExecutor 類的構造器建立一個執行緒池;
- 定義一個普通函式作為執行緒任務;
- 呼叫 ThreadPoolExecutor 物件的 submit()方法來提交執行緒任務;
- 呼叫 ThreadPoolExecutor 物件的 shutdown()方法來關閉執行緒池。
多程序
與多執行緒相比,多程序具有獨立的記憶體空間,避免了全域性直譯器鎖(GIL)的影響,因此更適合於 CPU 密集型的任務。多程序模組透過調動新的作業系統程序來實現。在 Windows 機器上,這一操作的代價相對來說比較昂貴;在 Linux 上,程序在核心中的實現方式和執行緒一樣,因此其開支受限於每個程序中執行的 Python 直譯器。
Python 中的多程序是透過 multiprocessing 包來實現的,和多執行緒中的 threading.Thread 差不多,它可以利用 multiprocessing.Process 物件來建立一個程序物件。這個程序物件的方法和執行緒物件的方法差不多也有 start(), run(), join()等方法。Python 中多程序實現類似於上述的多執行緒實現一樣,可以使用透過類繼承的方法。