Python 中一種輕鬆實現併發程式設計的方法

DeanWu發表於2020-05-24

原文地址:rednafi.github.io/digressions/pyth...

文章較長,建議收藏後再看。

使用Python編寫併發程式碼時並不流暢,我們需要一些額外的思考,你手中的任務是I/O密集型別還是CPU密集型。此外,由於全域性解釋鎖的存在,進一步增加了編寫真正的併發程式碼的難度。

在Python中編寫併發程式碼常常這樣做:

如果任務是I/O密集的,可以使用標準庫的threading模組,如果任務是CPU密集的,則multiprocessing更合適。threadingmultiprocessing的API提供了很多功能,但他們都是很低階別的程式碼,在我們業務核心邏輯上增加了額外的複雜性。

Python標準庫還包含一個名為concurrent.futrures的模組。在Python3.2中新增了該模組,為開發人員提供了更高階別的非同步任務介面。它是在threadingmultiprocessing模組之上的通用抽象層,提供了使用執行緒池和程式池執行任務的一些介面。當你只需要併發的執行一段完整的程式碼,而不想使用threadingmultiprocessing增加額外的邏輯,破壞程式碼的完整性時,這個模組是一個好的解決方案。

concurrent.futures 剖析

根據官方文件,

The concurrent.futures module provides a high-level interface for asynchronously executing callables.

這意味著你可以使用更高階別的介面來使用執行緒和程式。該模組提供了一個名為Executor的抽象類,你不能直接例項化它,需要使用它提供的兩個子類之一來跑任務。


Executor (Abstract Base Class)
│
├── ThreadPoolExecutor
│ │A concrete subclass of the Executor class to
│ │manage I/O bound tasks with threading underneath
│
├── ProcessPoolExecutor
│ │A concrete subclass of the Executor class to
│ │manage CPU bound tasks with multiprocessing underneath

在模組內,這兩個類與服務池互動並管理工作單元(workers)。Future類用於管理工作單元的結果。使用工作池時,應用程式會建立適當的Executor類(ThreadPoolExecutorProcessPoolExecutor)的例項,然後提交給他們任務執行。提交後,將返回一個Future類的例項。當需要任務結果時,可以使用該Future物件進行阻塞,直到拿到任務結果為止。

Executor Objects

由於ThreadPoolExecutorProcessPoolExecutor有相同的API介面,我們主要來看下它們提供的兩個方法。

submit(func, args, *kwargs)

func 可排程物件(執行函式),執行引數args, kwargs。返回一個可呼叫的 Future 物件。

with ThreadPoolExecutor(max_workers=1) as executor:
    future = executor.submit(pow, 323, 1235)
        print(future.result())

map(func, *iterables, timeout=None, chunksize=1)

除了下邊部分其他同submit:

  • 可迭代物件資料是立即生成的,不像其他迭代器是惰性的。

  • func 是非同步執行的,並且可以同時進行對func的多次呼叫。

返回的迭代器呼叫__next__()方法時,將會引發concurrent.futures.TimeoutError。從開始呼叫到timeout時間後,結果將不獲取。timeout可以是intfloat,如果未指定或者是None,則等待時間沒有限制。

如果func呼叫引發異常,則從迭代器中檢索其結果時將丟擲該異常。

使用ProcessPoolExecutor時,此方法可將迭代項分為多個塊,將其作為單獨的任務塊提交給池。這些塊的大小可以通過chunksize來設定。相比非常長的併發列表,使用chunksize進行分塊提交執行可以顯著提高效能。

使用ThreadPoolExecutor時,chunksize無效。

執行併發任務的通用方法

我的許多指令碼包含類似這樣的程式碼:


for task in get_tasks():
    perform(task)

get_tasks返回一個可迭代物件,該迭代物件包含有特定功能任務的引數相關資訊。perform函式依次執行,每次只能執行一個。由於邏輯是順序執行的,因此很容易理解。當任務數量少或單個任務的執行時間少、複雜度較低時,沒有什麼大問題。但是當任務數量巨大或單個任務很耗時時,整端程式碼效能就變得非常低下。

根據一般經驗,ThreadPoolExecutor 在主要受I/O限制的任務時使用,例如發起多個http請求、將大量檔案儲存到磁碟等。ProcessPoolExecutor 在主要受CPU限制的任務中應用。如大運算量的任務、對大量圖片處理應用和一次處理多個文字檔案等。

使用 Executor.submit 執行任務

