一文講透“程序,線程和協程” 本文從作業系統原理出發結合代碼實踐講解了以下内容:
什麼是程序,線程和協程? 它們之間的關系是什麼? 為什麼說Python中的多線程是僞多線程? 不同的應用場景該如何選擇技術方案? ...
什麼是程序
程序-作業系統提供的抽象概念,是系統進行資源配置設定和排程的基本機關,是作業系統結構的基礎。程式是指令、資料及其組織形式的描述,程序是程式的實體。程式本身是沒有生命周期的,它隻是存在磁盤上的一些指令,程式一旦運作就是程序。
當程式需要運作時,作業系統将代碼和所有靜态資料記載到記憶體和程序的位址空間(每個程序都擁有唯一的位址空間,見下圖所示)中,通過建立和初始化棧(局部變量,函數參數和傳回位址)、配置設定堆記憶體以及與IO相關的任務,目前期準備工作完成,啟動程式,OS将CPU的控制權轉移到新建立的程序,程序開始運作。

作業系統對程序的控制和管理通過PCB(Processing Control Block),PCB通常是系統記憶體占用區中的一個連續存區,它存放着作業系統用于描述程序情況及控制程序運作所需的全部資訊(包括:程序辨別号,程序狀态,程序優先級,檔案系統指針以及各個寄存器的内容等),程序的PCB是系統感覺程序的唯一實體。
一個程序至少具有5種基本狀态:初始态、就緒狀态、等待(阻塞)狀态、執行狀态、終止狀态。
初始狀态:程序剛被建立,由于其他程序正占有CPU資源,是以得不到執行,隻能處于初始狀态。 就緒狀态:隻有處于就緒狀态的經過排程才能到執行狀态 等待狀态:程序等待某件事件完成 執行狀态:任意時刻處于執行狀态的程序隻能有一個(對于單核CPU來講)。 停止狀态:程序結束
程序間的切換
無論是在多核還是單核系統中,一個CPU看上去都像是在并發的執行多個程序,這是通過處理器在程序間切換來實作的。 作業系統對把CPU控制權在不同程序之間交換執行的機制稱為上下文切換(context switch),即儲存目前程序的上下文,恢複新程序的上下文,然後将CPU控制權轉移到新程序,新程序就會從上次停止的地方開始。是以,程序是輪流使用CPU的,CPU被若幹程序共享,使用某種排程算法來決定何時停止一個程序,并轉而為另一個程序提供服務。
單核CPU雙程序的情況
程序根據特定的排程機制和遇到I/O中斷等情況下,進行上下文切換,輪流使用CPU資源
雙核CPU雙程序的情況
每一個程序獨占一個CPU核心資源,在處理I/O請求的時候,CPU處于阻塞狀态
程序間資料共享
系統中的程序與其他程序共享CPU和主存資源,為了更好的管理主存,作業系統提供了一種對主存的抽象概念,即為虛拟存儲器(VM)。它也是一個抽象的概念,它為每一個程序提供了一個假象,即每個程序都在獨占地使用主存。
虛拟存儲器主要提供了三個能力:
将主存看成是一個存儲在磁盤上的高速緩存,在主存中隻儲存活動區域,并根據需要在磁盤和主存之間來回傳送資料,通過這種方式,更高效地使用主存 為每個程序提供一緻的位址空間,進而簡化存儲器管理 保護每個程序的位址空間不被其他程序破壞 由于程序擁有自己獨占的虛拟位址空間,CPU通過位址翻譯将虛拟位址轉換成真實的實體位址,每個程序隻能通路自己的位址空間。是以,在沒有其他機制(程序間通信)的輔助下,程序之間是無法共享資料的
以python中多程序(multiprocessing)為例:
import multiprocessing
import threading
import time
n = 0
def count(num):
global n
for i in range(100000):
n += i
print("Process {0}:n={1},id(n)={2}".format(num, n, id(n)))
if __name__ == '__main__':
start_time = time.time()
process = list()
for i in range(5):
p = multiprocessing.Process(target=count, args=(i,)) # 測試多程序使用
# p = threading.Thread(target=count, args=(i,)) # 測試多線程使用
process.append(p)
for p in process:
p.start()
for p in process:
p.join()
print("Main:n={0},id(n)={1}".format(n, id(n)))
end_time = time.time()
print("Total time:{0}".format(end_time - start_time))
結果
Process 1:n=4999950000,id(n)=139854202072440
Process 0:n=4999950000,id(n)=139854329146064
Process 2:n=4999950000,id(n)=139854202072400
Process 4:n=4999950000,id(n)=139854201618960
Process 3:n=4999950000,id(n)=139854202069320
Main:n=0,id(n)=9462720
Total time:0.03138256072998047
變量n在程序p{0,1,2,3,4}和主程序(main)中均擁有唯一的位址空間
什麼是線程
線程-也是作業系統提供的抽象概念,是程式執行中一個單一的順序控制流程,是程式執行流的最小單元,是處理器排程和分派的基本機關。一個程序可以有一個或多個線程,同一程序中的多個線程将共享該程序中的全部系統資源,如虛拟位址空間,檔案描述符和信号處理等等。但同一程序中的多個線程有各自的調用棧和線程本地存儲(如下圖所示)。
系統利用PCB來完成對程序的控制和管理。同樣,系統為線程配置設定一個線程控制塊TCB(Thread Control Block),将所有用于控制和管理線程的資訊記錄線上程的控制塊中,TCB中通常包括:
線程标志符 一組寄存器 線程運作狀态 優先級 線程專有存儲區 信号屏蔽
和程序一樣,線程同樣至少具有五種狀态:初始态、就緒狀态、等待(阻塞)狀态、執行狀态和終止狀态
線程之間的切換和程序一樣也需要上下文切換,這裡不再贅述。
程序和線程之間有許多相似的地方,那它們之間到底有什麼差別呢? 程序 VS 線程
程序是資源的配置設定和排程的獨立單元。程序擁有完整的虛拟位址空間,當發生程序切換時,不同的程序擁有不同的虛拟位址空間。而同一程序的多個線程共享同一位址空間(不同程序之間的線程無法共享) 線程是CPU排程的基本單元,一個程序包含若幹線程(至少一個線程)。 線程比程序小,基本上不擁有系統資源。線程的建立和銷毀所需要的時間比程序小很多 由于線程之間能夠共享位址空間,是以,需要考慮同步和互斥操作 一個線程的意外終止會影響整個程序的正常運作,但是一個程序的意外終止不會影響其他的程序的運作。是以,多程序程式安全性更高。 總之,多程序程式安全性高,程序切換開銷大,效率低;多線程程式維護成本高,線程切換開銷小,效率高。(python的多線程是僞多線程,下文中将詳細介紹)
什麼是協程
協程(Coroutine,又稱微線程)是一種比線程更加輕量級的存在,協程不是被作業系統核心所管理,而完全是由程式所控制。協程與線程以及程序的關系見下圖所示。
協程可以比作子程式,但執行過程中,子程式内部可中斷,然後轉而執行别的子程式,在适當的時候再傳回來繼續執行。協程之間的切換不需要涉及任何系統調用或任何阻塞調用 協程隻在一個線程中執行,是子程式之間的切換,發生在使用者态上。而且,線程的阻塞狀态是由作業系統核心來完成,發生在核心态上,是以協程相比線程節省了線程建立和切換的開銷 協程中不存在同時寫變量沖突,是以,也就不需要用來守衛關鍵區塊的同步性原語,比如互斥鎖、信号量等,并且不需要來自作業系統的支援。
協程适用于IO阻塞且需要大量并發的場景,當發生IO阻塞,由協程的排程器進行排程,通過将資料流yield掉,并且記錄目前棧上的資料,阻塞完後立刻再通過線程恢複協程棧,并把阻塞的結果放到這個線程上去運作。
下面,将針對在不同的應用場景中如何選擇使用Python中的程序,線程,協程進行分析。
如何選擇?
在針對不同的場景對比三者的差別之前,首先需要介紹一下python的多線程(一直被程式員所诟病,認為是"假的"多線程)。
那為什麼認為Python中的多線程是“僞”多線程呢?
更換上面multiprocessing示例中, p = multiprocessing.Process(target=count, args=(i,))為p = threading.Thread(target=count, args=(i,)),其他代碼不變,運作結果如下:
為了減少代碼備援和文章篇幅,命名和列印不規則問題請忽略
Process 0:n=5756690257,id(n)=140103573185600
Process 2:n=10819616173,id(n)=140103573185600
Process 1:n=11829507727,id(n)=140103573185600
Process 4:n=17812587459,id(n)=140103573072912
Process 3:n=14424763612,id(n)=140103573185600
Main:n=17812587459,id(n)=140103573072912
Total time:0.1056210994720459
n是全局變量,Main的列印結果與線程相等,證明了線程之間是資料共享
但是,為什麼多線程運作時間比多程序還要長?這與我們上面所說(線程的開銷<<程序的開銷)的事實嚴重不相符。這就要輪到Cpython(python的預設解釋器)中GIL(Global Interpreter Lock,全局解釋鎖)登場了。
什麼是GIL
GIL來源于Python設計之初的考慮,為了資料安全(由于記憶體管理機制中采用引用計數)所做的決定。某個線程想要執行,必須先拿到 GIL。是以,可以把 GIL 看作是“通行證”,并且在一個 Python程序中,GIL 隻有一個,拿不到通行證的線程,就不允許進入 CPU 執行。 Cpython解釋器在記憶體管理中采用引用計數,當對象的引用次數為0時,會将對象當作垃圾進行回收。(有關Python記憶體管理機制的相關内容可以參見面試必備:Python記憶體管理機制)設想這樣一種場景:
一個程序中含有兩個線程,分别為線程0和線程1,兩個線程全都引用對象a。
當兩個線程同時對a發生引用(并未修改,不需要使用同步性原語),就會發生同時修改對象a的引用計數器,造成引用計數少于實質性的引用,當進行垃圾回收時,造成記憶體異常錯誤。是以,需要一把全局鎖(即為GIL)來保證對象引用計數的正确性和安全性。
無論是單核還是多核,一個程序永遠隻能同時執行一個線程(拿到 GIL 的線程才能執行,如下圖所示),這就是為什麼在多核CPU上,Python 的多線程性能不高的根本原因。
那是不是在Python中遇到并發的需求就使用多程序就萬事大吉了?其實不然,軟體工程中有一句名言:沒有銀彈!
何時用?
常見的應用場景不外乎三種:
CPU密集型:程式需要占用CPU進行大量的運算和資料處理; I/O密集型:程式中需要頻繁的進行I/O操作;例如網絡中socket資料傳輸和讀取等; CPU密集+I/O密集:以上兩種的結合 CPU密集型的情況可以對比上面Python中multiprocessing和threading的例子:多程序的性能 > 多線程的性能。
下面主要解釋一下I/O密集型的情況。與I/O裝置互動,作業系統最常用的解決方案就是DMA。
什麼是DMA
DMA(Direct Memory Access)是系統中的一個特殊裝置,它可以協調完成記憶體到裝置間的資料傳輸,中間過程不需要CPU介入。 以檔案寫入為例:
程序p1發出資料寫入磁盤檔案的請求 CPU處理寫入請求,通過程式設計告訴DMA引擎資料在記憶體的位置,要寫入資料的大小以及目标裝置等資訊 CPU處理其他程序p2的請求,DMA負責将記憶體資料寫入到裝置中 DMA完成資料傳輸,中斷CPU CPU從p2上下文切換到p1,繼續執行p1
Python多線程的表現(I/O密集型)
線程Thread0首先執行,線程Thread1等待(GIL的存在) Thread0收到I/O請求,将請求轉發給DMA,DMA執行請求 Thread1占用CPU資源,繼續執行 CPU收到DMA的中斷請求,切換到Thread0繼續執行
與程序的執行模式相似,彌補了GIL帶來的缺陷,又由于線程的開銷遠遠小于程序的開銷,是以,在IO密集型場景中,多線程的性能更高
實踐是檢驗真理的唯一标準,下面将針對I/O密集型場景進行測試。
測試
執行代碼
import multiprocessing
import threading
import time
def count(num):
time.sleep(1) ## 模拟IO操作
print("Process {0} End".format(num))
if __name__ == '__main__':
start_time = time.time()
process = list()
for i in range(5):
p = multiprocessing.Process(target=count, args=(i,))
# p = threading.Thread(target=count, args=(i,))
process.append(p)
for p in process:
p.start()
for p in process:
p.join()
end_time = time.time()
print("Total time:{0}".format(end_time - start_time))
結果
多程序
Process 0 End
Process 3 End
Process 4 End
Process 2 End
Process 1 End
Total time:1.383193016052246
## 多線程
Process 0 End
Process 4 End
Process 3 End
Process 1 End
Process 2 End
Total time:1.003425121307373
多線程的執行效性能高于多程序 正如上面所述,針對I/O密集型的程式,協程的執行效率更高,因為它是程式自身所控制的,這樣将節省線程建立和切換所帶來的開銷。
以Python中asyncio并發代碼庫為依賴,使用async/await文法進行協程的建立和使用。 程式代碼
import time
import asyncio
async def coroutine():
await asyncio.sleep(1) ## 模拟IO操作
if __name__ == "__main__":
start_time = time.time()
loop = asyncio.get_event_loop()
tasks = []
for i in range(5):
task = loop.create_task(coroutine())
tasks.append(task)
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
end_time = time.time()
print("total time:", end_time - start_time)
結果
total time: 1.001854419708252
協程的執行效性能高于多線程
總結
本文從作業系統原理出發結合代碼實踐講解了程序,線程和協程以及他們之間的關系。并且,總結和整理了Python實踐中針對不同的場景如何選擇對應的方案,歸結如下:
CPU密集型: 多程序 IO密集型: 多線程(協程維護成本較高,而且在讀寫檔案方面效率沒有顯著提升) CPU密集和IO密集: 多程序+協程
※更多文章和資料|點選後方文字直達 ↓↓↓ 100GPython自學資料包 阿裡雲K8s實戰手冊 [阿裡雲CDN排坑指南]CDN ECS運維指南 DevOps實踐手冊 Hadoop大資料實戰手冊 Knative雲原生應用開發指南 OSS 運維實戰手冊 雲原生架構白皮書 Zabbix企業級分布式監控系統源碼文檔 Linux&Python自學資料包 10G面試題戳領