HackPython致力于有趣有价值的编程教学
asyncio是Python3.x中比较复杂的概念?,虽然在前面已经有了《百万「并发」基础之异步编程》的内容,但对asyncio并没有比较系统的讲解,所以这里再次讨论一下asyncio,利用asyncio实现一个简单的协程爬虫?。
为了便于理解,这里先以不同的方式写个爬虫,为了简单起见,这里爬取一个豆瓣电影广州地区最新的电影资源?,地址为:https://movie.douban.com/cinema/later/guangzhou/。
因为访问这个界面不需要登录,所以简单几行代码即可获得其中的信息,代码如下:
import requests
from bs4 import BeautifulSoup
from utils import run_time
@run_time
def spider():
url = 'https://movie.douban.com/cinema/later/guangzhou/'
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'
}
res = requests.get(url, headers=headers).text
soup = BeautifulSoup(res, 'lxml')
all_movies = soup.find('div', id='showing-soon')
# 查找 class 名称为 item 的 div 元素
for each_movie in all_movies.find_all('div', class_='item'):
all_a_tag = each_movie.find_all('a')
all_li_tag = each_movie.find_all('li')
movie_name = all_a_tag[1].text
url_to_fetch = all_a_tag[1]['href']
movie_data = all_li_tag[0].text
res2 = requests.get(url_to_fetch).text
soup2 = BeautifulSoup(res2, 'lxml')
img_tag = soup2.find('img')['src']
print(f'电影名称: {movie_name}, 电影上映日期: {movie_data}, 缩略图: {img_tag}')
run_time 是一个装饰器?,用于打印spider()方法总共的运行时间,简单看一下spider()中的逻辑,主要就是一个for循环,循环中会获得电影名称,上映时间,缩略图url等信息?,输出如下:
python 1.py
电影名称: 隧道尽头, 电影上映日期: 07月25日, 缩略图: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2563538599.jpg
....
电影名称: 红花绿叶, 电影上映日期: 08月05日, 缩略图: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2563535973.jpg
run time : 15.511346817016602 s
花了15.5s多的时间,这肯定是不理想的☹️,对于大型爬虫,每天要爬取大量的数据,这样的耗时是不能上线使用的,之所以会消耗这么久的时间是因为整个爬取的过程是「同步」的?,即爬取这个电影的数据后才能继续爬取下一个的,当存在耗时操作时,整个爬取的流程就会非常的慢,接着使用多线程的方式来优化一下,多线程的方式其实就是将这种「同步」的形式改变为「异步并发」的形式?。
Python3.2中引入了concurrent.futures库?,利用这个库可以非常方便的使用多线程、多进程等,下面就通过创建线程池的方式来实现上相同的逻辑,主要代码如下:
import requests
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor, wait
from utils import run_time
# 切割出逻辑上独立的任务
def spider_task(each_movie):
all_a_tag = each_movie.find_all('a')
all_li_tag = each_movie.find_all('li')
movie_name = all_a_tag[1].text
url_to_fetch = all_a_tag[1]['href']
movie_data = all_li_tag[0].text
res2 = requests.get(url_to_fetch).text
soup2 = BeautifulSoup(res2, 'lxml')
img_tag = soup2.find('img')['src']
print(f'电影名称: {movie_name}, 电影上映日期: {movie_data}, 缩略图: {img_tag}')
@run_time
def spider():
url = 'https://movie.douban.com/cinema/later/guangzhou/'
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'
}
res = requests.get(url, headers=headers).text
soup = BeautifulSoup(res, 'lxml')
all_movies = soup.find('div', id='showing-soon')
executor = ThreadPoolExecutor(5)
# 使用线程池运行,运行记过放入列表中
fs = [executor.submit(spider_task, each_movie) for each_movie in all_movies.find_all('div', class_='item')]
# 等待列表中的所有任务都有执行完毕
wait(fs)
# 销毁线程
executor.shutdown()
if __name__ == '__main__':
spider()
看到上述代码,其实主要逻辑并没有改变,我们只是将此前for循环的逻辑抽离了出来?,之所以可以这样做,是因为这部分代码的逻辑是相互独立的,获取每部电影的具体信息与其他电影的信息没有关系,所以可以抽离成一个独立的方法?,然后再将该方法丢入线程池中就好了?,具体就是:
# 创建线程池
executor = ThreadPoolExecutor(5)
# 任务提交到线程池
executor.submit(spider_task, each_movie)
接着调用wait()方法等待线程池的任务执行,当所有任务执行完后,整个程序才会继续进行?。
使用多线程方式的输出如下:
python 2.py
电影名称: 跳舞吧!大象, 电影上映日期: 07月26日, 缩略图: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2562934437.jpg
...
电影名称: 红花绿叶, 电影上映日期: 08月05日, 缩略图: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2563535973.jpg
run time : 5.526644945144653 s
如果子任务有返回结果,还可以直接从任务队列中使用 resulst() 方法获取?,简单修改一下上面的代码,来使用一下 resulst()
# 切割出逻辑上独立的任务
def spider_task(each_movie):
all_a_tag = each_movie.find_all('a')
....
return movie_name, movie_data, img_tag
@run_time
def spider():
url = 'https://movie.douban.com/cinema/later/guangzhou/'
....
wait(fs)
for f in fs:
# 获得线程池中任务的执行结果
movie_name, movie_data, img_tag = f.result()
print(f'电影名称: {movie_name}, 电影上映日期: {movie_data}, 缩略图: {img_tag}')
# 销毁线程
executor.shutdown()
输出的内容是相同的。
值得注意的是,使用多线程爬虫时,线程不是创建越多越好,因为线程之间的调度、维护是需要消耗资源的?,所以这里存在一个临界值,临界值是什么取决于你爬取的具体网站,而且多线程的逻辑如果没有处理好,可能会出现竞争状态,此时就可能需要使用锁了?。
可能有人会说为何不测试一下多进程爬虫,多进程爬虫其速度会更慢?,但也不是说其一无是处,多进程爬虫常用于分布式爬虫中,因为进程之间不能直接共享变量数据(通常要通过队列来实现进程间数据的传递)的特点适合于分布式爬虫?。
整体来说,多进程会让Python利用CPU多个核的能力,更多用于计算密集型的程序中?,而对于I/O密集型的程序而已,多线程会是更好的选择,因为多进程虽然使用了多个核,但每个进程中默认只有以主线程,此时还是以「同步」的方式在执行,造成其在I/O密集型的程序中并不能发挥出很大的优势?。
接着将眼光放回协程上,在Python 3.x 不同的版本中,协程的使用方式有所差别,这里使用 Python 3.7 进行演示?,Python 3.7 使用协程是最方便的,为了实现异步请求网络的目的,这里使用aiohttp来做http请求,因为requests本身是不支持异步的(当然你可以通过其他的方式来让requests实现异步)?,然后使用 asyncio 来让程序实现异步,其主要的语法就是 async/await ,其中 async 是将一个方法定义为可以异步执行的协程方法(也常称为异步方法),而await 顾名思义就是等待协程方法的执行?,如果单纯使用 await 来调用协程方法,此时每次执行都是需要等待的,所以效果跟同步一样,这是种错误的使用方式,下面来看一下具体的代码:
import asyncio
import aiohttp
from bs4 import BeautifulSoup
import time
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'
}
async def get(url):
async with aiohttp.ClientSession(
headers=headers, connector=aiohttp.TCPConnector(ssl=False)) as session:
async with session.get(url) as resp:
return await resp.text()
async def spider():
url = 'https://movie.douban.com/cinema/later/guangzhou/'
# 会以同步的形式等待其执行完成
res = await get(url)
soup = BeautifulSoup(res, 'lxml')
movie_names, url_to_fetch, movie_dates = [],[],[]
all_movies = soup.find('div', id='showing-soon')
for each_movie in all_movies.find_all('div', class_='item'):
all_a_tag = each_movie.find_all('a')
all_li_tag = each_movie.find_all('li')
movie_names.append(all_a_tag[1].text)
url_to_fetch.append(all_a_tag[1]['href'])
movie_dates.append(all_li_tag[0].text)
# 构造任务队列
tasks = [get(url) for url in url_to_fetch]
pages = await asyncio.gather(*tasks)
for name, date, page in zip(movie_names, movie_dates, pages):
soup2 = BeautifulSoup(page, 'lxml')
img_tag = soup.find('img')
print(f'{name}, {date}, {url}')
if __name__ == '__main__':
start = time.time()
asyncio.run(spider())
print('run time : %s s'%(time.time()-start))
上述代码中,将spider()方法定义为了协程方法,将get()方法也定义为了协程方法,在spider()中,一开始时先通过 awaite 的发公式调用了get()方法去请求主url,此时要等到get()方法执行完,才能继续执行后续的逻辑?,然后再利用循环构造了一个任务对象,因为没有调用 awaite 方法,所以for循环构造的队列中的元素都是一个协程方法对象?,是未执行的状态,接着使用 asyncio.gather() 方法。
asyncio.gather() 方法会将任务队列中的协程方法对象放到事件循环(event loop)中,让事件循环去使用协程方法?,执行其中具体的逻辑,此时当遇到耗时操作如I/O的读写、等待网络的响应,方法就会被挂起,耗时操作继续有操作系统底层去完成?,而事件循环继续调用其他方法,当此前的方法耗时操作完成后,会再次加入到事件循环的队列中,等待事件循环的调用,从而实现了异步的效果?,节省了时间。
我们都知道,Python中存在GIL锁,多线程的方式其实还是以单线程的方式在运行,多线程实现异步其实依赖于操作系统?,当某个线程执行耗时操作时,操作系统就将执行权限交由其他线程,而耗时操作线程就等待(耗时操作同样在等待的过程中交由操作系统完成),通过这种方式实现异步?,而协程的不同之处在于,协程是将切换权限的这个动作交由编程者本身来控制?,我们认为请求网络这部分逻辑会有一定的耗时,所以将其封装为协程方法,让事件循环去调用,实现这部分逻辑的异步执行?,这种方式的好处时不需要耗费资源去进行线程之间的切换?。
来看一下上述代码的运行效果,输出如下:
python 3.py
哪吒之魔童降世, 07月26日, https://movie.douban.com/cinema/later/guangzhou/
...
某日某月, 08月07日, https://movie.douban.com/cinema/later/guangzhou/
run time : 2.796186923980713 s
使用协程编写的爬虫爬取同样的内容花了2.79秒。
通过上面一步步的调整,最开始的同步爬虫需要15.5秒,然后改为多线程,此时使用了5.5秒,然后再优化成使用协程的方式,此时只用了2.79秒?。
本节中简单讨论同步爬虫、多线程爬虫、协程爬虫,以及给出了对应的实现代码?,关于Python协程方面的内容依旧只是浅尝而止?,在后面的章节中会再次详细讨论asyncio相关的内容,最后欢迎学习 HackPython 的教学课程并感觉您的阅读与支持。
??