python異步爬蟲

本文英文原文來自于 500 lines or less -- A Web Crawler With asyncio Coroutines中的對于爬蟲的代碼的解釋

  • python之父和另外一位python大牛實(shí)現(xiàn)了簡單的異步爬蟲來展示python的推薦異步方式和效果。利用協(xié)程充分提升爬蟲性能。同時也展示了一個簡單的異步爬蟲編寫流程和結(jié)構(gòu)。

  • 代碼下載解壓后直接
    pip install aiohttp安裝依賴庫
    然后使用命令
    python3 crawl.py -q xkcd.com
    就可以直接先體驗(yàn)下異步爬蟲的效果

  • 可以先直接看第二部分 使用標(biāo)準(zhǔn)庫asyncio和aiohttp 再回頭看第一部分 基于生成器構(gòu)建協(xié)程的簡單架構(gòu)爬蟲


基于生成器構(gòu)建協(xié)程的簡單架構(gòu)爬蟲

我們知道,生成器會暫停,然后它能被喚醒且能同時傳入值,它還有返回值。聽上去就像一個構(gòu)建異步編程模型的良好原型,而且不會產(chǎn)生面條代碼!我們希望能夠構(gòu)建一個協(xié)程:一段程序能夠和其它程序合作式被調(diào)度。我們的協(xié)程將是一個python標(biāo)準(zhǔn)庫"asyncio"的簡化版本。正如在asyncio中一樣, 我們使用生成器、futures、和yield from 語句。
首先我們需要一個方式來代表一個協(xié)程所等待的某些未來結(jié)果。一個精簡版本是這樣的:

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一開始就是待續(xù)(暫停)狀態(tài),當(dāng)它被解決時會調(diào)用set_result

  • 這個future有許多不足之處。例如,一旦這個future被解決,產(chǎn)生它的協(xié)程應(yīng)當(dāng)立即被喚醒而非繼續(xù)暫停,但是我們的代碼中它并沒有。asyncio的Future類是完整的實(shí)現(xiàn)。

讓我們改寫抓取器,讓它使用futures和協(xié)程。我們之前寫的具有回調(diào)的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開始,然后注冊回調(diào)函數(shù)connected,當(dāng)套接字就緒后這個回調(diào)被執(zhí)行。現(xiàn)在我們將這兩步合并到一個協(xié)程中:

    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!')

現(xiàn)在fetch是一個生成器函數(shù) ,而不是一個普通函數(shù),因?yàn)樗哂幸粋€yield語句。我們創(chuàng)建了一個待續(xù)的future,然后將它yield來暫停fetch直到套接字準(zhǔn)備好。內(nèi)置函數(shù)on_connected來解決這個future。
但是當(dāng)future解決后,什么喚醒生成器呢?我們需要一個協(xié)程驅(qū)動。讓我們叫他“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發(fā)送None開始了fetch生成器。然后fetch運(yùn)行直到產(chǎn)出一個future,而task類捕獲它為next_future。當(dāng)套接字被連接上,事件循環(huán)調(diào)用回調(diào)函數(shù)on_connected,它會解決future,future再調(diào)用stepstep再喚醒fetch

使用標(biāo)準(zhǔn)庫asyncio和aiohttp

首先,我們的爬蟲將會抓取第一個頁面,分析頁面中鏈接,然后將它們加入隊(duì)列。接著它會并發(fā)地爬取頁面,爬完整個站點(diǎn)。但是為了限制客戶端和服務(wù)器端的負(fù)載,我們希望設(shè)置一個運(yùn)行中的爬蟲的最大數(shù)目。當(dāng)一個爬蟲完成了它當(dāng)前的頁面抓取,它應(yīng)當(dāng)立即從隊(duì)列中獲取下一個鏈接來執(zhí)行。當(dāng)然有些時候隊(duì)列里并沒有足夠的鏈接,所以某些爬蟲需要暫停。但是當(dāng)一個爬蟲偶遇一個滿是鏈接的頁面時,隊(duì)列會突然增長,然后暫停的爬蟲應(yīng)該立刻被喚醒開工。最終,我們的程序應(yīng)當(dāng)在它爬完后立刻退出。
想象下如果我們的爬蟲是一個個線程。我們該怎樣表達(dá)爬蟲的算法呢?我們應(yīng)當(dāng)使用一個python標(biāo)準(zhǔn)庫中的同步隊(duì)列。每當(dāng)一個項(xiàng)目被加進(jìn)隊(duì)列,隊(duì)列就增加它的tasks數(shù)目。爬蟲線程在完成一個項(xiàng)目后調(diào)用task_done。主線程在Queue.join處阻塞直到每一個隊(duì)列中的項(xiàng)目都有一個task_done調(diào)用,然后退出。
協(xié)程使用了幾乎相同的模式,不過是利用一個異步隊(duì)列。首先我們將它引入:

try:
    from asyncio import JoinableQueue as Queue
