文章較長,建議收藏後再看。
使用Python編寫併發程式碼時並不流暢,我們需要一些額外的思考,你手中的任務是I/O密集型別還是CPU密集型。此外,由於全域性解釋鎖的存在,進一步增加了編寫真正的併發程式碼的難度。
在Python中編寫併發程式碼常常這樣做:
如果任務是I/O密集的,可以使用標準庫的
threading
模組,如果任務是CPU密集的,則multiprocessing
更合適。threading
和multiprocessing
的API提供了很多功能,但他們都是很低階別的程式碼,在我們業務核心邏輯上增加了額外的複雜性。
Python標準庫還包含一個名為concurrent.futrures
的模組。在Python3.2中新增了該模組,為開發人員提供了更高階別的非同步任務介面。它是在threading
和multiprocessing
模組之上的通用抽象層,提供了使用執行緒池和程式池執行任務的一些介面。當你只需要併發的執行一段完整的程式碼,而不想使用threading
和multiprocessing
增加額外的邏輯,破壞程式碼的完整性時,這個模組是一個好的解決方案。
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
類(ThreadPoolExecutor
或ProcessPoolExecutor
)的例項,然後提交給他們任務執行。提交後,將返回一個Future
類的例項。當需要任務結果時,可以使用該Future
物件進行阻塞,直到拿到任務結果為止。
Executor Objects
由於ThreadPoolExecutor
和ProcessPoolExecutor
有相同的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
可以是int
或float
,如果未指定或者是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
替換為ThreadPoolExecutor
或ProcessPoolExecutor
這些可呼叫的類。然後使用推導式來開始所有的任務,使用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_one
和hash_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倍。
併發中常遇到的坑
ThreadPoolExecutor
和ProcessPoolExecutor
它適用於簡單的併發任務,有迴圈迭代的情況或僅允許執行子程式的情況。如果你的任務需要排隊,或者需要用到多程式和多執行緒,你任然需要使用threading
和multiprocessing
模組。
在使用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、任務非常繁碎,無法使用多執行緒或多程式來處理。
生成和釋放執行緒或程式是需要額外開銷的。通常執行緒的操作比程式快的多,但是錯誤的使用反而會拖慢你的程式碼效能。下邊是一個繁碎的示例,ThreadPoolExecutor
和ProcessPoolExecutor
效能均比順序的效能差。
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 協議》,轉載必須註明作者和本文連結