Python 多進程與多線程

進程與線程

進程(Process)和線程(Thread)都是操作系統中的基本概念,它們之間有一些優劣和差異。

1. 進程

進程是程序執行時的一個實例,是系統進行資源分配的基本單位
所有與該進程有關的資源,都被記錄在進程控制塊(PCB)中。以表示該進程擁有這些資源或正在使用它們。
另外,進程也是搶占處理機的調度單位,它擁有一個完整的虛擬地址空間。
當進程發生調度時,不同的進程擁有不同的虛擬地址空間,而同一進程內的不同線程共享同一地址空間。
進程可以通過fork或spawn的方式來創建新的進程來執行其他的任務,不過新的進程也有自己獨立的內存空間,因此必須通過進程間通信機制(IPC,Inter-Process Communication)來實現數據共享,具體的方式包括管道、信號、套接字、共享內存區等。

2. 線程

線程,是進程中的一個實體,是被系統獨立調度和分派的基本單位
與進程不同,線程與資源分配無關,線程自己不擁有系統資源,它屬于某一個進程,并與進程內的其他線程一起共享進程的資源。
由于線程在同一個進程下,它們可以共享相同的上下文,因此相對于進程而言,線程間的信息共享和通信更加容易。
線程只由相關堆棧(系統棧或用戶棧)寄存器和線程控制表TCB組成。

3. 進程與線程的關系

通常在一個進程中可以包含若干個線程,它們可以利用進程所擁有的資源。
但是,一個線程只屬于一個進程。
進程間相互獨立,同一進程的各線程間共享。某進程內的線程在其它進程不可見。
而且需要注意的是,線程不是一個可執行的實體。

進程與線程.png

4. 進程和線程的比較