except ImportError:
    # In Python 3.5, asyncio.JoinableQueue is
    # merged into Queue.
    from asyncio import Queue

我們在一個爬蟲類中收集每個小爬蟲的共享狀態(tài)。,然后將主要邏輯放在它的crawl方法中。我們開始crawl協(xié)程,然后運(yùn)行asyncio的事件循環(huán),直到爬蟲結(jié)束。

loop = asyncio.get_event_loop()

crawler = crawling.Crawler('http://xkcd.com',
                           max_redirect=10)

loop.run_until_complete(crawler.crawl())

爬蟲開始于一個根鏈接和一個max_redirect(對于任一URL,重定向所能允許的最大數(shù)目)。它將(URL, max_redirect)對放入隊(duì)列(稍后會解釋原因)。

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))

現(xiàn)在隊(duì)列中沒完成的任務(wù)只有一個。回到我們的主腳本,我們開事件循環(huán)和crawl方法:

loop.run_until_complete(crawler.crawl())

crawl協(xié)程驅(qū)動所有的小爬蟲。它就像一個主線程:當(dāng)小爬蟲們都在背后默默工作時,它阻塞在join直到所有任務(wù)都完成。

    @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()

如果小爬蟲是線程我們不會希望一開始就啟動它們。為了避免創(chuàng)建線程的開銷,我們只有必要的時候才開始一個新線程,線程池就是按需增長的。但是協(xié)程是廉價的,所以我們簡單地開始了允許的最大數(shù)量。
注意到我們怎樣關(guān)閉爬蟲是有趣的。當(dāng)joinfuture被解決,爬蟲任務(wù)還活著但是暫停了:它們等待更多的URL但是沒有新增的了。所以,主協(xié)程負(fù)責(zé)在退出之前取消它們。否則,當(dāng)python解釋器關(guān)閉,調(diào)用所有對象的析構(gòu)函數(shù)時,尚還存活的任務(wù)就會哭叫:

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喚醒,但是它現(xiàn)在會拋出一個異常。如果在生成器的調(diào)用棧中沒有代碼來捕獲這個異常,這個異常就會往回冒泡到頂端。所以要取消一個任務(wù)的協(xié)程的話:

    # 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)

現(xiàn)在task知道它自己被取消了,所以當(dāng)它被銷毀是它就不會怒斥光明的消逝了。
一旦crawl取消了小爬蟲,它就退出了。事件循環(huán)看到協(xié)程全都完成了(稍后我們會解釋),然后它也退出了:

loop.run_until_complete(crawler.crawl())

crawl方法包括了所有我們主協(xié)程所必須做的事情。包括小爬蟲協(xié)程從隊(duì)列中獲取URL,抓取它們,然后解析它們獲取新的鏈接。每個小爬蟲獨(dú)立地運(yùn)行一個work協(xié)程:

    @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語句,所以它將之編譯為一個生成器函數(shù)。所以在crawl中,當(dāng)主協(xié)程調(diào)用self.work十次,它并沒有真正執(zhí)行這個方法:它只是創(chuàng)建了十個具有這段代碼的引用的生成器對象。它將每一個生成器對象都用一個Task封裝。Task接收每一個生成器產(chǎn)出的future,并且在每個future解決時,通過send調(diào)用傳入future的結(jié)果驅(qū)動生成器運(yùn)行。因?yàn)樯善骶哂兴鼈冏约旱臈运鼈兌吉?dú)立的運(yùn)行,擁有各自的本地變量和指令指針。
小爬蟲和它的同事們通過隊(duì)列互相配合。它這樣等待一個新的URL:

url, max_redirect = yield from self.q.get()

隊(duì)列的get方法本身就是一個協(xié)程:它會暫停直到有人把項(xiàng)目放入隊(duì)列,然后會蘇醒并返回該項(xiàng)目。
順便的,這也是小爬蟲在爬蟲工作末尾時停下來的地方,然后它會被主協(xié)程取消。從寫成的角度,它的循環(huán)之旅在yield from拋出一個CancelledError異常時結(jié)束。
當(dāng)一個小爬蟲抓取到頁面時它解析頁面上的鏈接然后將新鏈接放入隊(duì)列,探后調(diào)用task_done來減少計(jì)數(shù)器。最終,一個小爬蟲抓取到一個頁面,上面全是已經(jīng)爬過的鏈接,并且隊(duì)列中也沒有任何任務(wù)了。這個小爬蟲調(diào)用task_done將計(jì)數(shù)器減至0。然后crawl——一直在等待隊(duì)列的join方法的——會繼續(xù)運(yùn)行并結(jié)束。

之前我們說要解釋為什么隊(duì)列中的項(xiàng)目都是成對的,像這樣:

# URL to fetch, and the number of redirects left.
('http://xkcd.com/353', 10)

新的URL具有10次剩余的重定向機(jī)會。抓取這個特定的URL導(dǎo)致重定向到一個具有尾斜杠的新位置。我們減少剩余的重定向次數(shù),并且將下一個位置放入隊(duì)列:

