python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

前言

對我來說,編程的樂趣之一是想辦法讓程序執行的越來越快,代碼越寫越優雅。在剛開始學習併發編程時,相信你它會有一些困惑,本文將解釋協程的Web爬蟲問題並幫助你快速瞭解併發編程的不同場景和應該使用的解決方案。小編推薦一個企鵝群,群裡分子非常踴躍交流經驗遇坑問題。也有初學者交流討論,群內整理了也整理了大量的PDF書籍和學習資料。程序員也很熱心的幫助解決問題,還有討論工作上的解決方案,非常好的學習交流地方!群內大概有好幾千人了,喜歡python的朋友可以加入python群:526929231歡迎大家交流討論各種奇技淫巧,一起快速成長

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

經典的計算機科學強調高效的算法,儘可能快地完成計算。但是很多網絡程序的時間並不是消耗在計算上,而是在等待許多慢速的連接或者低頻事件的發生。這些程序暴露出一個新的挑戰:如何高效的等待大量網絡事件。一個現代的解決方案是異步I/O。

這一章我們將實現一個簡單的網絡爬蟲。這個爬蟲只是一個原型式的異步應用,因為它等待許多響應而只做少量的計算。一次爬的網頁越多,它就能越快的完成任務。如果它為每個動態的請求啟動一個線程的話,隨著併發請求數量的增加,它會在耗盡套接字之前,耗盡內存或者線程相關的資源。使用異步I/O可以避免這個的問題。

我們將分三個階段展示這個例子。首先,我們會實現一個事件循環並用這個事件循環和回調來勾畫出一個網絡爬蟲。它很有效,但是當把它擴展成更復雜的問題時,就會導致無法管理的混亂代碼。然後,由於Python的協程不僅有效而且可擴展,我們將用Python的生成器函數實現一個簡單的協程。在最後一個階段,我們將使用Python標準庫”asyncio”中功能完整的協程和異步隊列完成這個網絡爬蟲。

The Task

網絡爬蟲尋找並下載一個網站上的所有網頁,也許還會把它們存檔,為它們建立索引。從根URL開始,它獲取每個網頁,解析出沒有遇到過的鏈接加到隊列中。當網頁沒有未見到過的鏈接並且隊列為空時,它便停止運行。

我們可以通過同時下載大量的網頁來加快這一過程。當爬蟲發現新的鏈接,它使用一個新的套接字並行的處理這個新鏈接,解析響應,添加新鏈接到隊列。當併發很大時,可能會導致性能下降,所以我們會限制併發的數量,在隊列保留那些未處理的鏈接,直到一些正在執行的任務完成。

The Traditional Approach

怎麼使一個爬蟲併發?傳統的做法是創建一個線程池,每個線程使用一個套接字在一段時間內負責一個網頁的下載。比如,下載xkcd.com網站的一個網頁:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

套接字操作默認是阻塞的:當一個線程調用一個類似connect和recv方法時,它會阻塞,直到操作完成.1因此,為了同一時間內下載多個網頁,我們需要很多線程。一個複雜的應用會通過線程池保持空閒的線程來分攤創建線程的開銷。同樣的做法也適用於套接字,使用連接池。

到目前為止,線程是昂貴的,操作系統對一個進程,一個用戶,一臺機器能使用線程做了不同的硬性限制。在Jesse系統中,一個Python線程需要50K的內存,開啟上萬個線程會失敗。每個線程的開銷和系統的限制就是這種方式的瓶頸所在。

在Dan Kegel那一篇很有影響力的文章”The C10K problem”2中,它提出多線程方式在I/O併發上的侷限性。他在開始寫道,

是時候網絡服務器要同時處理成千上萬的客戶啦,你不這樣認為麼?畢竟,現在網絡是個很大的地方。

Kegel在1999年創造出”C10K”術語。一萬個連接在今天看來還是可接受的,但是問題依然存在,只不過大小不同。回到那時候,對於C10K問題,每個連接啟一個線程是不切實際的。現在這個限制已經成指數級增長。確實,我們的玩具網絡爬蟲使用線程也可以工作的很好。但是,對於有著千萬級連接的大規模應用來說,限制依然存在:會消耗掉所有線程,即使套接字還夠用。那麼我們該如何解決這個問題?

