1659877680
本教程著眼於如何使用多處理、線程和 AsyncIO 加速 CPU 密集型和 IO 密集型操作。
並發和並行是相似的術語,但它們不是一回事。
並發是在 CPU 上同時運行多個任務的能力。任務可以在重疊的時間段內開始、運行和完成。在單個 CPU 的情況下,多個任務在上下文切換的幫助下運行,其中存儲了進程的狀態,以便以後可以調用和執行。
與此同時,並行性是跨多個 CPU 內核同時運行多個任務的能力。
儘管它們可以提高應用程序的速度,但不應在任何地方使用並發和並行性。用例取決於任務是 CPU 密集型還是 IO 密集型。
受 CPU 限制的任務是 CPU 綁定的。例如,數學計算受 CPU 限制,因為計算能力隨著計算機處理器數量的增加而增加。並行性適用於 CPU 密集型任務。理論上,如果將一個任務分成n個子任務,這些n個任務中的每一個都可以並行運行,從而有效地將時間減少到原來非並行任務的1/n。IO 綁定任務首選並發,因為您可以在獲取 IO 資源時執行其他操作。
CPU 密集型任務的最佳示例是數據科學。數據科學家處理大量數據。對於數據預處理,他們可以將數據分成多個批次並並行運行,從而有效地減少總處理時間。增加內核數量可以加快處理速度。
Web 抓取受 IO 限制。因為任務對 CPU 的影響很小,因為大部分時間都花在讀寫網絡上。其他常見的 IO-bound 任務包括數據庫調用和讀寫文件到磁盤。Web 應用程序,如 Django 和 Flask,是 IO-bound 應用程序。
如果您有興趣詳細了解 Python 中的線程、多處理和異步之間的區別,請查看使用並發、並行和異步加速 Python 的文章。
有了這個,讓我們看看如何加速以下任務:
# tasks.py
import os
from multiprocessing import current_process
from threading import current_thread
import requests
def make_request(num):
# io-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
requests.get("https://httpbin.org/ip")
async def make_request_async(num, client):
# io-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
await client.get("https://httpbin.org/ip")
def get_prime_numbers(num):
# cpu-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
numbers = []
prime = [True for i in range(num + 1)]
p = 2
while p * p <= num:
if prime[p]:
for i in range(p * 2, num + 1, p):
prime[i] = False
p += 1
prime[0] = False
prime[1] = False
for p in range(num + 1):
if prime[p]:
numbers.append(p)
return numbers
本教程中的所有代碼示例都可以在parallel-concurrent-examples-python存儲庫中找到。
筆記:
make_request
向https://httpbin.org/ip發出 HTTP 請求X 次。make_request_async
使用HTTPX異步發出相同的 HTTP 請求。get_prime_numbers
通過Eratosthenes方法從 2 到提供的極限計算素數。我們將使用標準庫中的以下庫來加速上述任務:
圖書館 | 類/方法 | 加工類型 |
---|---|---|
穿線 | 線 | 同時 |
並發期貨 | 線程池執行器 | 同時 |
異步 | 收集 | 並發(通過協程) |
多處理 | 水池 | 平行 |
並發期貨 | ProcessPoolExecutor | 平行 |
同樣,IO 密集型任務在 IO 上花費的時間比在 CPU 上的時間要多。
由於網頁抓取受 IO 限制,我們應該使用線程來加快處理速度,因為 HTML (IO) 的檢索比解析它 (CPU) 慢。
場景:如何加速基於 Python 的網頁抓取和爬取腳本?
讓我們從一個基准開始。
# io-bound_sync.py
import time
from tasks import make_request
def main():
for num in range(1, 101):
make_request(num)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們使用該make_request
函數發出了 100 個 HTTP 請求。由於請求是同步發生的,因此每個任務都是按順序執行的。
Elapsed run time: 15.710984757 seconds.
因此,每個請求大約需要 0.16 秒。
# io-bound_concurrent_1.py
import threading
import time
from tasks import make_request
def main():
tasks = []
for num in range(1, 101):
tasks.append(threading.Thread(target=make_request, args=(num,)))
tasks[-1].start()
for task in tasks:
task.join()
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,同一個make_request
函數被調用了 100 次。這次該threading
庫用於為每個請求創建一個線程。
Elapsed run time: 1.020112515 seconds.
總時間從~16s 減少到~1s。
由於我們為每個請求使用單獨的線程,您可能想知道為什麼整個事情沒有花費大約 0.16 秒來完成。這個額外的時間是管理線程的開銷。Python 中的全局解釋器鎖(GIL) 確保一次只有一個線程使用 Python 字節碼。
# io-bound_concurrent_2.py
import time
from concurrent.futures import ThreadPoolExecutor, wait
from tasks import make_request
def main():
futures = []
with ThreadPoolExecutor() as executor:
for num in range(1, 101):
futures.append(executor.submit(make_request, num))
wait(futures)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
這裡我們用來concurrent.futures.ThreadPoolExecutor
實現多線程。在創建了所有期貨/承諾之後,我們過去常常wait
等待它們全部完成。
Elapsed run time: 1.340592231 seconds
concurrent.futures.ThreadPoolExecutor
實際上是對multithreading
庫的抽象,這使得它更易於使用。在前面的示例中,我們將每個請求分配給一個線程,總共使用了 100 個線程。但ThreadPoolExecutor
默認工作線程數為min(32, os.cpu_count() + 4)
. ThreadPoolExecutor 的存在是為了簡化實現多線程的過程。如果您想對多線程進行更多控制,請改用該multithreading
庫。
# io-bound_concurrent_3.py
import asyncio
import time
import httpx
from tasks import make_request_async
async def main():
async with httpx.AsyncClient() as client:
return await asyncio.gather(
*[make_request_async(num, client) for num in range(1, 101)]
)
if __name__ == "__main__":
start_time = time.perf_counter()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
end_time = time.perf_counter()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")
httpxrequests
因為不支持異步操作,所以在這裡使用。
在這裡,我們用來asyncio
實現並發。
Elapsed run time: 0.553961068 seconds
asyncio
比其他方法更快,因為threading
使用了 OS(操作系統)線程。因此線程由操作系統管理,其中線程切換由操作系統搶占。asyncio
使用由 Python 解釋器定義的協程。使用協程,程序可以決定何時以最佳方式切換任務。這由even_loop
in asyncio 處理。
場景:如何加速一個簡單的數據處理腳本?
再次,讓我們從基准開始。
# cpu-bound_sync.py
import time
from tasks import get_prime_numbers
def main():
for num in range(1000, 16000):
get_prime_numbers(num)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們對從 1000 到 16000 的數字執行了get_prime_numbers
函數。
Elapsed run time: 17.863046316 seconds.
# cpu-bound_parallel_1.py
import time
from multiprocessing import Pool, cpu_count
from tasks import get_prime_numbers
def main():
with Pool(cpu_count() - 1) as p:
p.starmap(get_prime_numbers, zip(range(1000, 16000)))
p.close()
p.join()
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們用來multiprocessing
計算素數。
Elapsed run time: 2.9848740599999997 seconds.
# cpu-bound_parallel_2.py
import time
from concurrent.futures import ProcessPoolExecutor, wait
from multiprocessing import cpu_count
from tasks import get_prime_numbers
def main():
futures = []
with ProcessPoolExecutor(cpu_count() - 1) as executor:
for num in range(1000, 16000):
futures.append(executor.submit(get_prime_numbers, num))
wait(futures)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們使用concurrent.futures.ProcessPoolExecutor
. 將作業添加到期貨後,wait(futures)
等待它們完成。
Elapsed run time: 4.452427557 seconds.
concurrent.futures.ProcessPoolExecutor
是一個包裝器multiprocessing.Pool
。它具有與ThreadPoolExecutor
. 如果您想更好地控制多處理,請使用multiprocessing.Pool
. concurrent.futures
提供了對多處理和線程的抽象,使其易於在兩者之間切換。
值得注意的是,使用多處理來執行make_request
函數將比線程風格慢得多,因為進程需要等待 IO。不過,多處理方法將比同步方法更快。
同樣,與並行性相比,對 CPU 密集型任務使用並發性也不值得。
話雖如此,使用並發性或併行性來執行腳本會增加複雜性。您的代碼通常更難閱讀、測試和調試,因此只有在絕對需要長時間運行的腳本時才使用它們。
concurrent.futures
是我通常開始的地方-
requests
vs httpx
)從GitHub 上的parallel-concurrent-examples-python存儲庫中獲取代碼。
1659877680
本教程著眼於如何使用多處理、線程和 AsyncIO 加速 CPU 密集型和 IO 密集型操作。
並發和並行是相似的術語,但它們不是一回事。
並發是在 CPU 上同時運行多個任務的能力。任務可以在重疊的時間段內開始、運行和完成。在單個 CPU 的情況下,多個任務在上下文切換的幫助下運行,其中存儲了進程的狀態,以便以後可以調用和執行。
與此同時,並行性是跨多個 CPU 內核同時運行多個任務的能力。
儘管它們可以提高應用程序的速度,但不應在任何地方使用並發和並行性。用例取決於任務是 CPU 密集型還是 IO 密集型。
受 CPU 限制的任務是 CPU 綁定的。例如,數學計算受 CPU 限制,因為計算能力隨著計算機處理器數量的增加而增加。並行性適用於 CPU 密集型任務。理論上,如果將一個任務分成n個子任務,這些n個任務中的每一個都可以並行運行,從而有效地將時間減少到原來非並行任務的1/n。IO 綁定任務首選並發,因為您可以在獲取 IO 資源時執行其他操作。
CPU 密集型任務的最佳示例是數據科學。數據科學家處理大量數據。對於數據預處理,他們可以將數據分成多個批次並並行運行,從而有效地減少總處理時間。增加內核數量可以加快處理速度。
Web 抓取受 IO 限制。因為任務對 CPU 的影響很小,因為大部分時間都花在讀寫網絡上。其他常見的 IO-bound 任務包括數據庫調用和讀寫文件到磁盤。Web 應用程序,如 Django 和 Flask,是 IO-bound 應用程序。
如果您有興趣詳細了解 Python 中的線程、多處理和異步之間的區別,請查看使用並發、並行和異步加速 Python 的文章。
有了這個,讓我們看看如何加速以下任務:
# tasks.py
import os
from multiprocessing import current_process
from threading import current_thread
import requests
def make_request(num):
# io-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
requests.get("https://httpbin.org/ip")
async def make_request_async(num, client):
# io-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
await client.get("https://httpbin.org/ip")
def get_prime_numbers(num):
# cpu-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
numbers = []
prime = [True for i in range(num + 1)]
p = 2
while p * p <= num:
if prime[p]:
for i in range(p * 2, num + 1, p):
prime[i] = False
p += 1
prime[0] = False
prime[1] = False
for p in range(num + 1):
if prime[p]:
numbers.append(p)
return numbers
本教程中的所有代碼示例都可以在parallel-concurrent-examples-python存儲庫中找到。
筆記:
make_request
向https://httpbin.org/ip發出 HTTP 請求X 次。make_request_async
使用HTTPX異步發出相同的 HTTP 請求。get_prime_numbers
通過Eratosthenes方法從 2 到提供的極限計算素數。我們將使用標準庫中的以下庫來加速上述任務:
圖書館 | 類/方法 | 加工類型 |
---|---|---|
穿線 | 線 | 同時 |
並發期貨 | 線程池執行器 | 同時 |
異步 | 收集 | 並發(通過協程) |
多處理 | 水池 | 平行 |
並發期貨 | ProcessPoolExecutor | 平行 |
同樣,IO 密集型任務在 IO 上花費的時間比在 CPU 上的時間要多。
由於網頁抓取受 IO 限制,我們應該使用線程來加快處理速度,因為 HTML (IO) 的檢索比解析它 (CPU) 慢。
場景:如何加速基於 Python 的網頁抓取和爬取腳本?
讓我們從一個基准開始。
# io-bound_sync.py
import time
from tasks import make_request
def main():
for num in range(1, 101):
make_request(num)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們使用該make_request
函數發出了 100 個 HTTP 請求。由於請求是同步發生的,因此每個任務都是按順序執行的。
Elapsed run time: 15.710984757 seconds.
因此,每個請求大約需要 0.16 秒。
# io-bound_concurrent_1.py
import threading
import time
from tasks import make_request
def main():
tasks = []
for num in range(1, 101):
tasks.append(threading.Thread(target=make_request, args=(num,)))
tasks[-1].start()
for task in tasks:
task.join()
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,同一個make_request
函數被調用了 100 次。這次該threading
庫用於為每個請求創建一個線程。
Elapsed run time: 1.020112515 seconds.
總時間從~16s 減少到~1s。
由於我們為每個請求使用單獨的線程,您可能想知道為什麼整個事情沒有花費大約 0.16 秒來完成。這個額外的時間是管理線程的開銷。Python 中的全局解釋器鎖(GIL) 確保一次只有一個線程使用 Python 字節碼。
# io-bound_concurrent_2.py
import time
from concurrent.futures import ThreadPoolExecutor, wait
from tasks import make_request
def main():
futures = []
with ThreadPoolExecutor() as executor:
for num in range(1, 101):
futures.append(executor.submit(make_request, num))
wait(futures)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
這裡我們用來concurrent.futures.ThreadPoolExecutor
實現多線程。在創建了所有期貨/承諾之後,我們過去常常wait
等待它們全部完成。
Elapsed run time: 1.340592231 seconds
concurrent.futures.ThreadPoolExecutor
實際上是對multithreading
庫的抽象,這使得它更易於使用。在前面的示例中,我們將每個請求分配給一個線程,總共使用了 100 個線程。但ThreadPoolExecutor
默認工作線程數為min(32, os.cpu_count() + 4)
. ThreadPoolExecutor 的存在是為了簡化實現多線程的過程。如果您想對多線程進行更多控制,請改用該multithreading
庫。
# io-bound_concurrent_3.py
import asyncio
import time
import httpx
from tasks import make_request_async
async def main():
async with httpx.AsyncClient() as client:
return await asyncio.gather(
*[make_request_async(num, client) for num in range(1, 101)]
)
if __name__ == "__main__":
start_time = time.perf_counter()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
end_time = time.perf_counter()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")
httpxrequests
因為不支持異步操作,所以在這裡使用。
在這裡,我們用來asyncio
實現並發。
Elapsed run time: 0.553961068 seconds
asyncio
比其他方法更快,因為threading
使用了 OS(操作系統)線程。因此線程由操作系統管理,其中線程切換由操作系統搶占。asyncio
使用由 Python 解釋器定義的協程。使用協程,程序可以決定何時以最佳方式切換任務。這由even_loop
in asyncio 處理。
場景:如何加速一個簡單的數據處理腳本?
再次,讓我們從基准開始。
# cpu-bound_sync.py
import time
from tasks import get_prime_numbers
def main():
for num in range(1000, 16000):
get_prime_numbers(num)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們對從 1000 到 16000 的數字執行了get_prime_numbers
函數。
Elapsed run time: 17.863046316 seconds.
# cpu-bound_parallel_1.py
import time
from multiprocessing import Pool, cpu_count
from tasks import get_prime_numbers
def main():
with Pool(cpu_count() - 1) as p:
p.starmap(get_prime_numbers, zip(range(1000, 16000)))
p.close()
p.join()
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們用來multiprocessing
計算素數。
Elapsed run time: 2.9848740599999997 seconds.
# cpu-bound_parallel_2.py
import time
from concurrent.futures import ProcessPoolExecutor, wait
from multiprocessing import cpu_count
from tasks import get_prime_numbers
def main():
futures = []
with ProcessPoolExecutor(cpu_count() - 1) as executor:
for num in range(1000, 16000):
futures.append(executor.submit(get_prime_numbers, num))
wait(futures)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")
在這裡,我們使用concurrent.futures.ProcessPoolExecutor
. 將作業添加到期貨後,wait(futures)
等待它們完成。
Elapsed run time: 4.452427557 seconds.
concurrent.futures.ProcessPoolExecutor
是一個包裝器multiprocessing.Pool
。它具有與ThreadPoolExecutor
. 如果您想更好地控制多處理,請使用multiprocessing.Pool
. concurrent.futures
提供了對多處理和線程的抽象,使其易於在兩者之間切換。
值得注意的是,使用多處理來執行make_request
函數將比線程風格慢得多,因為進程需要等待 IO。不過,多處理方法將比同步方法更快。
同樣,與並行性相比,對 CPU 密集型任務使用並發性也不值得。
話雖如此,使用並發性或併行性來執行腳本會增加複雜性。您的代碼通常更難閱讀、測試和調試,因此只有在絕對需要長時間運行的腳本時才使用它們。
concurrent.futures
是我通常開始的地方-
requests
vs httpx
)