python非同步asyncio模組的使用

dwzb發表於2018-03-13

本文首發於知乎
非同步是繼多執行緒、多程式之後第三種實現併發的方式,主要用於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程式設計

專欄目錄:目錄

版本說明:軟體及包版本說明

相關文章