Python協程你學會了嗎?

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

在學習協程之前,你需要先知道協程是什麼?協程又稱為微執行緒,一個程式可以包含多個協程,可以對比與一個程式包含多個執行緒,因而下面我們來比較協程和執行緒。我們知道多個執行緒相對獨立,有自己的上下文,切換受系統控制;而協程也相對獨立,有自己的上下文,但是其切換由自己控制。    協程是一個執行緒執行,兩個子過程通過相互協作完成某個任務。協程和子程式呼叫很像,但協程是在子程式內部中斷去執行別的子程式,適當時候返回接著執行,中斷有別於函式呼叫。

       好了,廢話不多說,我們直接上例項,結合實戰來搞懂這個不算特別容易理解的概念。之後,我們再由淺入深,直擊協的核心。

      我們先看一個簡單的爬蟲例子:

import time

def crawl_page(url):
    print('crawling {}'.format(url))
    time.sleep(2)
    print('OK {}'.format(url))


a=['url1', 'url2', 'url3', 'url4']
start=time.time()
for url in a:
    crawl_page(url)
end=time.time()
print('use {}'.format(end-start))

###輸出
crawling url1
OK url1
crawling url2
OK url2
crawling url3
OK url3
crawling url4
OK url4
use 8.007500886917114

    這是一個很簡單的爬蟲函式執行時,調取 crawl_page() 函式進行網路通訊,經過2秒等待後收到結果,然後執行下一個。4個url抓取耗費了8s的時間。下面我們來看看協程的實現。

import asyncio

async def crawl_page(url):
    print('crawling {}'.format(url))
    await asyncio.sleep(2)
    print('OK {}'.format(url))

async def run(urls):
    for url in urls:
        await crawl_page(url)

start=time.time()
asyncio.run(run(['url1', 'url2', 'url3', 'url4']))
end=time.time()
print('use {}s'.format(end-start))

####輸出
crawling url1
OK url1
crawling url2
OK url2
crawling url3
OK url3
crawling url4
OK url4
use 8.012088060379028s

  

 也許你會發現,怎麼還是耗時8s,和順序執行沒任何區別啊,這什麼玩意。你說對了,我們先帶著這個疑問繼續往下看。在講解之前,我們先分析一下這個程式碼,首先來看 import asyncio(注意是在python3.7版本以上才可以哦),這個庫包含了大部分我們實現協程所需的魔法工具。

     async 修飾詞宣告非同步函式,於是,這裡的 crawl_page 和 run 都變成了非同步函式。而呼叫非同步函式,我們便可得到一個協程物件(coroutine object)。

     我們再來說說協程的執行。這裡我介紹一下常用的三種。

  • 我們可以通過 await 來呼叫。await 執行的效果,和 Python 正常執行是一樣的,也就是說程式會阻塞在這裡,進入被呼叫的協程函式,執行完畢返回後再繼續,而這也是 await 的字面意思。程式碼中 await asyncio.sleep(sleep_time) 會在這裡休息若干秒,await crawl_page(url) 則會執行 crawl_page() 函式。

  • 我們可以通過 asyncio.create_task() 來建立任務,這個我們下節課會詳細講一下,你先簡單知道即可。

  • 我們需要 asyncio.run 來觸發執行。asyncio.run 這個函式是 Python 3.7 之後才有的特性,可以讓 Python 的協程介面變得非常簡單,你不用去理會事件迴圈怎麼定義和怎麼使用的問題(我們會在下面講)。

     

    到此,我們應該可以看懂上面的程式碼了吧,不懂也沒關係,我們繼續分析。還記得上面await 是同步呼叫,因此, crawl_page(url) 在當前的呼叫結束之前,是不會觸發下一次呼叫的。於是,這個程式碼效果就和上面完全一樣了,相當於我們用非同步介面寫了個同步程式碼。我們接下來就來真正寫一個非同步的程式碼。

 

import asyncio

async def crawl_page(url):
    print('crawling {}'.format(url))
    await asyncio.sleep(2)
    print('OK {}'.format(url))

async def run(urls):
    tasks = []
    for url in urls:
        tasks.append(asyncio.create_task(crawl_page(url)))

    for task in tasks:
        await task

start=time.time()
urls=['url1', 'url2', 'url3', 'url4']
asyncio.run(run(urls))
end=time.time()
print('use {}s'.format(end-start))

####輸出#####
crawling url1
crawling url2
crawling url3
crawling url4
OK url1
OK url2
OK url3
OK url4
use 2.0025851726531982s

  

你可以看到,我們有了協程物件後,便可以通過 asyncio.create_task 來建立任務。任務建立後很快就會被排程執行,這樣,我們的程式碼也不會阻塞在任務這裡。所以,我們要等所有任務都結束才行,用for task in tasks: await task 即可。

    下面我們來深入分析一下協程的執行過程。

import asyncio

async def worker_1():
    print('worker_1 start')
    await asyncio.sleep(1)
    print('worker_1 done')

async def worker_2():
    print('worker_2 start')
    await asyncio.sleep(2)
    print('worker_2 done')

async def run():
    task1 = asyncio.create_task(worker_1())
    task2 = asyncio.create_task(worker_2())
    print('before await')
    await task1
    print('awaited worker_1')
    await task2
    print('awaited worker_2')
    
start=time.time()
asyncio.run(run())
end=time.time()
print('use {}s'.format(end-start))

#####結果#####
before await
worker_1 start
worker_2 start
worker_1 done
awaited worker_1
worker_2 done
awaited worker_2
use 2.001868963241577s

  

我們來執行分析一下

  1. asyncio.run(run()),程式進入 run() 函式,事件迴圈開啟。

  2. task1 和 task2 任務被建立,並進入事件迴圈等待執行;執行到 print,輸出 'before await';

  3. await task1 執行,使用者選擇從當前的主任務中切出,事件排程器開始排程 worker_1;

  4. worker_1 開始執行,執行 print 輸出'worker_1 start',然後執行到 await asyncio.sleep(1), 從當前任務切出,事件排程器開始排程 worker_2;

  5. worker_2 開始執行,執行 print 輸出 'worker_2 start',然後執行 await asyncio.sleep(2) 從當前任務切出;

  6. 一秒鐘後,worker_1 的 sleep 完成,事件排程器將控制權重新傳給 task_1,輸出 'worker_1 done',task_1 完成任務,從事件迴圈中退出;

  7. await task1 完成,事件排程器將控制器傳給主任務,輸出 'awaited worker_1',然後在 await task2 處繼續等待;

  8. 兩秒鐘後,worker_2 的 sleep 完成,事件排程器將控制權重新傳給 task_2,輸出 'worker_2 done',task_2 完成任務,從事件迴圈中退出;

  9. 主任務輸出 'awaited worker_2',協程全任務結束,事件迴圈結束。

 

最後我們來總結一下今天的內容。

 

  • 協程和多執行緒的區別,主要在於兩點,一是協程為單執行緒;是協程由使用者決定,在哪些地方交出控制權,切換到下一個任務

  • 程的寫法更加簡潔清晰,把 async、await  create_task 結合來用,對於中小級別的併發需求已經毫無壓力。

     

 

      歡迎大家留言和我交流。

 

相關文章