非同步爬蟲之理解協程

脱下长日的假面發表於2024-05-05

案例引入:

先看一個網站:https://www.httpbin.org/delay/5, 該網站會強制等待5秒後才返回響應。如果想訪問100次該網站,單執行緒的情況下,至少要等待500秒才能全部執行完畢。為了提高訪問效率,可以使用協程實現加速。

首先需要了解一些基礎概念:

阻塞:指程式未得到所需計算資源時被掛起的狀態,程式在等待某個操作完成期間,自身無法繼續做別的事情,稱該程式在該操作是阻塞的。
非阻塞:程式在等待某個操作的過程中,可以繼續幹別的事情,稱該程式在該操作上是非阻塞的。
同步:不同程式單元為了完成一個任務,在執行過程中需靠某種通訊方式保持協調一致,這些程式單元是同步執行的。同步意味著有序。
非同步:為了完成某個任務,不同程式單元無需通訊協調也能完成任務,此時不相關的程式單元之間是可以非同步的。非同步意味著無需。
多程序:利用CPU的多核優勢,在同一時間並行執行多個任務。

協程:又稱微執行緒、纖程,是一種執行在使用者態的輕量級執行緒。
協程有自己的暫存器上下文和棧,協程在排程切換時,將暫存器上下文和棧儲存到其他地方,等切回來時再恢復先前儲存的狀態,每次過程重入就相當於進入上一次呼叫的狀態。
協程本質是單程序,相對於多程序來說,它沒有執行緒上下文切換的開銷,沒有原子操作鎖定和同步的開銷,程式設計模型也很簡單。
協程可以實現非同步操作,如爬蟲等待響應時,等待過程繼續幹其他事情,等響應之後再切回來繼續處理,充分利用CPU和其他資源。

協程的用法:

python 中使用協程最常用的庫是 asyncio,需瞭解的相關概念:

event_loop:事件迴圈,相當於一個無限迴圈,可以把一些函式註冊到這個事件迴圈上,當滿足發生的條件時,就呼叫對應的方法。
coroutine:中文翻譯為協程,在python中常指協程物件型別,可以將協程物件註冊到事件迴圈中,它會被事件迴圈呼叫。可以使用async定義一個方法,該方法在呼叫時不會立即執行,而是返回一個協程物件。
task:任務,這是對協程物件的進一步封裝,包含協程物件的各個狀態。
future:代表將來執行或沒有執行的任務的結果,實際上和task沒有本質區別。
async可以定義協程,await可以掛起阻塞方法的執行。

定義協程,體驗和普通程序的不同:

import asyncio

async def execute(x):
    print('Number: ', x)

# 返回協程物件
coroutine = execute(1)
print('Coroutine: ', coroutine)
print('After calling execute')

# 建立事件迴圈loop
loop = asyncio.get_event_loop()
# 將協程物件註冊到事件迴圈中並啟動
loop.run_until_complete(coroutine)
print('After calling loop')

輸出如下:

Coroutine:  <coroutine object execute at 0x00000201B045BB40>
After calling execute
Number:  1
After calling loop

前面提到,task是對協程的進一步封裝,比協程物件多了執行狀態,如running、finished等,可以利用這些狀態獲取協程物件的執行情況。
loop.run_until_complete(coroutine) 這一步實際上內部執行了將協程物件封裝為task物件,也可以顯式宣告,如下:

import asyncio

async def execute(x):
    print('Number: ', x)
    return x

# 返回協程物件
coroutine = execute(1)
print('Coroutine: ', coroutine)
print('After calling execute')

# 建立事件迴圈loop
loop = asyncio.get_event_loop()

task = loop.create_task(coroutine)
print('Task: ', task)
loop.run_until_complete(task)
print('Task: ', task)
print('After calling loop')

輸出大致如下:

Coroutine:  <coroutine object execute at 0x00000227AACFBB40>
After calling execute
Task:  <Task pending name='Task-1' coro=<execute() running at ***/asyncio_.py:4>>
Number:  1
Task:  <Task finished name='Task-1' coro=<execute() done, defined at ***/asyncio_.py:4> result=1>
After calling loop

這裡我們顯示的呼叫了task並列印其狀態,可以看到,第一次列印處於pending狀態,新增到事件迴圈並執行後列印就處於finished狀態了,同時result變成了1,也就是定義的execute方法返回的結果。

另一種定義task的方式是直接呼叫 asyncio 包的 ensure_future 方法,返回也是task物件,這樣就可以不借助loop物件,也能提前定義好task,如:

import asyncio

async def execute(x):
    print('Number: ', x)
    return x

# 返回協程物件
coroutine = execute(1)
print('Coroutine: ', coroutine)
print('After calling execute')

