Python3.5協程學習研究

nmask發表於2018-07-13
今夕何夕故人不來遲暮連山黛

   之前有研究過python協程相關的知識,但一直沒有進行深入探究。平常工作中使用的也還是以python2為主,然而最近的專案需要使用python3協程相關的內容,因此湊出時間學習了一番python3的協程語法。    本篇主要以介紹python3.5的async/await協程語法為主,因為這種語法看上去很彆扭,不容易理解。如果對python協程基礎不是很瞭解,建議可以先看此篇:Python協程

協程函式(非同步函式)

   我們平常使用最多的函式都是同步函式,即不同函式執行是按順序執行的。那麼什麼是非同步函式呢?怎麼建立非同步函式?怎麼在非同步函式之間來回切換執行?不急,請往下看。

建立協程函式

先來看下普通函式:

def test1():
    print("1")
    print("2")

def test2():
    print("3")
    print("4")

a = test1()
b = test2()
print(a,type(a))
print(b,type(b))
複製程式碼

執行以上程式碼得到結果:

1
2
3
4
None <class 'NoneType'>
None <class 'NoneType'>
複製程式碼

說明:程式順序執行了test1、test2函式,在呼叫函式的時候就自動進入了函式體,並執行了函式的內容。

然後使用async關鍵詞將普通函式變成協程函式,即非同步函式:

async def test1():
    print("1")
    print("2")

async def test2():
    print("3")
    print("4")

print(test1())
print(test2())

複製程式碼

執行以上程式碼得到結果:

<coroutine object test1 at 0x109f4c620>
asyncio_python3_test.py:16: RuntimeWarning: coroutine 'test1' was never awaited
  print(test1())
<coroutine object test2 at 0x109f4c620>
asyncio_python3_test.py:17: RuntimeWarning: coroutine 'test2' was never awaited
  print(test2())
複製程式碼

說明:忽略結果中的告警,可以看到呼叫函式test1、test2的時候,並沒有進入函式體且執行函式內容,而是返回了一個coroutine(協程物件)。

除了函式外,類的方法也可以使用async關鍵詞將其變成協程方法:

class test:
    async def run(self):
        print("1")
複製程式碼

執行協程函式

   前面我們成功建立了協程函式,並且在呼叫函式的時候返回了一個協程物件,那麼怎麼進入函式體並執行函式內容呢?類似於生成器,可以使用send方法執行函式,修改下前面的程式碼:

async def test1():
    print("1")
    print("2")

async def test2():
    print("3")
    print("4")

a = test1()
b = test2()

a.send(None)
b.send(None)
複製程式碼

執行以上程式碼得到以下結果:

1
2
Traceback (most recent call last):
  File "asyncio_python3_test.py", line 19, in <module>
    a.send(None)
StopIteration
sys:1: RuntimeWarning: coroutine 'test2' was never awaited
複製程式碼

   說明:程式先執行了test1協程函式,當test1執行完時報了StopIteration異常,這是協程函式執行完飯回的一個異常,我們可以用try except捕捉,來用判斷協程函式是否執行完畢。

async def test1():
    print("1")
    print("2")

async def test2():
    print("3")
    print("4")

a = test1()
b = test2()

try:
    a.send(None) # 可以通過呼叫 send 方法,執行協程函式
except StopIteration as e:
    print(e.value)
    # 協程函式執行結束時會丟擲一個StopIteration 異常,標誌著協程函式執行結束,返回值在value中
    pass
try:
    b.send(None) # 可以通過呼叫 send 方法,執行協程函式
except StopIteration:
    print(e.value)
    # 協程函式執行結束時會丟擲一個StopIteration 異常,標誌著協程函式執行結束,返回值在value中
    pass
複製程式碼

執行以上程式碼得到以下結果:

1
2
3
4
複製程式碼

   說明:程式先執行了test1函式,等到test1函式執行完後再執行test2函式。從執行過程上來看目前協程函式與普通函式沒有區別,並沒有實現非同步函式,那麼如何交叉執行協程函式呢?

交叉執行協程函式(await)

   通過以上例子,我們發現定義協程函式可以使用async關鍵詞,執行函式可以使用send方法,那麼如何實現在兩個協程函式間來回切換執行呢?這裡需要使用await關鍵詞,修改一下程式碼:

import asyncio

async def test1():
    print("1")
    await asyncio.sleep(1) # asyncio.sleep(1)返回的也是一個協程物件
    print("2")

async def test2():
    print("3")
    print("4")

a = test1()
b = test2()