Async異步

異步I/O框架在一個線程中完成併發操作。讓我們看看這是怎麼做到的。

異步框架使用*非阻塞*套接字。異步爬蟲中,我們在發起到服務器的連接前把套接字設為非阻塞:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

對一個非阻塞套接字調用connect方法會立即拋出異常,即使它正常工作。這個異常模擬了底層C語言函數的行為,它把errno設置為EINPROGRESS,告訴你操作已經開始。

現在我們的爬蟲需要一種知道連接何時建立的方法,這樣它才能發送HTTP請求。我們可以簡單地使用循環來重試:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

這種方法不僅消耗CPU,也不能有效的等待多個套接字。在遠古時代,BSD Unix的解決方法是select,一個C函數,它在一個或一組非阻塞套接字上等待事件發生。現在,互聯網應用大量連接的需求,導致select被poll代替,以及BSD的kqueue和Linux的epoll。它們的API和select相似,但在大數量的連接中也能有較好的性能。

Python 3.4的DefaultSelector使用你係統上最好的類select函數。去註冊一個網絡I/O事件,我們創建一個非阻塞套接字,並使用默認的selector註冊。

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

我們不理會這個偽造的錯誤,調用selector.register,傳遞套接字文件描述符,一個表示我們想要監聽什麼事件的常量。為了當連接建立時收到提醒,我們使用EVENT_WRITE:它表示什麼時候這個套接字可寫。我們還傳遞了一個Python函數,connected,當對應事件發生時被調用。這樣的函數被稱為回調。

我們在一個循環中處理I/O提醒,隨著selector接收到它們。

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

connected回調函數被保存在event_key.data中,一旦這個非阻塞套接字建立連接,它就會被取出來執行。

不像我們前面那個快速重試的循環,這裡的select調用會阻塞,等待下一個I/O事件,接著執行等待這個事件的回調函數。

到目前為止我們展現了什麼?我們展示瞭如何開始一個I/O操作和當操作準備好時調用回調函數。異步框架,它在單線程中執行併發操作,建立在兩個功能之上,非阻塞套接字和事件循環。

Programming With Callbacks

用我們剛剛建立的異步框架,怎麼才能完成一個網絡爬蟲?即使是一個簡單的網頁下載程序也是很難寫的。

首先,我們有一個未獲取的URL集合,和一個已經解析過的URL集合。

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

兩個集合加在一起就是所有的URL。用”/“初始化它們。

獲取一個網頁需要一系列的回調。在套接字連接建立時connected回調觸發,它向服務器發送一個GET請求。但是它要等待響應,所以我們需要註冊另一個回調函數,當回調被調用,它也不能一次讀取完整的請求,所以,需要再一次註冊,如此反覆。

讓我們把這些回調放在一個Fetcher對象中,它需要一個URL,一個套接字,還需要一個地方保存返回的字節:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

我們的入口點在Fetcher.fetch:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

fetch方法從連接一個套接字開始。但是要注意這個方法在連接建立前就返回了。它必須返回到事件循環中等待連接建立。為了理解為什麼要要這樣,假設我們程序的整體結構如下:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

所有的事件提醒都在事件循環中的select函數後處理。所以fetch必須把控制權交給事件循環。這樣我們的程序才能知道什麼時候連接已建立,接著循環調用connected回調,它已經在fetch方法中註冊過。

這裡是我們connected方法的實現:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

這個方法發送一個GET請求。一個真正的應用會檢查send的返回值,以防所有的信息沒能一次發送出去。但是我們的請求很小,應用也不復雜。它只是簡單的調用send,然後等待響應。當然,它必須註冊另一個回調並把控制權交給事件循環。接下來也是最後一個回調函數read_response,它處理服務器的響應:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

這個回調在每次selector發現套接字可讀時被調用,可讀有兩種情況:套接字接受到數據或它被關閉。