當你有許多工時,可一次性執行它們,然後等待它們全部完成,再收集結果。


import concurrent.futures

with concurrent.futures.Executor() as executor:
    futures = {executor.submit(perform, task) for task in get_tasks()}
    for fut in concurrent.futures.as_completed(futures):
        print(f"The outcome is {fut.result()}")

這裡你首先建立一個執行器,該執行器在單獨的程式或執行緒中執行所有任務。使用with語句將建立一個上下文管理器,該管理器可確保在完成後通過隱士呼叫executor.shutdown()函式來清理掉所有執行緒或程式。

在真實的程式碼中,你需要將Executor替換為ThreadPoolExecutorProcessPoolExecutor這些可呼叫的類。然後使用推導式來開始所有的任務,使用executor.submit()來排程每一個任務開始執行。每個任務執行後,會生成一個future物件。一旦所有的任務都完成了,concurrent.futures.as_completed()方法便可獲取所有任務的結果。executor.result()返回給你任務執行函式的結果值或丟擲任務失敗異常。

executor.submit()方法是非同步來呼叫執行任務的,並不包含任務的任何上下文資訊。所以如果你想同時獲取每個任務的資訊,你需要自己處理。


import concurrent.futures

with concurrent.futures.Executor() as executor:
    futures = {executor.submit(perform, task): task for task in get_tasks()}

    for fut in concurrent.futures.as_completed(futures):
        original_task = futures[fut]
        print(f"The result of {original_task} is {fut.result()}")

注意,futures是一個任務結果對應任務的一個字典結構。

使用Executor.map來執行任務

按照預定的順序收集結果的另一種方式是使用executor.map()方法:


import concurrent.futures
with concurrent.futures.Executor() as executor:
    for arg, res in zip(get_tasks(), executor.map(perform, get_tasks())):
        print(f"The result of {arg} is {res}")

注意,map方法會一次性執行所有任務,不會像正常迭代器那樣有惰性。如果任務在執行的時候有問題,它會立即丟擲,不再繼續執行。

在Python3.5+中,executor.map()可以設定一個引數chunksize。當我們使用ProcessPoolExecutor時,會使用該引數設定的值來分批次的執行任務。每次執行chunksize個任務,該引數預設為1。當使用ThreadPoolExecutor時,引數無效。

真實的例子

在開始例子之前,我們先寫一個簡單的裝飾器,來幫助我們更好的檢視執行時間。

import time
from functools import wraps

def timeit(method):
    @wraps(method)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = method(*args, **kwargs)
        end_time = time.time()
        print(f"{method.__name__} => {(end_time-start_time)*1000} ms")
        return result
    return wrapper

這個裝飾器可以這樣用:


@timeit
def func(n):
    return list(range(n))

它會列印執行函式的名字和執行時間。

使用多執行緒下載和儲存檔案

首先,讓我們從這堆URL中下載一些pdf檔案,並將它們儲存到我們的磁碟。這是一個I/O型別的任務,我們使用ThreadPoolExecutor類來操作。開始之前我們先看下順序執行的程式碼:

from pathlib import Path
import urllib.request

def download_one(url):
    """
    Downloads the specified URL and saves it to disk
    """

    req = urllib.request.urlopen(url)
    fullpath = Path(url)
    fname = fullpath.name
    ext = fullpath.suffix

    if not ext:
        raise RuntimeError("URL does not contain an extension")

    with open(fname, "wb") as handle:
        while True:
            chunk = req.read(1024)
            if not chunk:
                break
            handle.write(chunk)

    msg = f"Finished downloading {fname}"
    return msg

@timeit
def download_all(urls):
    return [download_one(url) for url in urls]

if __name__ == "__main__":
    urls = (
        "http://www.irs.gov/pub/irs-pdf/f1040.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040a.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040ez.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040es.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040sb.pdf",
    )

    results = download_all(urls)
    for result in results:
        print(result)

>>> download_all => 22850.6863117218 ms
... Finished downloading f1040.pdf
... Finished downloading f1040a.pdf
... Finished downloading f1040ez.pdf
... Finished downloading f1040es.pdf
... Finished downloading f1040sb.pdf

在上邊的程式碼塊中,我定義了兩個方法。download_one方法負責從url下載pdf檔案並儲存到磁碟。它會檢查url中的檔案是否有副檔名,如果沒有,它會丟擲RunTimeError異常。如果有副檔名,它會下載檔案,並儲存到磁碟。第二個方法download_all僅僅遍歷這些URL並依次對URL應用download_one方法。這個順序執行的程式碼大概花費了22.8s的時間。現在讓我們來看下多執行緒版本的程式碼:

