本文英文原文来自于 500 lines or less -- A Web Crawler With asyncio Coroutines中的对于爬虫的代码的解释
python之父和另外一位python大牛实现了简单的异步爬虫来展示python的推荐异步方式和效果。利用协程充分提升爬虫性能。同时也展示了一个简单的异步爬虫编写流程和结构。
将代码下载解压后直接
pip install aiohttp
安装依赖库
然后使用命令
python3 crawl.py -q xkcd.com
就可以直接先体验下异步爬虫的效果可以先直接看第二部分 使用标准库asyncio和aiohttp 再回头看第一部分 基于生成器构建协程的简单架构爬虫
基于生成器构建协程的简单架构爬虫
我们知道,生成器会暂停,然后它能被唤醒且能同时传入值,它还有返回值。听上去就像一个构建异步编程模型的良好原型,而且不会产生面条代码!我们希望能够构建一个协程:一段程序能够和其它程序合作式被调度。我们的协程将是一个python标准库"asyncio"的简化版本。正如在asyncio中一样, 我们使用生成器、futures、和yield from 语句。
首先我们需要一个方式来代表一个协程所等待的某些未来结果。一个精简版本是这样的:
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一开始就是待续(暂停)状态,当它被解决时会调用set_result
。
- 这个future有许多不足之处。例如,一旦这个future被解决,产生它的协程应当立即被唤醒而非继续暂停,但是我们的代码中它并没有。asyncio的Future类是完整的实现。
让我们改写抓取器,让它使用futures和协程。我们之前写的具有回调的fetch
是这样的:
class Fetcher:
def fetch(self):
self.sock = socket.socket()
self.sock.setblocking(False)
try:
self.sock.connect(('xkcd.com', 80))
except BlockingIOError:
pass
selector.register(self.sock.fileno(),
EVENT_WRITE,
self.connected)
def connected(self, key, mask):
print('connected!')
# And so on....
这个fetch
方法以连接一个socket开始,然后注册回调函数connected,当套接字就绪后这个回调被执行。现在我们将这两步合并到一个协程中:
def fetch(self):
sock = socket.socket()
sock.setblocking(False)
try:
sock.connect(('xkcd.com', 80))
except BlockingIOError:
pass
f = Future()
def on_connected():
f.set_result(None)
selector.register(sock.fileno(),
EVENT_WRITE,
on_connected)
yield f
selector.unregister(sock.fileno())
print('connected!')
现在fetch
是一个生成器函数 ,而不是一个普通函数,因为它具有一个yield
语句。我们创建了一个待续的future,然后将它yield来暂停fetch
直到套接字准备好。内置函数on_connected
来解决这个future。
但是当future解决后,什么唤醒生成器呢?我们需要一个协程驱动。让我们叫他“task”:
class Task:
def __init__(self, coro):
self.coro = coro
f = Future()
f.set_result(None)
self.step(f)
def step(self, future):
try:
next_future = self.coro.send(future.result)
except StopIteration:
return
next_future.add_done_callback(self.step)
# Begin fetching http://xkcd.com/353/
fetcher = Fetcher('/353/')
Task(fetcher.fetch())
loop()
这个类通过future.result
发送None
开始了fetch
生成器。然后fetch
运行直到产出一个future,而task类捕获它为next_future
。当套接字被连接上,事件循环调用回调函数on_connected
,它会解决future,future再调用step
,step
再唤醒fetch
。
使用标准库asyncio和aiohttp
- 源码参看 crawling.py crawl.py
首先,我们的爬虫将会抓取第一个页面,分析页面中链接,然后将它们加入队列。接着它会并发地爬取页面,爬完整个站点。但是为了限制客户端和服务器端的负载,我们希望设置一个运行中的爬虫的最大数目。当一个爬虫完成了它当前的页面抓取,它应当立即从队列中获取下一个链接来执行。当然有些时候队列里并没有足够的链接,所以某些爬虫需要暂停。但是当一个爬虫偶遇一个满是链接的页面时,队列会突然增长,然后暂停的爬虫应该立刻被唤醒开工。最终,我们的程序应当在它爬完后立刻退出。
想象下如果我们的爬虫是一个个线程。我们该怎样表达爬虫的算法呢?我们应当使用一个python标准库中的同步队列。每当一个项目被加进队列,队列就增加它的tasks
数目。爬虫线程在完成一个项目后调用task_done
。主线程在Queue.join
处阻塞直到每一个队列中的项目都有一个task_done
调用,然后退出。
协程使用了几乎相同的模式,不过是利用一个异步队列。首先我们将它引入:
try:
from asyncio import JoinableQueue as Queue
except ImportError:
# In Python 3.5, asyncio.JoinableQueue is
# merged into Queue.
from asyncio import Queue
我们在一个爬虫类中收集每个小爬虫的共享状态。,然后将主要逻辑放在它的crawl
方法中。我们开始crawl
协程,然后运行asyncio的事件循环,直到爬虫结束。
loop = asyncio.get_event_loop()
crawler = crawling.Crawler('http://xkcd.com',
max_redirect=10)
loop.run_until_complete(crawler.crawl())
爬虫开始于一个根链接和一个max_redirect
(对于任一URL,重定向所能允许的最大数目)。它将(URL, max_redirect)
对放入队列(稍后会解释原因)。
class Crawler:
def __init__(self, root_url, max_redirect):
self.max_tasks = 10
self.max_redirect = max_redirect
self.q = Queue()
self.seen_urls = set()
# aiohttp's ClientSession does connection pooling and
# HTTP keep-alives for us.
self.session = aiohttp.ClientSession(loop=loop)
# Put (URL, max_redirect) in the queue.
self.q.put((root_url, self.max_redirect))
现在队列中没完成的任务只有一个。回到我们的主脚本,我们开事件循环和crawl
方法:
loop.run_until_complete(crawler.crawl())
crawl
协程驱动所有的小爬虫。它就像一个主线程:当小爬虫们都在背后默默工作时,它阻塞在join
直到所有任务都完成。
@asyncio.coroutine
def crawl(self):
"""Run the crawler until all work is done."""
workers = [asyncio.Task(self.work())
for _ in range(self.max_tasks)]
# When all work is done, exit.
yield from self.q.join()
for w in workers:
w.cancel()
如果小爬虫是线程我们不会希望一开始就启动它们。为了避免创建线程的开销,我们只有必要的时候才开始一个新线程,线程池就是按需增长的。但是协程是廉价的,所以我们简单地开始了允许的最大数量。
注意到我们怎样关闭爬虫是有趣的。当join
future被解决,爬虫任务还活着但是暂停了:它们等待更多的URL但是没有新增的了。所以,主协程负责在退出之前取消它们。否则,当python解释器关闭,调用所有对象的析构函数时,尚还存活的任务就会哭叫:
ERROR:asyncio:Task was destroyed but it is pending!
但是cancel
是怎样工作的呢?生成器拥有一个特性,那就是你可以从外面抛入一个异常到生成器中:
>>> gen = gen_fn()
>>> gen.send(None) # Start the generator as usual.
1
>>> gen.throw(Exception('error'))
Traceback (most recent call last):
File "<input>", line 3, in <module>
File "<input>", line 2, in gen_fn
Exception: error
生成器将被throw
唤醒,但是它现在会抛出一个异常。如果在生成器的调用栈中没有代码来捕获这个异常,这个异常就会往回冒泡到顶端。所以要取消一个任务的协程的话:
# Method of Task class.
def cancel(self):
self.coro.throw(CancelledError)
无论生成器在哪一个yield from
语句处被暂停,它唤醒其并抛出一个异常。我们在task的step
方法处处理这个取消:
# Method of Task class.
def step(self, future):
try:
next_future = self.coro.send(future.result)
except CancelledError:
self.cancelled = True
return
except StopIteration:
return
next_future.add_done_callback(self.step)
现在task知道它自己被取消了,所以当它被销毁是它就不会怒斥光明的消逝了。
一旦crawl
取消了小爬虫,它就退出了。事件循环看到协程全都完成了(稍后我们会解释),然后它也退出了:
loop.run_until_complete(crawler.crawl())
crawl
方法包括了所有我们主协程所必须做的事情。包括小爬虫协程从队列中获取URL,抓取它们,然后解析它们获取新的链接。每个小爬虫独立地运行一个work
协程:
@asyncio.coroutine
def work(self):
while True:
url, max_redirect = yield from self.q.get()
# Download page and add new links to self.q.
yield from self.fetch(url, max_redirect)
self.q.task_done()
python看到这个代码包含了yield from
语句,所以它将之编译为一个生成器函数。所以在crawl
中,当主协程调用self.work
十次,它并没有真正执行这个方法:它只是创建了十个具有这段代码的引用的生成器对象。它将每一个生成器对象都用一个Task封装。Task接收每一个生成器产出的future,并且在每个future解决时,通过send
调用传入future的结果驱动生成器运行。因为生成器具有它们自己的栈帧,所以它们都独立的运行,拥有各自的本地变量和指令指针。
小爬虫和它的同事们通过队列互相配合。它这样等待一个新的URL:
url, max_redirect = yield from self.q.get()
队列的get
方法本身就是一个协程:它会暂停直到有人把项目放入队列,然后会苏醒并返回该项目。
顺便的,这也是小爬虫在爬虫工作末尾时停下来的地方,然后它会被主协程取消。从写成的角度,它的循环之旅在yield from
抛出一个CancelledError
异常时结束。
当一个小爬虫抓取到页面时它解析页面上的链接然后将新链接放入队列,探后调用task_done
来减少计数器。最终,一个小爬虫抓取到一个页面,上面全是已经爬过的链接,并且队列中也没有任何任务了。这个小爬虫调用task_done
将计数器减至0。然后crawl
——一直在等待队列的join
方法的——会继续运行并结束。
之前我们说要解释为什么队列中的项目都是成对的,像这样:
# URL to fetch, and the number of redirects left.
('http://xkcd.com/353', 10)
新的URL具有10次剩余的重定向机会。抓取这个特定的URL导致重定向到一个具有尾斜杠的新位置。我们减少剩余的重定向次数,并且将下一个位置放入队列:
# URL with a trailing slash. Nine redirects left.
('http://xkcd.com/353/', 9)
我们使用的aiohttp
包将默认跟随重定向然后给我们最后的响应。但是我们告诉它不要,并且在爬虫中处理重定向,所以它能合并引向相同目的地的重定向路径:如果我们已经处理过这个URL,它就会在self.seen_urls
中并且我们已经在其它不同的入口点处开始过这条路径了。
小爬虫抓取了
foo
并且看到它重定向到baz
,所以它将baz
放入队列和seen_urls
。如果下一个它要爬取的页面是bar
,同样也会被重定向到baz
的话,这个爬虫就不会再将baz
放入队列了。如果返回的是一个页面,而不是一个重定向,fetch
将会分析页面的链接并把新的加入队列。
@asyncio.coroutine
def fetch(self, url, max_redirect):
# Handle redirects ourselves.
response = yield from self.session.get(
url, allow_redirects=False)
try:
if is_redirect(response):
if max_redirect > 0:
next_url = response.headers['location']
if next_url in self.seen_urls:
# We have been down this path before.
return
# Remember we have seen this URL.
self.seen_urls.add(next_url)
# Follow the redirect. One less redirect remains.
self.q.put_nowait((next_url, max_redirect - 1))
else:
links = yield from self.parse_links(response)
# Python set-logic:
for link in links.difference(self.seen_urls):
self.q.put_nowait((link, self.max_redirect))
self.seen_urls.update(links)
finally:
# Return connection to pool.
yield from response.release()
如果这是多线程代码,它将会有糟糕的竞态。例如,一个小爬虫先检查一个链接是否在seen_urls
中,如果没有它就会将它放入队列然后将它添加到seen_urls
中。如果它在两个步骤中间被打断了,其它小爬虫可能会从其它页面分析到相同的URL,同样也查看这个URL是否在seen_urls
中,然后将它加入队列。现在同样的链接就在队列中两次出现了,这至少会导致重复的工作和错误的数据。
但是,协程只在yield from
语句中易受中断。这是一个关键的差异,使得协程代码远不如多线程哪样容易产生竞态:多线程代码必须通过获得一个锁明确地进入一个临界区域,否则它将是可中断的。一个Python协程是默认不可中断的,并且只会在它明确地yield时让出控制权。
我们不再需要一个fetcher类——像我们之前的基于回调的程序所拥有的。这个类是回调的缺点的一个解决办法:它们需要某个地方在等待IO时存放状态,因为它们自己的局部变量不能跨调用保留。但是fetch
协程能像普通函数那样存储它的状态在本地变量中,所以这里就没有必要设置这样一个类。
当fetch
完成对服务器响应的处理时它返回调用者,work
。work
方法调用队列上的task_done
方法,然后从队列上获取下一个要抓取的URL。
当fetch
将新的链接放入队列中,它增加了未完成任务的计数并且使等待q.join
的主协程暂停。但是如果没有任何新的链接并且这是队列中的最后一个URL,那么work
调用task_done
使得未完成的任务数减至0。这一事件将取消join
的暂停并使主协程完成。
协调小爬虫们和主协程的队列代码类似下面:
class Queue:
def __init__(self):
self._join_future = Future()
self._unfinished_tasks = 0
# ... other initialization ...
def put_nowait(self, item):
self._unfinished_tasks += 1
# ... store the item ...
def task_done(self):
self._unfinished_tasks -= 1
if self._unfinished_tasks == 0:
self._join_future.set_result(None)
@asyncio.coroutine
def join(self):
if self._unfinished_tasks > 0:
yield from self._join_future
主协程,crawl
,yield from join
。所以当最后一个小爬虫将未完成任务计数减至0时,它发信号给crawl
使之苏醒,然后结束。
爬虫之旅就要结束了。我们的程序开始于对crawl
的调用:
loop.run_until_complete(self.crawler.crawl())
那么这个程序怎么结束呢?既然crawl
是一个生成器函数,调用它返回一个生成器。为了驱动这个生成器,asyncio 将它封装进一个task:
class EventLoop:
def run_until_complete(self, coro):
"""Run until the coroutine is done."""
task = Task(coro)
task.add_done_callback(stop_callback)
try:
self.run_forever()
except StopError:
pass
class StopError(BaseException):
"""Raised to stop the event loop."""
def stop_callback(future):
raise StopError
当任务完成,它抛出StopError
异常——循环将之当作是它正常完成的信号。
但是这是什么?这个task具有名为add_done_callback
和result
的方法?你可能认为一个task类似于一个future。你的直觉是正确的。我们必须承认一个关于Task类隐藏的细节:一个task就是一个future。
class Task(Future):
"""A coroutine wrapped in a Future."""
通常一个future通过其它东西调用它的set_result
来解决它。但是对一个task,当它的协程停止时,它会自己解决自己。在我们之前对于Python生成器的探索中,当一个生成器返回时,它抛出一个特殊的StopIteration
异常:
# Method of class Task.
def step(self, future):
try:
next_future = self.coro.send(future.result)
except CancelledError:
self.cancelled = True
return
except StopIteration as exc:
# Task resolves itself with coro's return
# value.
self.set_result(exc.value)
return
next_future.add_done_callback(self.step)
所以当时间循环调用task.add_done_callback(stop_callback)
时,它就准备好被task停止。这里又是run_until_complete
:
# Method of event loop.
def run_until_complete(self, coro):
task = Task(coro)
task.add_done_callback(stop_callback)
try:
self.run_forever()
except StopError:
pass
当task捕获到StopIteration
并且解决它自己,回调就在循环中抛出一个StopError
。循环停止,调用栈展开到run_until_complete
。我们的程序就结束了。