Async
案例场景
爬虫是一个 IO 型密集的任务,例如当使用 request 库去爬取一个站点,当发出一个请求后,程序必须等待返回响应,才能接着运行,在等待响应请求的过程中,,整个爬虫程序是一直在等待的,实际上没有做任何事情。对于这种情况,我们有没有优化方案呢?
案例介绍如下:
有这样一个网站地址为:httpbin.org/delay/5,如果我… 5 秒的时间才返回响应。
假设需要对该网站进行 10 次的请求,直接用循环的方式构造了 10 个 Request,使用的是 requests 单线程,在爬取之前和爬取之后记录了时间,最后输出爬取了 10 个页面消耗的时间,实现代码如下:
import requests
import logging
import time
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s: %(message)s')
TOTAL_NUMBER = 100
URL = 'https://httpbin.org/delay/5'
start_time = time.time()
for _ in range(1, TOTAL_NUMBER + 1):
logging.info('scraping %s', URL)
response = requests.get(URL)
end_time = time.time()
logging.info('total time %s seconds', end_time - start_time)
运行结果如下:
2023-11-05 16:16:36,773 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:16:42,853 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:16:48,942 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:16:55,081 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:17:01,531 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:17:07,848 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:17:14,488 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:17:20,642 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:17:28,466 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:17:35,170 - INFO: scraping https://httpbin.org/delay/5
2023-11-05 16:17:41,452 - INFO: total time 64.6795928478241 seconds
由于每个页面至少要等待 5 秒才能加载出来,因此 10 个页面至少要花费 50 秒的时间,加上网站本身负载的问题,总的爬取时间最终为 64 秒,大约 1 分钟。如果请求的数量不是 10 ,而是 100、1000,那么消耗的时间就会成倍的提高。
这就是单线程爬虫需要消耗的时间,如果开了多线程或者多进程来进行爬取,速度与单线程相比确实会提升很多。但是多线程和多进程比较耗费 cpu 资源,开启的数量都是有限制的,所以这不是一个完美的方案?
接下来就来了解一下使用协程来加速的方法,此种方法对于 IO 密集型任务非常有效。如将其应用到网络爬虫中,爬取效率甚至可以成百倍地提升。
协程
协程,英文叫作 coroutine,又称微线程、纤程,它是一种用户态的轻量级线程。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。
协程本质上是个单进程,它相对于多进程来说,无须线程上下文切换的开销,无须原子操作锁定及同步的开销,编程模型也非常简单。
可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定时间才能得到响应,但其实在这个等待过程中,程序可以干许多其他事情,等到响应得到之后才切换回来继续处理,这样可以充分利用 CPU 和其他资源,这就是协程的优势。
如何用
以 asyncio 为基础来介绍协程的用法,介绍下面几个概念:
event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。coroutine:中文翻译叫协程,在 Python 中常指代协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用async关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。future:代表将来执行或没有执行的任务的结果,实际上和task没有本质区别。async定义一个协程await用来挂起阻塞方法的执行。
写一个代码,看看协程是如何进行运行的。
import asyncio
async def execute(x):
"""Print the number."""
print('Number:', x)
# 当我们调用 execute(1) 时,函数会立即返回一个协程对象,而不是执行函数体内的代码。
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
# 如果需要执行 execute 的代码,我们需要将协程对象传递给 asyncio 的事件循环(event loop)。
loop = asyncio.get_event_loop()
# 将 coroutine 对象传递给 run_until_complete 方法的时候,实际上它进行了一个操作,就是将 coroutine 封装成了 task 对象。我们也可以显式地进行声明,
loop.run_until_complete(coroutine)
print('After calling loop')
loop = asyncio.get_event_loop()
# 可以对协程对象使用 asyncio.ensure_future() 或 loop.create_task() 方法,将协程对象封装成一个 Task 对象。
task = loop.create_task(coroutine)
# task 对象可以获取运行状态,用于判断协程是否执行完毕。
print('Task:', task)
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')
接下来,对案例场景利用协程进行优化。
协程实现
import asyncio
import requests
import time
start = time.time()
async def request():
url = 'https://httpbin.org/delay/5'
print('Waiting for', url)
response = requests.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)
经过运行后,消耗的时间是:
Cost time: 62.446749687194824
说明这个代码是串行的,没有实现异步的处理。 要想实现异步操作,需要使用到 await 这个关键词,它可以将等待的操作进行挂起,让出控制权,转而去执行别的协程。
await 关键词后面的对象需要是以下之一:
- 原生协程对象
types.coroutine修饰的生成器- 包含
__await__的迭代器
基于此需要一个可以实现异步请求的库,来帮助实现真正的异步。
这里用到了 aiohttp。
异步实现
安装命令:
conda install aiohttp
另外官方推荐安装两个库:
conda install cchardet # 字符编码检测库
conda install aiodns # 加速 DNS 解析库
写于 2023/11/5 这里有一个坑:
cchardet对python版本要求很高,容易破坏其他库的依赖性,后续可以考虑进行更换。
import asyncio
import aiohttp
import time
start = time.time()
async def get(url):
session = aiohttp.ClientSession()
response = await session.get(url)
await response.text()
await session.close()
return response
async def request():
url = 'https://static4.scrape.cuiqingcai.com/'
print('Waiting for', url)
response = await get(url)
print('Get response from', url, 'response')
tasks = [asyncio.ensure_future(request()) for _ in range(100)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print('Cost time:', end - start)
代码执行时间:
Cost time: 6.446749687194824
你可以进行比较,时间耗时减少了很多。