from pathlib import Path
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed

def download_one(url):
    """
    Downloads the specified URL and saves it to disk
    """

    req = urllib.request.urlopen(url)
    fullpath = Path(url)
    fname = fullpath.name
    ext = fullpath.suffix

    if not ext:
        raise RuntimeError("URL does not contain an extension")

    with open(fname, "wb") as handle:
        while True:
            chunk = req.read(1024)
            if not chunk:
                break
            handle.write(chunk)

    msg = f"Finished downloading {fname}"
    return msg

@timeit
def download_all(urls):
    """
    Create a thread pool and download specified urls
    """

    with ThreadPoolExecutor(max_workers=13) as executor:
        return executor.map(download_one, urls, timeout=60)

if __name__ == "__main__":
    urls = (
        "http://www.irs.gov/pub/irs-pdf/f1040.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040a.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040ez.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040es.pdf",
        "http://www.irs.gov/pub/irs-pdf/f1040sb.pdf",
    )

    results = download_all(urls)
    for result in results:
        print(result)
>>> download_all => 5042.651653289795 ms
... Finished downloading f1040.pdf
... Finished downloading f1040a.pdf
... Finished downloading f1040ez.pdf
... Finished downloading f1040es.pdf
... Finished downloading f1040sb.pdf

這個併發版本只花費了順序版本大約1/4的時間。timeout引數是說在這個時間之後,就不能再獲取任務結果。max_works引數是啟動多少個工作執行緒,一般為2*multiprocessing.cpu_count()+1。我的機器是6和12執行緒的,所以我選擇啟動13個執行緒。

你也可以嘗試使用ProcessPoolExecutor類相同的介面方法來跑上邊的程式碼。但是因為任務本身的特性,還是使用執行緒的效能稍好。

使用多程式執行CPU密集型任務

下邊的示例,一個CPU密集型雜湊函式。核心方法是順序多次執行CPU密集型的雜湊演算法,另一個方法是多次執行這個核心方法。讓我們來看下程式碼:

import hashlib

def hash_one(n):
    """A somewhat CPU-intensive task."""

    for i in range(1, n):
        hashlib.pbkdf2_hmac("sha256", b"password", b"salt", i * 10000)

    return "done"

@timeit
def hash_all(n):
    """Function that does hashing in serial."""

    for i in range(n):
        hsh = hash_one(n)

    return "done"

if __name__ == "__main__":
    hash_all(20)
>>> hash_all => 18317.330598831177 ms

分析hash_onehash_all方法,會發現一個共同點,他們都有一個CPU密集的for迴圈程式碼。它大概執行花費了18s,讓我們來看下使用ProcessPoolExecutor版本:

import hashlib
from concurrent.futures import ProcessPoolExecutor

def hash_one(n):
    """A somewhat CPU-intensive task."""

    for i in range(1, n):
        hashlib.pbkdf2_hmac("sha256", b"password", b"salt", i * 10000)

    return "done"

@timeit
def hash_all(n):
    """Function that does hashing in serial."""

    with ProcessPoolExecutor(max_workers=10) as executor:
        for arg, res in zip(range(n), executor.map(hash_one, range(n), chunksize=2)):
            pass

    return "done"

if __name__ == "__main__":
    hash_all(20)
>>> hash_all => 1673.842430114746 ms

仔細檢視程式碼,你會發現在hash_one中for迴圈的程式碼,仍然是順序執行的。但是,在hash_all中的for程式碼塊是多程式執行的。這裡我用10個工作程式按每個批次2個任務來併發執行。你可以嘗試調整工作程式和併發任務數,已到達最佳的效能。這個併發版本比順序版本快了近11倍。

併發中常遇到的坑

ThreadPoolExecutorProcessPoolExecutor它適用於簡單的併發任務,有迴圈迭代的情況或僅允許執行子程式的情況。如果你的任務需要排隊,或者需要用到多程式和多執行緒,你任然需要使用threadingmultiprocessing模組。

在使用ThreadPoolExecutor時,可能會出現死鎖問題。當與Future相關聯的可呼叫物件等待另一個Future的結果時,它們可能永遠不會釋放對執行緒的控制從而導致死鎖。讓我們來看一個示例:

