在web迅速发展的现在,io密集型应用已经逐渐成为当今最流行的应用形式之一。尤其是网络爬虫,对网络的环境状态和时延有着非常高的要求,这时候,网络io往往成为一个爬虫的爬取效率上的瓶颈之处,如果采用同步模式的网络请求,当socket处于io阻塞时,就会造成整个系统停顿,效率十分低下。随着技术演进,针对该问题的解决方案也越来越多,比较好的解决方法无一例外都会要求并发性,就是说,系统不能在阻塞在网络io的等待中。实现网络io并发操作的常见编程模型有:多进程、多线程、异步回调、协程等等。
让我们看看他们各自的特点:
-
多进程:可以充分地利用cpu的多核,实现真正的并行(尤其是在python这种带有GIL锁的语言里),每个进程处理各自的请求,实现并发io处理。但使用多进程有一些不足之处,优先,网络应用是io密集型的,多cpu处理并没有带来多大的优势,而且在多进程中,各个进程不方便互相通信,为每一条请求创建一个进程开销太大,当有成千上万条请求的时候极容易拖垮整个系统。
-
多线程:各个线程都在同一个进程中进行处理,线程之间很方便就能共享一些数据和资源,线程的开销小于进程。多线程看似比多进程更适合io密集型的应用,但它也有几个缺陷,在python这类语言里,传统的多线程因为全局解释锁的缘故并不能利用到多核,而且,由于多线程都在同一个进程进行处理,这时候对数据的一致性和执行顺序都要保证被正确处理,往往需要通过事件或锁机制来保证这个步骤,编程难度变高。
-
异步回调:使用异步回调虽然能避免一些多线程中的坑,但记住,异步回调是不符合人类思维的,用过js的童鞋大概都清楚什么叫做callback hell。例如下面一段代码,一层嵌套一层,让人难以理解。promise解决了callback hell的问题,但在语法上还是不如同步语法清晰,捕获异常也比较繁琐。那有没有以同步的语法实现异步操作呢,这就要说到下面的协程。
getData(function(x){ getMoreData(x, function(y){ getMoreData(y, function(z){ ... }); }); }); // callback hell getData() .then(callback.success) .then(undefined, callback.error); // promise -
协程:协程的最大的优势是他用的是同步的语法,通过保存上下文的状态,实现在各个协程中自由的切换而保证程序的状态,在python3.5中,async和await已经被纳入了标准的语法中,方便与asyncio库一同使用。
这篇文章将会讲述如何用协程打造一个基于事件驱动的异步爬虫。
首先,我们用同步的方式,抓取百度的一百个网页。
def sync_way():
for i in range(100):
sock = socket.socket()
sock.connect(('www.baidu.com', 80))
print('connected')
request = 'GET {} HTTP/1.0\r\nHost: www.baidu.com\r\n\r\n'.format('/s?wd={}'.format(i))
sock.send(request.encode('ascii'))
response = b''
chunk = sock.recv(4096)
while chunk:
response += chunk
chunk = sock.recv(4096)
print('done!!')
from time import time
start = time()
sync_way() #Cost 47.757508993148804 seconds
end = time()
print ('Cost {} seconds'.format(end - start))
总共耗时47秒,这对于一个要求性能的爬虫来说是不可接受的,看看我们有没有办法将这个爬虫的性能提高十倍以上,把时间缩短到5秒之内。
首先考虑上面这个程序的瓶颈出在哪个地方,经过思考,很容易看出上面的程序有几个不足之处:
- socket连接的建立需要等待,一旦握手建立的时间漫长,就会影响下面的流程正常运行。
- socket接收数据的过程是阻塞式的,等待buffer的过程也是需要一段时间的。
- socket的建立连接-接收过程都是一个一个来的,在没完成一个连接时不能进行其他连接的处理。
好了,先解决第一个问题:socket的等待。痛点很明显,我们不能一直等待socket的状态发生改变,而是当socket的状态发生改变时,让它告诉我们。要解决这个问题,可以利用io复用,先看看io复用的定义:
IO复用:预先告知内核,使内核一旦发现进程指定的一个或多个IO条件就绪(输入准备被读取,或描述符能承接更多的输出),它就通知进程。
阻塞IO模型看起来是这样的:
recvfrom->无数据报准备好->等待数据->数据报准备好->数据从内核复制到用户空间->复制完成->返回成功指示
而IO复用模型看起来是这样的:
select->无数据报准备好->据报准备好->返回可读条件->recvfrom->数据从内核复制到用户空间->复制完成->返回成功指示
于是我们可以对上面的代码这样修改。
from selectors import DefaultSelector, EVENT_WRITE
selector = DefaultSelector()
sock = socket.socket()
sock.setblocking(False)
try:
sock.connect(('www.baidu.com', 80))
except BlockingIOError:
pass
def connected():
selector.unregister(sock.fileno())
print('connected!')
selector.register(sock.fileno(), EVENT_WRITE, connected)
把socket设置为非阻塞,把socket的句柄注册到事件轮询中,当socket发生可写事件时,表示socket连接就绪了,这时候再把socket从事件轮询中删除,在socket返回可写事件之前,系统都不是阻塞状态的。同理,对于socket从网络中接收数据,也可以用同样的方法,只需要把要监听的事件改为可读事件就行了。
当然,仅仅这样还是不够的,试想一下,如果有多个socket进行连接,采用上面的非阻塞方式,当一个socket开始等待事件返回时,理论上系统此时应该做的是处理另一个socket的流程,但这里还缺乏了一个必要的机制,当从一个处理socket流程切到另一个处理socket流程时,原来的流程的上下文状态该怎么保存下来以便恢复呢,显然易见这里需要用到上面说到的协程机制,在python中通过yield语法可以把一个函数或方法包装成一个生成器,当生成器执行yield语句时,生成器内部的上下文状态就会被保存,如果想要在未来的操作中把这个生成器恢复,只需要调用生成器的send方法即可从原流程中继续往下走。
有了上面这个概念,我们可以创建一个Future类,它代表了协程中等待的“未来发生的结果”,举例来说,在发起网络请求时,socket会在buffer中返回一些数据,这个获取的动作在异步流程中发生的时间是不确定的,Future就是用来封装这个未来结果的类,但当socket在某个时间段监测到可读事件,读取到数据了,那么他就会把数据写入Future里,并告知Future要执行某些回调动作。
class Future:
def __init__(self):
self.result = None
self._callbacks = []
def add_done_callback(self, fn):
self._callbacks.append(fn)
def set_result(self, result):
self.result = result
for fn in self._callbacks:
fn(self)
有了Future,我们可以包装一个AsyncRequest类,用以发起异步请求的操作。
class AsyncRequest:
def __init__(self, host, url, port, timeout=5):
self.sock = socket.socket()
self.sock.settimeout(timeout)
self.sock.setblocking(False)
self.host = host
self.url = url
self.port = port
self.method = None
def get(self):
self.method = 'GET'
self.request = '{} {} HTTP/1.0\r\nHost: {}\r\n\r\n'.format(self.method, self.url, self.host)
return self
def process(self):
if self.method is None:
self.get()
try:
self.sock.connect((self.host, self.port))
except BlockingIOError:
pass
self.f = Future()
selector.register(self.sock.fileno(),
EVENT_WRITE,
self.on_connected)
yield self.f
selector.unregister(self.sock.fileno())
self.sock.send(self.request.encode('ascii'))
chunk = yield from read_all(self.sock)
return chunk
def on_connected(self, key, mask):
self.f.set_result(None)
在AsyncRequest的process方法里,实例在发起异步连接请求后通过yield一个future阻断了程序流,表示他需要等待未来发生的动作发生(在这里是等待socket可写),这时候系统会去执行其他事件,当未来socket变成可写时,future被写入数据,同时执行回调,从原来停下的地方开始执行,执行读取socket数据的处理。
这里关键的地方就是future在yield之后会在未来某个时候再次被send然后继续往下走,这时候就需要一个用来驱动Future的类。这里称为Task,它需要接受一个协程作为参数,并驱动协程的程序流执行。
class Task(Future):
def __init__(self, coro):
super().__init__()
self.coro = coro
f = Future()
f.set_result(None)
self.step(f)
def step(self, future):
try:
next_future = self.coro.send(future.result)
if next_future is None:
return
except StopIteration as exc:
self.set_result(exc.value)
return
next_future.add_done_callback(self.step)
最终,整个程序还需要一个EventLoop类,用来监听到来的事件为socket执行回调以及把协程包装成Task来实现异步驱动。
class EventLoop:
stopped = False
select_timeout = 5
def run_until_complete(self, coros):
tasks = [Task(coro) for coro in coros]
try:
self.run_forever()
except StopError:
pass
def run_forever(self):
while not self.stopped:
events = selector.select(self.select_timeout)
if not events:
raise SelectTimeout('轮询超时')
for event_key, event_mask in events:
callback = event_key.data
callback(event_key, event_mask)
def close(self):
self.stopped = True
OK,那么现在用新的方法再测试一遍,通过python3的yield from语法我们把协程操作代理到AsyncRequest类的process方法中,最终把协程放到EventLoop中执行。
def fetch(url):
request = AsyncRequest('www.baidu.com', url, 80)
data = yield from request.process()
return data
def get_page(url):
page = yield from fetch(url)
return page
def async_way():
ev_loop = get_event_loop()
ev_loop.run_until_complete([
get_page('/s?wd={}'.format(i)) for i in range(100)
])
from time import time
start = time()
async_way() # Cost 3.534296989440918 seconds
end = time()
print ('Cost {} seconds'.format(end - start))
可以看到总共耗时3.5秒,通过把同步改写成基于事件驱动的异步,整个程序的效率提高的十倍以上。
有了上面的基础,可以更进一步改写出一个的任务队列的异步处理形式,把EventLoop的实现隐藏,提供更简单的接口。
from collections import deque
class Queue:
def __init__(self):
self._q = deque()
self.size = 0
def put(self, item):
self.size += 1
self._q.append(item)
def get(self):
item = self._q.popleft()
return item
def task_done(self):
self.size -= 1
if self.size == 0:
self.empty_callback()
class AsyncWorker(Queue):
def __init__(self, coroutine, workers=10, loop_timeout=5):
super().__init__()
self.func = coroutine
self.stopped = False
self.ev_loop = get_event_loop()
self.ev_loop.select_timeout = loop_timeout
self.workers = workers
self.result_callbacks = []
def work(self):
def _work():
while not self.stopped:
item = None
try:
item = self.get()
except IndexError:
yield None
result = yield from self.func(item)
self.task_done()
for rcb in self.result_callbacks:
rcb(result)
self.tasks = []
for _ in range(self.workers):
self.tasks.append(_work())
self.ev_loop.run_until_complete(self.tasks)
def add_result_callback(self, func):
self.result_callbacks.append(func)
def empty_callback(self):
self.ev_loop.close()
def print_content_length(data):
print(len(data))
async_worker = AsyncWorker(get_page, workers=20)
async_worker.add_result_callback(print_content_length)
for i in range(15):
async_worker.put('/s?wd={}'.format(i))
async_worker.work()