高性能异步爬虫
引入
很多同学对于异步这个概念只是停留在了“听说很NB”的认知层面上,很少有人能够在项目中真正的使用异步实现高性能的相关操作。接下来,咱们就一起来学习一下,爬虫中如何使用异步实现高性能的数据爬取操作。
背景
其实爬虫的本质就是client发请求批量获取server的响应数据,如果我们有多个url待爬取,只用一个线程且采用串行的方式执行,那只能等待爬取一个结束后才能继续下一个,效率会非常低。
所以单线程下串行多个爬虫任务是比较低效的,是因为爬虫任务是明显的IO密集型(阻塞)程序。那么该如何提高爬取性能呢?查看下述的分析测试!
分析处理
-
实验环境创建:
-
由于网络请求会受到个人电脑或者服务器响应时长不同等因素,我们可以自己创建一个专门用于测试的实验环境,搭建一个flask服务器,爬取本机自己服务器中的数据,避免其他因素的干扰,以便非常明显的观测出异步的效果。
-
#安装flask模块:pip install flask from flask import Flask,render_template from time import sleep #1.实例化app对象 app = Flask(__name__) #装饰器中的参数就是路由地址 @app.route('/main') def main():#视图函数 sleep(2) return 'i am main' #一旦启动了服务器后,在浏览器中访问路由地址,在服务器端就会执行视图函数 @app.route('/bobo') def index1(): sleep(2) return render_template('test.html') @app.route('/jay') def index2(): sleep(2) return render_template('test.html') @app.route('/tom') def index3(): sleep(2) return render_template('test.html') if __name__ == "__main__": app.run()
-
-
同步调用:即提交一个任务后就在原地等待任务结束,等到拿到任务的结果后再继续下一行代码,效率低下
-
import requests import time start = time.time()#程序执行开始的时间 urls = ['http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/tom', 'http://127.0.0.1:5000/jay'] def get_request(url): page_text = requests.get(url=url).text print(len(page_text)) for url in urls: #发起了三次请求 get_request(url) print('总耗时:',time.time()-start) #程序运行的总耗时
- 上述性能问题如何解决呢?
- 查看如下方式!
- 上述性能问题如何解决呢?
-
a. 解决同步调用方案之多线程/多进程
-
好处:使用多线程(或多进程)。多线程(或多进程)的目的是让每个爬取任务都拥有独立的线程(或进程),这样任何一个爬取任务的阻塞都不会影响其他的爬取任务。
-
弊端:开启多进程或都线程的方式,我们是无法无限制地开启多进程或多线程的:在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率。
-
测试代码:
-
import requests import time from threading import Thread #线程模块 start = time.time()#程序执行开始的时间 urls = ['http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/tom', 'http://127.0.0.1:5000/jay'] def get_request(url): page_text = requests.get(url=url).text print(len(page_text)) ts = [] for url in urls: t = Thread(target=get_request,args=(url,)) ts.append(t) t.start() for t in ts:#让主线程等在所有的子线程结束后再结束 t.join() print('总耗时:',time.time()-start) #程序运行的总耗时
-
-
b. 解决同步调用方案之线程/进程池
-
好处:很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。可以很好的降低系统开销。
-
弊端:“线程池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
-
通常使用multiprocessing.dummy线程池的方案。
-
import requests import time from multiprocessing.dummy import Pool #线程池模块 start = time.time()#程序执行开始的时间 urls = ['http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/tom', 'http://127.0.0.1:5000/jay'] def get_request(url): page_text = requests.get(url=url).text print(len(page_text)) pool = Pool(3) pool.map(get_request,urls) print('总耗时:',time.time()-start) #程序运行的总耗时
-
-
总结:
- 对应需求中可能出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈。
终极处理方案
-
上述无论哪种解决方案其实没有解决一个性能相关的问题:
- IO阻塞,无论是多进程还是多线程,在遇到IO阻塞时都会被操作系统强行剥夺走CPU的执行权限(使得cup执行其他操作,其他操作可能是我们程序的其他部分,也可能是其他的应用程序),我们自己程序的执行效率因此就降低了下来。
-
解决这一问题的关键在于:
- 我们自己从自己的应用程序级别检测到IO阻塞,然后使得cpu切换到我们自己程序的其他部分/任务执行(这里的任务指的是当前我们自己程序表示的进程或线程中的某一组操作/子程序),这样可以把我们程序的IO阻塞降到最低,我们的程序处于就绪态就会增多,以此来迷惑操作系统,操作系统便以为我们的程序是IO阻塞比较少的程序,从而会尽可能多的分配CPU给我们,这样也就达到了提升程序执行效率的目的。
- 通俗理解:
- 一个线程/进程可以表示一组指定行为的操作,这个操作可以由多个执行步骤组成,这些执行步骤有的是阻塞操作有的非阻塞操作,那么,当cpu执行当前进程/线程的时候遇到了阻塞的执行步骤的时候,如果不对其处理,则包含当前执行步骤的进程/线程就会被挂起进入到阻塞状态,且交出cpu的使用权(cpu就被别人抢走了)。那么如果遇到阻塞的执行步骤,我们的程序可以检测出它是阻塞的,且可以将cpu切换到我们自己程序其他非阻塞的执行步骤时,则包含这些执行步骤的进程/线程就不会进入到阻塞状态,从而减少进程/线程的阻塞状态,增加就绪状态(牢牢抢占cup)极大幅度提升程序执行的效率。
- 因此,有了协程后,在单进程或者单线程的模式下,就可以大幅度提升程序的运行效率了!
- 通俗理解:
- 我们自己从自己的应用程序级别检测到IO阻塞,然后使得cpu切换到我们自己程序的其他部分/任务执行(这里的任务指的是当前我们自己程序表示的进程或线程中的某一组操作/子程序),这样可以把我们程序的IO阻塞降到最低,我们的程序处于就绪态就会增多,以此来迷惑操作系统,操作系统便以为我们的程序是IO阻塞比较少的程序,从而会尽可能多的分配CPU给我们,这样也就达到了提升程序执行效率的目的。
-
在python3.5之后新增了asyncio模块,可以帮我们检测IO(只能是网络IO【HTTP连接就是网络IO操作】),实现应用程序级别的切换(异步IO)。
-
接下来让我们来了解下协程的实现,从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 asyncio,使得协程的实现更加方便。首先我们需要了解下面几个概念:
-
特殊函数:
- 在函数定义前添加一个async关键字,则该函数就变为了一个特殊的函数!
- 特殊函数的特殊之处是什么?
- 1.特殊函数被调用后,函数内部的程序语句(函数体)没有被立即执行
- 2.特殊函数被调用后,会返回一个协程对象
-
协程:
- 协程对象,特殊函数调用后就可以返回/创建了一个协程对象。
- 协程对象 == 特殊的函数 == 一组指定形式的操作
- 协程对象 == 一组指定形式的操作
-
任务:
- 任务对象就是一个高级的协程对象。高级之处,后面讲,不着急!
- 任务对象 == 协程对象 == 一组指定形式的操作
- 任务对象 == 一组指定形式的操作
-
事件循环:
- 事件循环对象(Event Loop),可以将其当做是一个容器,该容器是用来装载任务对象的。所以说,让创建好了一个或多个任务对象后,下一步就需要将任务对象全部装载在事件循环对象中。
- 思考:为什么需要将任务对象装载在事件循环对象?
- 当将任务对象装载在事件循环中后,启动事件循环对象,则其内部装载的任务对象对应的相关操作就会被立即执行。
-
import asyncio import time #特殊的函数 async def get_request(url): print('正在请求的网址是:',url) time.sleep(2) print('请求网址结束!') return 123 #创建了一个协程对象 c = get_request('www.1.com') #创建任务对象 task = asyncio.ensure_future(c) #创建事件循环对象 loop = asyncio.get_event_loop() #将任务对象装载在loop对象中且启动事件循环对象 loop.run_until_complete(task)
-
任务对象对比协程对象的高级之处重点在于:
-
可以给任务对象绑定一个回调函数!
-
回调函数有什么作用?
- 回调函数就是回头调用的函数,因此要这么理解,当任务对象被执行结束后,会立即调用给任务对象绑定的这个回调函数!
-
import asyncio import time #特殊的函数 async def get_request(url): print('正在请求的网址是:',url) time.sleep(2) print('请求网址结束!') return 123 #回调函数的封装:必须有一个参数 def t_callback(t): #参数t就是任务对象 # print('回调函数的参数t是:',t) # print('我是任务对象的回调函数!') data = t.result()#result()函数就可以返回特殊函数内部的返回值 print('我是任务对象的回调函数!,获取到特殊函数的返回值为:',data) #创建协程对象 c = get_request('www.1.com') #创建任务对象 task = asyncio.ensure_future(c) #给任务对象绑定回调函数:add_done_callback的参数就是回调函数的名字 task.add_done_callback(t_callback) #创建事件循环对象 loop = asyncio.get_event_loop() loop.run_until_complete(task)
-
-
多任务的协程
-
import asyncio import time start = time.time() urls = [ 'www.1.com','www.2.com','www.3.com' ] async def get_request(url): print('正在请求:',url) time.sleep(2) print('请求结束:',url) #有了三个任务对象和一个事件循环对象 if __name__ == '__main__': tasks = [] for url in urls: c = get_request(url) task = asyncio.ensure_future(c) tasks.append(task) #将三个任务对象,添加到一个事件循环对象中 loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.wait(tasks)) print('总耗时:',time.time()-start)
- 出现两个问题:
- 1.没有实现异步效果
- 2.wait()是什么意思?
- 出现两个问题:
-
-
wait()函数:
- 给任务列表中的每一个任务对象赋予一个可被挂起的权限!当cpu执行的任务对象遇到阻塞操作的时候,当前任务对象就会被挂起,则cup就可以执行其他任务对象,提高整体程序运行的效率!
- 挂起任务对象:让当前正在被执行的任务对象交出cpu的使用权,cup就可以被其他任务对象抢占和使用,从而可以执行其他任务对象。
- 注意:特殊函数内部,不可以出现不支持异步模块的代码,否则会中断整个异步效果!
-
await关键字:挂起发生阻塞操作的任务对象。在任务对象表示的操作中,凡是阻塞操作的前面都必须加上await关键字进行修饰!
-
完整的实现了,多任务的异步协程操作
-
import asyncio import time start = time.time() urls = [ 'www.1.com','www.2.com','www.3.com' ] async def get_request(url): print('正在请求:',url) # time.sleep(2) #time模块不支持异步 await asyncio.sleep(2) print('请求结束:',url) #有了三个任务对象和一个事件循环对象 if __name__ == '__main__': tasks = [] for url in urls: c = get_request(url) task = asyncio.ensure_future(c) tasks.append(task) #将三个任务对象,添加到一个事件循环对象中 loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.wait(tasks)) print('总耗时:',time.time()-start)
-
-
真正的将多任务的异步协程作用在爬虫中
-
需求:爬取自己服务器中的页面数据,并将其进行数据解析操作
-
aiohttp:是一个基于网络请求的模块,功能和requests相似,但是,requests是不支持异步的,而aiohttp是支持异步的模块。
-
环境安装:pip install aiohttp
-
具体用法:
-
1.先写大致加购
-
with aiohttp.ClientSession() as sess: #基于请求对象发起请求 #此处的get是发起get请求,常用参数:url,headers,params,proxy #post方法发起post请求,常用参数:url,headers,data,proxy #发现处理代理的参数和requests不一样(注意),此处处理代理使用proxy='http://ip:port' with sess.get(url=url) as response: page_text = response.text() #text():获取字符串形式的响应数据 #read():获取二进制形式的响应数据 return page_text
-
2.在第一步的基础上补充细节
-
在每一个with前加上async关键字
-
在阻塞操作前加上await关键字
-
完整代码:
-
async def get_request(url): #requests是不支持异步的模块 # response = await requests.get(url=url) # page_text = response.text #创建请求对象(sess) async with aiohttp.ClientSession() as sess: #基于请求对象发起请求 #此处的get是发起get请求,常用参数:url,headers,params,proxy #post方法发起post请求,常用参数:url,headers,data,proxy #发现处理代理的参数和requests不一样(注意),此处处理代理使用proxy='http://ip:port' async with await sess.get(url=url) as response: page_text = await response.text() #text():获取字符串形式的响应数据 #read():获取二进制形式的响应数据 return page_text
-
-
-
-
-
-
-
多任务异步爬虫的完整代码实现:
-
import requests import asyncio import time from lxml import etree import aiohttp start = time.time() urls = [ 'http://127.0.0.1:5000/bobo', 'http://127.0.0.1:5000/jay', 'http://127.0.0.1:5000/tom' ] #该任务是用来对指定url发起请求,获取响应数据 async def get_request(url): #requests是不支持异步的模块 # response = await requests.get(url=url) # page_text = response.text #创建请求对象(sess) async with aiohttp.ClientSession() as sess: #基于请求对象发起请求 #此处的get是发起get请求,常用参数:url,headers,params,proxy #post方法发起post请求,常用参数:url,headers,data,proxy #发现处理代理的参数和requests不一样(注意),此处处理代理使用proxy='http://ip:port' async with await sess.get(url=url) as response: page_text = await response.text() #text():获取字符串形式的响应数据 #read():获取二进制形式的响应数据 return page_text def parse(t):#回调函数专门用于数据解析 #获取任务对象请求到的页面源码数据 page_text = t.result() tree = etree.HTML(page_text) a = tree.xpath('//a[@id="feng"]/@href')[0] print(a) tasks = [] for url in urls: c = get_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)) print('总耗时:',time.time()-start)
-
使用uvloop加速(了解)
- 环境安装:pip install uvloop
- uvloop基于libuv,libuv是一个使用C语言实现的高性能异步I/O库,uvloop用来代替asyncio默认事件循环,可以进一步加快异步I/O操作的速度。
- uvloop的使用非常简单,只要在run_until_complete前,调用如下方法,将asyncio的事件循环策略设置为uvloop的事件循环策略。
- asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
import uvloop
loop = asyncio.get_event_loop()
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
loop.run_until_complete(asyncio.wait(tasks))