import time
from concurrent.futures import ThreadPoolExecutor

def wait_on_b():
    time.sleep(5)
    print(b.result())  # b will never complete because it is waiting on a.
    return 5

def wait_on_a():
    time.sleep(5)
    print(a.result())  # a will never complete because it is waiting on b.
    return 6

with ThreadPoolExecutor(max_workers=2) as executor:
    # here, the future from a depends on the future from b
    # and vice versa
    # so this is never going to be completed
    a = executor.submit(wait_on_b)
    b = executor.submit(wait_on_a)

    print("Result from wait_on_b", a.result())
    print("Result from wait_on_a", b.result())

在這個例子中,wait_on_b函式依賴wait_on_a函式的結果。同時wait_on_a函式又依賴wait_on_b函式的結果,程式碼由於死鎖永遠不會結束。讓我們看一下另一種死鎖情況:

from concurrent.futures import ThreadPoolExecutor

def wait_on_future():
    f = executor.submit(pow, 5, 2)
    # This will never complete because there is only one worker thread and
    # it is executing this function.
    print(f.result())

with ThreadPoolExecutor(max_workers=1) as executor:
    future = executor.submit(wait_on_future)
    print(future.result())

上邊的程式碼,在一個執行緒中,提交了一個子執行任務。在子任務結束之前,不會釋放執行緒,但是執行緒又被主任務使用,便造成死鎖。使用多個執行緒可解決這個問題,但是這種寫法本身就非常糟糕的,不提倡的。

有時候,併發程式碼的效能可能比順序執行程式碼效率低下。發生這種情況的原因有很多:

  • 1、執行緒用於執行CPU密集的任務。

  • 2、使用多程式處理I/O密集的任務。

  • 3、任務非常繁碎,無法使用多執行緒或多程式來處理。

生成和釋放執行緒或程式是需要額外開銷的。通常執行緒的操作比程式快的多,但是錯誤的使用反而會拖慢你的程式碼效能。下邊是一個繁碎的示例,ThreadPoolExecutorProcessPoolExecutor效能均比順序的效能差。

import math

PRIMES = [num for num in range(19000, 20000)]

def is_prime(n):
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False

    sqrt_n = int(math.floor(math.sqrt(n)))
    for i in range(3, sqrt_n + 1, 2):
        if n % i == 0:
            return False
    return True

@timeit
def main():
    for number in PRIMES:
        print(f"{number} is prime: {is_prime(number)}")

if __name__ == "__main__":
    main()
>>> 19088 is prime: False
... 19089 is prime: False
... 19090 is prime: False
... ...
... main => 67.65174865722656 ms

上邊的示例驗證列表中的數是否為質數。順序版本大約花費了67ms完成。但是併發版本卻化了 140ms 才完成。

from concurrent.futures import ThreadPoolExecutor
import math

num_list = [num for num in range(19000, 20000)]

def is_prime(n):
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False

    sqrt_n = int(math.floor(math.sqrt(n)))
    for i in range(3, sqrt_n + 1, 2):
        if n % i == 0:
            return False
    return True

@timeit
def main():
    with ThreadPoolExecutor(max_workers=13) as executor:
        for number, prime in zip(PRIMES, executor.map(is_prime, num_list)):
            print(f"{number} is prime: {prime}")

if __name__ == "__main__":
    main()
>>> 19088 is prime: False
... 19089 is prime: False
... 19090 is prime: False
... ...
... main => 140.17250061035156 ms

相同的程式碼,多程式版本:

from concurrent.futures import ProcessPoolExecutor
import math

num_list = [num for num in range(19000, 20000)]

def is_prime(n):
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False

    sqrt_n = int(math.floor(math.sqrt(n)))
    for i in range(3, sqrt_n + 1, 2):
        if n % i == 0:
            return False
    return True

@timeit
def main():
    with ProcessPoolExecutor(max_workers=13) as executor:
        for number, prime in zip(PRIMES, executor.map(is_prime, num_list)):
            print(f"{number} is prime: {prime}")

if __name__ == "__main__":
    main()
>>> 19088 is prime: False
... 19089 is prime: False
... 19090 is prime: False
... ...
... main => 311.3126754760742 ms

從直觀上看,檢測質數應該是CPU密集型的操作,最終結果卻是順序執行最快。所以,合理的評估任務計算是否值得使用多執行緒或多程式也是非常重要的。否則,我們寫出的程式碼效能不一定更高效。

參考

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章