Python网络爬虫(高性能异步爬虫)
一、背景
其实爬虫的本质就是client发请求批量获取server的响应数据,如果我们有多个url待爬取,只用一个线程且采用串行的方式执行,那只能等待爬取一个结束后才能继续下一个,效率会非常低。需要强调的是:对于单线程下串行N个任务,并不完全等同于低效,如果这N个任务都是纯计算的任务,那么该线程对cpu的利用率仍然会很高,之所以单线程下串行多个爬虫任务低效,是因为爬虫任务是明显的IO密集型(阻塞)程序。那么该如何提高爬取性能呢?
二、分析python中同步请求与池处理
同步调用:即提交一个任务后就在原地等待任务结束,等到拿到任务的结果后再继续下一行代码,效率低下
import requests def parse_page(res): print('解析 %s' %(len(res))) def get_page(url): print('下载 %s' %url) response=requests.get(url) if response.status_code == 200: return response.text urls=['https://www.baidu.com/','http://www.sina.com.cn/','https://www.python.org'] for url in urls: res=get_page(url) #调用一个任务,就在原地等待任务结束拿到结果后才继续往后执行 parse_page(res)
- a. 解决同步调用方案之多线程/多进程
-
好处:在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。
- 弊端:开启多进程或都线程的方式,我们是无法无限制地开启多进程或多线程的:在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。
- b. 解决同步调用方案之线程/进程池
- 好处:很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。可以很好的降低系统开销。
- 弊端:“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
- 案例:基于multiprocessing.dummy线程池爬取梨视频的视频信息(爬虫线程池实例)
总结:对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。
三、异步IO模块asynic
- 上述无论哪种解决方案其实没有解决一个性能相关的问题:IO阻塞,无论是多进程还是多线程,在遇到IO阻塞时都会被操作系统强行剥夺走CPU的执行权限,程序的执行效率因此就降低了下来。
- 解决这一问题的关键在于,我们自己从应用程序级别检测IO阻塞然后切换到我们自己程序的其他任务执行,这样把我们程序的IO降到最低,我们的程序处于就绪态就会增多,以此来迷惑操作系统,操作系统便以为我们的程序是IO比较少的程序,从而会尽可能多的分配CPU给我们,这样也就达到了提升程序执行效率的目的。
- 在python3.4之后新增了asyncio模块,可以帮我们检测IO(只能是网络IO【HTTP连接就是网络IO操作】),实现应用程序级别的切换(异步IO)。注意:asyncio只能发tcp级别的请求,不能发http协议。
- 异步IO:所谓「异步 IO」,就是你发起一个 网络IO 操作,却不用等它结束,你可以继续做其他事情,当它结束时,你会得到通知。
- 实现方式:单线程+协程实现异步IO操作。
- 异步协程用法
3.1 协程的实现:
从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。首先我们需要了解下面几个概念:
-
event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。
-
coroutine:中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
-
task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。
-
future:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。
另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。
3.2 协程函数示例:
import asyncio import time start_time = time.time() # 创建特殊函数,内部不可以出现不支持异步模块相关的代码 async def request(url): print("开启任务", url) # time.sleep(2) # time不是异步模块不可用 await asyncio.sleep(2) # 阻塞操作必须使用await关键字进行挂起 print("结束任务", url) return url # 任务回调函数 def task_callback(task): # 执行result函数,返回任务对象执行完成后的结果 print(task.result()) # 返回协程对象 c = request(url="www.1.jpg") # 封装任务对象 task_A = asyncio.ensure_future(c) task_A.add_done_callback(task_callback) # 创建事件循环对象 loop = asyncio.get_event_loop() # 将任务注册到事件循环中,并开启事件循环 loop.run_until_complete(task_A) print("执行时间:", time.time() - start_time) # 打印结果: 开启任务 www.1.jpg 结束任务 www.1.jpg www.1.jpg 执行时间: 2.001450300216675
总结一下:
- 创建一个协程对象
- 将协程对象进一步封装成任务对象
- 创建一个事件循环对象,将任务对象注册到事件循环对象中,开启事件循环对象
- 回调函数:只需要调用 add_done_callback() 方法即可,我们将 callback() 方法传递给了封装好的 task 对象,这样当 task 执行完毕之后就可以调用 callback() 方法了,同时 task 对象还会作为参数传递给 callback() 方法,调用 task 对象的 result() 方法就可以获取返回结果了。
3.3 多任务协程:
上面的例子我们只执行了一次请求,如果我们想执行多次请求应该怎么办呢?我们可以定义一个 task 列表,然后使用 asyncio 的 wait() 方法即可执行。
注意:asyncio异步多任务协程,必须满足几个条件才能实现异步请求:
- 保证特殊函数内部不可以出现不支持异步模块对应的代码
- 在特殊函数内部遇到阻塞操作必须使用await关键字对其进行手动挂起
- 如果想要将多个任务对象注册到事件循环中,必须将多个任务对象封装到一个列表中,然后将列表注册(必须使用wait方法将列表中的任务对象进行挂起)到事件循环中
创建一个服务器文件,falskserver.py
from flask import Flask import time app = Flask(__name__) @app.route('/bobo') def index_bobo(): time.sleep(2) return 'Hello bobo' @app.route('/jay') def index_jay(): time.sleep(2) return 'Hello jay' @app.route('/tom') def index_tom(): time.sleep(2) return 'Hello tom' if __name__ == '__main__': app.run(threaded=True)
创建requests模拟异步请求,测试结果:
结论:requests模块不支持异步请求,所有执行结果并不能达到高效率异步请求,只能是同步获取网页!!!
import asyncio import time import requests start_time = time.time() # 创建特殊函数 # 在特殊函数内部不可以出现不支持异步模块相关的代码 # requests.get这个方法并不是异步模块,所有本次测试多任务异步执行失败,异步请求可以用aiohttp(支持异步)模块 async def request(url): print('正在请求:', url) response = requests.get(url) return response.text # 任务回调函数 def task_callback(task): # 执行result函数,返回任务对象执行完成后的结果 page_text = task.result() print(page_text + ',请求到的数据!!!') # flask服务地址 urls = [ 'http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/tom', 'http://127.0.0.1:5000/jay' ] tasks = [] # 返回协程对象 for url in urls: c = request(url=url) # 封装任务对象 task = asyncio.ensure_future(c) # 把每一个任务对象存放进任务列表 tasks.append(task) # 添加任务回调函数 task.add_done_callback(task_callback) # 创建事件循环对象 loop = asyncio.get_event_loop() # 如果想要将多个任务对象注册到事件循环中,必须将多个任务对象封装到一个列表中,然后将列表注册并将任务挂起 loop.run_until_complete(asyncio.wait(tasks)) print("共执行时间:", time.time() - start_time) # 打印结果: # 正在请求: http://127.0.0.1:5000/bobo # 正在请求: http://127.0.0.1:5000/tom # 正在请求: http://127.0.0.1:5000/jay # Hello bobo,请求到的数据!!! # Hello tom,请求到的数据!!! # Hello jay,请求到的数据!!! # 共执行时间: 6.031164646148682
四、异步请求模块(aiohttp)实现
我们仅仅将涉及 IO 操作的代码封装到 async 修饰的方法里面是不可行的!我们必须要使用支持异步操作的请求方式才可以实现真正的异步,所以这里就需要 aiohttp模块了
4.1 环境安装:
# 安装aiohttp pip install aiohttp
4.2 aiohttp与requests不同点:
#在特殊函数内部不可以出现不支持异步模块相关的代码 #简单的基本架构: async def request(url):
# 创建aiohttp连接对象 with aiohttp.ClientSession() as s: # s.get/post和requests中的get/post用法几乎一样:url,headers,data/prames # 不同点:在s.get中如果使用代理操作:proxy="http://ip:port" with s.get(url) as response: # 获取字符串形式的响应数据:response.text() # 获取byte类型的:response.read() page_text = response.text() return page_text
4.3 模拟测试aiohttp异步请求:
import asyncio import time # 异步请求模块 import aiohttp start_time = time.time() # 创建特殊函数 # 在特殊函数内部不可以出现不支持异步模块相关的代码 # 异步请求aiohttp(支持异步)模块 # 细节1:在每一个with前加上async关键字 # 细节2:在get方法前和response.text()前加上await关键字进行手动挂起操作(多任务) async def request(url): # 创建aiohttp链接 async with aiohttp.ClientSession() as cs: # aiohttp发送get请求 async with await cs.get(url=url) as response: # 返回网页文本字符串响应数据 return await response.text() # 任务回调函数 def task_callback(task): # 执行result函数,返回任务对象执行完成后的结果 page_text = task.result() print(page_text + ',请求到的数据!!!') # flask服务地址,一个线程多任务时默认最多500协程(推荐) urls = [ 'http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/tom', 'http://127.0.0.1:5000/jay' ] # 任务列表 tasks = [] # 返回协程对象 for url in urls: c = request(url=url) # 封装任务对象 task = asyncio.ensure_future(c) # 把每一个任务对象存放进任务列表 tasks.append(task) # 添加任务回调函数 task.add_done_callback(task_callback) # 创建事件循环对象 loop = asyncio.get_event_loop() # 如果想要将多个任务对象注册到事件循环中,必须将多个任务对象封装到一个列表中,然后将列表注册并将任务挂起 loop.run_until_complete(asyncio.wait(tasks)) print("共执行时间:", time.time() - start_time) # 打印结果: # Hello jay,请求到的数据!!! # Hello bobo,请求到的数据!!! # Hello tom,请求到的数据!!! # 共执行时间: 2.0130958557128906
4.4 aiohttp异步请求实例(阳光热线爬取)
import aiohttp import asyncio from lxml import etree # 设置请求头信息 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36' } # 创建特殊函数,获取网页信息 async def request(url): async with aiohttp.ClientSession() as s: async with await s.get(url, headers=headers) as response: page_text = await response.text() return page_text urls = [] url = 'http://wz.sun0769.com/index.php/question/questionType?type=4&page=%d' for page in range(100): u_page = page * 30 new_url = format(url % u_page) urls.append(new_url) # 回调函数 def parse(task): page_text = task.result() # 乱码处理,这里处理乱码存在问题,可以实现replace替换保存字符 page_text = page_text.encode("gb2312").decode("gbk") tree = etree.HTML(page_text) # 获取所有的tr标签 tr_list = tree.xpath('//*[@id="morelist"]/div/table[2]//tr/td/table//tr') for tr in tr_list: # 获取标题 title = tr.xpath('./td[2]/a[2]/text()')[0] print(title) # 任务列表 tasks = [] for url in urls: c = request(url) task = asyncio.ensure_future(c) task.add_done_callback(parse) tasks.append(task) # 创建事件循环对象 loop = asyncio.get_event_loop() # 多任务对象注册到事件循环,并挂起 loop.run_until_complete(asyncio.wait(tasks))
总结:
当遇到阻塞式操作时,任务被挂起,程序接着去执行其他的任务,这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上
服务器在同一时刻接受无限次请求都能保证正常返回结果,也就是服务器无限抗压,另外还要忽略 IO 传输时延,确实可以做到无限 task 一起执行且在预想时间内得到结果(推荐一个线程500task)
五、aiohttp与多进程结合
在最新的 PyCon 2018 上,来自 Facebook 的 John Reese 介绍了 asyncio 和 multiprocessing 各自的特点,并开发了一个新的库,叫做 aiomultiprocess,感兴趣的可以了解下:https://www.youtube.com/watch?v=0kXaLh8Fz3k。
这个库的安装方式是:pip install aiomultiprocess
需要 Python 3.6 及更高版本才可使用。
使用这个库,我们可以将上面的例子改写如下:
import asyncio import aiohttp import time from aiomultiprocess import Pool start = time.time() async def get(url): session = aiohttp.ClientSession() response = await session.get(url) result = await response.text() session.close() return result async def request(): url = 'http://127.0.0.1:5000' urls = [url for _ in range(100)] async with Pool() as pool: result = await pool.map(get, urls) return result coroutine = request() task = asyncio.ensure_future(coroutine) loop = asyncio.get_event_loop() loop.run_until_complete(task) end = time.time() print('Cost time:', end - start)
这样就会同时使用多进程和异步协程进行请求,当然最后的结果其实和异步是差不多的:
Cost time: 3.1156570434570312
因为我的测试接口的原因,最快的响应也是 3 秒,所以这部分多余的时间基本都是 IO 传输时延。但在真实情况下,我们在做爬取的时候遇到的情况千变万化,一方面我们使用异步协程来防止阻塞,另一方面我们使用 multiprocessing 来利用多核成倍加速,节省时间其实还是非常可观的。