本文首發於知乎
非同步是繼多執行緒、多程式之後第三種實現併發的方式,主要用於IO密集型任務的執行效率提升。python中的非同步基於yield
生成器,在講解這部分原理之前,我們先學會非同步庫asyncio的使用。
本文主要講解asyncio
模組的通用性問題,對一些函式細節的使用就簡單略過。
本文分為如下部分
- 最簡單的使用
- 另一種常見的使用方式
- 一個問題
- 一般函式下的非同步
- 理解非同步、協程
- 單個執行緒的的非同步爬蟲
最簡單的使用
import asyncio
async def myfun(i):
print('start {}th'.format(i))
await asyncio.sleep(1)
print('finish {}th'.format(i))
loop = asyncio.get_event_loop()
myfun_list = (myfun(i) for i in range(10))
loop.run_until_complete(asyncio.gather(*myfun_list))
複製程式碼
這樣執行,10次等待總共只等待了1秒。
上面程式碼一些約定俗成的用法記住就好,如
- 要想非同步執行函式,需要在定義函式時前面加
async
- 後三行都是記住就行,到時候把函式傳入
另一種常見的使用方式
上面是第一種常見的用法,下面是另外一種
import asyncio
async def myfun(i):
print('start {}th'.format(i))
await asyncio.sleep(1)
print('finish {}th'.format(i))
loop = asyncio.get_event_loop()
myfun_list = [asyncio.ensure_future(myfun(i)) for i in range(10)]
loop.run_until_complete(asyncio.wait(myfun_list))
複製程式碼
這種用法和上面一種的不同在於後面呼叫的是asyncio.gather
還是asyncio.wait
,當前看成完全等價即可,所以平時使用用上面哪種都可以。
上面是最常看到的兩種使用方式,這裡列出來保證讀者在看其他文章時不會發蒙。
另外,二者其實是有細微差別的
gather
更擅長於將函式聚合在一起wait
更擅長篩選執行狀況
細節可以參考這篇回答
一個問題
與之前學過的多執行緒、多程式相比,asyncio
模組有一個非常大的不同:傳入的函式不是隨心所欲
- 比如我們把上面
myfun
函式中的sleep
換成time.sleep(1)
,執行時則不是非同步的,而是同步,共等待了10秒 - 如果我換一個
myfun
,比如換成下面這個使用request
抓取網頁的函式
import asyncio
import requests
from bs4 import BeautifulSoup
async def get_title(a):
url = 'https://movie.douban.com/top250?start={}&filter='.format(a*25)
r = requests.get(url)
soup = BeautifulSoup(r.content, 'html.parser')
lis = soup.find('ol', class_='grid_view').find_all('li')
for li in lis:
title = li.find('span', class_="title").text
print(title)
loop = asyncio.get_event_loop()
fun_list = (get_title(i) for i in range(10))
loop.run_until_complete(asyncio.gather(*fun_list))
複製程式碼
依然不會非同步執行。
到這裡我們就會想,是不是非同步只對它自己定義的sleep
(await asyncio.sleep(1)
)才能觸發非同步?
一般函式下的非同步
對於上述函式,asyncio
庫只能通過新增執行緒的方式實現非同步,下面我們實現time.sleep
時的非同步
import asyncio
import time
def myfun(i):
print('start {}th'.format(i))
time.sleep(1)
print('finish {}th'.format(i))
async def main():
loop = asyncio.get_event_loop()
futures = (
loop.run_in_executor(
None,
myfun,
i)
for i in range(10)
)
for result in await asyncio.gather(*futures):
pass
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
複製程式碼
上面run_in_executor
其實開啟了新的執行緒,再協調各個執行緒。呼叫過程比較複雜,只要當模板一樣套用即可。
上面10次迴圈仍然不是一次性列印出來的,而是像分批次一樣列印出來的。這是因為開啟的執行緒不夠多,如果想要實現一次列印,可以開啟10個執行緒,程式碼如下
import concurrent.futures as cf # 多加一個模組
import asyncio
import time
def myfun(i):
print('start {}th'.format(i))
time.sleep(1)
print('finish {}th'.format(i))
async def main():
with cf.ThreadPoolExecutor(max_workers = 10) as executor: # 設定10個執行緒
loop = asyncio.get_event_loop()
futures = (
loop.run_in_executor(
executor, # 按照10個執行緒來執行
myfun,
i)
for i in range(10)
)
for result in await asyncio.gather(*futures):
pass
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
複製程式碼
用這種方法實現requests
非同步爬蟲程式碼如下
import concurrent.futures as cf
import asyncio
import requests
from bs4 import BeautifulSoup
def get_title(i):
url = 'https://movie.douban.com/top250?start={}&filter='.format(i*25)
r = requests.get(url)
soup = BeautifulSoup(r.content, 'html.parser')
lis = soup.find('ol', class_='grid_view').find_all('li')
for li in lis:
title = li.find('span', class_="title").text
print(title)
async def main():
with cf.ThreadPoolExecutor(max_workers = 10) as executor:
loop = asyncio.get_event_loop()
futures = (
loop.run_in_executor(
executor,
get_title,
i)
for i in range(10)
)
for result in await asyncio.gather(*futures):
pass
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
複製程式碼
這種開啟多個執行緒的方式也算非同步的一種,下面一節詳細解釋。
理解非同步、協程
現在我們講了一些非同步的使用,是時候解釋一些概念了
- 首先,我們要理清楚同步、非同步、阻塞、非阻塞四個詞語之間的聯絡
- 首先要明確,前兩者後後兩者並不是一一對應的,它們不是在說同一件事情,但是非常類似,容易搞混
- 一般我們說非同步程式是非阻塞的,而同步既有阻塞也有非阻塞的
- 非阻塞是指一個任務沒做完,沒有必要停在那裡等它結束就可以開始下一個任務,保證一直在幹活沒有等待;阻塞就相反是一件事完全結束才開始另一件事
- 在非阻塞的情況下,同步與非同步都有可能,它們都可以在一個任務沒結束就開啟下一個任務。而二者的區別在於:(且稱正在進行的程式為主程式)當第一個程式做完的時候(比如網路請求終於相應了),會自動通知主程式回來繼續操作第一個任務的結果,這種是非同步;而同步則是需要主程式不斷去問第一個程式是否已經完成。
- 四個詞的區別參考知乎回答
- (協程與多執行緒的區別)在非阻塞的情況下,多執行緒是同步的代表,協程是非同步的代表。二者都開啟了多個執行緒
- 多執行緒中,多個執行緒會競爭誰先執行,一個等待結束也不會去通知主程式,這樣沒有章法的隨機執行會造成一些資源浪費
- 而協程中,多個執行緒(稱為微執行緒)的呼叫和等待都是通過明確程式碼組織的。協程就像目標明確地執行一個又一個任務,而多執行緒則有一些彷徨迷茫的時間
- 兩種非同步
- 前面幾節涉及到兩種非同步,一種是
await
只使用一個執行緒就可以實現任務切換,另一種是開啟了多個執行緒,通過執行緒排程實現非同步 - 一般只用一個執行緒將任務在多個函式之間來回切換,是使用yield生成器實現的,例子可以看這篇文章最後生產消費者例子
- 多程式、多執行緒、非同步擅長方向
- 非同步和多執行緒都是在IO密集型任務上優勢明顯,因為它們的本質都是在儘量避免IO等待時間造成的資源浪費。而多程式可以利用多核優勢,適合CPU密集型任務
- 相比於多執行緒,非同步更適合每次等待時間較長、需要等待的任務較多的程式。因為多執行緒畢竟要建立新的執行緒,執行緒過多使執行緒競爭現象更加明顯,資源浪費也就更多。如果每個任務等待時間過長,等待時間內勢必開啟了非常多工,非常多執行緒,這時使用多執行緒就不是一個明智的決定。而非同步則可以只開啟一個執行緒在各個任務之間有條不紊進行,即能充分利用CPU資源,又不會影響程式執行效率
單個執行緒的的非同步爬蟲
上面我們是通過開啟多個執行緒來實現requests
的非同步,如果我們想只用一個執行緒(用await
),就要換一個網頁請求函式。
事實上要想用await
,必須是一個awaitable物件,這是不能使用requests
的原因。而轉化成awaitable物件這樣的事當然也不用我們自己實現,現在有一個aiohttp
模組可以將網頁請求和asyncio
模組完美對接。使用這個模組改寫程式碼如下
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def get_title(i):
url = 'https://movie.douban.com/top250?start={}&filter='.format(i*25)
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
print(resp.status)
text = await resp.text()
print('start', i)
soup = BeautifulSoup(text, 'html.parser')
lis = soup.find('ol', class_='grid_view').find_all('li')
for li in lis:
title = li.find('span', class_="title").text
print(title)
loop = asyncio.get_event_loop()
fun_list = (get_title(i) for i in range(10))
loop.run_until_complete(asyncio.gather(*fun_list))
複製程式碼
歡迎關注我的知乎專欄
專欄主頁:python程式設計
專欄目錄:目錄
版本說明:軟體及包版本說明