# URL with a trailing slash. Nine redirects left.
('http://xkcd.com/353/', 9)

我們使用的aiohttp包將默認(rèn)跟隨重定向然后給我們最后的響應(yīng)。但是我們告訴它不要,并且在爬蟲中處理重定向,所以它能合并引向相同目的地的重定向路徑:如果我們已經(jīng)處理過這個URL,它就會在self.seen_urls中并且我們已經(jīng)在其它不同的入口點(diǎn)處開始過這條路徑了。

重定向

小爬蟲抓取了foo并且看到它重定向到baz,所以它將baz放入隊(duì)列和seen_urls。如果下一個它要爬取的頁面是bar,同樣也會被重定向到baz的話,這個爬蟲就不會再將baz放入隊(duì)列了。如果返回的是一個頁面,而不是一個重定向,fetch將會分析頁面的鏈接并把新的加入隊(duì)列。

    @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()

如果這是多線程代碼,它將會有糟糕的競態(tài)。例如,一個小爬蟲先檢查一個鏈接是否在seen_urls中,如果沒有它就會將它放入隊(duì)列然后將它添加到seen_urls中。如果它在兩個步驟中間被打斷了,其它小爬蟲可能會從其它頁面分析到相同的URL,同樣也查看這個URL是否在seen_urls中,然后將它加入隊(duì)列。現(xiàn)在同樣的鏈接就在隊(duì)列中兩次出現(xiàn)了,這至少會導(dǎo)致重復(fù)的工作和錯誤的數(shù)據(jù)。
但是,協(xié)程只在yield from語句中易受中斷。這是一個關(guān)鍵的差異,使得協(xié)程代碼遠(yuǎn)不如多線程哪樣容易產(chǎn)生競態(tài):多線程代碼必須通過獲得一個鎖明確地進(jìn)入一個臨界區(qū)域,否則它將是可中斷的。一個Python協(xié)程是默認(rèn)不可中斷的,并且只會在它明確地yield時讓出控制權(quán)。
我們不再需要一個fetcher類——像我們之前的基于回調(diào)的程序所擁有的。這個類是回調(diào)的缺點(diǎn)的一個解決辦法:它們需要某個地方在等待IO時存放狀態(tài),因?yàn)樗鼈冏约旱木植孔兞坎荒芸缯{(diào)用保留。但是fetch協(xié)程能像普通函數(shù)那樣存儲它的狀態(tài)在本地變量中,所以這里就沒有必要設(shè)置這樣一個類。
當(dāng)fetch完成對服務(wù)器響應(yīng)的處理時它返回調(diào)用者,workwork方法調(diào)用隊(duì)列上的task_done方法,然后從隊(duì)列上獲取下一個要抓取的URL。
當(dāng)fetch將新的鏈接放入隊(duì)列中,它增加了未完成任務(wù)的計(jì)數(shù)并且使等待q.join的主協(xié)程暫停。但是如果沒有任何新的鏈接并且這是隊(duì)列中的最后一個URL,那么work調(diào)用task_done使得未完成的任務(wù)數(shù)減至0。這一事件將取消join的暫停并使主協(xié)程完成。
協(xié)調(diào)小爬蟲們和主協(xié)程的隊(duì)列代碼類似下面:

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

主協(xié)程,crawl,yield from join。所以當(dāng)最后一個小爬蟲將未完成任務(wù)計(jì)數(shù)減至0時,它發(fā)信號給crawl使之蘇醒,然后結(jié)束。
爬蟲之旅就要結(jié)束了。我們的程序開始于對crawl的調(diào)用:

loop.run_until_complete(self.crawler.crawl())

那么這個程序怎么結(jié)束呢?既然crawl是一個生成器函數(shù),調(diào)用它返回一個生成器。為了驅(qū)動這個生成器,asyncio 將它封裝進(jìn)一個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

當(dāng)任務(wù)完成,它拋出StopError異常——循環(huán)將之當(dāng)作是它正常完成的信號。
但是這是什么?這個task具有名為add_done_callbackresult的方法?你可能認(rèn)為一個task類似于一個future。你的直覺是正確的。我們必須承認(rèn)一個關(guān)于Task類隱藏的細(xì)節(jié):一個task就是一個future。

class Task(Future):
    """A coroutine wrapped in a Future."""

通常一個future通過其它東西調(diào)用它的set_result來解決它。但是對一個task,當(dāng)它的協(xié)程停止時,它會自己解決自己。在我們之前對于Python生成器的探索中,當(dāng)一個生成器返回時,它拋出一個特殊的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)

所以當(dāng)時間循環(huán)調(diào)用task.add_done_callback(stop_callback)時,它就準(zhǔn)備好被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

當(dāng)task捕獲到StopIteration并且解決它自己,回調(diào)就在循環(huán)中拋出一個StopError。循環(huán)停止,調(diào)用棧展開到run_until_complete。我們的程序就結(jié)束了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容