为什么要用异步爬虫?
爬虫本质上就是模拟客户端与服务端的通讯过程。以浏览器端的爬虫为例,我们在爬取不同网页过程中,需要根据url构建很多HTTP请求去爬取,而如果以单个线程为参考对象,平常我们所采取的编码习惯,通常是基于同步模式的,也就是串行的方式去执行这些请求,只有当一个url爬取结束后才会进行下一个url的爬取,由于网络IO的延时存在,效率非常低。
到这里可能会有人说,那么我们可以使用多进程+多线程来提高效率啊,为什么要使用异步编程,毕竟异步编程会大大增加编程难度。【进程、线程、协程简单梳理】在这篇整理文章中有提到,多进程/多线程虽然能提高效率,但是在进程/线程切换的时候,也会消耗很多资源。而且就IO密集型任务来说,虽然使用多线程并发可以提高CUP使用率,提升爬取效率,但是却还是没有解决IO阻塞问题。无论是多进程还是多线程,在遇到IO阻塞时都会被操作系统强行剥夺走CPU的执行权限,爬虫的执行效率因此就降低了下来。而异步编程则是我们在应用程序级别来检测IO阻塞然后主动切换到其他任务执行,以此来'降低'我们爬虫的IO,使我们的爬虫更多的处于就绪态,这样操作系统就会让CPU尽可能多地临幸我们的爬虫,从而提高爬虫的爬取效率。
补充:常见的IO模型有阻塞、非阻塞、IO多路复用,异步。下面举个小栗子来简单描述一下这四个场景。
当快乐的敲代码时光结束时,没有女朋友的单身狗只能约上好基友去召唤师峡谷傲游,当我秒选快乐风男,然后发送“亚索中单,不给就送后”,在队友一片欢声笑语中进入加载界面,奈何遇到小霸王,加载异常缓慢。。。此时!
- 你选择什么也不做,直直地看着辅助妹子的琴女原画,等待加载完成。这是阻塞。
- 你选择切换出去到某鱼看球,但是你得时不时的切换回LOL,看看是否加载完成,就这样来来回回,累得要死,还错过很多精彩画面。这是非阻塞。
- 你掏出自己的爱疯XL,打开某鱼APP看球,这样就不用来回切换,只是时不时地看一下电脑显示器,看是否加载完成了。这是IO多路复用。
- 连泡面都舍不得一次用完一包酱包的你,哪有爱疯XL,但是身为码农的尊严让你写个个小程序,在检测到游戏在加载的时候,自动切换到浏览器,打开某鱼的球星直播间。而当游戏加载完成后,自动切换回LOL游戏界面。无缝切换丝滑感受。这个叫异步。
下面开始进入正题
asyncio
在介绍aiohttp、tornado、twisted之前,先了解下python3.4版本引入的标准库asyncio
。它可以帮助我们检测IO(只能是网络IO),实现应用程序级别的切换。它的编程模型是一个消息循环。我们可以从asyncio
模块中直接获取一个EventLoop
的引用,然后把需要执行的协程扔到EventLoop
中执行,就实现了异步IO。
基本使用
import asyncio
import random
import datetime
urls=['www.baidu.com','www.qq.com','www.douyu.com']
@asyncio.coroutine
def crawl(url):
print("正在抓取:{}-{}".format(url,datetime.datetime.now().time()))
io_time = random.random()*3 #随机模拟网络IO时间
yield from asyncio.sleep(io_time) #模拟网络IO
print('{}-抓取完成,用时{}s'.format(url,io_time))
loop = asyncio.get_event_loop() #获取EventLoop
loop.run_until_complete(asyncio.wait(map(crawl,urls))) #执行coroutine
loop.close()
运行结果:
正在抓取:www.baidu.com-12:45:26.517226
正在抓取:www.douyu.com-12:45:26.517226
正在抓取:www.qq.com-12:45:26.517226
www.douyu.com-抓取完成,用时0.1250027573049739s
www.baidu.com-抓取完成,用时0.450045918339271s
www.qq.com-抓取完成,用时0.6967129499714361s
[Finished in 0.9s]
运行的时候可以发现三个请求几乎是同时发出的,而返回顺序则是根据网络IO完成时间顺序返回的。
由于asyncio主要应用于TCP/UDP socket通讯,不能直接发送http请求,因此,我们需要自己定义http报头。
补充:
- 客户端发送一个HTTP请求到服务器的请求消息包括以下格式:请求行、消息报头和请求正文三个部分。
例如:GET / HTTP/1.1\r\nHost: www.sina.com.cn\r\nConnection: close\r\n\r\n
- 用
asyncio
提供的@asyncio.coroutine
可以把一个generator
标记为coroutine
类型,然后在coroutine
内部用yield from
调用另一个coroutine
实现异步操作。为了简化并更好地标识异步IO,从Python 3.5
开始引入了新的语法async
和await
,可以让coroutine
的代码更简洁易读。
概念补充
event_loop
:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。coroutine
:中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。task
:任务,它是对协程对象的进一步封装,包含了任务的各个状态。future
:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。
有了以上知识基础,就可以撸代码啦
import asyncio
import uuid
user_agent='Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.221 Safari/537.36 SE 2.X MetaSr 1.0'
def parse_page(host,res):
print('%s 解析结果 %s' %(host,len(res)))
with open('%s.html' %(uuid.uuid1()),'wb') as f:
f.write(res)
async def get_page(host,port=80,url='/',callback=parse_page,ssl=False,encode_set='utf-8'):
print('下载 http://%s:%s%s' %(host,port,url))
if ssl:
port=443
#发起tcp连接, IO阻塞操作
recv,send=await asyncio.open_connection(host=host,port=port,ssl=ssl)
#封装http协议的报头,因为asyncio模块只能封装并发送tcp包,因此这一步需要我们自己封装http协议的包
request_headers="""GET {} HTTP/1.0\r\nHost: {}\r\nUser-agent: %s\r\n\r\n""".format(url,host,user_agent)
request_headers=request_headers.encode(encode_set)
#发送构造好的http请求(request),IO阻塞
send.write(request_headers)
await send.drain()
#接收响应头 IO阻塞操作
while True:
line=await recv.readline()
if line == b'\r\n':
break
print('%s Response headers:%s' %(host,line))
#接收响应体 IO阻塞操作
text=await recv.read()
#执行回调函数
callback(host,text)
#关闭套接字
send.close() #没有recv.close()方法,因为是四次挥手断链接,双向链接的两端,一端发完数据后执行send.close()另外一端就被动地断开
if __name__ == '__main__':
tasks=[
get_page('www.gov.cn',url='/',ssl=False),
get_page('www.douyu.com',url='/',ssl=True),
]
loop=asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
用上async/await关键字,是不是既简洁,也更便于理解了
自己动手封装HTTP(S)报头确实很麻烦,所以接下来就要请出这一小节的正主aiohttp了,它里面已经帮我们封装好了。
补充:asyncio
可以实现单线程并发IO操作。如果仅用在客户端,发挥的威力不大。如果把asyncio
用在服务器端,例如Web服务器,由于HTTP连接就是IO操作,因此可以用单线程+coroutine
实现多用户的高并发支持。asyncio
实现了TCP
、UDP
、SSL
等协议,aiohttp
则是基于asyncio
实现的HTTP框架。它分为两部分,一部分是Client
(我们将要使用的部分,因为我们爬虫是模拟客户端操作嘛),一部分是 Server
,详细的内容可以参考官方文档。
下面我们用aiohttp来改写上面的代码:
import asyncio
import uuid
import aiohttp
user_agent='Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.221 Safari/537.36 SE 2.X MetaSr 1.0'
def parse_page(url,res):
print('{} 解析结果 {}'.format(url,len(res)))
with open('{}.html'.format(uuid.uuid1()),'wb') as f:
f.write(res)
async def get_page(url,callback=parse_page):
session = aiohttp.ClientSession()
response = await session.get(url)
if response.reason == 'OK':
result = await response.read()
session.close()
callback(url,result)
if __name__ == '__main__':
tasks=[
get_page('http://www.gov.cn'),
get_page('https://www.douyu.com'),
]
loop=asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
是不是更加简洁了呢?
Twisted
twisted
是一个网络框架,哪怕刚刚接触python爬虫的萌新都知道的Scrapy
爬虫框架,就是基于twisted
写的。它其中一个功能就是发送异步请求,检测IO并自动切换。
基于twisted修改上面的代码如下:
from twisted.web.client import getPage,defer
from twisted.internet import reactor
import uuid
def tasks_done(arg):
reactor.stop() #停止reactor
#定义回调函数
def parse_page(res):
print('解析结果 {}'.format(len(res)))
with open('{}.html'.format(uuid.uuid1()),'wb') as f:
f.write(res)
defer_list=[]#初始化一个列表来存放getPage返回的defer对象
urls=[
'http://www.gov.cn',
'https://www.douyu.com',
]
for url in urls:
obj = getPage(url.encode('utf-8'),) #getPage会返回一个defer对象
obj.addCallback(parse_page) #给defer对象添加回调函数
defer_list.append(obj) #将defer对象添加到列表中
defer.DeferredList(defer_list).addBoth(tasks_done) #任务列表结束后停止reactor.stop
reactor.run #启动监听
这只是一个简单的应用,后面会看情况可能会写一篇Twisted的整理文章。