Python併發程式設計

公眾號老韓隨筆發表於2021-07-12

     正確合理地使用併發程式設計,無疑會給我們的程式帶來極大的效能提升。今天我就帶大家一起來剖析一下python的併發程式設計。這進入併發程式設計之前,我們首先需要先了解一下併發和並行的區別。

首先你需要知道,併發並不是指同一時刻有多個操作同時進行。相反,某個特定的時刻,它只允許有一個操作發生,只不過執行緒或任務之間會互相切換,直到完成。如下圖所示:

Python併發程式設計

 

     圖中出現了執行緒(thread) 和任務(task) 分別對應Python中兩種併發形式--多執行緒(threading)和協程(asyncio)。對於多執行緒來說,是由作業系統來控制執行緒切換的。而對於 asyncio來說,主程式想要切換任務時,必須得到此任務可以被切換的通知。

     對於並行來說,是指同一時刻、同時執行任務。如下圖所示:

 

Python併發程式設計

 

     python中的多程式(multi-processing)是Python中的並行的實現形式。

     對比來看,併發通常應用於I/O操作頻繁的場景,而並行通常應用於CPU負載重的場景。

 

單執行緒與多執行緒效能比較

 

    下面我們來比較一下單執行緒和多執行緒的效能區別。

    我們先看一下單執行緒版本。

import time

def process(work):
    time.sleep(2)
    print('process {}'.format(work))


def process_works(works):
    for work in works:
        process(work)

def main():
    works = [
        'work1',
        'work2',
        'work3',
        'work4'
    ]
    start_time = time.time()
    process_works(works)
    end_time = time.time()
    print('use {} seconds'.format(end_time - start_time))


if __name__ == '__main__':
    main()

##輸出##
process work1
process work2
process work3
process work4
use 8.016737222671509 seconds

  

    單執行緒是最簡單也是最直接的。

  • 先是遍歷任務列表;
  • 然後對當前任務進行操作;
  • 等到當前操作完成後,再對下一個任務進行同樣的操作,一直到結束。

    我們可以看到總共耗時約 8s。單執行緒的優點是簡單明瞭,但是明顯效率低下,因為上述程式的絕大多數時間,都浪費在了 I/O 等待上(假設time.sleep(2)是處理IO的時間)。下面我們來看一下多執行緒實現的版本。

import time
import concurrent.futures

def process(work):
    time.sleep(2)
    print('process {} '.format(work))


def process_works(works):
    with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
        executor.map(process, works)

def main():
    works = [
        'work1',
        'work2',
        'work3',
        'work4'
    ]
    start_time = time.time()
    process_works(works)
    end_time = time.time()
    print('use {} seconds'.format(end_time - start_time))


if __name__ == '__main__':
    main()


####輸出####
process work1 
process work2 
process work3 
process work4 

use 2.006268262863159 seconds

  

可以看到耗時用了2s多,一下子效率提升了4倍。我們來分析一下下面這段程式碼。
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
         executor.map(process, works)

  

   這裡我們建立了一個執行緒池,總共有4個執行緒可以分配使用。excuter.map()表示對 works 中的每一個元素,併發地呼叫函式 process()。

 

併發程式設計之Asyncio

 

    下面我們在來學習一下併發程式設計的另一種實現形式--Asyncio。Asyncio是單執行緒的,它只有一個主執行緒,但是可以執行多個不同的任務(task),這些不同的任務,被一個叫做 event loop 的物件所控制。你可以把這裡的任務,類比成多執行緒版本里的執行緒。

    為了簡化講解這個問題,我們可以假設任務只有兩個狀態:一是預備狀態;二是等待狀態。所謂的預備狀態,是指任務目前空閒,但隨時待命準備執行。而等待狀態,是指任務已經執行,但正在等待外部的操作完成,比如 I/O 操作。在這種情況下,event loop 會維護兩個任務列表,分別對應這兩種狀態;並且選取預備狀態的一個任務,使其執行,一直到這個任務把控制權交還給 event loop 為止。當任務把控制權交還給 event loop 時,event loop會根據其是否完成,把任務放到預備或等待狀態的列表,然後遍歷等待狀態列表的任務,檢視他們是否完成。如果完成,則將其放到預備狀態的列表;如果未完成,則繼續放在等待狀態的列表。而原先在預備狀態列表的任務位置仍舊不變,因為它們還未執行。這樣,當所有任務被重新放置在合適的列表後,新一輪的迴圈又開始了:event loop 繼續從預備狀態的列表中選取一個任務使其執行…如此周而復始,直到所有任務完成。

     接下來我們看一下如何通過Asyncio來實現併發程式設計。

import asyncio
import time


async def process(work):
    await asyncio.sleep(2)
    print('process {}'.format(work))



async def process_works(works):
    tasks = [asyncio.create_task(process(work)) for work in works]
    await asyncio.gather(*tasks)


def main():
    works = [
                'work1',
                'work2',
                'work3',
                'work4'
            ]
    start_time = time.time()
    asyncio.run(process_works(works))
    end_time = time.time()
    print('use {} seconds'.format(end_time - start_time))


if __name__ == '__main__':
    main()
    
####輸出####
process work1
process work2
process work3
process work4
use 2.0058629512786865 seconds

  

    到此為止,我們已經把python的兩種併發程式設計方式多執行緒和Asyncio都講完了。不過,遇到實際問題時,我們該如何進行選擇呢?總的來說我們應該遵循以下規範。

  • 如何I/O負載高,並且I/O操作很慢,需要很多工/執行緒協同實現,那麼使用 Asyncio 更合適。
  • 如何I/O負載高,並且I/O操作很快,只需要有限數量的任務/執行緒,那麼使用多執行緒就可以了。

歡迎大家留言和我交流。

需要更多知識,請關注公眾號,老韓隨筆。

相關文章