這個回調函數從套接字讀取4K數據。如果沒有4k,那麼有多少讀多少。如果比4K多,chunk只包4K數據並且這個套接字保持可讀,這樣在事件循環的下一個週期,會在次回到這個回調函數。當響應完成時,服務器關閉這個套接字,chunk為空。

沒有展示的parselinks方法,它返回一個URL集合。我們為每個新的URL啟動一個fetcher。注意一個使用異步回調方式編程的好處:我們不需要為共享數據加鎖,比如我們往seenurls增加新鏈接時。這是一種非搶佔式的多任務,它不會在我們代碼中的任意一個地方中斷。

我們增加了一個全局變量stopped,用它來控制這個循環:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

一旦所有的網頁被下載下來,fetcher停止這個事件循環,程序退出。

這個例子讓異步編程的一個問題明顯的暴露出來:意大利麵代碼。

我們需要某種方式來表達一串計算和I/O操作,並且能夠調度多個這樣的操作讓他們併發的執行。但是,沒有線程你不能把這一串操作寫在一個函數中:當函數開始一個I/O操作,它明確的把未來所需的狀態保存下來,然後返回。你需要考慮如何寫這個狀態保存的代碼。

讓我們來解釋下這到底是什麼意思。考慮在線程中使用通常的阻塞套接字來獲取一個網頁時是多麼簡單。

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

在一個套接字操作和下一個操作之間這個函數到底記住了什麼?它有一個套接字,一個URL和一個可增長的response。運行在線程中的函數使用編程語言的基本功能,棧中的局部變量來保存臨時的狀態。這樣的函數有一個”continuation”—-在I/O結束後它要執行的代碼。運行時通過線程的指令指針來記住這個continuation。你不必考慮怎麼在I/O操作後恢復局部變量和這個continuation。語言本身的特性幫你解決。

但是用一個基於回調的異步框架,這些語言特性不能提供一點幫助。當等待I/O操作時,一個函數必須明確的保存它的狀態,因為它會在I/O操作完成之前返回並清除棧幀。為了在我們基於回調的例子中代替局部變量,我們把sock和response作為Fetcher實例self屬性。為了代替指令指針,它通過註冊connnected和read_response回調來保存continuation。隨著應用功能的增長,我們手動保存回調的複雜性也會增加。如此繁複的記賬式工作會讓編碼者感到頭痛。

更糟糕的是,當我們的回調函數拋出異常會發生什麼?假設我們沒有寫好parse_links方法,它在解析HTML時拋出異常:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

這個堆棧回溯只能顯示出事件循環調用了一個回調。我們不知道是什麼導致了這個錯誤。這條鏈的兩邊都被破壞:不知道從哪來也不知到哪去。這種丟失上下文的現象被稱為”stack ripping”,它還會阻止我們為回調鏈設置異常處理。

所以,除了關於多線程和異步那個更高效的爭議,還有一個關於這兩者之間的爭論,誰更容易出錯。如果在同步上出現失誤,線程更容易出現數據競爭的問題,而回調因為”stack ripping”問題而非常難於調試。

Coroutines

還記得我們對你許下的承諾麼?我們可以寫出這樣的異步代碼,它既有回調方式的高效,也有多線程代碼的簡潔。這個結合是同過一種稱為協程的模式來實現的。使用Python3.4標準庫asyncio和一個叫”aiohttp”的包,在協程中獲取一個網頁是非常直接的:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

也是可擴展的。在Jesse系統上,與每個線程50k內存相比,一個Python協程只需要3k內存。Python很容易就可以啟動上千個協程。

協程的概念可以追溯到計算機科學的遠古時代,它很簡單,一個可以暫停和恢復的子過程。線程是被操作系統控制的搶佔式多任務,而協程是可合作的,它們自己選擇什麼時候暫停去執行下一個協程。

有很多協程的實現。甚至在Python中也有幾種。Python3.4標準庫asyncio中的協程,它是建立在生成器,一個Future類和”yield from”語句之上。從Python3.5開始,協程變成了語言本身的特性。然而,理解Python3.4中這個通過語言原有功能實現的協程,是我們處理Python3.5中原生協程的基礎。

How Python Generators Work

