《流暢的Python》筆記。
本篇主要討論concurrent.futures模組,並用它實現一個簡單的併發操作。
1. 前言
我們都知道,如果有大量資料要處理,或者要處理大量連結,非同步操作會比順序操作快很多。Python中,concurrent
和asyncio
則是標準庫中進行了高度封裝的兩個非同步操作包。它們在底層使用了Python提供的更基礎的兩個模組,分別是multiprocessing
和threading
。
future
(全小寫)並不具體指某個類的例項,而且筆者查了老多資料也沒看到哪個類叫做future
,它泛指用於非同步操作的物件。concurrent.futures
和asyncio
這兩個模組中有兩個名為Future
的類:concurrent.futures.Future
和asyncio.Future
。這兩個類的作用相同,都表示可能已經完成或尚未完成的延遲計算。這兩個Future
的例項並不應該由我們手動建立,而應交由併發框架(也就是前面那兩個模組)來例項化。
本篇主要介紹concurrent.futures
模組的簡單使用,並會將其和順序計算進行對比,其中還會涉及GIL和阻塞型I/O的概念。asyncio
將在下一篇進行介紹。
2. 順序執行
首先實現一個下載各國國旗的程式,隨後再將它與併發版本進行對比。以下是順序執行的版本,它下載人口前20的國家的國旗,並儲存到本地:
# 程式碼2.1,flags.py
import os, time, sys # 這麼引用只是為了節省篇幅,並不提倡
import requests # 第三方庫
POP20_CC = ("CN IN US ID BR PK NG BD RU JP MX PH VN ET EG DE IR TR CD FR").split()
# 如果想測試自己的併發程式,為了避免被誤認為是DOS攻擊,請自建http服務
BASE_URL = "http://flupy.org/data/flags"
DEST_DIR = "downloads/"
def save_flag(img, filename): # 儲存圖片到本地
path = os.path.join(DEST_DIR, filename)
with open(path, "wb") as fp:
fp.write(img)
def get_flag(cc): # 請求圖片
url = "{}/{cc}/{cc}.gif".format(BASE_URL, cc=cc.lower())
resp = requests.get(url)
return resp.content
def show(text): # 每獲取一張圖片就給出一個提示
print(text, end=" ")
sys.stdout.flush()
def download_one(cc): # 下載一張圖片
image = get_flag(cc)
show(cc)
save_flag(image, cc.lower() + ".gif")
return cc # 這個return主要是給後面的併發程式用的,此處不要這行程式碼也可以
def download_many(cc_list): # 下載多張圖片
for cc in sorted(cc_list):
download_one(cc)
return len(cc_list)
def main(download_many): # 主程式,接收一個函式為引數
t0 = time.time() # 開始時間
count = download_many(POP20_CC)
elapsed = time.time() - t0 # 結束時間
msg = "\n{} flags downloaded in {:.2f}s"
print(msg.format(count, elapsed))
if __name__ == "__main__":
main(download_many)
# 結果
BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN
20 flags downloaded in 14.83s # 耗時,只做了一次
複製程式碼
3. concurrent.futures
現在我們用concurrent.futures
模組將上述程式碼改寫為執行緒版本,使其非同步執行,其中有大部分函式延用上述程式碼。
3.1 futures.as_completed
首先實現一個更具有細節的版本,我們手動提交執行緒,然後再執行。這個版本只是為了講述細節,所以並沒有全部下載,最大執行緒數也沒有設定得很高:
# 程式碼3.1,flags_threadpool.py
from concurrent import futures
from flags import save_flag, get_flag, download_one, show, main
def download_many_ac(cc_list):
cc_list = cc_list[:5] # 只下載前五個用於測試
with futures.ThreadPoolExecutor(len(cc_list) / 2) as executor:
to_do = {} # 有意寫出字典,其實也可以是列表或集合,但這是個慣用方法
for cc in sorted(cc_list):
future = executor.submit(download_one, cc)
to_do[future] = cc
msg = "Scheduled for {}: {}"
print(msg.format(cc, future))
results = []
for future in futures.as_completed(to_do):
res = future.result()
msg = "{} result: {!r}"
print(msg.format(future, res))
results.append(res)
return len(results)
if __name__ == "__main__":
main(download_many_ac)
# 結果:
Scheduled for BR: <Future at 0x1cbca5ab0f0 state=running>
Scheduled for CN: <Future at 0x1cbcb339b00 state=running>
Scheduled for ID: <Future at 0x1cbcb3490f0 state=running>
Scheduled for IN: <Future at 0x1cbcb349748 state=pending>
Scheduled for US: <Future at 0x1cbcb3497f0 state=pending>
CN <Future at 0x1cbcb339b00 state=finished returned str> result: 'CN'
BR <Future at 0x1cbca5ab0f0 state=finished returned str> result: 'BR'
IN <Future at 0x1cbcb349748 state=finished returned str> result: 'IN'
US <Future at 0x1cbcb3497f0 state=finished returned str> result: 'US'
ID <Future at 0x1cbcb3490f0 state=finished returned str> result: 'ID'
5 flags downloaded in 2.39s # 20個一起下載只需要1.6s左右
複製程式碼
解釋:
-
在
concurrent.futures
中有一個名為Executor
的抽象基類,由它定義執行非同步操作的介面。在這個模組中有它的兩個具體類:的ThreadPoolExecutor
和ProcessPoolExecutor
,前者是執行緒,後者是程式。Executor
的第一個引數指定最大執行執行緒數。 -
Executor.submit(func, *args, **kwargs)
方法會線上程中執行func(*args, **kwargs)
,它將這個方法封裝成Future
物件並返回(假設這個例項叫做future
)。submit
方法會對future
進行排期,如果執行的執行緒數沒達到最大執行緒數,則future
會被立即執行,並將其狀態置為running
;否則就等待,並將其狀態置為pending
。這同時也表明,執行緒在submit
方法中啟動。 -
futures.as_completed
函式的第一個引數是一個future
序列,在內部會被轉換成set
。它返回一個迭代器,在future
執行結束後產出future
。在使用這個函式時還有一個慣用方法:將future
放到一個字典中。因為as_completed
返回的future
的順序不一定是傳入時的順序,使用字典可以很輕鬆的做一些後續處理。 -
上述程式碼中,從第31-35行的最開始兩個字母是由
show
函式輸出的。光看上述結果,會讓人覺得執行緒是在as_completed
中啟動的,而之所以結果輸出得這麼整齊,是因為for
迴圈裡只是“提交”,實際執行是線上程中。如果在每次迴圈最後都執行sleep(2)
,你將會看到這樣的結果:# 程式碼3.2 Scheduled for BR: <Future at 0x13e6b30b2b0 state=running> BR Scheduled for CN: <Future at 0x13e6b5820b8 state=running> CN Scheduled for ID: <Future at 0x13e6c099278 state=running> -- snip -- 複製程式碼
-
concurrent.futures.Future
有一個**result
方法,它返回future
中可呼叫物件執行完成後的結果,或者重新丟擲可呼叫物件執行時的異常**。如果future
還未執行完成,呼叫future.result()
將阻塞呼叫方所在的執行緒,直到有結果可返回;它還可以接受一個timeout
引數用於指定執行時間,如果在timeout
時間內future
沒有執行完畢,將丟擲TimeoutError
異常。
3.2 Executor.map
在程式碼3.1
中,我們自行提交執行緒,其實,上述可改為更簡潔的版本:使用Executor.map
批量提交,只需要新建一個download_many
函式,其餘不變:
# 程式碼3.3
def download_many(cc_list):
with futures.ThreadPoolExecutor(len(cc_list)) as executor:
res = executor.map(download_one, sorted(cc_list))
return len(list(res))
# 結果:
JP RUBR EG CN VN BD TR FR ID NG DE IN PK ET PH IR US CD MX
20 flags downloaded in 1.69s
複製程式碼
Executor.map()
方法和內建的map
函式類似,它將第一個引數(可呼叫物件)對映到第二個引數(可迭代物件)的每一個元素上以建立Future
列表。Executor.map()
方法內部也是通過呼叫Future.submit
來建立Future
物件。
3.3 比較
從上面程式碼可以看出,雖然使用Executor.map()
的程式碼量比較少,但Executor.submit()
和futures.as_completed()
的組合更靈活。
Executor.map()
更適合於需要批量處理的情況,比如同一函式(或者可呼叫物件)不同引數。而Executor.submit()
則更適合於零散的情況,比如不同函式同一引數,不同函式不同引數,甚至兩個執行緒毫無關聯。
4. 補充
本文主體部分已經結束,下面是一些補充。
4.1 I/O密集型和GIL
CPython本身並不是執行緒安全的,因此有全域性直譯器鎖(Global Interpreter Lock, GIL),一次只允許使用一個執行緒執行Python位元組碼。
以這個為基礎,按理說上述所有程式碼將都不能並行下載,因為一次只能執行一個執行緒,並且執行緒版本的執行時間應該比順序版本的還要多才對(執行緒切換耗時)。但結果也表明,兩個執行緒版本的耗時都大大降低了。
這是因為,Python標準庫中所有執行阻塞型I/O操作的函式,在等待作業系統返回結果時都會釋放GIL。這就意味著,GIL幾乎對I/O密集型處理並沒有什麼影響,依然可以使用多執行緒。
4.2 CPU密集型
concurrent.futures
中還有一個ProcessPoolExecutor
類,它實現的是真正的平行計算。它和ThreadPoolExecutro
一樣,繼承自Executor
,兩者實現了共同的介面,因此使用concurrent.futures
編寫的程式碼可以輕鬆地線上程版本與程式版本之間轉換,比如要講上述程式碼改為程式版本,只需更改download_many()
中的一行程式碼:
# 程式碼3.4
with futures.ThreadPoolExecutor(len(cc_list)) as executor:
# 改為:
with futures.ProcessPoolExecutor() as executor:
複製程式碼
也可以指定程式數,但預設是os.cpu_count()
的返回值,即電腦的CPU核心數。
這個類非常適合於CPU密集型作業上。使用這個類實現的上述程式碼雖然比執行緒版本慢一些,但依然比順序版本快很多。
4.3 進度條
如果你用最新版pip
下載過第三方庫,你會發現在下載時會有一個文字進度條。在Python中想要實現這種效果可以使用第三方庫tqdm
,以下是它的一個簡單用法:
# 程式碼3.5
import tqdm
from time import sleep
for i in tqdm.tqdm(range(1000)):
sleep(0.01)
# 結果:
40%|████ | 400/1000 [00:10<00:00, 98.11it/s]
複製程式碼
迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~