异步爬虫之理解协程
案例引入:
先看一个网站:https://www.httpbin.org/delay/5, 该网站会强制等待5秒后才返回响应。如果想访问100次该网站,单线程的情况下,至少要等待500秒才能全部执行完毕。为了提高访问效率,可以使用协程实现加速。
首先需要了解一些基础概念:
阻塞:指程序未得到所需计算资源时被挂起的状态,程序在等待某个操作完成期间,自身无法继续做别的事情,称该程序在该操作是阻塞的。
非阻塞:程序在等待某个操作的过程中,可以继续干别的事情,称该程序在该操作上是非阻塞的。
同步:不同程序单元为了完成一个任务,在执行过程中需靠某种通信方式保持协调一致,这些程序单元是同步执行的。同步意味着有序。
异步:为了完成某个任务,不同程序单元无需通信协调也能完成任务,此时不相关的程序单元之间是可以异步的。异步意味着无需。
多进程:利用CPU的多核优势,在同一时间并行执行多个任务。
协程:又称微线程、纤程,是一种运行在用户态的轻量级线程。
协程有自己的寄存器上下文和栈,协程在调度切换时,将寄存器上下文和栈保存到其他地方,等切回来时再恢复先前保存的状态,每次过程重入就相当于进入上一次调用的状态。
协程本质是单进程,相对于多进程来说,它没有线程上下文切换的开销,没有原子操作锁定和同步的开销,编程模型也很简单。
协程可以实现异步操作,如爬虫等待响应时,等待过程继续干其他事情,等响应之后再切回来继续处理,充分利用CPU和其他资源。
协程的用法:
python 中使用协程最常用的库是 asyncio,需了解的相关概念:
event_loop:事件循环,相当于一个无限循环,可以把一些函数注册到这个事件循环上,当满足发生的条件时,就调用对应的方法。
coroutine:中文翻译为协程,在python中常指协程对象类型,可以将协程对象注册到事件循环中,它会被事件循环调用。可以使用async定义一个方法,该方法在调用时不会立即执行,而是返回一个协程对象。
task:任务,这是对协程对象的进一步封装,包含协程对象的各个状态。
future:代表将来执行或没有执行的任务的结果,实际上和task没有本质区别。
async可以定义协程,await可以挂起阻塞方法的执行。
定义协程,体验和普通进程的不同:
import asyncio
async def execute(x):
print('Number: ', x)
# 返回协程对象
coroutine = execute(1)
print('Coroutine: ', coroutine)
print('After calling execute')
# 创建事件循环loop
loop = asyncio.get_event_loop()
# 将协程对象注册到事件循环中并启动
loop.run_until_complete(coroutine)
print('After calling loop')
输出如下:
Coroutine: <coroutine object execute at 0x00000201B045BB40>
After calling execute
Number: 1
After calling loop
前面提到,task是对协程的进一步封装,比协程对象多了运行状态,如running、finished等,可以利用这些状态获取协程对象的执行情况。
loop.run_until_complete(coroutine) 这一步实际上内部执行了将协程对象封装为task对象,也可以显式声明,如下:
import asyncio
async def execute(x):
print('Number: ', x)
return x
# 返回协程对象
coroutine = execute(1)
print('Coroutine: ', coroutine)
print('After calling execute')
# 创建事件循环loop
loop = asyncio.get_event_loop()
task = loop.create_task(coroutine)
print('Task: ', task)
loop.run_until_complete(task)
print('Task: ', task)
print('After calling loop')
输出大致如下:
Coroutine: <coroutine object execute at 0x00000227AACFBB40>
After calling execute
Task: <Task pending name='Task-1' coro=<execute() running at ***/asyncio_.py:4>>
Number: 1
Task: <Task finished name='Task-1' coro=<execute() done, defined at ***/asyncio_.py:4> result=1>
After calling loop
这里我们显示的调用了task并打印其状态,可以看到,第一次打印处于pending状态,添加到事件循环并执行后打印就处于finished状态了,同时result变成了1,也就是定义的execute方法返回的结果。
另一种定义task的方式是直接调用 asyncio 包的 ensure_future 方法,返回也是task对象,这样就可以不借助loop对象,也能提前定义好task,如:
import asyncio
async def execute(x):
print('Number: ', x)
return x
# 返回协程对象
coroutine = execute(1)
print('Coroutine: ', coroutine)
print('After calling execute')
task = asyncio.ensure_future(coroutine)
print('Task: ', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task: ', task)
print('After calling loop')
运行结果同上。
绑定回调:
可以为某个task对象绑定一个回调方法:
import asyncio
import requests
async def request():
url = 'https://www.baidu.com'
status = requests.get(url)
return status
def callback(task):
print('Status: ', task.result())
coroutine = request()
task = asyncio.ensure_future(coroutine)
task.add_done_callback(callback)
print('Task: ', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task: ', task)
输出如下:
Task: <Task pending name='Task-1' coro=<request() running at **/asyncio_.py:29> cb=[callback() at **/asyncio_.py:35]>
Status: <Response [200]>
Task: <Task finished name='Task-1' coro=<request() done, defined at **/asyncio_.py:29> result=<Response [200]>>
其实不使用回调,直接调用task的result方法也是可以获取结果的:
比如去掉 task.add_done_callback(callback),并在最后一行打印 print('Task: ', task.result()),运行结果也是一样的。
多任务协程:
之前都只执行了一次请求,若想执行多次,可以定义一个task列表,使用 asyncio 中的 wait 方法执行:
import asyncio
import requests
async def request():
url = 'https://www.baidu.com'
status = requests.get(url)
return status
tasks = [asyncio.ensure_future(request()) for _ in range(5)]
print('Tasks: ', tasks)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
for task in tasks:
print('Task Result: ', task.result())
输出如下:
Tasks: [<Task pending name='Task-1' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-2' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-3' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-4' coro=<request() running at **/asyncio_.py:29>>, <Task pending name='Task-5' coro=<request() running at **/asyncio_.py:29>>]
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
可以看到,5个任务被顺次执行,并得到了结果。
协程实现:
有了之前的基础,以实例看下协程在解决IO密集型任务时有怎样的优势。
爬取网站:https://www.httpbin.org/delay/5
为了让协程可以实现异步,需要在有阻塞的代码处加上 await ,告诉程序这里可以挂起,执行别的协程,直到其他协程挂起或执行完毕。
并且 await 后面的对象必须为如下格式之一:
- 一个原生协程对象;
- 一个由 types.coroutine 修饰的生成器,这个生成器可以返回协程对象;
- 由一个包含 __await__ 方法的对象返回的一个迭代器。
因此,多任务协程爬取目标网站的代码初步如下:
import asyncio
import time
import requests
start = time.time()
async def get(url):
return requests.get(url)
async def request():
url = 'https://www.httpbin.org/delay/5'
print('Waiting for ', url)
response = await 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)
运行后发现,程序执行时间和单线程一样,需要花费一分多钟,也就是程序并没有真正实现异步。我们仅仅将涉及IO操作的代码封装到async修饰的方法里是不可行的。只有使用支持异步操作的请求方式才可以真正实现异步,这里就需要使用到 aiohttp 了。
使用 aiohttp:
pip3 install aiohttp
使用此库配合 asyncio,就可以方便的实现异步操作了,改写之前代码如下(主要是改了get(url)方法):
import asyncio
import time
import aiohttp
start = time.time()
async def get(url):
session = aiohttp.ClientSession()
response = await session.get(url)
await response.text()
await session.close()
return requests
async def request():
url = 'https://www.httpbin.org/delay/5'
print('Waiting for ', url)
response = await 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)
运行后发现,程序异步执行了,最终耗时一般为 6 到 8 秒。
程序执行流程如下:
开始时,事件循环会执行第一个task。对于第一个task来说,执行到第一个await跟着的get方法时会被挂起,但这个get方法第一步的执行是非阻塞的,挂起后会立马被唤醒,立即进入执行并创建ClientSession对象。接着遇到第二个await,调用session.get方法后就被挂起了。由于等待响应耗时较久,因此一直没有被唤醒,接下来事件循环会寻找当前未被挂起的协程继续执行,于是第二个task开始执行,执行的流程和第一个一样,在session.get处被挂起,以此类推,直至所有协程都被挂起,此时服务器端仍未有响应,那么就是继续等待,5秒后,几个请求几乎同时获取了响应,然后几个task也被唤醒继续执行并输出请求结果,最后总耗时只有6到8秒!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】