在你理解生成器之前,你需要知道普通的Python函數是怎麼工作的。當一個函數調用一個子過程,這個被調用函數獲得控制權。直到它返回或者有異常發生,才把控制權交給調用者:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

標準的Python解釋器是C語言寫的。一個Python函數被調用對應的C函數是PyEval_EvalFrameEx。它獲得一個Python棧幀結構並在這個棧幀的上下文中執行Python字節碼。這裡是foo的字節碼:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

foo函數在它棧中加載bar並調用它,然後把bar的返回值從棧中彈出,加載None值並返回。

當PyEval_EvalFrameEx遇到CALL_FUNCTION字節碼時,它會創建一個新的棧幀,並用這個棧幀遞歸的調用PyEval_EvalFrameEx來執行bar函數。

非常重要的一點是,Python的棧幀在堆中分配!Python解釋器是一個標準的C程序,所以他的棧幀是正常的棧幀。但是Python的棧幀是在堆中處理。這意味著Python棧幀在函數調用結束後依然可以存在。我們在bar函數中保存當前的棧幀,交互式的看看這種現象:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

現在該說Python生成器了,它使用同樣構件–code object和棧幀–去完成一個不可思議的任務。

這是一個生成器函數:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

在Python把gen_fn編譯成字節碼的過程中,一旦它看到yield語句就知道這是一個生成器函數而不是普通的函數。它就會設置一個標誌來記住這個事實:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

當你調用一個生成器函數,Python看到這個標誌,就不會運行它而是創建一個生成器:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

Python生成器封裝了一個棧幀和函數體代碼:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

所有通過調用gen_fn的生成器指向同一段代碼,但都有各自的棧幀。這些棧幀不再任何一個C函數棧中,而是在堆空間中等待被使用:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

棧幀中有一個指向最後執行指令的指針。初始化為-1,意味著它沒開始運行:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

當我們調用send時,生成器一直運行到第一個yield語句處停止。並且send返回1,yield語句後的表達式的值。

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

現在生成器的指令指針是3,字節碼一共有56個字節:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

這個生成器可以在任何時候,任何函數中恢復運行,因為它的棧幀並不在真正的棧中,而是堆中。在調用鏈中它的位置也是不確定的,它不必遵循普通函數先進後出的順序。它像雲一樣自由。

我們可以傳遞一個hello給生成器,它會成為yield語句的結果,並且生成器運行到第二個yield語句處。

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

現在棧幀中包含局部變量result:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

其它從gen_fn創建的生成器有著它自己的棧幀和局部變量。

當我們在一次調用send,生成器從第二個yield開始運行,以拋出一個特殊的StopIteration異常為結束。

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

這個異常有一個值”done”,它就是生成器的返回值。

Building Coroutines With Generators

所以生成器可以暫停,可以給它一個值讓它恢復,並且它還有一個返回值。這些特性看起來很適合去建立一個不使用回調的異步編程模型。我們想創造一個協程:一個在程序中可以和其他過程合作調度的過程。我們的協程將會是標準庫asyncio中協程的一個簡化版本,我們將使用生成器,futures和yield from語句。

首先,我們需要一種方法去代表協程需要等待的未來事件。一個簡化的版本是:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

一個future初始化為未解決的,它同過調用set_result來解決。

讓我們用futures和協程來改寫我們的fetcher。我們之前用回調寫的fetcher如下:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

fetch方法開始連接一個套接字,然後註冊connected回調函數,它會在套接字建立連接後調用。現在我們使用協程把這兩步合併:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

在,fetch是一個生成器,因為他有一個yield語句。我們創建一個未決的future,然後yield它,暫停執行直到套接字連接建立。內函數on_connected解決這個future。

但是當future被解決,誰來恢復這個生成器?我們需要一個協程驅動器。讓我們叫它task:

python3的新特性協程爬蟲 比之前快了2.3倍速度 經典案例 超詳細

task通過傳遞一個None值給fetch來啟動它。fetch運行到它yeild一個future,這個future被task捕獲作為next_future。當套接字連接建立,事件循環運行回調函數on_connected,這裡future被解決,step被調用,生成器恢復運行。


分享到:


相關文章: