在批評Python的討論中,常常說起Python多執行緒是多麼的難用。還有人對 global interpreter lock(也被親切的稱為“GIL”)指指點點,說它阻礙了Python的多執行緒程式同時執行。因此,如果你是從其他語言(比如C++或Java)轉過來的話,Python執行緒模組並不會像你想象的那樣去執行。必須要說明的是,我們還是可以用Python寫出能併發或並行的程式碼,並且能帶來效能的顯著提升,只要你能顧及到一些事情。如果你還沒看過的話,我建議你看看Eqbal Quran的文章《Ruby中的併發和並行》。
在本文中,我們將會寫一個小的Python指令碼,用於下載Imgur上最熱門的圖片。我們將會從一個按順序下載圖片的版本開始做起,即一個一個地下載。在那之前,你得註冊一個Imgur上的應用。如果你還沒有Imgur賬戶,請先註冊一個。
本文中的指令碼在Python3.4.2中測試通過。稍微改一下,應該也能在Python2中執行——urllib是兩個版本中區別最大的部分。
開始動手
讓我們從建立一個叫“download.py”的Python模組開始。這個檔案包含了獲取圖片列表以及下載這些圖片所需的所有函式。我們將這些功能分成三個單獨的函式:
- get_links
- download_link
- setup_download_dir
第三個函式,“setup_download_dir”,用於建立下載的目標目錄(如果不存在的話)。
Imgur的API要求HTTP請求能支援帶有client ID的“Authorization”頭部。你可以從你註冊的Imgur應用的皮膚上找到這個client ID,而響應會以JSON進行編碼。我們可以使用Python的標準JSON庫去解碼。下載圖片更簡單,你只需要根據它們的URL獲取圖片,然後寫入到一個檔案即可。
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import json import logging import os from pathlib import Path from urllib.request import urlopen, Request logger = logging.getLogger(__name__) def get_links(client_id): headers = {'Authorization': 'Client-ID {}'.format(client_id)} req = Request('https://api.imgur.com/3/gallery/', headers=headers, method='GET') with urlopen(req) as resp: data = json.loads(resp.readall().decode('utf-8')) return map(lambda item: item['link'], data['data']) def download_link(directory, link): logger.info('Downloading %s', link) download_path = directory / os.path.basename(link) with urlopen(link) as image, download_path.open('wb') as f: f.write(image.readall()) def setup_download_dir(): download_dir = Path('images') if not download_dir.exists(): download_dir.mkdir() return download_dir |
接下來,你需要寫一個模組,利用這些函式去逐個下載圖片。我們給它命名為“single.py”。它包含了我們最原始版本的Imgur圖片下載器的主要函式。這個模組將會通過環境變數“IMGUR_CLIENT_ID”去獲取Imgur的client ID。它將會呼叫“setup_download_dir”去建立下載目錄。最後,使用get_links函式去獲取圖片的列表,過濾掉所有的GIF和專輯URL,然後用“download_link”去將圖片下載並儲存在磁碟中。下面是“single.py”的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import logging import os from time import time from download import setup_download_dir, get_links, download_link logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logging.getLogger('requests').setLevel(logging.CRITICAL) logger = logging.getLogger(__name__) def main(): ts = time() client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = [l for l in get_links(client_id) if l.endswith('.jpg')] for link in links: download_link(download_dir, link) print('Took {}s'.format(time() - ts)) if __name__ == '__main__': main() |
在我的筆記本上,這個指令碼花了19.4秒去下載91張圖片。請注意這些數字在不同的網路上也會有所不同。19.4秒並不是非常的長,但是如果我們要下載更多的圖片怎麼辦呢?或許是900張而不是90張。平均下載一張圖片要0.2秒,900張的話大概需要3分鐘。那麼9000張圖片將會花掉30分鐘。好訊息是使用了併發或者並行後,我們可以將這個速度顯著地提高。
接下來的程式碼示例將只會顯示匯入特有模組和新模組的import語句。所有相關的Python指令碼都可以在這方便地找到this GitHub repository。
使用執行緒
執行緒是最出名的實現併發和並行的方式之一。作業系統一般提供了執行緒的特性。執行緒比程式要小,而且共享同一塊記憶體空間。
在這裡,我們將寫一個替代“single.py”的新模組。它將建立一個有八個執行緒的池,加上主執行緒的話總共就是九個執行緒。之所以是八個執行緒,是因為我的電腦有8個CPU核心,而一個工作執行緒對應一個核心看起來還不錯。在實踐中,執行緒的數量是仔細考究的,需要考慮到其他的因素,比如在同一臺機器上跑的的其他應用和服務。
下面的指令碼幾乎跟之前的一樣,除了我們現在有個新的類,DownloadWorker,一個Thread類的子類。執行無限迴圈的run方法已經被重寫。在每次迭代時,它呼叫“self.queue.get()”試圖從一個執行緒安全的佇列裡獲取一個URL。它將會一直堵塞,直到佇列中出現一個要處理元素。一旦工作執行緒從佇列中得到一個元素,它將會呼叫之前指令碼中用來下載圖片到目錄中所用到的“download_link”方法。下載完成之後,工作執行緒向佇列傳送任務完成的訊號。這非常重要,因為佇列一直在跟蹤佇列中的任務數。如果工作執行緒沒有發出任務完成的訊號,“queue.join()”的呼叫將會令整個主執行緒都在阻塞狀態。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
from queue import Queue from threading import Thread class DownloadWorker(Thread): def __init__(self, queue): Thread.__init__(self) self.queue = queue def run(self): while True: # Get the work from the queue and expand the tuple # 從佇列中獲取任務並擴充套件tuple directory, link = self.queue.get() download_link(directory, link) self.queue.task_done() def main(): ts = time() client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = [l for l in get_links(client_id) if l.endswith('.jpg')] # Create a queue to communicate with the worker threads queue = Queue() # Create 8 worker threads # 建立八個工作執行緒 for x in range(8): worker = DownloadWorker(queue) # Setting daemon to True will let the main thread exit even though the workers are blocking # 將daemon設定為True將會使主執行緒退出,即使worker都阻塞了 worker.daemon = True worker.start() # Put the tasks into the queue as a tuple # 將任務以tuple的形式放入佇列中 for link in links: logger.info('Queueing {}'.format(link)) queue.put((download_dir, link)) # Causes the main thread to wait for the queue to finish processing all the tasks # 讓主執行緒等待佇列完成所有的任務 queue.join() print('Took {}'.format(time() - ts)) |
在同一個機器上執行這個指令碼,下載時間變成了4.1秒!即比之前的例子快4.7倍。雖然這快了很多,但還是要提一下,由於GIL的緣故,在這個程式中同一時間只有一個執行緒在執行。因此,這段程式碼是併發的但不是並行的。而它仍然變快的原因是這是一個IO密集型的任務。程式下載圖片時根本毫不費力,而主要的時間都花在了等待網路上。這就是為什麼執行緒可以提供很大的速度提升。每當執行緒中的一個準備工作時,程式可以不斷轉換執行緒。使用Python或其他有GIL的解釋型語言中的執行緒模組實際上會降低效能。如果你的程式碼執行的是CPU密集型的任務,例如解壓gzip檔案,使用執行緒模組將會導致執行時間變長。對於CPU密集型任務和真正的並行執行,我們可以使用多程式(multiprocessing)模組。
官方的Python實現——CPython——帶有GIL,但不是所有的Python實現都是這樣的。比如,IronPython,使用.NET框架實現的Python就沒有GIL,基於Java實現的Jython也同樣沒有。你可以點這檢視現有的Python實現。
生成多程式
多程式模組比執行緒模組更易使用,因為我們不需要像執行緒示例那樣新增一個類。我們唯一需要做的改變在主函式中。
為了使用多程式,我們得建立一個多程式池。通過它提供的map方法,我們把URL列表傳給池,然後8個新程式就會生成,它們將並行地去下載圖片。這就是真正的並行,不過這是有代價的。整個指令碼的記憶體將會被拷貝到各個子程式中。在我們的例子中這不算什麼,但是在大型程式中它很容易導致嚴重的問題。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
from functools import partial from multiprocessing.pool import Pool def main(): ts = time() client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = [l for l in get_links(client_id) if l.endswith('.jpg')] download = partial(download_link, download_dir) with Pool(8) as p: p.map(download, links) print('Took {}s'.format(time() - ts)) |
分散式任務
你已經知道了執行緒和多程式模組可以給你自己的電腦跑指令碼時提供很大的幫助,那麼在你想要在不同的機器上執行任務,或者在你需要擴大規模而超過一臺機器的的能力範圍時,你該怎麼辦呢?一個很好的使用案例是網路應用的長時間後臺任務。如果你有一些很耗時的任務,你不會希望在同一臺機器上佔用一些其他的應用程式碼所需要的子程式或執行緒。這將會使你的應用的效能下降,影響到你的使用者們。如果能在另外一臺甚至很多臺其他的機器上跑這些任務就好了。
Python庫RQ非常適用於這類任務。它是一個簡單卻很強大的庫。首先將一個函式和它的引數放入佇列中。它將函式呼叫的表示序列化(pickle),然後將這些表示新增到一個Redis列表中。任務進入佇列只是第一步,什麼都還沒有做。我們至少還需要一個能去監聽任務佇列的worker(工作執行緒)。
第一步是在你的電腦上安裝和使用Redis伺服器,或是擁有一臺能正常的使用的Redis伺服器的使用權。接著,對於現有的程式碼只需要一些小小的改動。先建立一個RQ佇列的例項並通過redis-py 庫傳給一臺Redis伺服器。然後,我們執行“q.enqueue(download_link, download_dir, link)”,而不只是呼叫“download_link” 。enqueue方法的第一個引數是一個函式,當任務真正執行時,其他的引數或關鍵字引數將會傳給該函式。
最後一步是啟動一些worker。RQ提供了方便的指令碼,可以在預設佇列上執行起worker。只要在終端視窗中執行“rqworker”,就可以開始監聽預設佇列了。請確認你當前的工作目錄與指令碼所在的是同一個。如果你想監聽別的佇列,你可以執行“rqworker queue_name”,然後將會開始執行名為queue_name的佇列。RQ的一個很好的點就是,只要你可以連線到Redis,你就可以在任意數量上的機器上跑起任意數量的worker;因此,它可以讓你的應用擴充套件性得到提升。下面是RQ版本的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 |
from redis import Redis from rq import Queue def main(): client_id = os.getenv('IMGUR_CLIENT_ID') if not client_id: raise Exception("Couldn't find IMGUR_CLIENT_ID environment variable!") download_dir = setup_download_dir() links = [l for l in get_links(client_id) if l.endswith('.jpg')] q = Queue(connection=Redis(host='localhost', port=6379)) for link in links: q.enqueue(download_link, download_dir, link) |
然而RQ並不是Python任務佇列的唯一解決方案。RQ確實易用並且能在簡單的案例中起到很大的作用,但是如果有更高階的需求,我們可以使用其他的解決方案(例如 Celery)。
總結
如果你的程式碼是IO密集型的,執行緒和多程式可以幫到你。多程式比執行緒更易用,但是消耗更多的記憶體。如果你的程式碼是CPU密集型的,多程式就明顯是更好的選擇——特別是所使用的機器是多核或多CPU的。對於網路應用,在你需要擴充套件到多臺機器上執行任務,RQ是更好的選擇。