写在前面:
本来这篇文章只是用来记录一下学习异步协程爬虫的笔记,感谢CSDN的大力支持,引来了很多关注和浏览,也有很多大佬的批评指针。
事先声明:本文只是学习使用,在爬虫的实战应用中还要添加诸多限制,比如UA伪装,添加timeout,设置代理等等。
学习爬虫过程中的代码都放在了GitHub上:https://github.com/koking0/Spider
在此感谢以下大佬的批评指针:
血色v残阳
热爱造轮子的程序员
…
一、引入
如果因为 IO 阻塞导致被操作系统强行剥夺走 CPU 的执行权限,程序的执行效率会降低了下来。
想要解决这个问题,我们可以自己从应用程序级别检测 IO 阻塞,如果阻塞就切换到程序的其它任务,这样就可以将程序的 IO 降到最低,程序处于就绪态就会增多,以此来迷惑操作系统。
操作系统会以为我们的程序是 IO 较少的程序,从而会尽可能多的分配到 CPU,这样也就达到了提升程序执行效率的目的。
在 Python 3.4 之后新增了 asyncio 模块,可以帮助我们检测 IO 阻塞,通过它可以帮助我们实现异步 IO。
注意:asyncio 只能发 TCP 级别的请求,不能发 HTTP 协议的请求。
-
什么是异步 IO
所谓的异步 IO,就是发起一个 IO 阻塞的操作,但是不用等到它结束,可以在它执行 IO 的过程中继续做别的事情,当 IO 执行完毕之后会收到它的通知。 -
实现异步 IO 的方式
通过单线程+异步协程的方式可以实现异步 IO 操作。
二、异步协程
在将异步协程之前,我们需要了解以下几个概念:
1. event_loop
事件循环,相当于一个无限循环。我们可以把一些函数注册到这个事件循环上,当满足某些条件的时候,函数就会被循环执行。
程序是按照设定的顺序从头执行到尾,运行的次数也是完全按照设定。在编写程序时,如果有一部分程序的运行耗时是比较久的,需要先让出其控制权,让它在后台运行,其它的程序可以先运行起来。
当后台运行的程序完成后,也需要及时通知主程序已经完成任务可以进行下一步操作,但这个过程所需的时间是不确定的,需要主程序不断的监听状态,一旦收到了任务完成的消息,就开始进行下一步。
loop就是这个持续不断的监视器。
2. coroutine
中文翻译叫协程,在 Python 中昌指代为协程对象类型,可以将协程对象注册到时间循环中被调用。使用 async 关键字来定义的方法在调用时不会立即执行,而是返回一个协程对象。
# 首先引入 asyncio 包,这样才能使用 async 和 await
import asyncio
# 使用 async 定义一个 execute 方法,接收一个参数并打印
async def execute(x):
print("Number = ", x)
# 此时调用 execute 函数并不会执行,而是返回一个协程对象
coroutine = execute(1)
print("coroutine:", coroutine)
print("After calling execute.")
# 然后使用 get_event_loop 方法创建一个事件循环 loop
loop = asyncio.get_event_loop()
# 之后调用 loop 对象的 run_until_complete 方法将协程对象注册到事件循环 loop 中并启动,函数才能运行
loop.run_until_complete(coroutine)
print("After calling loop.")
执行结果为:
coroutine: <coroutine object execute at 0x000001C714A91A48>
After calling execute.
Number = 1
After calling loop.
3. task
任务,它是对协程对象的进一步封装,包含了任务的各个状态,比如 running、finished 等,可以用这些状态来获取协程对象的执行情况。
import asyncio
async def execute(x):
print("Number = ", x)
return x
if __name__ == '__main__':
coroutine = execute(1)
print("Coroutine:", coroutine)
print("After calling execute.")
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 0x0000022105D1FB48>
After calling execute.
Task: <Task pending coro=<execute() running at G:/Python/Spider/4.高性能异步爬虫/02.协程/2.第一个task.py:16>>
Number = 1
Task: <Task finished coro=<execute() done, defined at G:/Python/Spider/4.高性能异步爬虫/02.协程/2.第一个task.py:16> result=1>
After calling loop.
4. future
代表将来执行或还没有执行的任务,实际上和 task 没有本质区别。通过 asyncio 的 ensure_future() 方法也可以返回一个 task 对象,这样可以不借助于 loop 来定义。
import asyncio
async def execute(x):
print("Number = ", x)
if __name__ == '__main__':
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.")
执行结果为:
Coroutine: <coroutine object execute at 0x000001A6BD67FC48>
After calling execute.
Task: <Task pending coro=<execute() running at G:/Python/Spider/4.高性能异步爬虫/02.协程/3.第一个ensure_future.py:16>>
Number = 1
Task: <Task finished coro=<execute() done, defined at G:/Python/Spider/4.高性能异步爬虫/02.协程/3.第一个ensure_future.py:16> result=None>
After calling loop.
5. 绑定回调
可以为某个 task 绑定一个回调方法。
import asyncio
import requests
async def request():
url = "https://www.baidu.com"
status = requests.get(url=url).status_code
return status
def callback(task):
print("Status:", task.result())
if __name__ == '__main__':
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)
通过 requests() 方法请求百度,接收其返回的状态码,然后定义一个 callback() 方法,接收一个 task 对象,通过 result() 方法打印其返回结果,最后调用 add_done_callback() 方法就可以给 coroutine 对象添加回调函数了,当 coroutine 对象执行完毕之后,就会执行其回调方法。
执行结果为:
Task: <Task pending coro=<request() running at G:/Python/Spider/4.高性能异步爬虫/02.协程/4.绑定回调.py:17> cb=[callback() at G:/Python/Spider/4.高性能异步爬虫/02.协程/4.绑定回调.py:23]>
Status: 200
Task: <Task finished coro=<request() done, defined at G:/Python/Spider/4.高性能异步爬虫/02.协程/4.绑定回调.py:17> result=200>
三、多任务协程
目前为止,我们的协程还是只执行一个任务,我们的目的是想它能够同时执行多个任务,为此我们可以定义一个 task 列表存放多个任务对象。
import time
import asyncio
import requests
async def getPage(name, url):
print("正在爬取%s......" % name)
response = requests.get(url=url).text
with open("%s.html" % name, "w", encoding="utf-8") as fp:
fp.write(response)
print("%s爬取完毕......" % name)
if __name__ == '__main__':
startTime = time.time()
urlDict = {
"百度搜索": "https://www.baidu.com/",
"百度翻译": "https://fanyi.baidu.com/",
"CSDN": "https://www.csdn.net/",
"博客园": "https://www.cnblogs.com/",
"哔哩哔哩": "https://www.bilibili.com/",
"码云": "https://gitee.com/",
"拉勾网": "https://www.lagou.com/",
}
taskList = []
for key, value in urlDict.items():
request = getPage(key, value)
task = asyncio.ensure_future(request)
taskList.append(task)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(taskList))
print("Time consuming:", time.time() - startTime)
输出结果为:
正在爬取百度搜索......
百度搜索爬取完毕......
正在爬取百度翻译......
百度翻译爬取完毕......
正在爬取CSDN......
CSDN爬取完毕......
正在爬取博客园......
博客园爬取完毕......
正在爬取哔哩哔哩......
哔哩哔哩爬取完毕......
正在爬取码云......
码云爬取完毕......
正在爬取拉勾网......
拉勾网爬取完毕......
Time consuming: 2.6479198932647705
总耗时大概是2.65秒,你是不是觉得这就很快了?其实还有更快的代码:
import time
import asyncio
import aiohttp
async def getPage(name, url):
print("正在爬取%s......" % name)
async with aiohttp.ClientSession() as session:
async with await session.get(url) as response:
responseText = await response.text()
save(name, responseText)
print("%s爬取完毕......" % name)
def save(name, response):
with open("%s.html" % name, "w", encoding="utf-8") as fp:
fp.write(response)
if __name__ == '__main__':
startTime = time.time()
urlDict = {
"百度搜索": "https://www.baidu.com/",
"百度翻译": "https://fanyi.baidu.com/",
"CSDN": "https://www.csdn.net/",
"博客园": "https://www.cnblogs.com/",
"哔哩哔哩": "https://www.bilibili.com/",
"码云": "https://gitee.com/",
"拉勾网": "https://www.lagou.com/",
}
taskList = []
for key, value in urlDict.items():
request = getPage(key, value)
task = asyncio.ensure_future(request)
taskList.append(task)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(taskList))
print("Time consuming:", time.time() - startTime)
输出结果为:
正在爬取百度搜索......
正在爬取百度翻译......
正在爬取CSDN......
正在爬取博客园......
正在爬取哔哩哔哩......
正在爬取码云......
正在爬取拉勾网......
百度搜索爬取完毕......
博客园爬取完毕......
百度翻译爬取完毕......
码云爬取完毕......
哔哩哔哩爬取完毕......
拉勾网爬取完毕......
CSDN爬取完毕......
Time consuming: 0.9793801307678223
大约0.98秒就可以爬完所有的网页。
这是因为第一种方法并不是真正的异步请求,在异步协程中如果出现同步模块相关的代码则无法实现异步,比如requests.get()
属于同步模块的代码。
要想实现真正的异步协程爬虫必须使用基于异步的网络请求模块,所以要使用 aiohttp 模块,这个模块需要安装:
pip install -i http://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com aiohttp
它的使用与 requests 模块类似,需要注意的是,aiohttp 获取响应数据操作之前一定要使用 await 进行挂起。
在执行协程的时候,如果遇到了 await,那么就会将当前协程挂起,转而执行其它的协程,直到其它协程也挂起或执行完毕,再进行下一个协程的执行。
异步协程的便捷之处就在于,当遇到阻塞操作时,任务被挂起,程序接着执行其它的任务,这样可以充分利用 CPU 时间片,而不必把时间都浪费在等待 IO 上,把这个运用在爬虫上则可以在相同的时间内实现成百上千此的网络请求。
小生才疏学浅,如有谬误,恭请指正。
写在最后:
通过写爬虫来练习协程的时候是可以的,实际应用中爬虫线程池很香
欢迎来学习哦