進行和線程之間的差異可以從下面幾個方面來闡述:

  1. 調度 :
    在引入線程的操作系統中,線程是調度和分配的基本單位 ,進程是資源擁有的基本單位 。把傳統進程的兩個屬性分開,線程便能輕裝運行,從而可顯著地提高系統的并發程度。在同一進程中,線程的切換不會引起進程的切換;在由一個進程中的線程切換到另一個進程中的線程時,才會引起進程的切換。

  2. 并發性 :
    在引入線程的操作系統中,不僅進程之間可以并發執行,而且在一個進程中的多個線程之間亦可并發執行,因而使操作系統具有更好的并發性,從而能更有效地使用系統資源和提高系統吞吐量

  3. 擁有資源 :
    不論是傳統的操作系統,還是設有線程的操作系統,進程都是擁有資源的一個獨立 單位,它可以擁有自己的資源。一般地說,線程自己不擁有系統資源(只有一些必不可少的資源,但它可以訪問其隸屬進程的資源。

  4. 系統開銷:
    由于在創建或撤消進程時,系統都要為之分配或回收資源,因此,操作系統所付出的開銷將顯著地大于在創建或撤消線程時的開銷。進程切換的開銷也遠大于線程切換的開銷

  5. 通信:
    進程間通信IPC,線程間可以直接讀寫進程數據段(如全局變量)來進行通信——需要進程同步和互斥手段的輔助,以保證數據的一致性,因此共享簡單。但是線程的數據同步要比進程略復雜。

python 中的多進程 multiprocessing

Linux操作系統上提供了fork()系統調用來創建進程,調用fork()函數的是父進程,創建出的是子進程,子進程是父進程的一個拷貝,但是子進程擁有自己的PID。fork()函數非常特殊它會返回兩次,父進程中可以通過fork()函數的返回值得到子進程的PID,而子進程中的返回值永遠都是0。
Python的os模塊提供了fork()函數。由于Windows系統沒有fork()調用,因此要實現跨平臺的多進程編程,可以使用multiprocessing模塊的Process類來創建子進程,而且該模塊還提供了更高級的封裝,例如批量啟動進程的進程池(Pool)、用于進程間通信的隊列(Queue)和管道(Pipe)等。

# 非多進程下載
import random
import time
def download(file):
    download_time= random.randint(1,10)
    time.sleep(download_time)
    print('%s下載完成! 耗費了%d秒' % (file, download_time))

def main():
    # 非多進程,按代碼順序執行download函數
    start=time.time()
    download('1hello')
    download('2python')
    end=time.time()
    print('總共耗費了%.2f秒.' % (end - start))

main()
1hello下載完成! 耗費了8秒
2python下載完成! 耗費了7秒
總共耗費了15.01秒.

從上面的例子可以看出,如果程序中的代碼只能按順序一點點的往下執行,那么即使執行兩個毫不相關的下載任務,也需要先等待一個文件下載完成后才能開始下一個下載任務,很顯然這并不合理也沒有效率。
接下來我們使用多進程的方式將兩個下載任務放到不同的進程中,代碼如下所示。

# 多進程下載
import random
import time
from multiprocessing import Process
from os import getpid

def download(file):
    print('啟動下載進程,進程號[%d].' % getpid())
    download_time= random.randint(1,10)
    time.sleep(download_time)
    print('%s下載完成! 耗費了%d秒' % (file, download_time))

def main1():
    start=time.time()
    pid1= Process(target = download , args=('1hello',))
    pid1.start()
    pid2= Process(target = download , args=('2python',))
    pid2.start()
    pid1.join()
    pid2.join()
    end=time.time()
    print('總共耗費了%.2f秒.' % (end - start))

if __name__ == '__main__':
    main1()
啟動下載進程1hello,進程號[10304].  
1hello下載完成! 耗費了2秒  
2python下載完成! 耗費了9秒  
總共耗費了9.55秒.

在上面的代碼中,我們通過Process類創建了進程對象,
通過target參數我們傳入一個函數來表示進程啟動后要執行的代碼,
后面的args是一個元組,它代表了傳遞給函數的參數。
Process對象的start方法用來啟動進程,而join方法表示等待進程執行結束。
運行上面的代碼可以明顯發現兩個下載任務“同時”啟動了,而且程序的執行時間將大大縮短,不再是兩個任務的時間總和。下面是程序的一次執行結果。

運行時,系統里面存在此進程

由圖可知,運行時系統里面存在此進程


multiprocessing.Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

  • target 是函數名字,需要調用的函數
  • args 函數需要的位置參數,以 tuple 的形式傳入。args=(1,2,'justin',)
  • kwargs 函數需要的命名參數,以 dict 的形式傳入。kwargs={'name':'jack','age':18}
  • name 子進程的名稱,默認為None
  • group 參數未使用,默認為None

方法:

  • star() 方法啟動進程
  • join() 方法實現進程間的同步,等待所有進程退出。

python 中的多線程threading

目前的多線程開發我們推薦使用threading模塊,該模塊對多線程編程提供了更好的面向對象的封裝。我們把剛才下載文件的例子用多線程的方式來實現一遍。

# 多線程下載
from threading import Thread
import time
import random

def download(file):
    print('開始下載[%s].' % file)
    download_time= random.randint(1,10)
    time.sleep(download_time)
    print('%s下載完成! 耗費了%d秒' % (file, download_time))

def main():
    start=time.time()
    t1= Thread(target = download , args=('1hello',))
    t1.start()
    t2=Thread(target = download , args=('2python',))
    t2.start()
    t1.join()
    t2.join()
    end=time.time()
    print('總共耗費了%.3f秒' % (end - start))

if __name__ == '__main__':
    main()
    
開始下載[1hello].
開始下載[2python].
2python下載完成! 耗費了4秒
1hello下載完成! 耗費了6秒
總共耗費了6.006秒

1. 線程鎖Lock

因為多個線程可以共享進程的內存空間,因此要實現多個線程間的通信相對簡單,大家能想到的最直接的辦法就是設置一個全局變量,多個線程共享這個全局變量即可。
但是當多個線程共享同一個變量(我們通常稱之為“資源”)的時候,很有可能產生不可控的結果從而導致程序失效甚至崩潰。
如果一個資源被多個線程競爭使用,那么我們通常稱之為“臨界資源”,對“臨界資源”的訪問需要加上保護,否則資源會處于“混亂”的狀態。
下面的例子演示了100個線程向同一個銀行賬戶轉賬(轉入1元錢)的場景,在這個例子中,銀行賬戶就是一個臨界資源,在沒有保護的情況下我們很有可能會得到錯誤的結果。

from time import sleep
from threading import Thread

class Account():
    """
    類:賬戶管理
    """
    def __init__(self):
        self._balance=0
        
    def deposit(self,money):
        """
        計算存款后的余額
        """
        new_balance = self._balance + money
        sleep(0.01)
        self._balance = new_balance
    
    @property
    def balance(self):
        return self._balance

class AddMoney(Thread):
    """
    繼承線程類: 存錢操作
    """
    def __init__(self, account, money):
        super().__init__()
        self._account = account
        self._money = money
    
    def run(self):
        self._account.deposit(self._money)

def main():
    account = Account()
    thread_pool = []
    # 啟動100個線程,并存1元錢
    for _ in range(100):
        t = AddMoney(account,1)
        thread_pool.append(t)
        t.start()
    
    for t in thread_pool:
        t.join()
    
    print('賬戶余額為:%d 元'% account.balance)

if __name__=='__main__':
    main()
賬戶余額為:3 元

運行上面程序會發現100個線程存錢后,賬戶里面的錢是小于100元的。
多個線程同時向賬戶中存錢時,會一起執行到new_balance = self._balance + money 這行代碼,多個線程得到的賬戶余額都是初始狀態下的0,所以都是0上面做了+1的操作,因此得到了錯誤的結果。
在這種情況下,“鎖”就可以派上用場了。我們可以通過“鎖”來保護“臨界資源”,只有獲得“鎖”的線程才能訪問“臨界資源”,而其他沒有得到“鎖”的線程只能被阻塞起來,直到獲得“鎖”的線程釋放了“鎖”,其他線程才有機會獲得“鎖”,進而訪問被保護的“臨界資源”。下面的代碼演示了如何使用“鎖”來保護對銀行賬戶的操作,從而獲得正確的結果。

from threading import Lock

class AccountWithLock(object):

    def __init__(self):
        self._balance = 0
        self._lock = Lock()

    def deposit(self, money):
        # 先獲取鎖才能執行后續的代碼
        self._lock.acquire()
        try:
            new_balance = self._balance + money
            sleep(0.01)
            self._balance = new_balance
        finally:
            # 在finally中執行釋放鎖的操作保證正常異常鎖都能釋放
            self._lock.release()

    @property
    def balance(self):
        return self._balance

def mainLock():
    account = AccountWithLock()
    thread_pool = []
    # 啟動100個線程,并存1元錢
    for _ in range(100):
        t = AddMoney(account,1)
        thread_pool.append(t)
        t.start()
    
    for t in thread_pool:
        t.join()
    
    print('賬戶余額為:%d 元'% account.balance)

if __name__=='__main__':
    mainLock()
賬戶余額為:100 元

2. GIL 全局解釋器鎖 (面試常考)

比較遺憾的一件事情是Python的多線程并不能發揮CPU的多核特性,這一點只要啟動幾個執行死循環的線程就可以得到證實了。
之所以如此,是因為Python的解釋器有一個“全局解釋器鎖”(GIL)的東西,任何線程執行前必須先獲得GIL鎖,然后每執行100條字節碼,解釋器就自動釋放GIL鎖,讓別的線程有機會執行,這是一個歷史遺留問題,但是即便如此,就如我們之前舉的例子,使用多線程在提升執行效率和改善用戶體驗方面仍然是有積極意義的。

多進程還是多線程選擇?

無論是多進程還是多線程,只要數量一多,效率肯定上不去,為什么呢?
我們打個比方,假設你不幸正在準備中考,每天晚上需要做語文、數學、英語、物理、化學這5科的作業,每項作業耗時1小時。如果你先花1小時做語文作業,做完了,再花1小時做數學作業,這樣,依次全部做完,一共花5小時,這種方式稱為單任務模型。如果你打算切換到多任務模型,可以先做1分鐘語文,再切換到數學作業,做1分鐘,再切換到英語,以此類推,只要切換速度足夠快,這種方式就和單核CPU執行多任務是一樣的了,以旁觀者的角度來看,你就正在同時寫5科作業。

但是,切換作業是有代價的,比如從語文切到數學,要先收拾桌子上的語文書本、鋼筆(這叫保存現場),然后,打開數學課本、找出圓規直尺(這叫準備新環境),才能開始做數學作業。操作系統在切換進程或者線程時也是一樣的,它需要先保存當前執行的現場環境(CPU寄存器狀態、內存頁等),然后,把新任務的執行環境準備好(恢復上次的寄存器狀態,切換內存頁等),才能開始執行。這個切換過程雖然很快,但是也需要耗費時間。如果有幾千個任務同時進行,操作系統可能就主要忙著切換任務,根本沒有多少時間去執行任務了,這種情況最常見的就是硬盤狂響,點窗口無反應,系統處于假死狀態。所以,多任務一旦多到一個限度,反而會使得系統性能急劇下降,最終導致所有任務都做不好。


可以把任務分為計算密集型和I/O密集型。
計算密集型任務的特點是要進行大量的計算,消耗CPU資源,比如對視頻進行編碼解碼或者格式轉換等等,這種任務全靠CPU的運算能力,雖然也可以用多任務完成,但是任務越多,花在任務切換的時間就越多,CPU執行任務的效率就越低。計算密集型任務由于主要消耗CPU資源,這類任務用Python這樣的腳本語言去執行效率通常很低,最能勝任這類任務的是C語言,我們之前提到了Python中有嵌入C/C++代碼的機制。此類任務一般適合多進程架構。

除了計算密集型任務,其他的涉及到網絡、存儲介質I/O的任務都可以視為I/O密集型任務,這類任務的特點是CPU消耗很少,任務的大部分時間都在等待I/O操作完成(因為I/O的速度遠遠低于CPU和內存的速度)。對于I/O密集型任務,如果啟動多任務,就可以減少I/O等待時間從而讓CPU高效率的運轉。此類任務一般適合多線程架構。


多進程模式最大的優點就是穩定性高,因為一個子進程崩潰了,不會影響主進程和其他子進程。(當然主進程掛了所有進程就全掛了,但是Master進程只負責分配任務,掛掉的概率低)著名的Apache最早就是采用多進程模式。

多進程模式的缺點是創建進程的代價大,在Unix/Linux系統下,用fork調用還行,在Windows下創建進程開銷巨大。另外,操作系統能同時運行的進程數也是有限的,在內存和CPU的限制下,如果有幾千個進程同時運行,操作系統連調度都會成問題。

多線程模式通常比多進程快一點,但是也快不到哪去,而且,多線程模式致命的缺點就是任何一個線程掛掉都可能直接造成整個進程崩潰,因為所有線程共享進程的內存。在Windows上,如果一個線程執行的代碼出了問題,你經常可以看到這樣的提示:“該程序執行了非法操作,即將關閉”,其實往往是某個線程出了問題,但是操作系統會強制結束整個進程。

在Windows下,多線程的效率比多進程要高,所以微軟的IIS服務器默認采用多線程模式。由于多線程存在穩定性的問題,IIS的穩定性就不如Apache。為了緩解這個問題,IIS和Apache現在又有多進程+多線程的混合模式,真是把問題越搞越復雜。

異步IO

考慮到CPU和IO之間巨大的速度差異,一個任務在執行的過程中大部分時間都在等待IO操作,單進程單線程模型會導致別的任務無法并行執行,因此,我們才需要多進程模型或者多線程模型來支持多任務并發執行。

現代操作系統對IO操作已經做了巨大的改進,最大的特點就是支持異步IO。如果充分利用操作系統提供的異步IO支持,就可以用單進程單線程模型來執行多任務,這種全新的模型稱為事件驅動模型,Nginx就是支持異步IO的Web服務器,它在單核CPU上采用單進程模型就可以高效地支持多任務。在多核CPU上,可以運行多個進程(數量與CPU核心數相同),充分利用多核CPU。由于系統總的進程數量十分有限,因此操作系統調度非常高效。用異步IO編程模型來實現多任務是一個主要的趨勢。

對應到Python語言,單線程的異步編程模型稱為協程,有了協程的支持,就可以基于事件驅動編寫高效的多任務程序。我們會在后面討論如何編寫協程。

本文部分內容來自于廖雪峰官方網站的《Python教程》

微信關注.png

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容