在現代程式語言中,為了讓處理器隨時保持忙碌,達到程式的高效率,同樣一件事情,可以有多種處理方式。如果使用Python,程式碼可以是簡單的循序,或者是多執行緒、多行程、協程等,而風格可以是回呼、future(promise)或async/await,到底該選哪個呢?

如果要爬行網頁

如果有一組網頁必須爬行,在Python中可以使用for迴圈,結合urllib.request中的urlopen,就能輕鬆完成任務。然而,這麼一來,等待的時間可能很久,因為前一個網頁下載完成後,才會下載下一個網頁,於是,聰明的開發者會想到利用threading模組,為每個頁面分配一個執行緒,「並行」下載多個頁面,縮短等待的時間。

若頁面數量龐多,為每個頁面分配一個執行緒,也是個負擔,這時,可以試著使用concurrent.futures的ThreadPoolExecutor,指定Worker執行緒數量,將每個下載頁面的任務submit給它,同時間也會有多個頁面在下載,數個Worker執行緒會取得下載頁面任務,完成後,繼續取得下個任務,Worker執行緒會重複使用,省去個別分配執行緒的負擔。

利用多個執行緒,可以加快任務執行的原因在於,網路連線是個I/O動作,在等待資料的過程中,處理器多半沒事可做,不如切換至另一個執行緒進行下載任務;當這個執行緒開始也進行網路I/O,也許前一個執行緒下載的資料已經準備好了,這時,若正好切換回原執行緒,就可以開始處理資料了。

現代處理器往往具備多個核心,或許有開發者會想到,可以使用multiprocessing模組,讓下載任務真正「平行」處理,看看是否能跑得更快,或是運用concurrent.futures中也有的ProcessPoolExecutor,看來也可以有行程池的效果,但是,對於具有頻繁I/O的下載任務來說,效率可能不如預期。

對了,聽說Python 3.4之後加入了asyncio模組,可以「非同步執行」IO任務,具有高效的表現,而且是……單執行緒?

術語之亂

並行?平行?非同步執行?電腦科學中許多術語,經常沒有嚴謹而明確的定義,然而,若想要能在threading、multiprocessing、concurrent.futures、asyncio等技術上,做出選擇(視需求而定,也還有其他可細部控制的模組存在),就必須稍微釐清這些術語,為了有一致的討論方向,這邊引用維基百科的條目,而且,暫不涉及Python的技術細節。

並行(Concurrency)是指程式、演算法或問題,可以切分為不同部份或單元,以無序或部份有序的方式執行,然而最後結果相同。單從此定義來看,爬行網頁若不在乎網頁的下載順序,執行緒是可行的方案,子行程也是,不過,有開發者對此提出異議,因為子行程經常用於平行演算。

平行演算(Parallel algorithm)是指演算法可拆分為數個部份,在同一時間內執行於不同的的處理設備,各部份演算的結果可以結合,最後取得正確的結果。就此定義來看,有些演算本身既是平行,也是並行,方才的爬行網頁就是一個例子,計算費式數也是,差別是在同一時間是否要求,被拆分的部份可執行於不同的處理設備。

以現代處理器往往具備多個核心為例,若任務可以分配到多個核心同時處理,我們會稱此時正在平行地執行程式,若使用執行緒,每個執行分配一個任務,然而任務僅在單個核心切換,則會稱此時正在並行地處理程式。

由於JavaScript的盛行,非同步也就廣為開發者討論,不少技術在實現時高掛非同步一詞,令其成為定義極為混亂的名詞,就維基的定義來說,非同步(Asynchrony)指的是獨立於程式主流程的事件生成,以及處理事件的方式,就這點來看,若將並行、平行某任務完成時視為事件,並且有方式可以註冊事件發生時的對應處理,並行、平行也可以是實現非同步的方式。

因而在運用到執行緒或行程的場合中,也會出現非同步這個名詞;然而,就算是單一、循序的程式流程,其實也有實現非同步的可能性。

實際上,就像〈程式設計該同步還是非同步?〉(https://goo.gl/jDG622)中談到的,IT系統先天上都是非同步.另一方面,就並行的定義「切分為不同部份或單元,以無序或部份有序的方式執行」來說,非同步也能實現並行的概念。

技術考量

別試圖為並行、平行、非同步畫界線,畢竟它們彼此並不互斥,而且有重疊部份,試著稍微釐清這些術語,是為了找到重要考量:「切分為不同部份或單元、無序或部份有序方式執行、同時執行於不同的的處理設備、事件生成、處理事件的方式」。

考量必須牽涉到技術細節,如果使用CPython,必須知道的是,CPython在實現執行緒時使用了GIL(Global Interpreter Lock),用以控制同一時間,只能有一個原生執行緒執行Python位元碼,可以在CPython 2.7的ceval.c(https://goo.gl/ycKrXK),親眼看到這把鎖:

static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */

直譯器中的C程式碼想執行Python位元碼,都必須獲取這把鎖,一個直譯器行程只會有一個GIL(細節可參考〈Grok the GIL〉),就算多個執行緒,在特定時間點上,也只有一個執行緒可以拿到GIL,如果目的是想同時執行於不同的處理器核心,就不用考慮執行緒的作法了,GIL阻止了這個可能性。

GIL的存在似乎令執行緒失去價值,然而,正在等待I/O的執行緒,可以釋放GIL,由另一個執行緒獲取GIL,並進入處理器執行,因而對於I/O密集式的需求,執行緒仍是個不錯的選擇;至於計算密集式的需求,由於執行緒無法善用多核心,在處理器中頻繁地切換執行緒,反而會降低效率,這時使用子行程來處理,或許才會有效率的提升。

雖然可以使用ThreadPoolExecutor,來處理執行緒生成、任務分配與生命週期管理,然而也有其負擔。在處理器的切換成本也是成本考量時,可以考慮asyncio(或第三方程式庫,往往透過產生器來實現),它以單執行緒、在一個事件迴圈中協調、執行多個流程,模擬了(輕量級的)多執行緒,因而沒有執行緒競爭GIL的問題,對於I/O密集式需求,有機會得到比執行緒更高的執行效率。

別忘了要考量事件生成與處理事件的方式!在concurrent.futures,提供查詢事件與註冊事件處理器的方法,然而回呼風格在情況複雜時,會造成順序難以掌握,或者是可讀性的問題,對此,Python 3.3引入yield from,Python 3.5引入async/await,正式在語法層面直接提供支援,令程式碼風格看似循序。

混搭也可以

對於爬行網頁,該用什麼?不負責任的回答是asyncio,因為同非步是不久前火熱的話題嘛!只不過Python的asyncio風格看來有些怪,不像ECMAScript如此直覺,那麼,你是否知道asyncio中事件迴圈是怎麼一回事?可曾試著用產生器實作出原型?之後,還會覺得Python的asyncio風格奇特?

以上漫談,目的絕不是在釐清界線,對於爬行網頁要用哪個,籠統回答就是看需求,漫談、探討定義或技術細節,目的是引出應該考慮的要點,從中進一步認清需求,最後就算是混搭了平行、並行、非同步,只要符合需求,又有什麼不行的呢?

作者簡介


Advertisement

更多 iThome相關內容