未聞 Code
已經發布過不少篇關於異步爬蟲與異步編程的文章,最近有讀者但願我能深刻介紹一下 asyncio 是如何經過單線程單進程實現併發效果的。以及異步
代碼是否是能在全部方面都代替同步代碼。python
假設你須要用電飯煲煮飯,用洗衣機洗衣服,給朋友打電話讓他過來吃飯。其中,電飯煲須要30分鐘才能把飯煮好,洗衣機須要40分鐘才能把衣服洗好,朋友須要50分鐘才能到你家。那麼,是否是你須要在這三件事情上面消耗30 + 40 + 50 = 120分鐘?數據庫
實際上,在現實中你只須要消耗50分鐘就能夠了————編程
而後,你要作的就是等待。json
如今,你須要完成語文試卷,數學試卷和英語試卷。每張試卷須要作1小時。因而你須要1 + 1 + 1 = 3小時來完成全部的試卷。沒人幫你,因此你沒有辦法在少於3小時的狀況下完成這三張試卷。bash
如今,你須要用電飯煲煮飯、用洗衣機洗衣服,並完成一張數學試卷。其中,電飯煲須要30分鐘才能把飯煮好,洗衣機須要40分鐘才能把衣服洗好,試卷須要1小時才能完成。服務器
但你並不須要30 + 40 + 60 = 130分鐘。你只須要70分鐘左右————網絡
在第一個例子裏面,煮飯、洗衣、等朋友有一個共同點,就是每一個操做看似耗時很長,但真正須要人去操做的只有不多的時間:淘米、打開電飯煲電源用時5分鐘;把衣服放進洗衣機,打開電源用時2分鐘;給朋友打電話用時1分鐘。剩下的大部分時間都不須要人來操做,都是等待便可。併發
再看第二個例子,每一張試卷都會佔用整個你,沒有等待的時間,因此必需一張一張試卷完成。異步
這兩個例子實際上對應了兩種程序類型:I/O 密集型程序和計算密集型程序。scrapy
咱們在使用 requests 請求 URL、查詢遠程數據庫或者讀寫本地文件的時候,就是 I/O操做。這些操做的共同特色就是要等待。
以 request 請求URL 爲例,requests 發起請求,也許只須要0.01秒的時間。而後程序就卡住,等待網站返回。請求數據經過網絡傳到網站服務器,網站服務器發起數據庫查詢請求,網站服務器返回數據,數據通過網線傳回你的電腦。requests 收到數據之後繼續後面的操做。
大量的時間浪費在等待網站返回數據。若是咱們能夠充分利用這個等待時間,就能發起更多的請求。而這就是異步請求爲何有用的緣由。
但對於須要大量計算任務的代碼來講,CPU 始終處於高速運轉的狀態,沒有等待,因此就不存在利用等待時間作其它事情的說法。
因此:異步只適用於 I/O 操做相關的代碼,不適用於非 I/O操做。
上面咱們使用生活中的例子來講明異步請求,這可能會給你們一種誤解————我能夠控制代碼,讓代碼在我想讓他異步的地方異步,不想異步的地方同步。例如,可能有人會但願能用下面這段僞代碼所描述方式來寫代碼:
請求 https://baidu.com,在網站返回期間:
a = 1 + 1
b = 2 + 2
c = 3 + 3
拿到返回的數據,作其餘事情
複製代碼
就像是咱們把電飯煲的電源插上後,等待飯煮好的過程當中,我能夠看書,能夠打電話,能夠看電視,想作什麼就作什麼。
這段僞代碼寫得很符合直覺,但在使用 Python裏面不能這樣寫。
下面咱們用一段真正的代碼,來講明這樣寫有什麼問題。
首先,咱們作一個網站,當咱們請求http://127.0.0.1:8000/sleep/<num>
時,網站會等待num
秒纔會返回。例如:http://127.0.0.1:8000/sleep/3
表示,當你發起請求後,網站會等待3秒鐘再返回。運行效果以下圖所示。
如今,咱們使用 aiohttp 發送3次請求,分別等待1秒、2秒、3秒返回:
import aiohttp
import asyncio
import time
async def request(sleep_time):
async with aiohttp.ClientSession() as client:
resp = await client.get(f'http://127.0.0.1:8000/sleep/{sleep_time}')
resp_json = await resp.json()
print(resp_json)
async def main():
start = time.perf_counter()
await request(1)
a = 1 + 1
b = 2 + 2
print('能不能在第一個請求等待的過程當中運行到這裏?')
await request(2)
print('能不能在第二個請求等待的過程當中運行到這裏?')
await request(3)
end = time.perf_counter()
print(f'總計耗時:{end - start}')
asyncio.run(main())
複製代碼
運行效果以下圖所示:
在圖中第15行代碼,發起了1秒的請求,那麼第15行應該會等待1秒鐘纔會返回數據。而第1六、1七、18行都是簡單的賦值和 print 函數,運行時間加在一塊兒都顯然小於1秒鐘,因此理論上咱們看到的返回應該是:
能不能在第一個請求等待的過程當中運行到這裏?
能不能在第二個請求等待的過程當中運行到這裏?
{'success': True, 'time': 1}
{'success': True, 'time': 2}
{'success': True, 'time': 3}
總計耗時:3.018130547
複製代碼
但實際上,咱們看到的效果,倒是:程序先運行到第15行,等待請求完成網站返回之後,再運行16,17,18行,而後運行19行,等2秒請求完成了,再運行第20行,最後運行第21行。3次請求串行發出,最終耗時6秒。
程序的運行邏輯與咱們指望的不同。程序並無利用 I/O 等待的時間發起新的請求,而是等上一個請求結束了再發送下一個請求。
問題出在哪裏?
問題出如今,Python 的異步代碼,請求之間的切換不能由開發者來直接管理。
開發者經過await
語句告訴 asyncio,它後面這個函數,能夠被異步等待。注意是能夠被
等待,但要不要等待,這是 Python 底層本身來決定的。
由於一個 I/O 操做,不管你是髮網絡請求,仍是讀寫硬盤,Python 都知道,因此當 Python 發現你如今的這個操做確實是一個 I/O操做時,它纔會利用I/O 等待時間。
因此,在 Python 的異步編程中,開發者能作的事情,就是把全部可以異步的操做,一批一批告訴 Python。而後由 Python 本身來協調、調度這批任務,並充分利用等待時間。開發者沒有權力直接決定這些 I/O操做的調度方式。
因此,上面的代碼咱們須要作一些修改:
import aiohttp
import asyncio
import time
async def request(sleep_time):
async with aiohttp.ClientSession() as client:
resp = await client.get(f'http://127.0.0.1:8000/sleep/{sleep_time}')
resp_json = await resp.json()
print(resp_json)
async def main():
start = time.perf_counter()
tasks_list = [
asyncio.create_task(request(1)),
asyncio.create_task(request(2)),
asyncio.create_task(request(3)),
]
await asyncio.gather(*tasks_list)
end = time.perf_counter()
print(f'總計耗時:{end - start}')
asyncio.run(main())
複製代碼
運行效果以下圖所示:
能夠看到,如今耗時3秒鐘,說明這3次請求,確實利用了請求的等待時間。
咱們經過asyncio.create_task()
把不一樣的協程定義成異步任務,並把這些異步任務放入一個列表中,湊夠一批任務之後,一次性提交給asyncio.gather()
。因而,Python 就會自動調度這一批異步任務,充分利用他們的請求等待時間發起新的請求。
咱們平時在寫 Scrapy 爬蟲時,會有相似下面這樣的代碼:
...
yield scrapy.Request(url, callback=self.parse)
next_url = url + '&page=2'
yield scrapy.Request(next_url, callback=self.parse)
複製代碼
看起來像是先「請求」url,而後利用這個請求的等待時間執行next_url = url + '&page=2'
接下來再發起另外一個請求。
但實際上,在 Scrapy 內部,當咱們執行yield scrapy.Request
後, 僅僅是把一個請求對象放入 Scrapy 的請求隊列裏面,而後就繼續執行next_url = url + '&page=2'
了。
請求對象放進請求隊列後,尚未真正發起 HTTP請求。只有湊夠了必定數量的請求對象或者等待一段時間之後,Scrapy 的下載器纔會統一調度這一批請求對象,統一發送 HTTP請求。當某個請求返回之後,Scrapy 把返回的 HTML 組裝成 Response 對象,並把這個對象傳入 callback 函數執行後續操做。
綜上所述,在 Python 裏面的異步編程,你須要先湊夠一批異步任務,而後統一提交給 asyncio,讓它來幫你調度這批任務。你不能像 JavaScrapt 中那樣手動直接控制在異步請求等待時執行什麼代碼。
在異步函數裏面是能夠調用同步函數的。可是若是被調用的同步函數很耗時,那麼就會卡住其餘異步函數。例如print
函數就是一個同步函數,可是因爲它耗時極短,因此不會卡住異步任務。
咱們如今寫一個基於遞歸的斐波那契數列第 n 項計算函數,並在另外一個異步函數中調用它:
def sync_calc_fib(n):
if n in [1, 2]:
return 1
return sync_calc_fib(n - 1) + sync_calc_fib(n - 2)
async def calc_fib(n):
result = sync_calc_fib(n)
print(f'第 {n} 項計算完成,結果是:{result}')
return result
複製代碼
衆所周知,基於遞歸的方式計算斐波那契數列第 n 項,速度很是慢,咱們計算一下第36項,能夠看到耗時在5秒鐘左右:
若是咱們把計算斐波那契數列(CPU 密集型)與請求網站(I/O密集型)任務放在一塊兒會怎麼樣呢?
咱們來看看效果:
能夠看出,總共耗時8秒左右,其中計算斐波那契數列第36項耗時5秒,剩下3次網絡請求耗時3秒,因此總共耗時8秒。
這段代碼說明,當一個異步函數(calc_fib)中調用了一個耗時很是長的同步函數(sync_calc_fib)時,這一批全部的異步任務都會被卡住,只有這個同步函數運行完成之後,其餘的異步函數才能被正常調度。這就是爲何在異步編程裏面,不建議使用 time.sleep
的緣由。