task = asyncio.ensure_future(coroutine)
print('Task: ', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task: ', task)
print('After calling loop')

執行結果同上。

繫結回撥:

可以為某個task物件繫結一個回撥方法:

import asyncio
import requests

async def request():
    url = 'https://www.baidu.com'
    status = requests.get(url)
    return status

def callback(task):
    print('Status: ', task.result())

coroutine = request()
task = asyncio.ensure_future(coroutine)
task.add_done_callback(callback)
print('Task: ', task)

loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task: ', task)

輸出如下:

Task:  <Task pending name='Task-1' coro=<request() running at **/asyncio_.py:29> cb=[callback() at **/asyncio_.py:35]>
Status:  <Response [200]>
Task:  <Task finished name='Task-1' coro=<request() done, defined at **/asyncio_.py:29> result=<Response [200]>>

其實不使用回撥,直接呼叫task的result方法也是可以獲取結果的:
比如去掉 task.add_done_callback(callback),並在最後一行列印 print('Task: ', task.result()),執行結果也是一樣的。

多工協程:

之前都只執行了一次請求,若想執行多次,可以定義一個task列表,使用 asyncio 中的 wait 方法執行:

import asyncio
import requests

async def request():
    url = 'https://www.baidu.com'
    status = requests.get(url)
    return status

tasks = [asyncio.ensure_future(request()) for _ in range(5)]
print('Tasks: ', tasks)

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

for task in tasks:
    print('Task Result: ', task.result())

輸出如下:

Tasks:  [<Task pending name='Task-1' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-2' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-3' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-4' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-5' coro=<request() running at **/asyncio_.py:29>>]
Task Result:  <Response [200]>
Task Result:  <Response [200]>
Task Result:  <Response [200]>
Task Result:  <Response [200]>
Task Result:  <Response [200]>

可以看到,5個任務被順次執行,並得到了結果。

協程實現:

有了之前的基礎,以例項看下協程在解決IO密集型任務時有怎樣的優勢。
爬取網站:https://www.httpbin.org/delay/5
為了讓協程可以實現非同步,需要在有阻塞的程式碼處加上 await ,告訴程式這裡可以掛起,執行別的協程,直到其他協程掛起或執行完畢。
並且 await 後面的物件必須為如下格式之一:

  1. 一個原生協程物件;
  2. 一個由 types.coroutine 修飾的生成器,這個生成器可以返回協程物件;
  3. 由一個包含 __await__ 方法的物件返回的一個迭代器。

因此,多工協程爬取目標網站的程式碼初步如下:

import asyncio
import time
import requests

start = time.time()

async def get(url):
    return requests.get(url)

async def request():
    url = 'https://www.httpbin.org/delay/5'
    print('Waiting for ', url)
    response = await get(url)
    print('Get response from ', url, 'response', response)

tasks = [asyncio.ensure_future(request()) for _ in range(10)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time ', end - start)

執行後發現,程式執行時間和單執行緒一樣,需要花費一分多鐘,也就是程式並沒有真正實現非同步。我們僅僅將涉及IO操作的程式碼封裝到async修飾的方法裡是不可行的。只有使用支援非同步操作的請求方式才可以真正實現非同步,這裡就需要使用到 aiohttp 了。

使用 aiohttp:

pip3 install aiohttp

使用此庫配合 asyncio,就可以方便的實現非同步操作了,改寫之前程式碼如下(主要是改了get(url)方法):

import asyncio
import time
import aiohttp

start = time.time()

async def get(url):
    session = aiohttp.ClientSession()
    response = await session.get(url)
    await response.text()
    await session.close()
    return requests

async def request():
    url = 'https://www.httpbin.org/delay/5'
    print('Waiting for ', url)
    response = await get(url)
    print('Get response from ', url, 'response', response)

tasks = [asyncio.ensure_future(request()) for _ in range(10)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

end = time.time()
print('Cost time ', end - start)

執行後發現,程式非同步執行了,最終耗時一般為 6 到 8 秒。

程式執行流程如下:
開始時,事件迴圈會執行第一個task。對於第一個task來說,執行到第一個await跟著的get方法時會被掛起,但這個get方法第一步的執行是非阻塞的,掛起後會立馬被喚醒,立即進入執行並建立ClientSession物件。接著遇到第二個await,呼叫session.get方法後就被掛起了。由於等待響應耗時較久,因此一直沒有被喚醒,接下來事件迴圈會尋找當前未被掛起的協程繼續執行,於是第二個task開始執行,執行的流程和第一個一樣,在session.get處被掛起,以此類推,直至所有協程都被掛起,此時伺服器端仍未有響應,那麼就是繼續等待,5秒後,幾個請求幾乎同時獲取了響應,然後幾個task也被喚醒繼續執行並輸出請求結果,最後總耗時只有6到8秒!

相關文章