本文英文原文來自于 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)用step
,step
再喚醒fetch
。
使用標(biāo)準(zhǔn)庫asyncio和aiohttp
- 源碼參看 crawling.py crawl.py
首先,我們的爬蟲將會抓取第一個頁面,分析頁面中鏈接,然后將它們加入隊(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)join
future被解決,爬蟲任務(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)用者,work
。work
方法調(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_callback
和result
的方法?你可能認(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é)束了。