try:
    a.send(None) # 可以通過呼叫 send 方法,執行協程函式
except StopIteration:
    # 協程函式執行結束時會丟擲一個StopIteration 異常,標誌著協程函式執行結束
    pass

try:
    b.send(None) # 可以通過呼叫 send 方法,執行協程函式
except StopIteration:
    pas
複製程式碼

執行以上函式得到以下結果:

1
3
4
複製程式碼

   說明:程式先執行test1協程函式,在執行到await時,test1函式停止了執行(阻塞);接著開始執行test2協程函式,直到test2執行完畢。從結果中,我們可以看到,直到程式執行完畢,test1函式也沒有執行完(沒有執行print("2")),那麼如何使test1函式執行完畢呢?可以使用asyncio自帶的方法迴圈執行協程函式。

await與阻塞

   使用async可以定義協程物件,使用await可以針對耗時的操作進行掛起,就像生成器裡的yield一樣,函式讓出控制權。協程遇到await,事件迴圈將會掛起該協程,執行別的協程,直到其他的協程也掛起或者執行完畢,再進行下一個協程的執行,協程的目的也是讓一些耗時的操作非同步化。

注意點:await後面跟的必須是一個Awaitable物件,或者實現了相應協議的物件,檢視Awaitable抽象類的程式碼,表明了只要一個類實現了__await__方法,那麼通過它構造出來的例項就是一個Awaitable,並且Coroutine類也繼承了Awaitable。

自動迴圈執行協程函式

   通過前面介紹我們知道執行協程函式需要使用send方法,但一旦協程函式執行過程中切換到其他函式了,那麼這個函式就不在被繼續執行了,並且使用sned方法不是很高效。那麼如何在執行整個程式過程中,自動得執行所有的協程函式呢,就如同多執行緒、多程式那樣,隱式得執行而不是顯示的通過send方法去執行函式。

事件迴圈方法

前面提到的問題就需要用到事件迴圈方法去解決,即asyncio.get_event_loop方法,修改以上程式碼如下:

import asyncio

async def test1():
    print("1")
    await test2()
    print("2")

async def test2():
    print("3")
    print("4")

loop = asyncio.get_event_loop()
loop.run_until_complete(test1())
複製程式碼

執行以上程式碼得到以下結果:

1
3
4
2
複製程式碼

說明:asyncio.get_event_loop方法可以建立一個事件迴圈,然後使用run_until_complete將協程註冊到事件迴圈,並啟動事件迴圈。

task任務

   由於協程物件不能直接執行,在註冊事件迴圈的時候,其實是run_until_complete方法將協程包裝成為了一個任務(task)物件。所謂task物件是Future類的子類,儲存了協程執行後的狀態,用於未來獲取協程的結果。我們也可以手動將協程物件定義成task,修改以上程式碼如下:

import asyncio

async def test1():
    print("1")
    await test2()
    print("2")

async def test2():
    print("3")
    print("4")

loop = asyncio.get_event_loop()
task = loop.create_task(test1())
loop.run_until_complete(task)
複製程式碼

   說明:前面說到task物件儲存了協程執行的狀態,並且可以獲取協程函式執行的返回值,那麼具體該如何獲取呢?這裡可以分兩種方式,一種需要繫結回撥函式,另外一種則直接在執行完task任務後輸出。值得一提的是,如果使用send方法執行函式,則返回值可以通過捕捉StopIteration異常,利用StopIteration.value獲取。

直接輸出task結果

當協程函式執行結束後,我們需要得到其返回值,第一種方式就是等到task狀態為finish時,呼叫task的result方法獲取返回值。

import asyncio

async def test1():
    print("1")
    await test2()
    print("2")
    return "stop"

async def test2():
    print("3")
    print("4")

loop = asyncio.get_event_loop()
task = asyncio.ensure_future(test1())
loop.run_until_complete(task)
print(task.result())
複製程式碼

執行以上程式碼得到以下結果:

1
3
4
2
stop
複製程式碼
回撥函式

   獲取返回值的第二種方法是可以通過繫結回撥函式,在task執行完畢的時候可以獲取執行的結果,回撥的最後一個引數是future物件,通過該物件可以獲取協程返回值。

import asyncio

async def test1():
    print("1")
    await test2()
    print("2")
    return "stop"

async def test2():
    print("3")
    print("4")

def callback(future):
    print('Callback:',future.result()) # 通過future物件的result方法可以獲取協程函式的返回值

