python异步爬虫

本文英文原文来自于 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再调用stepstep再唤醒fetch

使用标准库asyncio和aiohttp

首先,我们的爬虫将会抓取第一个页面,分析页面中链接,然后将它们加入队列。接着它会并发地爬取页面,爬完整个站点。但是为了限制客户端和服务器端的负载,我们希望设置一个运行中的爬虫的最大数目。当一个爬虫完成了它当前的页面抓取,它应当立即从队列中获取下一个链接来执行。当然有些时候队列里并没有足够的链接,所以某些爬虫需要暂停。但是当一个爬虫偶遇一个满是链接的页面时,队列会突然增长,然后暂停的爬虫应该立刻被唤醒开工。最终,我们的程序应当在它爬完后立刻退出。
想象下如果我们的爬虫是一个个线程。我们该怎样表达爬虫的算法呢?我们应当使用一个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()

如果小爬虫是线程我们不会希望一开始就启动它们。为了避免创建线程的开销,我们只有必要的时候才开始一个新线程,线程池就是按需增长的。但是协程是廉价的,所以我们简单地开始了允许的最大数量。
注意到我们怎样关闭爬虫是有趣的。当joinfuture被解决,爬虫任务还活着但是暂停了:它们等待更多的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完成对服务器响应的处理时它返回调用者,workwork方法调用队列上的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_callbackresult的方法?你可能认为一个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。我们的程序就结束了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容