Python學習之路36-使用future處理併發

VPointer發表於2018-08-24

《流暢的Python》筆記。

本篇主要討論concurrent.futures模組,並用它實現一個簡單的併發操作。

1. 前言

我們都知道,如果有大量資料要處理,或者要處理大量連結,非同步操作會比順序操作快很多。Python中,concurrentasyncio則是標準庫中進行了高度封裝的兩個非同步操作包。它們在底層使用了Python提供的更基礎的兩個模組,分別是multiprocessingthreading

future(全小寫)並不具體指某個類的例項,而且筆者查了老多資料也沒看到哪個類叫做future,它泛指用於非同步操作的物件。concurrent.futuresasyncio這兩個模組中有兩個名為Future的類:concurrent.futures.Futureasyncio.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的抽象基類,由它定義執行非同步操作的介面。在這個模組中有它的兩個具體類:的ThreadPoolExecutorProcessPoolExecutor,前者是執行緒,後者是程式。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 ~

Python學習之路36-使用future處理併發

相關文章