loop = asyncio.get_event_loop()
task = asyncio.ensure_future(test1()) # 建立task,test1()是一個協程物件
task.add_done_callback(callback) # 繫結回撥函式
loop.run_until_complete(task)
複製程式碼

執行以上程式碼得到以下結果:

1
3
4
2
Callback: stop
複製程式碼

如果回撥函式需要接受多個引數,可以通過偏函式匯入,修改程式碼如下:

import asyncio
import functools

async def test1():
    print("1")
    await test2()
    print("2")
    return "stop"

async def test2():
    print("3")
    print("4")

def callback(param1,param2,future):
    print(param1,param2)
    print('Callback:',future.result())

loop = asyncio.get_event_loop()
task = asyncio.ensure_future(test1())
task.add_done_callback(functools.partial(callback,"param1","param2"))
loop.run_until_complete(task)
複製程式碼

說明:回撥函式中的future物件就是建立的task物件。

future物件

   future物件有幾個狀態:Pending、Running、Done、Cancelled。建立future的時候,task為pending,事件迴圈呼叫執行的時候當然就是running,呼叫完畢自然就是done,如果需要停止事件迴圈,就需要先把task取消,可以使用asyncio.Task獲取事件迴圈的task。

協程停止

   前面介紹了使用事件迴圈執行協程函式,那麼怎麼停止執行呢?在停止執行協程前,需要先取消task,然後再停止loop事件迴圈。

import asyncio

async def test1():
    print("1")
    await asyncio.sleep(3)
    print("2")
    return "stop"

tasks = [
    asyncio.ensure_future(test1()),
    asyncio.ensure_future(test1()),
    asyncio.ensure_future(test1()),
]

loop = asyncio.get_event_loop()
try:
    loop.run_until_complete(asyncio.wait(tasks))
except KeyboardInterrupt as e:
    for task in asyncio.Task.all_tasks():
        task.cancel()
    loop.stop()
    loop.run_forever()
finally:
    loop.close()
複製程式碼

執行以上程式碼,按ctrl+c可以結束執行。

本文中用到的一些概念及方法

  • event_loop事件迴圈:程式開啟一個無限的迴圈,當把一些函式註冊到事件迴圈上時,滿足事件發生條件即呼叫相應的函式。
  • coroutine協程物件:指一個使用async關鍵字定義的函式,它的呼叫不會立即執行函式,而是會返回一個協程物件,協程物件需要註冊到事件迴圈,由事件迴圈呼叫。
  • task任務:一個協程物件就是一個原生可以掛起的函式,任務則是對協程進一步封裝,其中包含任務的各種狀態。
  • future:代表將來執行或沒有執行的任務的結果,它和task上沒有本質的區別
  • async/await關鍵字:python3.5用於定義協程的關鍵字,async定義一個協程,await用於掛起阻塞的非同步呼叫介面。

併發與並行

   併發通常指有多個任務需要同時進行,並行則是同一時刻有多個任務執行。用多執行緒、多程式、協程來說,協程實現併發,多執行緒與多程式實現並行。

asyncio協程如何實現併發

   asyncio想要實現併發,就需要多個協程來完成任務,每當有任務阻塞的時候就await,然後其他協程繼續工作,這需要建立多個協程的列表,然後將這些協程註冊到事件迴圈中。這裡指的多個協程,可以是多個協程函式,也可以是一個協程函式的多個協程物件。

import asyncio

async def test1():

    print("1")
    await asyncio.sleep(1)
    print("2")
    return "stop"

a = test1()
b = test1()
c = test1()

tasks = [
    asyncio.ensure_future(a),
    asyncio.ensure_future(b),
    asyncio.ensure_future(c),
]

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks)) # 注意asyncio.wait方法
for task in tasks:
    print("task result is ",task.result())
複製程式碼

執行以上程式碼得到以下結果:

1
1
1
2
2
2
task result is  stop
task result is  stop
task result is  stop
複製程式碼

說明:程式碼先是定義了三個協程物件,然後通過asyncio.ensure_future方法建立了三個task,並且將所有的task加入到了task列表,最終使用loop.run_until_complete將task列表新增到事件迴圈中。

協程爬蟲

   前面介紹瞭如何使用async與await建立協程函式,使用asyncio.get_event_loop建立事件迴圈並執行協程函式。例子很好地展示了協程併發的高效,但在實際應用場景中該如何開發協程程式?比如說非同步爬蟲。我嘗試用requests模組、urllib模組寫非同步爬蟲,但實際操作發現並不支援asyncio非同步,因此可以使用aiohttp模組編寫非同步爬蟲。

aiohttp實現

import asyncio
import aiohttp

async def run(url):
    print("start spider ",url)
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            print(resp.url)

url_list = ["https://thief.one","https://home.nmask.cn","https://movie.nmask.cn","https://tool.nmask.cn"]

tasks = [asyncio.ensure_future(run(url)) for url in url_list]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
複製程式碼

執行以上程式碼得到以下結果:

start spider  https://thief.one
start spider  https://home.nmask.cn
start spider  https://movie.nmask.cn
start spider  https://tool.nmask.cn
https://movie.nmask.cn
https://home.nmask.cn
https://tool.nmask.cn
https://thief.one
複製程式碼

說明:aiohttp基於asyncio實現,既可以用來寫webserver,也可以當爬蟲使用。

requests實現

   由於requests模組阻塞了客戶程式碼與asycio事件迴圈的唯一執行緒,因此在執行呼叫時,整個應用程式都會凍結,但如果一定要用requests模組,可以使用事件迴圈物件的run_in_executor方法,通過run_in_executor方法來新建一個執行緒來執行耗時函式,因此可以這樣修改程式碼實現:

import asyncio
import requests

async def run(url):
    print("start ",url)
    loop = asyncio.get_event_loop()
    response = await loop.run_in_executor(None, requests.get, url)
    print(response.url)
    
url_list = ["https://thief.one","https://home.nmask.cn","https://movie.nmask.cn","https://tool.nmask.cn"]

tasks = [asyncio.ensure_future(run(url)) for url in url_list]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
複製程式碼

如果要給requests帶上引數,可以使用functools:

import asyncio
import requests
import functools

async def run(url):
    print("start ",url)
    loop = asyncio.get_event_loop()
    try:
        response = await loop.run_in_executor(None,functools.partial(requests.get,url=url,params="",timeout=1))
    except Exception as e:
        print(e)
    else:
        print(response.url)

url_list = ["https://thief.one","https://home.nmask.cn","https://movie.nmask.cn","https://tool.nmask.cn"]

tasks = [asyncio.ensure_future(run(url)) for url in url_list]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
複製程式碼

asyncio中使用阻塞函式

   如同前面介紹如何在asyncio中使用requests模組一樣,如果想在asyncio中使用其他阻塞函式,該怎麼實現呢?雖然目前有非同步函式支援asyncio,但實際問題是大部分IO模組還不支援asyncio。

阻塞函式在asyncio中使用的問題

   阻塞函式(例如io讀寫,requests網路請求)阻塞了客戶程式碼與asycio事件迴圈的唯一執行緒,因此在執行呼叫時,整個應用程式都會凍結。

解決方案

   這個問題的解決方法是使用事件迴圈物件的run_in_executor方法。asyncio的事件迴圈在背後維護著一個ThreadPoolExecutor物件,我們可以呼叫run_in_executor方法,把可呼叫物件發給它執行,即可以通過run_in_executor方法來新建一個執行緒來執行耗時函式。

run_in_executor方法

AbstractEventLoop.run_in_executor(executor, func, *args)
複製程式碼
  • executor 引數應該是一個 Executor 例項。如果為 None,則使用預設 executor。
  • func 就是要執行的函式
  • args 就是傳遞給 func 的引數

實際例子(使用time.sleep()):

import asyncio
import time

async def run(url):
    print("start ",url)
    loop = asyncio.get_event_loop()
    try:
        await loop.run_in_executor(None,time.sleep,1)
    except Exception as e:
        print(e)
    print("stop ",url)

url_list = ["https://thief.one","https://home.nmask.cn","https://movie.nmask.cn","https://tool.nmask.cn"]

tasks = [asyncio.ensure_future(run(url)) for url in url_list]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
複製程式碼

執行以上程式碼得到以下函式:

start  https://thief.one
start  https://home.nmask.cn
start  https://movie.nmask.cn
start  https://tool.nmask.cn
stop  https://thief.one
stop  https://movie.nmask.cn
stop  https://home.nmask.cn
stop  https://tool.nmask.cn
複製程式碼

說明:有了run_in_executor方法,我們就可以使用之前熟悉的模組建立協程併發了,而不需要使用特定的模組進行IO非同步開發。

參考

https://www.oschina.net/translate/playing-around-with-await-async-in-python-3-5 https://www.jianshu.com/p/b5e347b3a17c https://zhuanlan.zhihu.com/p/27258289 https://juejin.im/entry/5aabb949f265da23a04951df

本文來自個人部落格:Python3.5協程學習研究 | nMask'Blog,轉載請說明出處!

相關文章