1668606668
This tutorial looks at how to speed up CPU-bound and IO-bound operations with multiprocessing, threading, and AsyncIO.
Concurrency and parallelism are similar terms, but they are not the same thing.
Concurrency is the ability to run multiple tasks on the CPU at the same time. Tasks can start, run, and complete in overlapping time periods. In the case of a single CPU, multiple tasks are run with the help of context switching, where the state of a process is stored so that it can be called and executed later.
Parallelism, meanwhile, is the ability to run multiple tasks at the same time across multiple CPU cores.
Though they can increase the speed of your application, concurrency and parallelism should not be used everywhere. The use case depends on whether the task is CPU-bound or IO-bound.
Tasks that are limited by the CPU are CPU-bound. For example, mathematical computations are CPU-bound since computational power increases as the number of computer processors increases. Parallelism is for CPU-bound tasks. In theory, If a task is divided into n-subtasks, each of these n-tasks can run in parallel to effectively reduce the time to 1/n of the original non-parallel task. Concurrency is preferred for IO-bound tasks, as you can do something else while the IO resources are being fetched.
The best example of CPU-bound tasks is in data science. Data scientists deal with huge chunks of data. For data preprocessing, they can split the data into multiple batches and run them in parallel, effectively decreasing the total time to process. Increasing the number of cores results in faster processing.
Web scraping is IO-bound. Because the task has little effect on the CPU since most of the time is spent on reading from and writing to the network. Other common IO-bound tasks include database calls and reading and writing files to disk. Web applications, like Django and Flask, are IO-bound applications.
If you're interested in learning more about the differences between threads, multiprocessing, and async in Python, check out the Speeding Up Python with Concurrency, Parallelism, and asyncio article.
With that, let's take a look at how to speed up the following tasks:
# 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
All of the code examples in this tutorial can be found in the parallel-concurrent-examples-python repo.
Notes:
make_request
makes an HTTP request to https://httpbin.org/ip X number of times.make_request_async
makes the same HTTP request asynchronously with HTTPX.get_prime_numbers
calculates the prime numbers, via the Sieve of Eratosthenes method, from two to the provided limit.We'll be using the following libraries from the standard library to speed up the above tasks:
Library | Class/Method | Processing Type |
---|---|---|
threading | Thread | concurrent |
concurrent.futures | ThreadPoolExecutor | concurrent |
asyncio | gather | concurrent (via coroutines) |
multiprocessing | Pool | parallel |
concurrent.futures | ProcessPoolExecutor | parallel |
Again, IO-bound tasks spend more time on IO than on the CPU.
Since web scraping is IO bound, we should use threading to speed up the processing as the retrieving of the HTML (IO) is slower than parsing it (CPU).
Scenario: How to speed up a Python-based web scraping and crawling script?
Let's start with a benchmark.
# 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.")
Here, we made 100 HTTP requests using the make_request
function. Since requests happen synchronously, each task is executed sequentially.
Elapsed run time: 15.710984757 seconds.
So, that's roughly 0.16 seconds per request.
# 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.")
Here, the same make_request
function is called 100 times. This time the threading
library is used to create a thread for each request.
Elapsed run time: 1.020112515 seconds.
The total time decreases from ~16s to ~1s.
Since we're using separate threads for each request, you might be wondering why the whole thing didn't take ~0.16s to finish. This extra time is the overhead for managing threads. The Global Interpreter Lock (GIL) in Python makes sure that only one thread uses the Python bytecode at a time.
# 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.")
Here we used concurrent.futures.ThreadPoolExecutor
to achieve multithreading. After all the futures/promises are created, we used wait
to wait for all of them to complete.
Elapsed run time: 1.340592231 seconds
concurrent.futures.ThreadPoolExecutor
is actually an abstraction around the multithreading
library, which makes it easier to use. In the previous example, we assigned each request to a thread and in total 100 threads were used. But ThreadPoolExecutor
defaults the number of worker threads to min(32, os.cpu_count() + 4)
. ThreadPoolExecutor exists to ease the process of achieving multithreading. If you want more control over multithreading, use the multithreading
library instead.
# 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")
httpx
is used here sincerequests
does not support async operations.
Here, we used asyncio
to achieve concurrency.
Elapsed run time: 0.553961068 seconds
asyncio
is faster than the other methods, because threading
makes use of OS (Operating System) threads. So the threads are managed by the OS, where thread switching is preempted by the OS. asyncio
uses coroutines, which are defined by the Python interpreter. With coroutines, the program decides when to switch tasks in an optimal way. This is handled by the even_loop
in asyncio.
Scenario: How to speed up a simple data processing script?
Again, let's start with a benchmark.
# 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.")
Here, we executed the get_prime_numbers
function for numbers from 1000 to 16000.
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.")
Here, we used multiprocessing
to calculate the prime numbers.
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.")
Here, we achieved multiprocessing using concurrent.futures.ProcessPoolExecutor
. Once the jobs are added to futures, wait(futures)
waits for them to finish.
Elapsed run time: 4.452427557 seconds.
concurrent.futures.ProcessPoolExecutor
is a wrapper around multiprocessing.Pool
. It has the same limitations as the ThreadPoolExecutor
. If you want more control over multiprocessing, use multiprocessing.Pool
. concurrent.futures
provides an abstraction over both multiprocessing and threading, making it easy to switch between the two.
It's worth noting that using multiprocessing to execute the make_request
function will be much slower than the threading flavor since the processes will be need to wait for the IO. The multiprocessing approach will be faster then the sync approach, though.
Similarly, using concurrency for CPU-bound tasks is not worth the effort when compared to parallelism.
That being said, using concurrency or parallelism to execute your scripts adds complexity. Your code will generally be harder to read, test, and debug, so only use them when absolutely necessary for long-running scripts.
concurrent.futures
is where I generally start since-
requests
vs httpx
)Grab the code from the parallel-concurrent-examples-python repo on GitHub.
Original article source at: https://testdriven.io/
1660270108
In this Python tutorial, you'll learn how to build lightning-fast apps with Asyncio. asyncio, Python's concurrent I/O library, can power very-high-performance applications.
asyncio
— Asynchronous I/OHello World!
import asyncio
async def main():
print('Hello ...')
await asyncio.sleep(1)
print('... World!')
asyncio.run(main())
asyncio is a library to write concurrent code using the async/await syntax.
asyncio is used as a foundation for multiple Python asynchronous frameworks that provide high-performance network and web-servers, database connection libraries, distributed task queues, etc.
asyncio is often a perfect fit for IO-bound and high-level structured network code.
asyncio provides a set of high-level APIs to:
Additionally, there are low-level APIs for library and framework developers to:
networking
, running subprocesses
, handling OS signals
, etc;
Come and hear the story of how we were able to replace a legacy service cluster with a single asyncio-powered instance, and how you can do it too.
Modern services must handle vast amounts of traffic efficiently and in a scalable manner. One method of achieving high throughput while keeping things simple is by utilizing concurrent I/O.
The asyncio package is Python's offering for building high-performance, concurrent applications using non-blocking I/O. It is also known as an event loop or async/await (among other names), but in essence, it's a useful method for achieving high concurrency efficiently – one that differs from the principles of multithreading and offers unique benefits.
In this talk, I will share the story of why we designed an asyncio-based Python service, how its performance exceeded that of the Java service it replaced by an order-of-magnitude, and what learnings we gained from it. These learnings can help us design super-fast, highly concurrent services.
We will talk about:
- The principle behind asyncio's efficiency - its secret sauce.
- When asyncio shines, and when you might opt for a different approach.
- How to combine it with other paradigms to maximize your application's performance.
#python #asyncio
1660227900
What are concurrency and parallelism, and how do they apply to Python?
You can find all the code examples from this article in the concurrency-parallelism-and-asyncio repo on GitHub.
Source: https://testdriven.io
1660220640
Concurrency và Parallelism là gì và chúng áp dụng cho Python như thế nào?
Có nhiều lý do khiến ứng dụng của bạn có thể bị chậm. Đôi khi điều này là do thiết kế thuật toán kém hoặc lựa chọn sai cấu trúc dữ liệu. Tuy nhiên, đôi khi, đó là do các lực lượng nằm ngoài tầm kiểm soát của chúng tôi, chẳng hạn như các hạn chế về phần cứng hoặc các trục trặc của mạng. Đó là nơi mà tính đồng thời và song song phù hợp. Chúng cho phép các chương trình của bạn thực hiện nhiều việc cùng một lúc, cùng một lúc hoặc bằng cách lãng phí ít thời gian nhất có thể cho việc chờ đợi các tác vụ bận rộn.
Cho dù bạn đang xử lý các tài nguyên web bên ngoài, đọc và ghi vào nhiều tệp hoặc cần sử dụng một hàm tính toán chuyên sâu nhiều lần với các tham số khác nhau, bài viết này sẽ giúp bạn tối đa hóa hiệu quả và tốc độ mã của mình.
Đầu tiên, chúng ta sẽ đi sâu vào vấn đề đồng thời và song song là gì và cách chúng phù hợp với lĩnh vực Python bằng cách sử dụng các thư viện tiêu chuẩn như phân luồng, đa xử lý và asyncio. Phần cuối cùng của bài viết này sẽ so sánh việc triển khai async
/ của Python await
với cách các ngôn ngữ khác đã triển khai chúng.
Bạn có thể tìm thấy tất cả các ví dụ mã từ bài viết này trong repo đồng thời-song song-và-asyncio trên GitHub.
Để làm việc với các ví dụ trong bài viết này, bạn nên biết cách làm việc với các yêu cầu HTTP.
Đến cuối bài viết này, bạn sẽ có thể trả lời các câu hỏi sau:
Đồng thời là gì?
Một định nghĩa hiệu quả cho đồng thời là "có thể thực hiện nhiều tác vụ cùng một lúc". Tuy nhiên, điều này có một chút sai lầm, vì các tác vụ có thể thực sự được thực hiện cùng một lúc. Thay vào đó, một quá trình có thể bắt đầu, sau đó khi nó đang đợi một hướng dẫn cụ thể kết thúc, hãy chuyển sang một nhiệm vụ mới, chỉ để quay lại khi nó không còn chờ đợi nữa. Khi một nhiệm vụ hoàn thành, nó lại chuyển sang một nhiệm vụ chưa hoàn thành cho đến khi tất cả chúng đã được thực hiện. Các tác vụ bắt đầu không đồng bộ, được thực hiện không đồng bộ và sau đó kết thúc không đồng bộ.
Nếu điều đó khiến bạn bối rối, thay vào đó hãy nghĩ đến một phép tương tự: Giả sử bạn muốn tạo BLT . Đầu tiên, bạn sẽ cho thịt xông khói vào chảo ở lửa vừa và nhỏ. Trong khi nấu thịt xông khói, bạn có thể lấy cà chua và rau diếp ra và bắt đầu sơ chế (rửa và cắt) chúng. Trong khi đó, bạn tiếp tục kiểm tra và thỉnh thoảng lật thịt xông khói của bạn.
Tại thời điểm này, bạn đã bắt đầu một nhiệm vụ, sau đó bắt đầu và hoàn thành hai nhiệm vụ khác trong thời gian chờ đợi, tất cả trong khi bạn vẫn đang chờ đợi nhiệm vụ đầu tiên.
Cuối cùng, bạn cho bánh mì của mình vào máy nướng bánh mì. Trong khi nướng, bạn tiếp tục kiểm tra thịt xông khói của mình. Khi các miếng hoàn thành, bạn kéo chúng ra và đặt chúng vào đĩa. Sau khi nướng xong bánh mì, bạn phết lên đó lớp bánh mì sandwich mà bạn lựa chọn, sau đó bạn có thể bắt đầu xếp lớp trên cà chua, rau diếp và sau đó, khi đã nấu xong là thịt xông khói của bạn. Chỉ khi mọi thứ đã được nấu chín, chuẩn bị và xếp lớp, bạn mới có thể đặt miếng bánh mì nướng cuối cùng lên bánh mì sandwich, cắt miếng (tùy chọn) và ăn.
Vì nó đòi hỏi bạn phải thực hiện nhiều nhiệm vụ cùng một lúc, nên việc tạo BLT vốn dĩ là một quá trình đồng thời, ngay cả khi bạn không tập trung toàn bộ vào từng nhiệm vụ đó cùng một lúc. Đối với tất cả các ý định và mục đích, trong phần tiếp theo, chúng tôi sẽ đề cập đến hình thức đồng thời này chỉ là "đồng thời". Chúng ta sẽ phân biệt nó ở phần sau trong bài viết này.
Vì lý do này, đồng thời là rất tốt cho các quy trình I / O chuyên sâu - các tác vụ liên quan đến việc chờ đợi các yêu cầu web hoặc thao tác đọc / ghi tệp.
Trong Python, có một số cách khác nhau để đạt được sự đồng thời. Đầu tiên chúng ta sẽ xem xét thư viện luồng.
Đối với các ví dụ của chúng tôi trong phần này, chúng tôi sẽ xây dựng một chương trình Python nhỏ để lấy một thể loại nhạc ngẫu nhiên từ API Genrenator của Binary Jazz năm lần, in thể loại đó ra màn hình và đưa mỗi thể loại vào tệp riêng của nó.
Để làm việc với phân luồng trong Python, bạn cần nhập duy nhất threading
, nhưng đối với ví dụ này, tôi cũng đã nhập urllib
để làm việc với các yêu cầu HTTP, time
để xác định thời gian hoàn thành các hàm và json
dễ dàng chuyển đổi dữ liệu json được trả về từ API Genrenator.
Bạn có thể tìm thấy mã cho ví dụ này ở đây .
Hãy bắt đầu với một hàm đơn giản:
def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
req = Request("https://binaryjazz.us/wp-json/genrenator/v1/genre/", headers={"User-Agent": "Mozilla/5.0"})
genre = json.load(urlopen(req))
with open(file_name, "w") as new_file:
print(f"Writing '{genre}' to '{file_name}'...")
new_file.write(genre)
Kiểm tra mã ở trên, chúng tôi đang thực hiện một yêu cầu đối với API Genrenator, tải phản hồi JSON của nó (một thể loại nhạc ngẫu nhiên), in nó, sau đó ghi nó vào một tệp.
Nếu không có tiêu đề "Tác nhân người dùng", bạn sẽ nhận được 304.
Điều chúng tôi thực sự quan tâm là phần tiếp theo, nơi diễn ra luồng thực tế:
threads = []
for i in range(5):
thread = threading.Thread(
target=write_genre,
args=[f"./threading/new_file{i}.txt"]
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Đầu tiên chúng ta bắt đầu với một danh sách. Sau đó, chúng tôi tiến hành lặp lại năm lần, mỗi lần tạo một chuỗi mới. Tiếp theo, chúng tôi bắt đầu từng chuỗi, thêm nó vào danh sách "chuỗi" của chúng tôi, sau đó lặp lại danh sách của chúng tôi lần cuối để tham gia từng chuỗi.
Giải thích: Tạo chủ đề trong Python rất dễ dàng.
Để tạo một chủ đề mới, hãy sử dụng threading.Thread()
. Bạn có thể chuyển vào nó kwarg (đối số từ khóa) target
với giá trị của bất kỳ hàm nào bạn muốn chạy trên chuỗi đó. Nhưng chỉ chuyển vào tên của hàm chứ không phải giá trị của nó (ý nghĩa, cho mục đích của chúng tôi write_genre
và không phải write_genre()
). Để chuyển các đối số, hãy chuyển vào "kwargs" (lấy một chính tả của kwargs của bạn) hoặc "args" (lấy một có thể lặp lại chứa các args của bạn - trong trường hợp này là một danh sách).
Tuy nhiên, tạo một chuỗi không giống như bắt đầu một chuỗi. Để bắt đầu chủ đề của bạn, hãy sử dụng {the name of your thread}.start()
. Bắt đầu một luồng có nghĩa là "bắt đầu thực thi của nó."
Cuối cùng, khi chúng tôi nối các chuỗi với thread.join()
, tất cả những gì chúng tôi đang làm là đảm bảo chuỗi đã kết thúc trước khi tiếp tục với mã của chúng tôi.
Nhưng chính xác thì một chủ đề là gì?
Luồng là một cách cho phép máy tính của bạn chia nhỏ một quy trình / chương trình thành nhiều phần nhẹ thực thi song song. Hơi khó hiểu, việc triển khai phân luồng theo tiêu chuẩn của Python giới hạn các luồng chỉ có thể thực thi từng luồng một do một thứ được gọi là Global Interpreter Lock (GIL). GIL là cần thiết vì quản lý bộ nhớ của CPython (triển khai mặc định của Python) không an toàn theo luồng. Do hạn chế này, luồng trong Python là đồng thời, nhưng không song song. Để giải quyết vấn đề này, Python có một multiprocessing
mô-đun riêng biệt không bị giới hạn bởi GIL có chức năng quay các quy trình riêng biệt, cho phép thực thi song song mã của bạn. Sử dụng multiprocessing
mô-đun gần giống như sử dụng threading
mô-đun.
Có thể tìm thấy thêm thông tin về GIL của Python và độ an toàn của chuỗi trên Real Python và các tài liệu chính thức của Python .
Chúng ta sẽ sớm xem xét sâu hơn về đa xử lý trong Python.
Trước khi chúng tôi cho thấy khả năng cải thiện tốc độ so với mã không phân luồng, tôi cũng đã tự do tạo một phiên bản không phân luồng của cùng một chương trình (một lần nữa, có sẵn trên GitHub ). Thay vì tạo một luồng mới và tham gia từng luồng, thay vào đó, nó gọi write_genre
trong một vòng lặp for lặp lại năm lần.
Để so sánh các điểm chuẩn tốc độ, tôi cũng đã nhập time
thư viện để tính thời gian thực thi các tập lệnh của chúng tôi:
Starting...
Writing "binary indoremix" to "./sync/new_file0.txt"...
Writing "slavic aggro polka fusion" to "./sync/new_file1.txt"...
Writing "israeli new wave" to "./sync/new_file2.txt"...
Writing "byzantine motown" to "./sync/new_file3.txt"...
Writing "dutch hate industrialtune" to "./sync/new_file4.txt"...
Time to complete synchronous read/writes: 1.42 seconds
Upon running the script, we see that it takes my computer around 1.49 seconds (along with classic music genres such as "dutch hate industrialtune"). Not too bad.
Now let's run the version that uses threading:
Starting...
Writing "college k-dubstep" to "./threading/new_file2.txt"...
Writing "swiss dirt" to "./threading/new_file0.txt"...
Writing "bop idol alternative" to "./threading/new_file4.txt"...
Writing "ethertrio" to "./threading/new_file1.txt"...
Writing "beach aust shanty français" to "./threading/new_file3.txt"...
Time to complete threading read/writes: 0.77 seconds
The first thing that might stand out to you is the functions not being completed in order: 2 - 0 - 4 - 1 - 3
This is because of the asynchronous nature of threading: as one function waits, another one begins, and so on. Because we're able to continue performing tasks while we're waiting on others to finish (either due to networking or file I/O operations), you may also have noticed that we cut our time roughly in half: 0.77 seconds. Whereas this might not seem like a lot now, it's easy to imagine the very real case of building a web application that needs to write much more data to a file or interact with much more complex web services.
So, if threading is so great, why don't we end the article here?
Because there are even better ways to perform tasks concurrently.
Let's take a look at an example using asyncio. For this method, we're going to install aiohttp using pip
. This will allow us to make non-blocking requests and receive responses using the async
/await
syntax that will be introduced shortly. It also has the extra benefit of a function that converts a JSON response without needing to import the json
library. We'll also install and import aiofiles, which allows non-blocking file operations. Other than aiohttp
and aiofiles
, import asyncio
, which comes with the Python standard library.
"Non-blocking" means a program will allow other threads to continue running while it's waiting. This is opposed to "blocking" code, which stops execution of your program completely. Normal, synchronous I/O operations suffer from this limitation.
You can find the code for this example here.
Once we have our imports in place, let's take a look at the asynchronous version of the write_genre
function from our asyncio example:
async def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
async with aiohttp.ClientSession() as session:
async with session.get("https://binaryjazz.us/wp-json/genrenator/v1/genre/") as response:
genre = await response.json()
async with aiofiles.open(file_name, "w") as new_file:
print(f'Writing "{genre}" to "{file_name}"...')
await new_file.write(genre)
For those not familiar with the async
/await
syntax that can be found in many other modern languages, async
declares that a function, for
loop, or with
statement must be used asynchronously. To call an async function, you must either use the await
keyword from another async function or call create_task()
directly from the event loop, which can be grabbed from asyncio.get_event_loop()
-- i.e., loop = asyncio.get_event_loop()
.
Additionally:
async with
allows awaiting async responses and file operations.async for
(not used here) iterates over an asynchronous stream.Event loops are constructs inherent to asynchronous programming that allow performing tasks asynchronously. As you're reading this article, I can safely assume you're probably not too familiar with the concept. However, even if you've never written an async application, you have experience with event loops every time you use a computer. Whether your computer is listening for keyboard input, you're playing online multiplayer games, or you're browsing Reddit while you have files copying in the background, an event loop is the driving force that keeps everything working smoothly and efficiently. In its purest essence, an event loop is a process that waits around for triggers and then performs specific (programmed) actions once those triggers are met. They often return a "promise" (JavaScript syntax) or "future" (Python syntax) of some sort to denote that a task has been added. Once the task is finished, the promise or future returns a value passed back from the called function (assuming the function does return a value).
The idea of performing a function in response to another function is called a "callback."
For another take on callbacks and events, here's a great answer on Stack Overflow.
Here's a walkthrough of our function:
We're using async with
to open our client session asynchronously. The aiohttp.ClientSession()
class is what allows us to make HTTP requests and remain connected to a source without blocking the execution of our code. We then make an async request to the Genrenator API and await the JSON response (a random music genre). In the next line, we use async with
again with the aiofiles
library to asynchronously open a new file to write our new genre to. We print the genre, then write it to the file.
Unlike regular Python scripts, programming with asyncio pretty much enforces* using some sort of "main" function.
*Unless you're using the deprecated "yield" syntax with the @asyncio.coroutine decorator, which will be removed in Python 3.10.
This is because you need to use the "async" keyword in order to use the "await" syntax, and the "await" syntax is the only way to actually run other async functions.
Here's our main function:
async def main():
tasks = []
for i in range(5):
tasks.append(write_genre(f"./async/new_file{i}.txt"))
await asyncio.gather(*tasks)
As you can see, we've declared it with "async." We then create an empty list called "tasks" to house our async tasks (calls to Genrenator and our file I/O). We append our tasks to our list, but they are not actually run yet. The calls don't actually get made until we schedule them with await asyncio.gather(*tasks)
. This runs all of the tasks in our list and waits for them to finish before continuing with the rest of our program. Lastly, we use asyncio.run(main())
to run our "main" function. The .run()
function is the entry point for our program, and it should generally only be called once per process.
For those not familiar, the
*
in front of tasks is called "argument unpacking." Just as it sounds, it unpacks our list into a series of arguments for our function. Our function isasyncio.gather()
in this case.
And that's all we need to do. Now, running our program (the source of which includes the same timing functionality of the synchronous and threading examples)...
Writing "albuquerque fiddlehaus" to "./async/new_file1.txt"...
Writing "euroreggaebop" to "./async/new_file2.txt"...
Writing "shoedisco" to "./async/new_file0.txt"...
Writing "russiagaze" to "./async/new_file4.txt"...
Writing "alternative xylophone" to "./async/new_file3.txt"...
Time to complete asyncio read/writes: 0.71 seconds
...we see it's even faster still. And, in general, the asyncio method will always be a bit faster than the threading method. This is because when we use the "await" syntax, we essentially tell our program "hold on, I'll be right back," but our program keeps track of how long it takes us to finish what we're doing. Once we're done, our program will know, and will pick back up as soon as it's able. Threading in Python allows asynchronicity, but our program could theoretically skip around different threads that may not yet be ready, wasting time if there are threads ready to continue running.
So when should I use threading, and when should I use asyncio?
When you're writing new code, use asyncio. If you need to interface with older libraries or those that don't support asyncio, you might be better off with threading.
It turns out testing async functions with pytest is as easy as testing synchronous functions. Just install the pytest-asyncio package with pip
, mark your tests with the async
keyword, and apply a decorator that lets pytest
know it's asynchronous: @pytest.mark.asyncio
. Let's look at an example.
First, let's write an arbitrary async function in a file called hello_asyncio.py:
import asyncio
async def say_hello(name: str):
""" Sleeps for two seconds, then prints 'Hello, {{ name }}!' """
try:
if type(name) != str:
raise TypeError("'name' must be a string")
if name == "":
raise ValueError("'name' cannot be empty")
except (TypeError, ValueError):
raise
print("Sleeping...")
await asyncio.sleep(2)
print(f"Hello, {name}!")
The function takes a single string argument: name
. Upon ensuring that name
is a string with a length greater than one, our function asynchronously sleeps for two seconds, then prints "Hello, {name}!"
to the console.
The difference between
asyncio.sleep()
andtime.sleep()
is thatasyncio.sleep()
is non-blocking.
Now let's test it with pytest. In the same directory as hello_asyncio.py, create a file called test_hello_asyncio.py, then open it in your favorite text editor.
Let's start with our imports:
import pytest # Note: pytest-asyncio does not require a separate import
from hello_asyncio import say_hello
Then we'll create a test with proper input:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
@pytest.mark.asyncio
async def test_say_hello(name):
await say_hello(name)
Things to note:
@pytest.mark.asyncio
decorator lets pytest work asynchronouslyasync
syntaxawait
ing our async function as we would if we were running it outside of a testNow let's run our test with the verbose -v
option:
pytest -v
...
collected 3 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 33%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 66%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [100%]
Looks good. Next we'll write a couple of tests with bad input. Back inside of test_hello_asyncio.py, let's create a class called TestSayHelloThrowsExceptions
:
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
@pytest.mark.asyncio
async def test_say_hello_value_error(self, name):
with pytest.raises(ValueError):
await say_hello(name)
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
@pytest.mark.asyncio
async def test_say_hello_type_error(self, name):
with pytest.raises(TypeError):
await say_hello(name)
Again, we decorate our tests with @pytest.mark.asyncio
, mark our tests with the async
syntax, then call our function with await
.
Run the tests again:
pytest -v
...
collected 7 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 14%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 28%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [ 42%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_value_error[] PASSED [ 57%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[19] PASSED [ 71%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name1] PASSED [ 85%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name2] PASSED [100%]
Alternatively to pytest-asyncio, you can create a pytest fixture that yields an asyncio event loop:
import asyncio
import pytest
from hello_asyncio import say_hello
@pytest.fixture
def event_loop():
loop = asyncio.get_event_loop()
yield loop
Then, rather than using the async
/await
syntax, you create your tests as you would normal, synchronous tests:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
def test_say_hello(event_loop, name):
event_loop.run_until_complete(say_hello(name))
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
def test_say_hello_value_error(self, event_loop, name):
with pytest.raises(ValueError):
event_loop.run_until_complete(say_hello(name))
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
def test_say_hello_type_error(self, event_loop, name):
with pytest.raises(TypeError):
event_loop.run_until_complete(say_hello(name))
If you're interested, here's a more advanced tutorial on asyncio testing.
If you want to learn more about what distinguishes Python's implementation of threading vs asyncio, here's a great article from Medium.
For even better examples and explanations of threading in Python, here's a video by Corey Schafer that goes more in-depth, including using the concurrent.futures
library.
Lastly, for a massive deep-dive into asyncio itself, here's an article from Real Python completely dedicated to it.
Bonus: One more library you might be interested in is called Unsync, especially if you want to easily convert your current synchronous code into asynchronous code. To use it, you install the library with pip, import it with from unsync import unsync
, then decorate whatever currently synchronous function with @unsync
to make it asynchronous. To await it and get its return value (which you can do anywhere -- it doesn't have to be in an async/unsync function), just call .result()
after the function call.
What is parallelism?
Parallelism is very-much related to concurrency. In fact, parallelism is a subset of concurrency: whereas a concurrent process performs multiple tasks at the same time whether they're being diverted total attention or not, a parallel process is physically performing multiple tasks all at the same time. A good example would be driving, listening to music, and eating the BLT we made in the last section at the same time.
Because they don't require a lot of intensive effort, you can do them all at once without having to wait on anything or divert your attention away.
Now let's take a look at how to implement this in Python. We could use the multiprocessing
library, but let's use the concurrent.futures
library instead -- it eliminates the need to manage the number of process manually. Because the major benefit of multiprocessing happens when you perform multiple cpu-heavy tasks, we're going to compute the squares of 1 million (1000000) to 1 million and 16 (1000016).
You can find the code for this example here.
The only import we'll need is concurrent.futures
:
import concurrent.futures
import time
if __name__ == "__main__":
pow_list = [i for i in range(1000000, 1000016)]
print("Starting...")
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
futures = [executor.submit(pow, i, i) for i in pow_list]
for f in concurrent.futures.as_completed(futures):
print("okay")
end = time.time()
print(f"Time to complete: {round(end - start, 2)}")
Because I'm developing on a Windows machine, I'm using
if __name__ == "main"
. This is necessary because Windows does not have thefork
system call inherent to Unix systems. Because Windows doesn't have this capability, it resorts to launching a new interpreter with each process that tries to import the main module. If the main module doesn't exist, it reruns your entire program, causing recursive chaos to ensue.
So taking a look at our main function, we use a list comprehension to create a list from 1 million to 1 million and 16, we open a ProcessPoolExecutor with concurrent.futures, and we use list comprehension and ProcessPoolExecutor().submit()
to start executing our processes and throwing them into a list called "futures."
We could also use
ThreadPoolExecutor()
if we wanted to use threads instead -- concurrent.futures is versatile.
And this is where the asynchronicity comes in: The "results" list does not actually contain the results from running our functions. Instead, it contains "futures" which are similar to the JavaScript idea of "promises." In order to allow our program to continue running, we get back these futures that represent a placeholder for a value. If we try to print the future, depending on whether it's finished running or not, we'll either get back a state of "pending" or "finished." Once it's finished we can get the return value (assuming there is one) using var.result()
. In this case, our var will be "result."
We then iterate through our list of futures, but instead of printing our values, we're simply printing out "okay." This is just because of how massive the resulting calculations come out to be.
Just as before, I built a comparison script that does this synchronously. And, just as before, you can find it on GitHub.
Running our control program, which also includes functionality for timing our program, we get:
Starting...
okay
...
okay
Time to complete: 54.64
Wow. 54.64 seconds is quite a long time. Let's see if our version with multiprocessing does any better:
Starting...
okay
...
okay
Time to complete: 6.24
Our time has been significantly reduced. We're at about 1/9th of our original time.
So what would happen if we used threading for this instead?
I'm sure you can guess -- it wouldn't be much faster than doing it synchronously. In fact, it might be slower because it still takes a little time and effort to spin up new threads. But don't take my word for it, here's what we get when we replace ProcessPoolExecutor()
with ThreadPoolExecutor()
:
Starting...
okay
...
okay
Time to complete: 53.83
As I mentioned earlier, threading allows your applications to focus on new tasks while others are waiting. In this case, we're never sitting idly by. Multiprocessing, on the other hand, spins up totally new services, usually on separate CPU cores, ready to do whatever you ask it completely in tandem with whatever else your script is doing. This is why the multiprocessing version taking roughly 1/9th of the time makes sense -- I have 8 cores in my CPU.
Now that we've talked about concurrency and parallelism in Python, we can finally set the terms straight. If you're having trouble distinguishing between the terms, you can safely and accurately think of our previous definitions of "parallelism" and "concurrency" as "parallel concurrency" and "non-parallel concurrency" respectively.
Real Python has a great article on concurrency vs parallelism.
Engineer Man has a good video comparison of threading vs multiprocessing.
Corey Schafer also has a good video on multiprocessing in the same spirit as his threading video.
If you only watch one video, watch this excellent talk by Raymond Hettinger. He does an amazing job explaining the differences between multiprocessing, threading, and asyncio.
What if I need to combine many I/O operations with heavy calculations?
We can do that too. Say you need to scrape 100 web pages for a specific piece of information, and then you need to save that piece of info in a file for later. We can separate the compute power across each of our computer's cores by making each process scrape a fraction of the pages.
For this script, let's install Beautiful Soup to help us easily scrape our pages: pip install beautifulsoup4
. This time we actually have quite a few imports. Here they are, and here's why we're using them:
import asyncio # Gives us async/await
import concurrent.futures # Allows creating new processes
import time
from math import floor # Helps divide up our requests evenly across our CPU cores
from multiprocessing import cpu_count # Returns our number of CPU cores
import aiofiles # For asynchronously performing file I/O operations
import aiohttp # For asynchronously making HTTP requests
from bs4 import BeautifulSoup # For easy webpage scraping
You can find the code for this example here.
First, we're going to create an async function that makes a request to Wikipedia to get back random pages. We'll scrape each page we get back for its title using BeautifulSoup
, and then we'll append it to a given file; we'll separate each title with a tab. The function will take two arguments:
async def get_and_scrape_pages(num_pages: int, output_file: str):
"""
Makes {{ num_pages }} requests to Wikipedia to receive {{ num_pages }} random
articles, then scrapes each page for its title and appends it to {{ output_file }},
separating each title with a tab: "\\t"
#### Arguments
---
num_pages: int -
Number of random Wikipedia pages to request and scrape
output_file: str -
File to append titles to
"""
async with \
aiohttp.ClientSession() as client, \
aiofiles.open(output_file, "a+", encoding="utf-8") as f:
for _ in range(num_pages):
async with client.get("https://en.wikipedia.org/wiki/Special:Random") as response:
if response.status > 399:
# I was getting a 429 Too Many Requests at a higher volume of requests
response.raise_for_status()
page = await response.text()
soup = BeautifulSoup(page, features="html.parser")
title = soup.find("h1").text
await f.write(title + "\t")
await f.write("\n")
We're both asynchronously opening an aiohttp ClientSession
and our output file. The mode, a+
, means append to the file and create it if it doesn't already exist. Encoding our strings as utf-8 ensures we don't get an error if our titles contain international characters. If we get an error response, we'll raise it instead of continuing (at high request volumes I was getting a 429 Too Many Requests). We asynchronously get the text from our response, then we parse the title and asynchronously and append it to our file. After we append all of our titles, we append a new line: "\n".
Our next function is the function we'll start with each new process to allow running it asynchronously:
def start_scraping(num_pages: int, output_file: str, i: int):
""" Starts an async process for requesting and scraping Wikipedia pages """
print(f"Process {i} starting...")
asyncio.run(get_and_scrape_pages(num_pages, output_file))
print(f"Process {i} finished.")
Now for our main function. Let's start with some constants (and our function declaration):
def main():
NUM_PAGES = 100 # Number of pages to scrape altogether
NUM_CORES = cpu_count() # Our number of CPU cores (including logical cores)
OUTPUT_FILE = "./wiki_titles.tsv" # File to append our scraped titles to
PAGES_PER_CORE = floor(NUM_PAGES / NUM_CORES)
PAGES_FOR_FINAL_CORE = PAGES_PER_CORE + NUM_PAGES % PAGES_PER_CORE # For our final core
And now the logic:
futures = []
with concurrent.futures.ProcessPoolExecutor(NUM_CORES) as executor:
for i in range(NUM_CORES - 1):
new_future = executor.submit(
start_scraping, # Function to perform
# v Arguments v
num_pages=PAGES_PER_CORE,
output_file=OUTPUT_FILE,
i=i
)
futures.append(new_future)
futures.append(
executor.submit(
start_scraping,
PAGES_FOR_FINAL_CORE, OUTPUT_FILE, NUM_CORES-1
)
)
concurrent.futures.wait(futures)
We create an array to store our futures, then we create a ProcessPoolExecutor
, setting its max_workers
equal to our number of cores. We iterate over a range equal to our number of cores minus 1, running a new process with our start_scraping
function. We then append it our futures list. Our final core will potentially have extra work to do as it will scrape a number of pages equal to each of our other cores, but will additionally scrape a number of pages equal to the remainder that we got when dividing our total number of pages to scrape by our total number of cpu cores.
Make sure to actually run your main function:
if __name__ == "__main__":
start = time.time()
main()
print(f"Time to complete: {round(time.time() - start, 2)} seconds.")
After running the program with my 8-core CPU (along with benchmarking code):
This version (asyncio with multiprocessing):
Time to complete: 5.65 seconds.
Time to complete: 8.87 seconds.
Time to complete: 47.92 seconds.
Time to complete: 88.86 seconds.
I'm actually quite surprised to see that the improvement of asyncio with multiprocessing over just multiprocessing wasn't as great as I thought it would be.
async
/await
and similar syntax also exist in other languages, and in some of those languages, its implementation can differ drastically.
The first programming language (back in 2007) to use the async
syntax was Microsoft's F#. Whereas it doesn't exactly use await
to wait on a function call, it uses specific syntax like let!
and do!
along with proprietary Async
functions included in the System
module.
You can find more about async programming in F# on Microsoft's F# docs.
Their C# team then built upon this concept, and that's where the async
/await
keywords that we're now familiar with were born:
using System;
// Allows the "Task" return type
using System.Threading.Tasks;
public class Program
{
// Declare an async function with "async"
private static async Task<string> ReturnHello()
{
return "hello world";
}
// Main can be async -- no problem
public static async Task Main()
{
// await an async string
string result = await ReturnHello();
// Print the string we got asynchronously
Console.WriteLine(result);
}
}
We ensure that we're using System.Threading.Tasks
as it includes the Task
type, and, in general, the Task
type is needed for an async function to be awaited. The cool thing about C# is that you can make your main function asynchronous just by declaring it with async
, and you won't have any issues.
If you're interested in learning more about
async
/await
in C#, Microsoft's C# docs have a good page on it.
First introduced in ES6, the async
/await
syntax is essentially an abstraction over JavaScript promises (which are similar to Python futures). Unlike Python, however, so long as you're not awaiting, you can call an async function normally without a specific function like Python's asyncio.start()
:
// Declare a function with async
async function returnHello(){
return "hello world";
}
async function printSomething(){
// await an async string
const result = await returnHello();
// print the string we got asynchronously
console.log(result);
}
// Run our async code
printSomething();
Xem MDN để biết thêm thông tin về
async
/await
trong JavaScript .
Rust hiện cũng cho phép sử dụng cú pháp async
/ await
và nó hoạt động tương tự như Python, C # và JavaScript:
// Allows blocking synchronous code to run async code
use futures::executor::block_on;
// Declare an async function with "async"
async fn return_hello() -> String {
"hello world".to_string()
}
// Code that awaits must also be declared with "async"
async fn print_something(){
// await an async String
let result: String = return_hello().await;
// Print the string we got asynchronously
println!("{0}", result);
}
fn main() {
// Block the current synchronous execution to run our async code
block_on(print_something());
}
Để sử dụng các hàm không đồng bộ, trước tiên chúng ta phải thêm futures = "0.3"
vào Cargo.toml của mình . Sau đó, chúng tôi nhập block_on
hàm với use futures::executor::block_on
- block_on
là cần thiết để chạy hàm không đồng bộ của chúng tôi từ main
hàm đồng bộ của chúng tôi.
Bạn có thể tìm thêm thông tin về
async
/await
về Rust trong tài liệu về Rust.
Thay vì cú pháp async
/ truyền thống await
vốn có cho tất cả các ngôn ngữ trước đây mà chúng tôi đã đề cập, Go sử dụng "goroutines" và "channel". Bạn có thể nghĩ về một kênh tương tự như một tương lai của Python. Trong Go, bạn thường gửi một kênh dưới dạng đối số cho một hàm, sau đó sử dụng go
để chạy hàm đồng thời. Bất cứ khi nào bạn cần đảm bảo rằng hàm đã hoàn tất, bạn sử dụng <-
cú pháp mà bạn có thể coi là await
cú pháp phổ biến hơn. Nếu goroutine của bạn (hàm bạn đang chạy không đồng bộ) có giá trị trả về, nó có thể được lấy theo cách này.
package main
import "fmt"
// "chan" makes the return value a string channel instead of a string
func returnHello(result chan string){
// Gives our channel a value
result <- "hello world"
}
func main() {
// Creates a string channel
result := make(chan string)
// Starts execution of our goroutine
go returnHello(result)
// Awaits and prints our string
fmt.Println(<- result)
}
Để biết thêm thông tin về đồng thời trong cờ vây, hãy xem Giới thiệu về lập trình trong cờ vây của Caleb Doxsey.
Tương tự như Python, Ruby cũng có giới hạn về Khóa thông dịch viên toàn cầu. Những gì nó không có là đồng thời được tích hợp sẵn trong ngôn ngữ. Tuy nhiên, có một loại đá quý do cộng đồng tạo ra cho phép sử dụng đồng thời trong Ruby và bạn có thể tìm thấy nguồn của nó trên GitHub .
Giống như Ruby, Java không có async
/ await
cú pháp tích hợp, nhưng nó có khả năng đồng thời bằng cách sử dụng java.util.concurrent
mô-đun. Tuy nhiên, Electronic Arts đã viết một thư viện Async cho phép sử dụng await
như một phương pháp. Nó không hoàn toàn giống với Python / C # / JavaScript / Rust, nhưng nó đáng để xem xét nếu bạn là nhà phát triển Java và quan tâm đến loại chức năng này.
Mặc dù C ++ cũng không có cú pháp async
/ await
, nhưng nó có khả năng sử dụng tương lai để chạy mã đồng thời bằng cách sử dụng futures
mô-đun:
#include <iostream>
#include <string>
// Necessary for futures
#include <future>
// No async declaration needed
std::string return_hello() {
return "hello world";
}
int main ()
{
// Declares a string future
std::future<std::string> fut = std::async(return_hello);
// Awaits the result of the future
std::string result = fut.get();
// Prints the string we got asynchronously
std::cout << result << '\n';
}
Không cần khai báo một hàm với bất kỳ từ khóa nào để biểu thị liệu nó có thể và nên chạy không đồng bộ hay không. Thay vào đó, bạn khai báo tương lai ban đầu của mình bất cứ khi nào bạn cần std::future<{{ function return type }}>
và đặt nó bằng std::async()
, bao gồm tên của hàm bạn muốn thực hiện không đồng bộ cùng với bất kỳ đối số nào mà nó cần - tức là std::async(do_something, 1, 2, "string")
. Để chờ đợi giá trị của tương lai, hãy sử dụng .get()
cú pháp trên đó.
Bạn có thể tìm thấy tài liệu về không đồng bộ trong C ++ trên cplusplus.com.
Cho dù bạn đang làm việc với các hoạt động mạng hoặc tệp không đồng bộ hay bạn đang thực hiện nhiều phép tính phức tạp, có một số cách khác nhau để tối đa hóa hiệu quả mã của bạn.
Nếu bạn đang sử dụng Python, bạn có thể sử dụng asyncio
hoặc threading
để tận dụng tối đa các hoạt động I / O hoặc multiprocessing
mô-đun dành cho mã đòi hỏi nhiều CPU.
Cũng nên nhớ rằng
concurrent.futures
mô-đun có thể được sử dụng thay cho một trong haithreading
hoặcmultiprocessing
.
Nếu bạn đang sử dụng một ngôn ngữ lập trình khác, rất có thể bạn cũng sẽ triển khai async
/ await
cho ngôn ngữ đó.
Nguồn: https://testdriven.io
1660212900
Что такое параллелизм и параллелизм и как они применимы к Python?
Есть много причин, по которым ваши приложения могут работать медленно. Иногда это происходит из-за плохого алгоритмического дизайна или неправильного выбора структуры данных. Однако иногда это происходит из-за не зависящих от нас сил, таких как аппаратные ограничения или особенности сети. Вот тут-то и подходят параллелизм и параллелизм. Они позволяют вашим программам делать несколько вещей одновременно, либо одновременно, либо тратя как можно меньше времени на ожидание загруженных задач.
Независимо от того, имеете ли вы дело с внешними веб-ресурсами, чтением и записью в несколько файлов или вам нужно несколько раз использовать функцию с интенсивными вычислениями с различными параметрами, эта статья должна помочь вам максимизировать эффективность и скорость вашего кода.
Во-первых, мы углубимся в то, что такое параллелизм и параллелизм и как они вписываются в область Python, используя стандартные библиотеки, такие как многопоточность, многопроцессорность и асинхронность. async
В последней части этой статьи реализация / в Python будет сравниваться await
с тем, как они реализованы в других языках.
Вы можете найти все примеры кода из этой статьи в репозитории concurrency-parallelism-and-asyncio на GitHub.
Чтобы работать с примерами в этой статье, вы уже должны знать, как работать с HTTP-запросами.
К концу этой статьи вы должны быть в состоянии ответить на следующие вопросы:
Что такое параллелизм?
Эффективным определением параллелизма является «способность выполнять несколько задач одновременно». Однако это немного вводит в заблуждение, поскольку задачи могут выполняться или не выполняться в одно и то же время. Вместо этого процесс может начаться, а затем, когда он ожидает завершения определенной инструкции, переключиться на новую задачу, чтобы вернуться только после того, как он больше не ждет. Как только одна задача завершена, она снова переключается на незавершенную задачу, пока все они не будут выполнены. Задачи начинаются асинхронно, выполняются асинхронно и затем асинхронно завершаются.
Если это сбивает вас с толку, давайте вместо этого придумаем аналогию: скажем, вы хотите создать BLT . Во-первых, вам нужно бросить бекон в сковороду на среднем огне. Пока бекон готовится, вы можете достать помидоры и листья салата и начать их готовить (мыть и нарезать). Все это время вы продолжаете проверять и время от времени переворачиваете свой бекон.
На этом этапе вы начали одну задачу, а затем тем временем начали и завершили еще две, все еще ожидая выполнения первой.
В конце концов, вы кладете свой хлеб в тостер. Пока он поджаривается, вы продолжаете проверять свой бекон. Когда кусочки готовы, вы вытаскиваете их и кладете на тарелку. Как только ваш хлеб поджарится, вы намазываете его выбранной пастой для сэндвичей, а затем можете начать выкладывать слоями помидоры, листья салата, а затем, когда все готово, бекон. Только после того, как все приготовлено, подготовлено и выложено слоями, вы можете положить последний кусок тоста на бутерброд, нарезать его (по желанию) и съесть.
Поскольку это требует от вас одновременного выполнения нескольких задач, создание BLT по своей сути является параллельным процессом, даже если вы не уделяете все свое внимание каждой из этих задач одновременно. Во всех смыслах и целях в следующем разделе мы будем называть эту форму параллелизма просто параллелизмом. Мы будем различать его позже в этой статье.
По этой причине параллелизм отлично подходит для процессов с интенсивным вводом-выводом — задач, включающих ожидание веб-запросов или операций чтения/записи файлов.
В Python существует несколько различных способов достижения параллелизма. Сначала мы рассмотрим библиотеку потоков.
Для наших примеров в этом разделе мы собираемся создать небольшую программу на Python, которая пять раз выбирает случайный музыкальный жанр из API Genrenator Binary Jazz , выводит жанр на экран и помещает каждый в отдельный файл.
Для работы с многопоточностью в Python вам потребуется единственный импорт threading
, но для этого примера я также импортировал urllib
для работы с HTTP-запросами, time
чтобы определить, сколько времени требуется для выполнения функций, и json
чтобы легко преобразовать возвращаемые данные json. через Genrenator API.
Вы можете найти код для этого примера здесь .
Начнем с простой функции:
def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
req = Request("https://binaryjazz.us/wp-json/genrenator/v1/genre/", headers={"User-Agent": "Mozilla/5.0"})
genre = json.load(urlopen(req))
with open(file_name, "w") as new_file:
print(f"Writing '{genre}' to '{file_name}'...")
new_file.write(genre)
Изучая приведенный выше код, мы делаем запрос к Genrenator API, загружаем его ответ JSON (случайный музыкальный жанр), распечатываем его, а затем записываем в файл.
Без заголовка «User-Agent» вы получите 304.
Что нас действительно интересует, так это следующий раздел, где происходит фактическая многопоточность:
threads = []
for i in range(5):
thread = threading.Thread(
target=write_genre,
args=[f"./threading/new_file{i}.txt"]
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Сначала мы начинаем со списка. Затем мы повторяем пять раз, каждый раз создавая новый поток. Затем мы запускаем каждый поток, добавляем его в наш список «потоков», а затем проходим по нашему списку в последний раз, чтобы присоединиться к каждому потоку.
Объяснение: Создавать потоки в Python очень просто.
Чтобы создать новый поток, используйте threading.Thread()
. Вы можете передать в него kwarg (аргумент ключевого слова) target
со значением любой функции, которую вы хотите запустить в этом потоке. Но передавать только имя функции, а не ее значение (имеющееся в виду для наших целей, write_genre
а не write_genre()
). Чтобы передать аргументы, передайте «kwargs» (который принимает dict ваших kwargs) или «args» (который принимает итерацию, содержащую ваши аргументы — в данном случае список).
Однако создание потока — это не то же самое, что запуск потока. Чтобы начать тему, используйте {the name of your thread}.start()
. Запуск потока означает «начало его выполнения».
Наконец, когда мы объединяем потоки с помощью thread.join()
, все, что мы делаем, — это обеспечиваем завершение потока, прежде чем продолжить работу с нашим кодом.
Но что такое нить?
Поток — это способ, позволяющий вашему компьютеру разбить один процесс/программу на множество легковесных частей, которые выполняются параллельно. Несколько сбивает с толку то, что стандартная реализация многопоточности в Python ограничивает возможность выполнения потоков только по одному из-за так называемой глобальной блокировки интерпретатора (GIL). GIL необходим, потому что управление памятью CPython (реализация Python по умолчанию) не является потокобезопасным. Из-за этого ограничения многопоточность в Python является одновременной, но не параллельной. Чтобы обойти это, в Python есть отдельный multiprocessing
модуль, не ограниченный GIL, который запускает отдельные процессы, обеспечивая параллельное выполнение вашего кода. Использование multiprocessing
модуля почти идентично использованию threading
модуля.
Дополнительную информацию о GIL Python и безопасности потоков можно найти в официальной документации Real Python и Python .
Вскоре мы более подробно рассмотрим многопроцессорность в Python.
Прежде чем мы покажем потенциальное улучшение скорости по сравнению с беспотоковым кодом, я позволил себе также создать непоточную версию той же программы (опять же, доступную на GitHub ). Вместо того, чтобы создавать новый поток и присоединяться к каждому из них, он вместо этого вызывает write_genre
цикл for, который повторяется пять раз.
Чтобы сравнить тесты скорости, я также импортировал time
библиотеку для измерения времени выполнения наших скриптов:
Starting...
Writing "binary indoremix" to "./sync/new_file0.txt"...
Writing "slavic aggro polka fusion" to "./sync/new_file1.txt"...
Writing "israeli new wave" to "./sync/new_file2.txt"...
Writing "byzantine motown" to "./sync/new_file3.txt"...
Writing "dutch hate industrialtune" to "./sync/new_file4.txt"...
Time to complete synchronous read/writes: 1.42 seconds
После запуска сценария мы видим, что мой компьютер занимает около 1,49 секунды (наряду с классическими музыкальными жанрами, такими как «голландская ненависть индастриалтюн»). Не так уж плохо.
Теперь давайте запустим версию, использующую многопоточность:
Starting...
Writing "college k-dubstep" to "./threading/new_file2.txt"...
Writing "swiss dirt" to "./threading/new_file0.txt"...
Writing "bop idol alternative" to "./threading/new_file4.txt"...
Writing "ethertrio" to "./threading/new_file1.txt"...
Writing "beach aust shanty français" to "./threading/new_file3.txt"...
Time to complete threading read/writes: 0.77 seconds
Первое, что может вас заинтересовать, это то, что функции выполняются не по порядку: 2 - 0 - 4 - 1 - 3.
Это связано с асинхронным характером многопоточности: пока одна функция ожидает, начинается другая и так далее. Поскольку мы можем продолжать выполнять задачи, пока ждем завершения других (из-за сетевых или файловых операций ввода-вывода), вы также могли заметить, что мы сократили наше время примерно вдвое: 0,77 секунды. Хотя сейчас это может показаться не таким уж большим, легко представить себе вполне реальный случай создания веб-приложения, которое должно записывать гораздо больше данных в файл или взаимодействовать с гораздо более сложными веб-сервисами.
Итак, если многопоточность — это так здорово, почему бы нам не закончить статью на этом?
Потому что есть еще лучшие способы одновременного выполнения задач.
Давайте рассмотрим пример использования asyncio. Для этого метода мы собираемся установить aiohttp с помощью pip
. Это позволит нам делать неблокирующие запросы и получать ответы, используя синтаксис async
/ await
, который вскоре будет представлен. Он также имеет дополнительное преимущество функции, которая преобразует ответ JSON без необходимости импортировать json
библиотеку. Мы также установим и импортируем файлы aiofiles , которые позволяют выполнять неблокирующие операции с файлами. Кроме aiohttp
and aiofiles
, import asyncio
, который входит в стандартную библиотеку Python.
«Неблокирующий» означает, что программа позволит другим потокам продолжать работу, пока она ожидает. Это противоположно «блокирующему» коду, который полностью останавливает выполнение вашей программы. Обычные синхронные операции ввода-вывода страдают от этого ограничения.
Вы можете найти код для этого примера здесь .
Когда у нас есть импорт, давайте взглянем на асинхронную версию write_genre
функции из нашего примера asyncio:
async def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
async with aiohttp.ClientSession() as session:
async with session.get("https://binaryjazz.us/wp-json/genrenator/v1/genre/") as response:
genre = await response.json()
async with aiofiles.open(file_name, "w") as new_file:
print(f'Writing "{genre}" to "{file_name}"...')
await new_file.write(genre)
For those not familiar with the async
/await
syntax that can be found in many other modern languages, async
declares that a function, for
loop, or with
statement must be used asynchronously. To call an async function, you must either use the await
keyword from another async function or call create_task()
directly from the event loop, which can be grabbed from asyncio.get_event_loop()
-- i.e., loop = asyncio.get_event_loop()
.
Additionally:
async with
allows awaiting async responses and file operations.async for
(not used here) iterates over an asynchronous stream.Циклы событий — это конструкции, присущие асинхронному программированию, которые позволяют выполнять задачи асинхронно. Поскольку вы читаете эту статью, я могу с уверенностью предположить, что вы, вероятно, не слишком знакомы с этой концепцией. Однако, даже если вы никогда не писали асинхронное приложение, у вас есть опыт работы с циклами событий каждый раз, когда вы используете компьютер. Независимо от того, прослушивает ли ваш компьютер ввод с клавиатуры, играете ли вы в многопользовательские онлайн-игры или просматриваете Reddit во время копирования файлов в фоновом режиме, цикл событий является движущей силой, обеспечивающей бесперебойную и эффективную работу. В чистом виде цикл событий — это процесс, который ожидает триггеров, а затем выполняет определенные (запрограммированные) действия, как только эти триггеры встречаются. Они часто возвращают «обещание» (синтаксис JavaScript) или «будущее». (синтаксис Python) для обозначения того, что задача была добавлена. После завершения задачи обещание или будущее возвращает значение, переданное из вызываемой функции (при условии, что функция действительно возвращает значение).
Идея выполнения функции в ответ на другую функцию называется «обратным вызовом».
Чтобы еще раз взглянуть на обратные вызовы и события, вот отличный ответ на Stack Overflow .
Вот пошаговое руководство по нашей функции:
Мы используем async with
для асинхронного открытия нашего клиентского сеанса. Класс aiohttp.ClientSession()
— это то, что позволяет нам делать HTTP-запросы и оставаться на связи с источником, не блокируя выполнение нашего кода. Затем мы делаем асинхронный запрос к Genrenator API и ждем ответа JSON (случайный музыкальный жанр). В следующей строке мы async with
снова используем aiofiles
библиотеку, чтобы асинхронно открыть новый файл, чтобы записать в него наш новый жанр. Печатаем жанр, потом пишем в файл.
В отличие от обычных скриптов Python, программирование с помощью asyncio в значительной степени требует * использования какой-то «основной» функции.
*Если вы не используете устаревший синтаксис «yield» с декоратором @asyncio.coroutine, который будет удален в Python 3.10 .
Это связано с тем, что вам нужно использовать ключевое слово «async», чтобы использовать синтаксис «ожидания», а синтаксис «ожидания» — единственный способ фактически запустить другие асинхронные функции.
Вот наша основная функция:
async def main():
tasks = []
for i in range(5):
tasks.append(write_genre(f"./async/new_file{i}.txt"))
await asyncio.gather(*tasks)
Как видите, мы объявили это с помощью «async». Затем мы создаем пустой список под названием «задачи» для размещения наших асинхронных задач (вызовы Genrenator и наш файловый ввод-вывод). Мы добавляем наши задачи в наш список, но на самом деле они еще не запущены. Звонки на самом деле не совершаются, пока мы не запланируем их с помощью await asyncio.gather(*tasks)
. Это запускает все задачи в нашем списке и ждет их завершения, прежде чем продолжить остальную часть нашей программы. Наконец, мы используем asyncio.run(main())
для запуска нашей «основной» функции. Функция .run()
является точкой входа для нашей программы, и обычно ее следует вызывать только один раз для каждого процесса .
Для тех, кто не знаком,
*
перед задачами называется «распаковка аргументов». Как это ни звучит, он распаковывает наш список в ряд аргументов для нашей функции. Наша функцияasyncio.gather()
в этом случае.
И это все, что нам нужно сделать. Теперь запустим нашу программу (источник которой включает в себя те же функции синхронизации, что и примеры синхронного и многопоточного выполнения)...
Writing "albuquerque fiddlehaus" to "./async/new_file1.txt"...
Writing "euroreggaebop" to "./async/new_file2.txt"...
Writing "shoedisco" to "./async/new_file0.txt"...
Writing "russiagaze" to "./async/new_file4.txt"...
Writing "alternative xylophone" to "./async/new_file3.txt"...
Time to complete asyncio read/writes: 0.71 seconds
...мы видим, что это еще быстрее. И вообще метод asyncio всегда будет немного быстрее, чем метод threading. Это связано с тем, что когда мы используем синтаксис «ожидания», мы, по сути, говорим нашей программе «подожди, я сейчас вернусь», но наша программа отслеживает, сколько времени нам потребуется, чтобы закончить то, что мы делаем. Как только мы закончим, наша программа узнает об этом и возобновит работу, как только сможет. Потоки в Python допускают асинхронность, но наша программа теоретически может пропускать разные потоки, которые могут быть еще не готовы, что приводит к потере времени, если есть потоки, готовые к продолжению выполнения.
Итак, когда я должен использовать многопоточность и когда я должен использовать asyncio?
Когда вы пишете новый код, используйте asyncio. Если вам нужно взаимодействовать со старыми библиотеками или теми, которые не поддерживают asyncio, вам может быть лучше использовать многопоточность.
Оказывается, тестировать асинхронные функции с помощью pytest так же просто, как тестировать синхронные функции. Просто установите пакет pytest-asyncio с помощью pip
, отметьте свои тесты async
ключевым словом и примените декоратор, который дает pytest
понять, что он асинхронный: @pytest.mark.asyncio
. Давайте посмотрим на пример.
Во-первых, давайте напишем произвольную асинхронную функцию в файле с именем hello_asyncio.py :
import asyncio
async def say_hello(name: str):
""" Sleeps for two seconds, then prints 'Hello, {{ name }}!' """
try:
if type(name) != str:
raise TypeError("'name' must be a string")
if name == "":
raise ValueError("'name' cannot be empty")
except (TypeError, ValueError):
raise
print("Sleeping...")
await asyncio.sleep(2)
print(f"Hello, {name}!")
Функция принимает один строковый аргумент: name
. Убедившись, что name
это строка длиной больше единицы, наша функция асинхронно приостанавливается на две секунды, а затем выводит "Hello, {name}!"
на консоль.
Разница между
asyncio.sleep()
иtime.sleep()
в том, чтоasyncio.sleep()
он не блокирует.
Теперь давайте проверим это с помощью pytest. В том же каталоге, что и hello_asyncio.py, создайте файл с именем test_hello_asyncio.py, а затем откройте его в своем любимом текстовом редакторе.
Начнем с нашего импорта:
import pytest # Note: pytest-asyncio does not require a separate import
from hello_asyncio import say_hello
Затем мы создадим тест с правильным вводом:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
@pytest.mark.asyncio
async def test_say_hello(name):
await say_hello(name)
Что следует отметить:
@pytest.mark.asyncio
работать асинхронноasync
синтаксисawait
запускаем нашу асинхронную функцию так, как если бы мы запускали ее вне теста.Теперь давайте запустим наш тест с подробной -v
опцией:
pytest -v
...
collected 3 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 33%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 66%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [100%]
Выглядит неплохо. Далее мы напишем пару тестов с плохим входом. Вернувшись внутрь test_hello_asyncio.py , давайте создадим класс с именем TestSayHelloThrowsExceptions
:
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
@pytest.mark.asyncio
async def test_say_hello_value_error(self, name):
with pytest.raises(ValueError):
await say_hello(name)
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
@pytest.mark.asyncio
async def test_say_hello_type_error(self, name):
with pytest.raises(TypeError):
await say_hello(name)
Опять же, мы украшаем наши тесты с помощью @pytest.mark.asyncio
, помечаем наши тесты async
синтаксисом, а затем вызываем нашу функцию с помощью await
.
Запустите тесты еще раз:
pytest -v
...
collected 7 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 14%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 28%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [ 42%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_value_error[] PASSED [ 57%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[19] PASSED [ 71%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name1] PASSED [ 85%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name2] PASSED [100%]
В качестве альтернативы pytest-asyncio вы можете создать фикстуру pytest, которая создает цикл событий asyncio:
import asyncio
import pytest
from hello_asyncio import say_hello
@pytest.fixture
def event_loop():
loop = asyncio.get_event_loop()
yield loop
Затем вместо использования синтаксиса async
/ await
вы создаете свои тесты, как обычные синхронные тесты:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
def test_say_hello(event_loop, name):
event_loop.run_until_complete(say_hello(name))
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
def test_say_hello_value_error(self, event_loop, name):
with pytest.raises(ValueError):
event_loop.run_until_complete(say_hello(name))
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
def test_say_hello_type_error(self, event_loop, name):
with pytest.raises(TypeError):
event_loop.run_until_complete(say_hello(name))
If you're interested, here's a more advanced tutorial on asyncio testing.
If you want to learn more about what distinguishes Python's implementation of threading vs asyncio, here's a great article from Medium.
For even better examples and explanations of threading in Python, here's a video by Corey Schafer that goes more in-depth, including using the concurrent.futures
library.
Lastly, for a massive deep-dive into asyncio itself, here's an article from Real Python completely dedicated to it.
Bonus: One more library you might be interested in is called Unsync, especially if you want to easily convert your current synchronous code into asynchronous code. To use it, you install the library with pip, import it with from unsync import unsync
, then decorate whatever currently synchronous function with @unsync
to make it asynchronous. To await it and get its return value (which you can do anywhere -- it doesn't have to be in an async/unsync function), just call .result()
after the function call.
What is parallelism?
Параллелизм очень сильно связан с параллелизмом. На самом деле, параллелизм — это подмножество параллелизма: в то время как параллельный процесс выполняет несколько задач одновременно, независимо от того, отвлекается ли на них все внимание или нет, параллельный процесс физически выполняет несколько задач одновременно. Хорошим примером может быть вождение, прослушивание музыки и одновременная поедание BLT, которое мы сделали в последнем разделе.
Поскольку они не требуют больших интенсивных усилий, вы можете выполнять их все сразу, не ожидая ничего и не отвлекая внимания.
Теперь давайте посмотрим, как это реализовать на Python. Мы могли бы использовать multiprocessing
библиотеку, но давайте concurrent.futures
вместо этого воспользуемся библиотекой — она устраняет необходимость управлять количеством процессов вручную. Поскольку основное преимущество многопроцессорной обработки возникает, когда вы выполняете несколько ресурсоемких задач, мы собираемся вычислить квадраты от 1 миллиона (1000000) до 1 миллиона и 16 (1000016).
Вы можете найти код для этого примера здесь .
Единственный импорт, который нам понадобится, это concurrent.futures
:
import concurrent.futures
import time
if __name__ == "__main__":
pow_list = [i for i in range(1000000, 1000016)]
print("Starting...")
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
futures = [executor.submit(pow, i, i) for i in pow_list]
for f in concurrent.futures.as_completed(futures):
print("okay")
end = time.time()
print(f"Time to complete: {round(end - start, 2)}")
Поскольку я разрабатываю на компьютере с Windows, я использую
if __name__ == "main"
. Это необходимо, поскольку в Windows нетfork
системного вызова, присущего системам Unix . Поскольку Windows не имеет такой возможности, она прибегает к запуску нового интерпретатора для каждого процесса, пытающегося импортировать основной модуль. Если основной модуль не существует, он перезапускает всю вашу программу, вызывая рекурсивный хаос.
Итак, взглянув на нашу основную функцию, мы используем понимание списка для создания списка от 1 миллиона до 1 миллиона и 16, мы открываем ProcessPoolExecutor с concurrent.futures и используем понимание списка и ProcessPoolExecutor().submit()
начинаем выполнять наши процессы и бросать их в список под названием «фьючерсы».
Мы также могли бы использовать
ThreadPoolExecutor()
, если бы вместо этого хотели использовать потоки — concurrent.futures универсален.
И здесь проявляется асинхронность: список «результаты» на самом деле не содержит результатов выполнения наших функций. Вместо этого он содержит «фьючерсы», которые аналогичны идее «обещаний» в JavaScript. Чтобы наша программа продолжала работать, мы возвращаем эти фьючерсы, которые представляют собой заполнитель для значения. Если мы попытаемся напечатать будущее, в зависимости от того, завершено оно или нет, мы либо вернемся в состояние «ожидание», либо «завершено». После завершения мы можем получить возвращаемое значение (при условии, что оно есть), используя var.result()
. В этом случае наша переменная будет «результатом».
Затем мы повторяем наш список фьючерсов, но вместо того, чтобы печатать наши значения, мы просто печатаем «хорошо». Это просто из-за того, насколько массивными получаются результирующие вычисления.
Как и прежде, я создал скрипт сравнения, который делает это синхронно. И, как и прежде, вы можете найти его на GitHub .
Запустив нашу управляющую программу, которая также включает в себя функции синхронизации нашей программы, мы получаем:
Starting...
okay
...
okay
Time to complete: 54.64
Ух ты. 54,64 секунды — это довольно много. Посмотрим, будет ли лучше наша версия с многопроцессорностью:
Starting...
okay
...
okay
Time to complete: 6.24
Наше время значительно сократилось. Мы находимся примерно в 1/9 от нашего первоначального времени.
Так что же произойдет, если вместо этого мы будем использовать потоки?
Я уверен, вы можете догадаться - это будет не намного быстрее, чем синхронное выполнение. На самом деле, это может быть медленнее, потому что для создания новых потоков по-прежнему требуется немного времени и усилий. Но не верьте мне на слово, вот что мы получим, если заменим ProcessPoolExecutor()
на ThreadPoolExecutor()
:
Starting...
okay
...
okay
Time to complete: 53.83
Как я упоминал ранее, многопоточность позволяет вашим приложениям сосредоточиться на новых задачах, пока другие ждут. В этом случае мы никогда не сидим сложа руки. С другой стороны, многопроцессорность запускает совершенно новые сервисы, обычно на отдельных ядрах ЦП, готовые делать все, что вы попросите, полностью в тандеме с тем, что делает ваш скрипт. Вот почему многопроцессорная версия, занимающая примерно 1/9 времени, имеет смысл — у меня 8 ядер в моем процессоре.
Теперь, когда мы поговорили о параллелизме и параллелизме в Python, мы можем, наконец, прояснить термины. Если у вас возникли проблемы с различием между терминами, вы можете безопасно и точно думать о наших предыдущих определениях «параллелизм» и «параллелизм» как «параллельный параллелизм» и «непараллельный параллелизм» соответственно.
В Real Python есть отличная статья о параллелизме и параллелизме .
У Engineer Man есть хорошее видео, сравнивающее многопоточность и многопроцессорность .
У Кори Шафера также есть хорошее видео о многопроцессорности в том же духе, что и его видео о многопоточности.
Если вы смотрите только одно видео, посмотрите это превосходное выступление Рэймонда Хеттингера . Он проделывает потрясающую работу, объясняя различия между многопроцессорностью, многопоточностью и асинхронностью.
Что делать, если мне нужно объединить множество операций ввода-вывода с тяжелыми вычислениями?
Мы тоже можем это сделать. Скажем, вам нужно очистить 100 веб-страниц для определенной части информации, а затем вам нужно сохранить эту часть информации в файле на потом. Мы можем разделить вычислительную мощность между каждым из ядер нашего компьютера, заставив каждый процесс очищать часть страниц.
Для этого скрипта давайте установим Beautiful Soup , который поможет нам легко очищать наши страницы: pip install beautifulsoup4
. На этот раз у нас на самом деле довольно много импорта. Вот они, и вот почему мы их используем:
import asyncio # Gives us async/await
import concurrent.futures # Allows creating new processes
import time
from math import floor # Helps divide up our requests evenly across our CPU cores
from multiprocessing import cpu_count # Returns our number of CPU cores
import aiofiles # For asynchronously performing file I/O operations
import aiohttp # For asynchronously making HTTP requests
from bs4 import BeautifulSoup # For easy webpage scraping
Вы можете найти код для этого примера здесь .
Во-первых, мы собираемся создать асинхронную функцию, которая отправляет запрос в Википедию на получение случайных страниц. Мы будем очищать заголовок каждой страницы, который получим, с помощью BeautifulSoup
, а затем добавим его в заданный файл; мы будем отделять каждый заголовок табуляцией. Функция будет принимать два аргумента:
async def get_and_scrape_pages(num_pages: int, output_file: str):
"""
Makes {{ num_pages }} requests to Wikipedia to receive {{ num_pages }} random
articles, then scrapes each page for its title and appends it to {{ output_file }},
separating each title with a tab: "\\t"
#### Arguments
---
num_pages: int -
Number of random Wikipedia pages to request and scrape
output_file: str -
File to append titles to
"""
async with \
aiohttp.ClientSession() as client, \
aiofiles.open(output_file, "a+", encoding="utf-8") as f:
for _ in range(num_pages):
async with client.get("https://en.wikipedia.org/wiki/Special:Random") as response:
if response.status > 399:
# I was getting a 429 Too Many Requests at a higher volume of requests
response.raise_for_status()
page = await response.text()
soup = BeautifulSoup(page, features="html.parser")
title = soup.find("h1").text
await f.write(title + "\t")
await f.write("\n")
Мы оба асинхронно открываем aiohttp ClientSession
и наш выходной файл. Режим a+
означает добавление к файлу и создание его, если он еще не существует. Кодирование наших строк как utf-8 гарантирует, что мы не получим ошибку, если наши заголовки содержат международные символы. Если мы получим ответ об ошибке, мы поднимем его вместо продолжения (при больших объемах запросов я получал 429 Too Many Requests). Мы асинхронно получаем текст из нашего ответа, затем разбираем заголовок и асинхронно добавляем его в наш файл. После того, как мы добавим все наши заголовки, мы добавим новую строку: "\n".
Наша следующая функция — это функция, которую мы будем запускать с каждым новым процессом, чтобы разрешить его асинхронный запуск:
def start_scraping(num_pages: int, output_file: str, i: int):
""" Starts an async process for requesting and scraping Wikipedia pages """
print(f"Process {i} starting...")
asyncio.run(get_and_scrape_pages(num_pages, output_file))
print(f"Process {i} finished.")
Теперь о нашей основной функции. Начнем с некоторых констант (и объявления нашей функции):
def main():
NUM_PAGES = 100 # Number of pages to scrape altogether
NUM_CORES = cpu_count() # Our number of CPU cores (including logical cores)
OUTPUT_FILE = "./wiki_titles.tsv" # File to append our scraped titles to
PAGES_PER_CORE = floor(NUM_PAGES / NUM_CORES)
PAGES_FOR_FINAL_CORE = PAGES_PER_CORE + NUM_PAGES % PAGES_PER_CORE # For our final core
А теперь логика:
futures = []
with concurrent.futures.ProcessPoolExecutor(NUM_CORES) as executor:
for i in range(NUM_CORES - 1):
new_future = executor.submit(
start_scraping, # Function to perform
# v Arguments v
num_pages=PAGES_PER_CORE,
output_file=OUTPUT_FILE,
i=i
)
futures.append(new_future)
futures.append(
executor.submit(
start_scraping,
PAGES_FOR_FINAL_CORE, OUTPUT_FILE, NUM_CORES-1
)
)
concurrent.futures.wait(futures)
Мы создаем массив для хранения наших фьючерсов, затем мы создаем ProcessPoolExecutor
, устанавливая его max_workers
равным нашему количеству ядер. Мы перебираем диапазон, равный нашему количеству ядер минус 1, запуская новый процесс с нашей start_scraping
функцией. Затем мы добавляем к нему наш список фьючерсов. У нашего последнего ядра потенциально будет дополнительная работа, поскольку оно будет очищать количество страниц, равное каждому из наших других ядер, но дополнительно будет очищать количество страниц, равное остатку, который мы получили при делении нашего общего количества страниц для очистки. по общему количеству ядер процессора.
Убедитесь, что ваша основная функция действительно запущена:
if __name__ == "__main__":
start = time.time()
main()
print(f"Time to complete: {round(time.time() - start, 2)} seconds.")
После запуска программы на моем 8-ядерном процессоре (вместе с кодом бенчмаркинга):
Эта версия ( asyncio с многопроцессорностью ):
Time to complete: 5.65 seconds.
Time to complete: 8.87 seconds.
Time to complete: 47.92 seconds.
Time to complete: 88.86 seconds.
На самом деле я очень удивлен, увидев, что улучшение asyncio с многопроцессорностью по сравнению с просто многопроцессорностью оказалось не таким значительным, как я думал.
async
/ await
и подобный синтаксис также существует в других языках, и в некоторых из этих языков его реализация может сильно отличаться.
Первым языком программирования (еще в 2007 году), использовавшим этот async
синтаксис, был Microsoft F#. В то время как он точно не использует await
ожидание вызова функции, он использует особый синтаксис, такой как let!
и do!
наряду с проприетарными Async
функциями, включенными в System
модуль.
Дополнительные сведения об асинхронном программировании на F# можно найти в документации Microsoft по F# .
Затем их команда C# построила эту концепцию, и именно здесь родились ключевые слова async
/ , с await
которыми мы теперь знакомы:
using System;
// Allows the "Task" return type
using System.Threading.Tasks;
public class Program
{
// Declare an async function with "async"
private static async Task<string> ReturnHello()
{
return "hello world";
}
// Main can be async -- no problem
public static async Task Main()
{
// await an async string
string result = await ReturnHello();
// Print the string we got asynchronously
Console.WriteLine(result);
}
}
Мы гарантируем, что мы, using System.Threading.Tasks
поскольку он включает Task
тип, и, как правило, Task
тип необходим для ожидания асинхронной функции. Самое классное в C# то, что вы можете сделать свою основную функцию асинхронной, просто объявив ее с помощью async
, и у вас не будет никаких проблем.
Если вы хотите узнать больше о
async
/await
в C#, в документации Microsoft по C# есть хорошая страница.
async
Синтаксис / , впервые представленный в ES6, по await
сути представляет собой абстракцию обещаний JavaScript (которые аналогичны фьючерсам Python). Однако, в отличие от Python, пока вы не ждете, вы можете вызывать асинхронную функцию в обычном режиме без специальной функции, такой как Python asyncio.start()
:
// Declare a function with async
async function returnHello(){
return "hello world";
}
async function printSomething(){
// await an async string
const result = await returnHello();
// print the string we got asynchronously
console.log(result);
}
// Run our async code
printSomething();
См. MDN для получения дополнительной информации о
async
/await
в JavaScript .
Теперь Rust также позволяет использовать синтаксис async
/ await
и работает аналогично Python, C# и JavaScript:
// Allows blocking synchronous code to run async code
use futures::executor::block_on;
// Declare an async function with "async"
async fn return_hello() -> String {
"hello world".to_string()
}
// Code that awaits must also be declared with "async"
async fn print_something(){
// await an async String
let result: String = return_hello().await;
// Print the string we got asynchronously
println!("{0}", result);
}
fn main() {
// Block the current synchronous execution to run our async code
block_on(print_something());
}
Чтобы использовать асинхронные функции, мы должны сначала добавить futures = "0.3"
в наш Cargo.toml . Затем мы импортируем block_on
функцию с use futures::executor::block_on
-- block_on
это необходимо для запуска нашей асинхронной функции из нашей синхронной main
функции.
Вы можете найти больше информации о
async
/await
в Rust в документации Rust.
Вместо традиционного синтаксиса async
/ await
, присущего всем предыдущим рассмотренным нами языкам, в Go используются «горутины» и «каналы». Вы можете думать о канале как о будущем Python. В Go вы обычно отправляете канал в качестве аргумента функции, а затем используете go
для одновременного запуска функции. Всякий раз, когда вам нужно убедиться, что функция завершила свое выполнение, вы используете <-
синтаксис, который вы можете считать более распространенным await
синтаксисом. Если ваша горутина (функция, которую вы запускаете асинхронно) имеет возвращаемое значение, ее можно получить таким образом.
package main
import "fmt"
// "chan" makes the return value a string channel instead of a string
func returnHello(result chan string){
// Gives our channel a value
result <- "hello world"
}
func main() {
// Creates a string channel
result := make(chan string)
// Starts execution of our goroutine
go returnHello(result)
// Awaits and prints our string
fmt.Println(<- result)
}
Запустите его на игровой площадке Go
Дополнительные сведения о параллелизме в Go см . в статье «Введение в программирование на Go » Калеба Докси.
Подобно Python, Ruby также имеет ограничение Global Interpreter Lock. Чего у него нет, так это параллелизма, встроенного в язык. Тем не менее, есть созданный сообществом гем, который позволяет параллелизм в Ruby, и вы можете найти его исходный код на GitHub .
Как и в Ruby, в Java нет встроенного синтаксиса async
/ , но есть возможности параллелизма с использованием модуля. Однако Electronic Arts написала асинхронную библиотеку , позволяющую использовать в качестве метода. Это не совсем то же самое, что Python/C#/JavaScript/Rust, но на него стоит обратить внимание, если вы являетесь Java-разработчиком и заинтересованы в такой функциональности.awaitjava.util.concurrentawait
Хотя C++ также не имеет синтаксиса async
/ await
, у него есть возможность использовать фьючерсы для одновременного запуска кода с использованием futures
модуля:
#include <iostream>
#include <string>
// Necessary for futures
#include <future>
// No async declaration needed
std::string return_hello() {
return "hello world";
}
int main ()
{
// Declares a string future
std::future<std::string> fut = std::async(return_hello);
// Awaits the result of the future
std::string result = fut.get();
// Prints the string we got asynchronously
std::cout << result << '\n';
}
Нет необходимости объявлять функцию с каким-либо ключевым словом, чтобы указать, может и должна ли она выполняться асинхронно. Вместо этого вы объявляете свое начальное будущее всякий раз, когда вам это нужно, std::future<{{ function return type }}>
и устанавливаете его равным std::async()
, включая имя функции, которую вы хотите выполнить асинхронно, вместе с любыми аргументами, которые она принимает, т . Е. std::async(do_something, 1, 2, "string")
. Чтобы дождаться значения будущего, используйте для него .get()
синтаксис.
Вы можете найти документацию по асинхронности в C++ на сайте cplusplus.com.
Независимо от того, работаете ли вы с асинхронными сетевыми или файловыми операциями или выполняете множество сложных вычислений, существует несколько различных способов максимизировать эффективность вашего кода.
Если вы используете Python, вы можете использовать asyncio
или threading
максимально использовать операции ввода-вывода или multiprocessing
модуль для кода, интенсивно использующего ЦП.
Также помните, что
concurrent.futures
модуль можно использовать вместо любогоthreading
илиmultiprocessing
.
Если вы используете другой язык программирования, скорее всего, для него тоже есть реализация async
/ .await
Источник: https://testdriven.io
1660205580
什麼是並發和並行性,它們如何應用於 Python?
您的應用程序運行緩慢的原因有很多。有時這是由於算法設計不佳或數據結構選擇錯誤造成的。然而,有時,這是由於我們無法控制的力量,例如硬件限製或網絡的怪癖。這就是並發性和並行性適合的地方。它們允許您的程序同時執行多項操作,或者同時或通過浪費最少的時間等待繁忙的任務。
無論您是處理外部 Web 資源、讀取和寫入多個文件,還是需要多次使用不同參數的計算密集型函數,本文都應幫助您最大限度地提高代碼的效率和速度。
首先,我們將深入研究什麼是並發和並行性,以及它們如何使用標準庫(如線程、多處理和異步)融入 Python 領域。本文的最後一部分將比較 Python 對async
/的實現await
與其他語言的實現方式。
您可以在 GitHub 上的concurrency-parallelism-and-asyncio 存儲庫中找到本文中的所有代碼示例。
要完成本文中的示例,您應該已經知道如何處理 HTTP 請求。
在本文結束時,您應該能夠回答以下問題:
什麼是並發?
An effective definition for concurrency is "being able to perform multiple tasks at once". This is a bit misleading though, as the tasks may or may not actually be performed at exactly the same time. Instead, a process might start, then once it's waiting on a specific instruction to finish, switch to a new task, only to come back once it's no longer waiting. Once one task is finished, it switches again to an unfinished task until they have all been performed. Tasks start asynchronously, get performed asynchronously, and then finish asynchronously.
If that was confusing to you, let's instead think of an analogy: Say you want to make a BLT. First, you'll want to throw the bacon in a pan on medium-low heat. While the bacon's cooking, you can get out your tomatoes and lettuce and start preparing (washing and cutting) them. All the while, you continue checking on and occasionally flipping over your bacon.
At this point, you've started a task, and then started and completed two more in the meantime, all while you're still waiting on the first.
Eventually you put your bread in a toaster. While it's toasting, you continue checking on your bacon. As pieces get finished, you pull them out and place them on a plate. Once your bread is done toasting, you apply to it your sandwich spread of choice, and then you can start layering on your tomatoes, lettuce, and then, once it's done cooking, your bacon. Only once everything is cooked, prepared, and layered can you place the last piece of toast onto your sandwich, slice it (optional), and eat it.
Because it requires you to perform multiple tasks at the same time, making a BLT is inherently a concurrent process, even if you are not giving your full attention to each of those tasks all at once. For all intents and purposes, for the next section, we'll refer to this form of concurrency as just "concurrency." We'll differentiate it later on in this article.
For this reason, concurrency is great for I/O-intensive processes -- tasks that involve waiting on web requests or file read/write operations.
In Python, there are a few different ways to achieve concurrency. The first we'll take a look at is the threading library.
For our examples in this section, we're going to build a small Python program that grabs a random music genre from Binary Jazz's Genrenator API five times, prints the genre to the screen, and puts each one into its own file.
To work with threading in Python, the only import you'll need is threading
, but for this example, I've also imported urllib
to work with HTTP requests, time
to determine how long the functions take to complete, and json
to easily convert the json data returned from the Genrenator API.
You can find the code for this example here.
Let's start with a simple function:
def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
req = Request("https://binaryjazz.us/wp-json/genrenator/v1/genre/", headers={"User-Agent": "Mozilla/5.0"})
genre = json.load(urlopen(req))
with open(file_name, "w") as new_file:
print(f"Writing '{genre}' to '{file_name}'...")
new_file.write(genre)
Examining the code above, we're making a request to the Genrenator API, loading its JSON response (a random music genre), printing it, then writing it to a file.
Without the "User-Agent" header you will receive a 304.
What we're really interested in is the next section, where the actual threading happens:
threads = []
for i in range(5):
thread = threading.Thread(
target=write_genre,
args=[f"./threading/new_file{i}.txt"]
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
We first start with a list. We then proceed to iterate five times, creating a new thread each time. Next, we start each thread, append it to our "threads" list, and then iterate over our list one last time to join each thread.
Explanation: Creating threads in Python is easy.
To create a new thread, use threading.Thread()
. You can pass into it the kwarg (keyword argument) target
with a value of whatever function you would like to run on that thread. But only pass in the name of the function, not its value (meaning, for our purposes, write_genre
and not write_genre()
). To pass arguments, pass in "kwargs" (which takes a dict of your kwargs) or "args" (which takes an iterable containing your args -- in this case, a list).
Creating a thread is not the same as starting a thread, however. To start your thread, use {the name of your thread}.start()
. Starting a thread means "starting its execution."
Lastly, when we join threads with thread.join()
, all we're doing is ensuring the thread has finished before continuing on with our code.
But what exactly is a thread?
A thread is a way of allowing your computer to break up a single process/program into many lightweight pieces that execute in parallel. Somewhat confusingly, Python's standard implementation of threading limits threads to only being able to execute one at a time due to something called the Global Interpreter Lock (GIL). The GIL is necessary because CPython's (Python's default implementation) memory management is not thread-safe. Because of this limitation, threading in Python is concurrent, but not parallel. To get around this, Python has a separate multiprocessing
module not limited by the GIL that spins up separate processes, enabling parallel execution of your code. Using the multiprocessing
module is nearly identical to using the threading
module.
More info about Python's GIL and thread safety can be found on Real Python and Python's official docs.
We'll take a more in-depth look at multiprocessing in Python shortly.
Before we show the potential speed improvement over non-threaded code, I took the liberty of also creating a non-threaded version of the same program (again, available on GitHub). Instead of creating a new thread and joining each one, it instead calls write_genre
in a for loop that iterates five times.
To compare speed benchmarks, I also imported the time
library to time the execution of our scripts:
Starting...
Writing "binary indoremix" to "./sync/new_file0.txt"...
Writing "slavic aggro polka fusion" to "./sync/new_file1.txt"...
Writing "israeli new wave" to "./sync/new_file2.txt"...
Writing "byzantine motown" to "./sync/new_file3.txt"...
Writing "dutch hate industrialtune" to "./sync/new_file4.txt"...
Time to complete synchronous read/writes: 1.42 seconds
Upon running the script, we see that it takes my computer around 1.49 seconds (along with classic music genres such as "dutch hate industrialtune"). Not too bad.
Now let's run the version that uses threading:
Starting...
Writing "college k-dubstep" to "./threading/new_file2.txt"...
Writing "swiss dirt" to "./threading/new_file0.txt"...
Writing "bop idol alternative" to "./threading/new_file4.txt"...
Writing "ethertrio" to "./threading/new_file1.txt"...
Writing "beach aust shanty français" to "./threading/new_file3.txt"...
Time to complete threading read/writes: 0.77 seconds
The first thing that might stand out to you is the functions not being completed in order: 2 - 0 - 4 - 1 - 3
This is because of the asynchronous nature of threading: as one function waits, another one begins, and so on. Because we're able to continue performing tasks while we're waiting on others to finish (either due to networking or file I/O operations), you may also have noticed that we cut our time roughly in half: 0.77 seconds. Whereas this might not seem like a lot now, it's easy to imagine the very real case of building a web application that needs to write much more data to a file or interact with much more complex web services.
So, if threading is so great, why don't we end the article here?
Because there are even better ways to perform tasks concurrently.
Let's take a look at an example using asyncio. For this method, we're going to install aiohttp using pip
. This will allow us to make non-blocking requests and receive responses using the async
/await
syntax that will be introduced shortly. It also has the extra benefit of a function that converts a JSON response without needing to import the json
library. We'll also install and import aiofiles, which allows non-blocking file operations. Other than aiohttp
and aiofiles
, import asyncio
, which comes with the Python standard library.
"Non-blocking" means a program will allow other threads to continue running while it's waiting. This is opposed to "blocking" code, which stops execution of your program completely. Normal, synchronous I/O operations suffer from this limitation.
You can find the code for this example here.
Once we have our imports in place, let's take a look at the asynchronous version of the write_genre
function from our asyncio example:
async def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
async with aiohttp.ClientSession() as session:
async with session.get("https://binaryjazz.us/wp-json/genrenator/v1/genre/") as response:
genre = await response.json()
async with aiofiles.open(file_name, "w") as new_file:
print(f'Writing "{genre}" to "{file_name}"...')
await new_file.write(genre)
For those not familiar with the async
/await
syntax that can be found in many other modern languages, async
declares that a function, for
loop, or with
statement must be used asynchronously. To call an async function, you must either use the await
keyword from another async function or call create_task()
directly from the event loop, which can be grabbed from asyncio.get_event_loop()
-- i.e., loop = asyncio.get_event_loop()
.
Additionally:
async with
allows awaiting async responses and file operations.async for
(not used here) iterates over an asynchronous stream.Event loops are constructs inherent to asynchronous programming that allow performing tasks asynchronously. As you're reading this article, I can safely assume you're probably not too familiar with the concept. However, even if you've never written an async application, you have experience with event loops every time you use a computer. Whether your computer is listening for keyboard input, you're playing online multiplayer games, or you're browsing Reddit while you have files copying in the background, an event loop is the driving force that keeps everything working smoothly and efficiently. In its purest essence, an event loop is a process that waits around for triggers and then performs specific (programmed) actions once those triggers are met. They often return a "promise" (JavaScript syntax) or "future" (Python syntax) of some sort to denote that a task has been added. Once the task is finished, the promise or future returns a value passed back from the called function (assuming the function does return a value).
The idea of performing a function in response to another function is called a "callback."
For another take on callbacks and events, here's a great answer on Stack Overflow.
Here's a walkthrough of our function:
We're using async with
to open our client session asynchronously. The aiohttp.ClientSession()
class is what allows us to make HTTP requests and remain connected to a source without blocking the execution of our code. We then make an async request to the Genrenator API and await the JSON response (a random music genre). In the next line, we use async with
again with the aiofiles
library to asynchronously open a new file to write our new genre to. We print the genre, then write it to the file.
Unlike regular Python scripts, programming with asyncio pretty much enforces* using some sort of "main" function.
*Unless you're using the deprecated "yield" syntax with the @asyncio.coroutine decorator, which will be removed in Python 3.10.
This is because you need to use the "async" keyword in order to use the "await" syntax, and the "await" syntax is the only way to actually run other async functions.
Here's our main function:
async def main():
tasks = []
for i in range(5):
tasks.append(write_genre(f"./async/new_file{i}.txt"))
await asyncio.gather(*tasks)
As you can see, we've declared it with "async." We then create an empty list called "tasks" to house our async tasks (calls to Genrenator and our file I/O). We append our tasks to our list, but they are not actually run yet. The calls don't actually get made until we schedule them with await asyncio.gather(*tasks)
. This runs all of the tasks in our list and waits for them to finish before continuing with the rest of our program. Lastly, we use asyncio.run(main())
to run our "main" function. The .run()
function is the entry point for our program, and it should generally only be called once per process.
For those not familiar, the
*
in front of tasks is called "argument unpacking." Just as it sounds, it unpacks our list into a series of arguments for our function. Our function isasyncio.gather()
in this case.
And that's all we need to do. Now, running our program (the source of which includes the same timing functionality of the synchronous and threading examples)...
Writing "albuquerque fiddlehaus" to "./async/new_file1.txt"...
Writing "euroreggaebop" to "./async/new_file2.txt"...
Writing "shoedisco" to "./async/new_file0.txt"...
Writing "russiagaze" to "./async/new_file4.txt"...
Writing "alternative xylophone" to "./async/new_file3.txt"...
Time to complete asyncio read/writes: 0.71 seconds
...we see it's even faster still. And, in general, the asyncio method will always be a bit faster than the threading method. This is because when we use the "await" syntax, we essentially tell our program "hold on, I'll be right back," but our program keeps track of how long it takes us to finish what we're doing. Once we're done, our program will know, and will pick back up as soon as it's able. Threading in Python allows asynchronicity, but our program could theoretically skip around different threads that may not yet be ready, wasting time if there are threads ready to continue running.
So when should I use threading, and when should I use asyncio?
When you're writing new code, use asyncio. If you need to interface with older libraries or those that don't support asyncio, you might be better off with threading.
It turns out testing async functions with pytest is as easy as testing synchronous functions. Just install the pytest-asyncio package with pip
, mark your tests with the async
keyword, and apply a decorator that lets pytest
know it's asynchronous: @pytest.mark.asyncio
. Let's look at an example.
First, let's write an arbitrary async function in a file called hello_asyncio.py:
import asyncio
async def say_hello(name: str):
""" Sleeps for two seconds, then prints 'Hello, {{ name }}!' """
try:
if type(name) != str:
raise TypeError("'name' must be a string")
if name == "":
raise ValueError("'name' cannot be empty")
except (TypeError, ValueError):
raise
print("Sleeping...")
await asyncio.sleep(2)
print(f"Hello, {name}!")
The function takes a single string argument: name
. Upon ensuring that name
is a string with a length greater than one, our function asynchronously sleeps for two seconds, then prints "Hello, {name}!"
to the console.
The difference between
asyncio.sleep()
andtime.sleep()
is thatasyncio.sleep()
is non-blocking.
Now let's test it with pytest. In the same directory as hello_asyncio.py, create a file called test_hello_asyncio.py, then open it in your favorite text editor.
Let's start with our imports:
import pytest # Note: pytest-asyncio does not require a separate import
from hello_asyncio import say_hello
Then we'll create a test with proper input:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
@pytest.mark.asyncio
async def test_say_hello(name):
await say_hello(name)
Things to note:
@pytest.mark.asyncio
decorator lets pytest work asynchronouslyasync
syntaxawait
ing our async function as we would if we were running it outside of a testNow let's run our test with the verbose -v
option:
pytest -v
...
collected 3 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 33%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 66%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [100%]
Looks good. Next we'll write a couple of tests with bad input. Back inside of test_hello_asyncio.py, let's create a class called TestSayHelloThrowsExceptions
:
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
@pytest.mark.asyncio
async def test_say_hello_value_error(self, name):
with pytest.raises(ValueError):
await say_hello(name)
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
@pytest.mark.asyncio
async def test_say_hello_type_error(self, name):
with pytest.raises(TypeError):
await say_hello(name)
Again, we decorate our tests with @pytest.mark.asyncio
, mark our tests with the async
syntax, then call our function with await
.
Run the tests again:
pytest -v
...
collected 7 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 14%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 28%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [ 42%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_value_error[] PASSED [ 57%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[19] PASSED [ 71%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name1] PASSED [ 85%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name2] PASSED [100%]
Alternatively to pytest-asyncio, you can create a pytest fixture that yields an asyncio event loop:
import asyncio
import pytest
from hello_asyncio import say_hello
@pytest.fixture
def event_loop():
loop = asyncio.get_event_loop()
yield loop
Then, rather than using the async
/await
syntax, you create your tests as you would normal, synchronous tests:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
def test_say_hello(event_loop, name):
event_loop.run_until_complete(say_hello(name))
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
def test_say_hello_value_error(self, event_loop, name):
with pytest.raises(ValueError):
event_loop.run_until_complete(say_hello(name))
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
def test_say_hello_type_error(self, event_loop, name):
with pytest.raises(TypeError):
event_loop.run_until_complete(say_hello(name))
If you're interested, here's a more advanced tutorial on asyncio testing.
If you want to learn more about what distinguishes Python's implementation of threading vs asyncio, here's a great article from Medium.
For even better examples and explanations of threading in Python, here's a video by Corey Schafer that goes more in-depth, including using the concurrent.futures
library.
Lastly, for a massive deep-dive into asyncio itself, here's an article from Real Python completely dedicated to it.
Bonus: One more library you might be interested in is called Unsync, especially if you want to easily convert your current synchronous code into asynchronous code. To use it, you install the library with pip, import it with from unsync import unsync
, then decorate whatever currently synchronous function with @unsync
to make it asynchronous. To await it and get its return value (which you can do anywhere -- it doesn't have to be in an async/unsync function), just call .result()
after the function call.
What is parallelism?
Parallelism is very-much related to concurrency. In fact, parallelism is a subset of concurrency: whereas a concurrent process performs multiple tasks at the same time whether they're being diverted total attention or not, a parallel process is physically performing multiple tasks all at the same time. A good example would be driving, listening to music, and eating the BLT we made in the last section at the same time.
Because they don't require a lot of intensive effort, you can do them all at once without having to wait on anything or divert your attention away.
Now let's take a look at how to implement this in Python. We could use the multiprocessing
library, but let's use the concurrent.futures
library instead -- it eliminates the need to manage the number of process manually. Because the major benefit of multiprocessing happens when you perform multiple cpu-heavy tasks, we're going to compute the squares of 1 million (1000000) to 1 million and 16 (1000016).
You can find the code for this example here.
The only import we'll need is concurrent.futures
:
import concurrent.futures
import time
if __name__ == "__main__":
pow_list = [i for i in range(1000000, 1000016)]
print("Starting...")
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
futures = [executor.submit(pow, i, i) for i in pow_list]
for f in concurrent.futures.as_completed(futures):
print("okay")
end = time.time()
print(f"Time to complete: {round(end - start, 2)}")
Because I'm developing on a Windows machine, I'm using
if __name__ == "main"
. This is necessary because Windows does not have thefork
system call inherent to Unix systems. Because Windows doesn't have this capability, it resorts to launching a new interpreter with each process that tries to import the main module. If the main module doesn't exist, it reruns your entire program, causing recursive chaos to ensue.
So taking a look at our main function, we use a list comprehension to create a list from 1 million to 1 million and 16, we open a ProcessPoolExecutor with concurrent.futures, and we use list comprehension and ProcessPoolExecutor().submit()
to start executing our processes and throwing them into a list called "futures."
We could also use
ThreadPoolExecutor()
if we wanted to use threads instead -- concurrent.futures is versatile.
And this is where the asynchronicity comes in: The "results" list does not actually contain the results from running our functions. Instead, it contains "futures" which are similar to the JavaScript idea of "promises." In order to allow our program to continue running, we get back these futures that represent a placeholder for a value. If we try to print the future, depending on whether it's finished running or not, we'll either get back a state of "pending" or "finished." Once it's finished we can get the return value (assuming there is one) using var.result()
. In this case, our var will be "result."
We then iterate through our list of futures, but instead of printing our values, we're simply printing out "okay." This is just because of how massive the resulting calculations come out to be.
Just as before, I built a comparison script that does this synchronously. And, just as before, you can find it on GitHub.
Running our control program, which also includes functionality for timing our program, we get:
Starting...
okay
...
okay
Time to complete: 54.64
Wow. 54.64 seconds is quite a long time. Let's see if our version with multiprocessing does any better:
Starting...
okay
...
okay
Time to complete: 6.24
Our time has been significantly reduced. We're at about 1/9th of our original time.
So what would happen if we used threading for this instead?
I'm sure you can guess -- it wouldn't be much faster than doing it synchronously. In fact, it might be slower because it still takes a little time and effort to spin up new threads. But don't take my word for it, here's what we get when we replace ProcessPoolExecutor()
with ThreadPoolExecutor()
:
Starting...
okay
...
okay
Time to complete: 53.83
As I mentioned earlier, threading allows your applications to focus on new tasks while others are waiting. In this case, we're never sitting idly by. Multiprocessing, on the other hand, spins up totally new services, usually on separate CPU cores, ready to do whatever you ask it completely in tandem with whatever else your script is doing. This is why the multiprocessing version taking roughly 1/9th of the time makes sense -- I have 8 cores in my CPU.
Now that we've talked about concurrency and parallelism in Python, we can finally set the terms straight. If you're having trouble distinguishing between the terms, you can safely and accurately think of our previous definitions of "parallelism" and "concurrency" as "parallel concurrency" and "non-parallel concurrency" respectively.
Real Python has a great article on concurrency vs parallelism.
Engineer Man has a good video comparison of threading vs multiprocessing.
Corey Schafer also has a good video on multiprocessing in the same spirit as his threading video.
If you only watch one video, watch this excellent talk by Raymond Hettinger. He does an amazing job explaining the differences between multiprocessing, threading, and asyncio.
What if I need to combine many I/O operations with heavy calculations?
We can do that too. Say you need to scrape 100 web pages for a specific piece of information, and then you need to save that piece of info in a file for later. We can separate the compute power across each of our computer's cores by making each process scrape a fraction of the pages.
For this script, let's install Beautiful Soup to help us easily scrape our pages: pip install beautifulsoup4
. This time we actually have quite a few imports. Here they are, and here's why we're using them:
import asyncio # Gives us async/await
import concurrent.futures # Allows creating new processes
import time
from math import floor # Helps divide up our requests evenly across our CPU cores
from multiprocessing import cpu_count # Returns our number of CPU cores
import aiofiles # For asynchronously performing file I/O operations
import aiohttp # For asynchronously making HTTP requests
from bs4 import BeautifulSoup # For easy webpage scraping
You can find the code for this example here.
First, we're going to create an async function that makes a request to Wikipedia to get back random pages. We'll scrape each page we get back for its title using BeautifulSoup
, and then we'll append it to a given file; we'll separate each title with a tab. The function will take two arguments:
async def get_and_scrape_pages(num_pages: int, output_file: str):
"""
Makes {{ num_pages }} requests to Wikipedia to receive {{ num_pages }} random
articles, then scrapes each page for its title and appends it to {{ output_file }},
separating each title with a tab: "\\t"
#### Arguments
---
num_pages: int -
Number of random Wikipedia pages to request and scrape
output_file: str -
File to append titles to
"""
async with \
aiohttp.ClientSession() as client, \
aiofiles.open(output_file, "a+", encoding="utf-8") as f:
for _ in range(num_pages):
async with client.get("https://en.wikipedia.org/wiki/Special:Random") as response:
if response.status > 399:
# I was getting a 429 Too Many Requests at a higher volume of requests
response.raise_for_status()
page = await response.text()
soup = BeautifulSoup(page, features="html.parser")
title = soup.find("h1").text
await f.write(title + "\t")
await f.write("\n")
We're both asynchronously opening an aiohttp ClientSession
and our output file. The mode, a+
, means append to the file and create it if it doesn't already exist. Encoding our strings as utf-8 ensures we don't get an error if our titles contain international characters. If we get an error response, we'll raise it instead of continuing (at high request volumes I was getting a 429 Too Many Requests). We asynchronously get the text from our response, then we parse the title and asynchronously and append it to our file. After we append all of our titles, we append a new line: "\n".
Our next function is the function we'll start with each new process to allow running it asynchronously:
def start_scraping(num_pages: int, output_file: str, i: int):
""" Starts an async process for requesting and scraping Wikipedia pages """
print(f"Process {i} starting...")
asyncio.run(get_and_scrape_pages(num_pages, output_file))
print(f"Process {i} finished.")
Now for our main function. Let's start with some constants (and our function declaration):
def main():
NUM_PAGES = 100 # Number of pages to scrape altogether
NUM_CORES = cpu_count() # Our number of CPU cores (including logical cores)
OUTPUT_FILE = "./wiki_titles.tsv" # File to append our scraped titles to
PAGES_PER_CORE = floor(NUM_PAGES / NUM_CORES)
PAGES_FOR_FINAL_CORE = PAGES_PER_CORE + NUM_PAGES % PAGES_PER_CORE # For our final core
And now the logic:
futures = []
with concurrent.futures.ProcessPoolExecutor(NUM_CORES) as executor:
for i in range(NUM_CORES - 1):
new_future = executor.submit(
start_scraping, # Function to perform
# v Arguments v
num_pages=PAGES_PER_CORE,
output_file=OUTPUT_FILE,
i=i
)
futures.append(new_future)
futures.append(
executor.submit(
start_scraping,
PAGES_FOR_FINAL_CORE, OUTPUT_FILE, NUM_CORES-1
)
)
concurrent.futures.wait(futures)
We create an array to store our futures, then we create a ProcessPoolExecutor
, setting its max_workers
equal to our number of cores. We iterate over a range equal to our number of cores minus 1, running a new process with our start_scraping
function. We then append it our futures list. Our final core will potentially have extra work to do as it will scrape a number of pages equal to each of our other cores, but will additionally scrape a number of pages equal to the remainder that we got when dividing our total number of pages to scrape by our total number of cpu cores.
Make sure to actually run your main function:
if __name__ == "__main__":
start = time.time()
main()
print(f"Time to complete: {round(time.time() - start, 2)} seconds.")
After running the program with my 8-core CPU (along with benchmarking code):
This version (asyncio with multiprocessing):
Time to complete: 5.65 seconds.
Time to complete: 8.87 seconds.
Time to complete: 47.92 seconds.
Time to complete: 88.86 seconds.
I'm actually quite surprised to see that the improvement of asyncio with multiprocessing over just multiprocessing wasn't as great as I thought it would be.
async
/await
and similar syntax also exist in other languages, and in some of those languages, its implementation can differ drastically.
The first programming language (back in 2007) to use the async
syntax was Microsoft's F#. Whereas it doesn't exactly use await
to wait on a function call, it uses specific syntax like let!
and do!
along with proprietary Async
functions included in the System
module.
You can find more about async programming in F# on Microsoft's F# docs.
Their C# team then built upon this concept, and that's where the async
/await
keywords that we're now familiar with were born:
using System;
// Allows the "Task" return type
using System.Threading.Tasks;
public class Program
{
// Declare an async function with "async"
private static async Task<string> ReturnHello()
{
return "hello world";
}
// Main can be async -- no problem
public static async Task Main()
{
// await an async string
string result = await ReturnHello();
// Print the string we got asynchronously
Console.WriteLine(result);
}
}
We ensure that we're using System.Threading.Tasks
as it includes the Task
type, and, in general, the Task
type is needed for an async function to be awaited. The cool thing about C# is that you can make your main function asynchronous just by declaring it with async
, and you won't have any issues.
If you're interested in learning more about
async
/await
in C#, Microsoft's C# docs have a good page on it.
First introduced in ES6, the async
/await
syntax is essentially an abstraction over JavaScript promises (which are similar to Python futures). Unlike Python, however, so long as you're not awaiting, you can call an async function normally without a specific function like Python's asyncio.start()
:
// Declare a function with async
async function returnHello(){
return "hello world";
}
async function printSomething(){
// await an async string
const result = await returnHello();
// print the string we got asynchronously
console.log(result);
}
// Run our async code
printSomething();
async
有關/await
in JavaScript的更多信息,請參閱 MDN 。
Rust 現在也允許使用async
/await
語法,它的工作方式類似於 Python、C# 和 JavaScript:
// Allows blocking synchronous code to run async code
use futures::executor::block_on;
// Declare an async function with "async"
async fn return_hello() -> String {
"hello world".to_string()
}
// Code that awaits must also be declared with "async"
async fn print_something(){
// await an async String
let result: String = return_hello().await;
// Print the string we got asynchronously
println!("{0}", result);
}
fn main() {
// Block the current synchronous execution to run our async code
block_on(print_something());
}
為了使用異步函數,我們必須首先添加futures = "0.3"
到我們的Cargo.toml中。然後,我們使用--導入block_on
函數,這是從同步函數運行異步函數所必需的。use futures::executor::block_onblock_onmain
你可以在 Rust 文檔中找到關於
async
/await
in Rust的更多信息。
Go 使用“goroutines”和“channels”,而不是我們之前介紹的所有語言固有的傳統async
/語法。await
您可以將通道視為類似於 Python 的未來。在 Go 中,您通常將通道作為參數發送給函數,然後用於go
並發運行該函數。每當您需要確保函數完成完成時,您都可以使用<-
語法,您可以將其視為更常見的await
語法。如果您的 goroutine(您正在異步運行的函數)有返回值,則可以通過這種方式獲取它。
package main
import "fmt"
// "chan" makes the return value a string channel instead of a string
func returnHello(result chan string){
// Gives our channel a value
result <- "hello world"
}
func main() {
// Creates a string channel
result := make(chan string)
// Starts execution of our goroutine
go returnHello(result)
// Awaits and prints our string
fmt.Println(<- result)
}
有關 Go 並發的更多信息,請參閱Caleb Doxsey的 Go 編程簡介。
與 Python 類似,Ruby 也有 Global Interpreter Lock 限制。它沒有的是語言內置的並發性。但是,有一個社區創建的 gem 允許在 Ruby 中進行並發,您可以在 GitHub 上找到它的源代碼。
與 Ruby 一樣,Java 沒有內置async
/語法,但它確實具有使用模塊的並發功能。但是,Electronic Arts 編寫了一個允許將其用作方法的異步庫。它與 Python/C#/JavaScript/Rust 並不完全相同,但如果您是 Java 開發人員並且對此類功能感興趣,則值得研究一下。awaitjava.util.concurrentawait
儘管 C++ 也沒有async
/await
語法,但它確實能夠使用期貨來使用futures
模塊同時運行代碼:
#include <iostream>
#include <string>
// Necessary for futures
#include <future>
// No async declaration needed
std::string return_hello() {
return "hello world";
}
int main ()
{
// Declares a string future
std::future<std::string> fut = std::async(return_hello);
// Awaits the result of the future
std::string result = fut.get();
// Prints the string we got asynchronously
std::cout << result << '\n';
}
無需使用任何關鍵字聲明函數來表示它是否可以並且應該異步運行。相反,您可以在需要時聲明您的初始未來,std::future<{{ function return type }}>
並將其設置為等於std::async()
,包括您要異步執行的函數的名稱以及它所採用的任何參數——即std::async(do_something, 1, 2, "string")
. 要等待未來的值,請使用其.get()
上的語法。
您可以在 cplusplus.com 上找到C++ 中的異步文檔。
無論您是在處理異步網絡或文件操作,還是在執行大量複雜的計算,都有幾種不同的方法可以最大限度地提高代碼的效率。
如果您使用的是 Python,則可以使用asyncio
或threading
充分利用 I/O 操作或multiprocessing
CPU 密集型代碼的模塊。
還要記住,該
concurrent.futures
模塊可以用來代替threading
或multiprocessing
。
如果您使用的是另一種編程語言,那麼可能也有async
/的實現await
。
1660198260
Que sont la concurrence et le parallélisme, et comment s'appliquent-ils à Python ?
Il existe de nombreuses raisons pour lesquelles vos applications peuvent être lentes. Parfois, cela est dû à une mauvaise conception algorithmique ou à un mauvais choix de structure de données. Parfois, cependant, cela est dû à des forces indépendantes de notre volonté, telles que des contraintes matérielles ou les bizarreries du réseau. C'est là que la concurrence et le parallélisme s'intègrent. Ils permettent à vos programmes de faire plusieurs choses à la fois, soit en même temps, soit en perdant le moins de temps possible à attendre des tâches occupées.
Que vous ayez affaire à des ressources Web externes, que vous lisiez et écriviez dans plusieurs fichiers, ou que vous ayez besoin d'utiliser plusieurs fois une fonction gourmande en calculs avec différents paramètres, cet article devrait vous aider à maximiser l'efficacité et la vitesse de votre code.
Tout d'abord, nous allons approfondir ce que sont la concurrence et le parallélisme et comment ils s'intègrent dans le domaine de Python en utilisant des bibliothèques standard telles que le threading, le multitraitement et l'asyncio. La dernière partie de cet article comparera l'implémentation de async
/ de Python await
avec la façon dont d'autres langages les ont implémentés.
Vous pouvez trouver tous les exemples de code de cet article dans le référentiel concurrency-parallelism-and-asyncio sur GitHub.
Pour parcourir les exemples de cet article, vous devez déjà savoir comment utiliser les requêtes HTTP.
À la fin de cet article, vous devriez être en mesure de répondre aux questions suivantes :
Qu'est-ce que la concurrence ?
Une définition efficace de la simultanéité est "être capable d'effectuer plusieurs tâches à la fois". C'est un peu trompeur, car les tâches peuvent ou non être effectuées exactement au même moment. Au lieu de cela, un processus peut démarrer, puis une fois qu'il attend une instruction spécifique pour se terminer, passer à une nouvelle tâche, pour revenir une fois qu'il n'attend plus. Une fois qu'une tâche est terminée, il repasse à une tâche inachevée jusqu'à ce qu'elles aient toutes été exécutées. Les tâches démarrent de manière asynchrone, sont exécutées de manière asynchrone, puis se terminent de manière asynchrone.
Si cela vous a déconcerté, pensons plutôt à une analogie : Supposons que vous vouliez faire un BLT . Tout d'abord, vous voudrez jeter le bacon dans une casserole à feu moyen-doux. Pendant la cuisson du bacon, vous pouvez sortir vos tomates et laitues et commencer à les préparer (laver et couper). Pendant tout ce temps, vous continuez à vérifier et à retourner de temps en temps votre bacon.
À ce stade, vous avez commencé une tâche, puis commencé et terminé deux autres entre-temps, tout en attendant toujours la première.
Finalement, vous mettez votre pain dans un grille-pain. Pendant qu'il grille, vous continuez à vérifier votre bacon. Au fur et à mesure que les pièces sont terminées, vous les sortez et les placez sur une assiette. Une fois votre pain grillé, vous y appliquez la pâte à tartiner de votre choix, puis vous pouvez commencer à superposer vos tomates, votre laitue, puis, une fois la cuisson terminée, votre bacon. Ce n'est qu'une fois que tout est cuit, préparé et en couches que vous pouvez placer le dernier morceau de pain grillé sur votre sandwich, le trancher (facultatif) et le manger.
Parce qu'il vous oblige à effectuer plusieurs tâches en même temps, la création d'un BLT est par nature un processus simultané, même si vous n'accordez pas toute votre attention à chacune de ces tâches en même temps. À toutes fins utiles, pour la section suivante, nous désignerons cette forme de concurrence par « concurrence ». Nous le différencierons plus tard dans cet article.
Pour cette raison, la simultanéité est idéale pour les processus gourmands en E/S, c'est-à-dire les tâches qui impliquent d'attendre des requêtes Web ou des opérations de lecture/écriture de fichiers.
En Python, il existe plusieurs façons d'obtenir la concurrence. La première que nous allons examiner est la bibliothèque de threads.
Pour nos exemples dans cette section, nous allons construire un petit programme Python qui récupère cinq fois un genre musical aléatoire de l'API Genrenator de Binary Jazz , imprime le genre à l'écran et place chacun dans son propre fichier.
Pour travailler avec le threading en Python, la seule importation dont vous aurez besoin est threading
, mais pour cet exemple, j'ai également importé urllib
pour travailler avec des requêtes HTTP, time
pour déterminer combien de temps les fonctions prennent pour se terminer et json
pour convertir facilement les données json renvoyées depuis l'API Genrenator.
Vous pouvez trouver le code de cet exemple ici .
Commençons par une fonction simple :
def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
req = Request("https://binaryjazz.us/wp-json/genrenator/v1/genre/", headers={"User-Agent": "Mozilla/5.0"})
genre = json.load(urlopen(req))
with open(file_name, "w") as new_file:
print(f"Writing '{genre}' to '{file_name}'...")
new_file.write(genre)
En examinant le code ci-dessus, nous faisons une demande à l'API Genrenator, chargeons sa réponse JSON (un genre musical aléatoire), l'imprimons, puis l'écrivons dans un fichier.
Sans l'en-tête "User-Agent", vous recevrez un 304.
Ce qui nous intéresse vraiment, c'est la section suivante, où le threading réel se produit :
threads = []
for i in range(5):
thread = threading.Thread(
target=write_genre,
args=[f"./threading/new_file{i}.txt"]
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Nous commençons d'abord par une liste. Nous procédons ensuite à une itération cinq fois, en créant un nouveau thread à chaque fois. Ensuite, nous démarrons chaque thread, l'ajoutons à notre liste de "threads", puis parcourons notre liste une dernière fois pour rejoindre chaque thread.
Explication : Créer des threads en Python est facile.
Pour créer un nouveau fil, utilisez threading.Thread()
. Vous pouvez y passer le kwarg (argument de mot clé) target
avec une valeur de la fonction que vous souhaitez exécuter sur ce thread. Mais ne transmettez que le nom de la fonction, pas sa valeur (c'est-à-dire, pour nos besoins, write_genre
et non write_genre()
). Pour passer des arguments, passez "kwargs" (qui prend un dict de vos kwargs) ou "args" (qui prend un itérable contenant vos arguments - dans ce cas, une liste).
Cependant, créer un thread n'est pas la même chose que démarrer un thread. Pour démarrer votre fil, utilisez {the name of your thread}.start()
. Démarrer un thread signifie "démarrer son exécution".
Enfin, lorsque nous rejoignons des threads avec thread.join()
, tout ce que nous faisons est de nous assurer que le thread est terminé avant de continuer avec notre code.
Mais qu'est-ce qu'un fil exactement ?
Un thread est un moyen de permettre à votre ordinateur de décomposer un processus/programme unique en plusieurs éléments légers qui s'exécutent en parallèle. De manière quelque peu déroutante, l'implémentation standard de Python du threading limite les threads à ne pouvoir s'exécuter qu'un seul à la fois en raison de quelque chose appelé le Global Interpreter Lock (GIL). Le GIL est nécessaire car la gestion de la mémoire de CPython (l'implémentation par défaut de Python) n'est pas thread-safe. En raison de cette limitation, le threading en Python est simultané, mais pas parallèle. Pour contourner ce problème, Python dispose d'un multiprocessing
module séparé non limité par le GIL qui exécute des processus séparés, permettant l'exécution parallèle de votre code. L'utilisation du multiprocessing
module est presque identique à l'utilisation du threading
module.
Plus d'informations sur le GIL de Python et la sécurité des threads peuvent être trouvées sur Real Python et la documentation officielle de Python .
Nous examinerons plus en détail le multitraitement en Python sous peu.
Avant de montrer l'amélioration potentielle de la vitesse par rapport au code non-thread, j'ai pris la liberté de créer également une version non-thread du même programme (là encore, disponible sur GitHub ). Au lieu de créer un nouveau thread et de joindre chacun d'eux, il appelle write_genre
à la place une boucle for qui itère cinq fois.
Pour comparer les benchmarks de vitesse, j'ai aussi importé la time
librairie pour chronométrer l'exécution de nos scripts :
Starting...
Writing "binary indoremix" to "./sync/new_file0.txt"...
Writing "slavic aggro polka fusion" to "./sync/new_file1.txt"...
Writing "israeli new wave" to "./sync/new_file2.txt"...
Writing "byzantine motown" to "./sync/new_file3.txt"...
Writing "dutch hate industrialtune" to "./sync/new_file4.txt"...
Time to complete synchronous read/writes: 1.42 seconds
Lors de l'exécution du script, nous constatons qu'il faut environ 1,49 seconde à mon ordinateur (ainsi que des genres musicaux classiques tels que "dutch hate industrialtune"). Pas mal.
Exécutons maintenant la version qui utilise le threading :
Starting...
Writing "college k-dubstep" to "./threading/new_file2.txt"...
Writing "swiss dirt" to "./threading/new_file0.txt"...
Writing "bop idol alternative" to "./threading/new_file4.txt"...
Writing "ethertrio" to "./threading/new_file1.txt"...
Writing "beach aust shanty français" to "./threading/new_file3.txt"...
Time to complete threading read/writes: 0.77 seconds
La première chose qui pourrait vous surprendre est que les fonctions ne sont pas complétées dans l'ordre : 2 - 0 - 4 - 1 - 3
Cela est dû à la nature asynchrone du threading : lorsqu'une fonction attend, une autre commence, et ainsi de suite. Étant donné que nous pouvons continuer à effectuer des tâches pendant que nous attendons que les autres finissent (soit en raison de la mise en réseau ou des opérations d'E/S de fichiers), vous avez peut-être également remarqué que nous réduisons notre temps environ de moitié : 0,77 seconde. Bien que cela ne semble pas beaucoup maintenant, il est facile d'imaginer le cas très réel de la création d'une application Web qui doit écrire beaucoup plus de données dans un fichier ou interagir avec des services Web beaucoup plus complexes.
Donc, si le threading est si génial, pourquoi ne pas terminer l'article ici ?
Parce qu'il existe des moyens encore meilleurs d'effectuer des tâches simultanément.
Jetons un coup d'œil à un exemple utilisant asyncio. Pour cette méthode, nous allons installer aiohttp en utilisant pip
. Cela nous permettra de faire des requêtes non bloquantes et de recevoir des réponses en utilisant la syntaxe async
/ await
qui sera introduite prochainement. Il a également l'avantage supplémentaire d'une fonction qui convertit une réponse JSON sans avoir besoin d'importer la json
bibliothèque. Nous installerons et importerons également des fichiers aio , ce qui permet des opérations de fichiers non bloquantes. Autre que aiohttp
et aiofiles
, import asyncio
, qui est fourni avec la bibliothèque standard Python.
"Non bloquant" signifie qu'un programme permettra à d'autres threads de continuer à s'exécuter pendant qu'il attend. Cela s'oppose au code "bloquant", qui arrête complètement l'exécution de votre programme. Les opérations d'E/S normales et synchrones souffrent de cette limitation.
Vous pouvez trouver le code de cet exemple ici .
Une fois nos importations en place, examinons la version asynchrone de la write_genre
fonction de notre exemple asyncio :
async def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
async with aiohttp.ClientSession() as session:
async with session.get("https://binaryjazz.us/wp-json/genrenator/v1/genre/") as response:
genre = await response.json()
async with aiofiles.open(file_name, "w") as new_file:
print(f'Writing "{genre}" to "{file_name}"...')
await new_file.write(genre)
Pour ceux qui ne connaissent pas la syntaxe async
/ await
que l'on trouve dans de nombreux autres langages modernes, async
déclare qu'une fonction, une for
boucle ou une with
instruction doit être utilisée de manière asynchrone. Pour appeler une fonction asynchrone, vous devez soit utiliser le mot- await
clé d'une autre fonction asynchrone, soit appeler create_task()
directement à partir de la boucle d'événements, qui peut être extraite de asyncio.get_event_loop()
-- c'est-à-dire loop = asyncio.get_event_loop()
.
En outre:
async with
permet d'attendre des réponses asynchrones et des opérations sur les fichiers.async for
(non utilisé ici) itère sur un flux asynchrone .Les boucles d'événements sont des constructions inhérentes à la programmation asynchrone qui permettent d'effectuer des tâches de manière asynchrone. Pendant que vous lisez cet article, je peux supposer que vous n'êtes probablement pas trop familier avec le concept. Cependant, même si vous n'avez jamais écrit d'application asynchrone, vous avez de l'expérience avec les boucles d'événements chaque fois que vous utilisez un ordinateur. Que votre ordinateur écoute les entrées au clavier, que vous jouiez à des jeux multijoueurs en ligne ou que vous naviguiez sur Reddit pendant que vous copiez des fichiers en arrière-plan, une boucle d'événements est la force motrice qui permet à tout de fonctionner de manière fluide et efficace. Dans son essence la plus pure, une boucle d'événements est un processus qui attend des déclencheurs, puis exécute des actions spécifiques (programmées) une fois que ces déclencheurs sont satisfaits. Ils renvoient souvent une "promesse" (syntaxe JavaScript) ou un "futur" (syntaxe Python) d'une sorte pour indiquer qu'une tâche a été ajoutée. Une fois la tâche terminée, la promesse ou le futur renvoie une valeur renvoyée par la fonction appelée (en supposant que la fonction renvoie une valeur).
L'idée d'exécuter une fonction en réponse à une autre fonction s'appelle un "rappel".
Pour une autre approche des rappels et des événements, voici une excellente réponse sur Stack Overflow .
Voici une présentation de notre fonction :
Nous utilisons async with
pour ouvrir notre session client de manière asynchrone. La aiohttp.ClientSession()
classe est ce qui nous permet de faire des requêtes HTTP et de rester connecté à une source sans bloquer l'exécution de notre code. Nous faisons ensuite une requête asynchrone à l'API Genrenator et attendons la réponse JSON (un genre musical aléatoire). Dans la ligne suivante, nous utilisons async with
à nouveau avec la aiofiles
bibliothèque pour ouvrir de manière asynchrone un nouveau fichier dans lequel écrire notre nouveau genre. Nous imprimons le genre, puis l'écrivons dans le fichier.
Contrairement aux scripts Python classiques, la programmation avec asyncio applique à peu près* l'utilisation d'une sorte de fonction "principale".
*Sauf si vous utilisez la syntaxe obsolète "yield" avec le décorateur @asyncio.coroutine, qui sera supprimé dans Python 3.10 .
En effet, vous devez utiliser le mot-clé "async" pour utiliser la syntaxe "wait", et la syntaxe "wait" est le seul moyen d'exécuter réellement d'autres fonctions asynchrones.
Voici notre fonction principale :
async def main():
tasks = []
for i in range(5):
tasks.append(write_genre(f"./async/new_file{i}.txt"))
await asyncio.gather(*tasks)
Comme vous pouvez le voir, nous l'avons déclaré avec "async". Nous créons ensuite une liste vide appelée "tâches" pour héberger nos tâches asynchrones (appels à Genrenator et nos E/S de fichiers). Nous ajoutons nos tâches à notre liste, mais elles ne sont pas encore exécutées. Les appels ne sont pas passés tant que nous ne les avons pas planifiés avec await asyncio.gather(*tasks)
. Cela exécute toutes les tâches de notre liste et attend qu'elles se terminent avant de continuer avec le reste de notre programme. Enfin, nous utilisons asyncio.run(main())
pour exécuter notre fonction "main". La .run()
fonction est le point d'entrée de notre programme, et elle ne doit généralement être appelée qu'une seule fois par processus .
Pour ceux qui ne sont pas familiers, le
*
devant des tâches est appelé "déballage des arguments". Tout comme cela sonne, il décompresse notre liste en une série d'arguments pour notre fonction. Notre fonction estasyncio.gather()
dans ce cas.
Et c'est tout ce que nous devons faire. Maintenant, exécutant notre programme (dont la source inclut la même fonctionnalité de synchronisation des exemples synchrones et de threading)...
Writing "albuquerque fiddlehaus" to "./async/new_file1.txt"...
Writing "euroreggaebop" to "./async/new_file2.txt"...
Writing "shoedisco" to "./async/new_file0.txt"...
Writing "russiagaze" to "./async/new_file4.txt"...
Writing "alternative xylophone" to "./async/new_file3.txt"...
Time to complete asyncio read/writes: 0.71 seconds
...on voit que c'est encore plus rapide. Et, en général, la méthode asyncio sera toujours un peu plus rapide que la méthode de threading. En effet, lorsque nous utilisons la syntaxe "attendre", nous disons essentiellement à notre programme "attendez, je reviens tout de suite", mais notre programme garde une trace du temps qu'il nous faut pour terminer ce que nous faisons. Une fois que nous aurons terminé, notre programme le saura et reprendra dès qu'il le pourra. Le threading en Python permet l'asynchronicité, mais notre programme pourrait théoriquement ignorer différents threads qui ne sont peut-être pas encore prêts, perdant du temps s'il y a des threads prêts à continuer à fonctionner.
Alors, quand dois-je utiliser le threading et quand dois-je utiliser asyncio ?
Lorsque vous écrivez un nouveau code, utilisez asyncio. Si vous avez besoin d'interfacer avec des bibliothèques plus anciennes ou celles qui ne prennent pas en charge l'asyncio, vous feriez peut-être mieux d'utiliser le threading.
Il s'avère que tester des fonctions asynchrones avec pytest est aussi simple que tester des fonctions synchrones. Installez simplement le package pytest-asyncio avec pip
, marquez vos tests avec le mot- async
clé et appliquez un décorateur qui indique pytest
qu'il est asynchrone : @pytest.mark.asyncio
. Prenons un exemple.
Commençons par écrire une fonction asynchrone arbitraire dans un fichier appelé hello_asyncio.py :
import asyncio
async def say_hello(name: str):
""" Sleeps for two seconds, then prints 'Hello, {{ name }}!' """
try:
if type(name) != str:
raise TypeError("'name' must be a string")
if name == "":
raise ValueError("'name' cannot be empty")
except (TypeError, ValueError):
raise
print("Sleeping...")
await asyncio.sleep(2)
print(f"Hello, {name}!")
La fonction prend un seul argument de chaîne : name
. Après s'être assuré qu'il name
s'agit d'une chaîne d'une longueur supérieure à un, notre fonction dort de manière asynchrone pendant deux secondes, puis imprime "Hello, {name}!"
sur la console.
La différence entre
asyncio.sleep()
ettime.sleep()
est qu'ilasyncio.sleep()
n'est pas bloquant.
Testons-le maintenant avec pytest. Dans le même répertoire que hello_asyncio.py, créez un fichier appelé test_hello_asyncio.py, puis ouvrez-le dans votre éditeur de texte préféré.
Commençons par nos importations :
import pytest # Note: pytest-asyncio does not require a separate import
from hello_asyncio import say_hello
Ensuite, nous allons créer un test avec une entrée appropriée :
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
@pytest.mark.asyncio
async def test_say_hello(name):
await say_hello(name)
À noter :
@pytest.mark.asyncio
décorateur permet à pytest de fonctionner de manière asynchroneasync
syntaxeawait
utilisons notre fonction asynchrone comme nous le ferions si nous l'exécutions en dehors d'un testExécutons maintenant notre test avec l' -v
option verbose :
pytest -v
...
collected 3 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 33%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 66%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [100%]
Cela semble bon. Ensuite, nous allons écrire quelques tests avec une mauvaise entrée. De retour à l'intérieur de test_hello_asyncio.py , créons une classe appelée TestSayHelloThrowsExceptions
:
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
@pytest.mark.asyncio
async def test_say_hello_value_error(self, name):
with pytest.raises(ValueError):
await say_hello(name)
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
@pytest.mark.asyncio
async def test_say_hello_type_error(self, name):
with pytest.raises(TypeError):
await say_hello(name)
Encore une fois, nous décorons nos tests avec @pytest.mark.asyncio
, marquons nos tests avec la async
syntaxe, puis appelons notre fonction avec await
.
Relancez les tests :
pytest -v
...
collected 7 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 14%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 28%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [ 42%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_value_error[] PASSED [ 57%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[19] PASSED [ 71%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name1] PASSED [ 85%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name2] PASSED [100%]
Alternativement à pytest-asyncio, vous pouvez créer un appareil pytest qui génère une boucle d'événement asyncio :
import asyncio
import pytest
from hello_asyncio import say_hello
@pytest.fixture
def event_loop():
loop = asyncio.get_event_loop()
yield loop
Ensuite, plutôt que d'utiliser la syntaxe async
/ await
, vous créez vos tests comme vous le feriez pour des tests synchrones normaux :
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
def test_say_hello(event_loop, name):
event_loop.run_until_complete(say_hello(name))
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
def test_say_hello_value_error(self, event_loop, name):
with pytest.raises(ValueError):
event_loop.run_until_complete(say_hello(name))
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
def test_say_hello_type_error(self, event_loop, name):
with pytest.raises(TypeError):
event_loop.run_until_complete(say_hello(name))
Si cela vous intéresse, voici un tutoriel plus avancé sur les tests asynchrones .
Si vous voulez en savoir plus sur ce qui distingue l'implémentation de Python du threading par rapport à l'asyncio, voici un excellent article de Medium .
Pour des exemples et des explications encore meilleurs sur le threading en Python, voici une vidéo de Corey Schafer qui va plus en profondeur, y compris l'utilisation de la concurrent.futures
bibliothèque.
Enfin, pour une plongée massive dans l'asyncio lui-même, voici un article de Real Python entièrement dédié à celui-ci.
Bonus : Une autre bibliothèque qui pourrait vous intéresser s'appelle Unsync , surtout si vous souhaitez convertir facilement votre code synchrone actuel en code asynchrone. Pour l'utiliser, vous installez la bibliothèque avec pip, l'importez avec from unsync import unsync
, puis décorez la fonction actuellement synchrone avec @unsync
pour la rendre asynchrone. Pour l'attendre et obtenir sa valeur de retour (ce que vous pouvez faire n'importe où - il n'est pas nécessaire qu'il soit dans une fonction async/unsync), appelez simplement .result()
après l'appel de la fonction.
Qu'est-ce que le parallélisme ?
Le parallélisme est très lié à la concurrence. En fait, le parallélisme est un sous-ensemble de la simultanéité : alors qu'un processus simultané exécute plusieurs tâches en même temps, qu'elles fassent l'objet d'une attention totale ou non, un processus parallèle exécute physiquement plusieurs tâches en même temps. Un bon exemple serait de conduire, d'écouter de la musique et de manger le BLT que nous avons préparé dans la dernière section en même temps.
Parce qu'ils ne nécessitent pas beaucoup d'efforts intensifs, vous pouvez les faire tous en même temps sans avoir à attendre quoi que ce soit ou à détourner votre attention.
Voyons maintenant comment implémenter cela en Python. Nous pourrions utiliser la multiprocessing
bibliothèque, mais utilisons concurrent.futures
plutôt la bibliothèque -- cela élimine le besoin de gérer manuellement le nombre de processus. Étant donné que le principal avantage du multitraitement se produit lorsque vous effectuez plusieurs tâches gourmandes en ressources processeur, nous allons calculer les carrés de 1 million (1000000) à 1 million et 16 (1000016).
Vous pouvez trouver le code de cet exemple ici .
La seule importation dont nous aurons besoin estconcurrent.futures
:
import concurrent.futures
import time
if __name__ == "__main__":
pow_list = [i for i in range(1000000, 1000016)]
print("Starting...")
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
futures = [executor.submit(pow, i, i) for i in pow_list]
for f in concurrent.futures.as_completed(futures):
print("okay")
end = time.time()
print(f"Time to complete: {round(end - start, 2)}")
Parce que je développe sur une machine Windows, j'utilise
if __name__ == "main"
. Cela est nécessaire car Windows ne dispose pas de l'fork
appel système inhérent aux systèmes Unix . Parce que Windows n'a pas cette capacité, il a recours au lancement d'un nouvel interpréteur avec chaque processus qui tente d'importer le module principal. Si le module principal n'existe pas, il relance tout votre programme, provoquant un chaos récursif.
Donc, en regardant notre fonction principale, nous utilisons une compréhension de liste pour créer une liste de 1 million à 1 million et 16, nous ouvrons un ProcessPoolExecutor avec concurrent.futures, et nous utilisons la compréhension de liste et ProcessPoolExecutor().submit()
pour commencer à exécuter nos processus et les lancer dans une liste appelée "futures".
Nous pourrions également utiliser
ThreadPoolExecutor()
si nous voulions utiliser des threads à la place - concurrent.futures est polyvalent.
Et c'est là qu'intervient l'asynchronicité : la liste des "résultats" ne contient pas réellement les résultats de l'exécution de nos fonctions. Au lieu de cela, il contient des "futurs" qui sont similaires à l'idée JavaScript de "promesses". Afin de permettre à notre programme de continuer à fonctionner, nous récupérons ces contrats à terme qui représentent un espace réservé pour une valeur. Si nous essayons d'imprimer le futur, selon qu'il est terminé ou non, nous retrouverons soit un état "en attente" soit "terminé". Une fois terminé, nous pouvons obtenir la valeur de retour (en supposant qu'il y en ait une) en utilisant var.result()
. Dans ce cas, notre var sera "résultat".
Nous parcourons ensuite notre liste de contrats à terme, mais au lieu d'imprimer nos valeurs, nous imprimons simplement "d'accord". C'est juste à cause de l'ampleur des calculs qui en résultent.
Comme avant, j'ai construit un script de comparaison qui fait cela de manière synchrone. Et, comme avant, vous pouvez le trouver sur GitHub .
En exécutant notre programme de contrôle, qui inclut également une fonctionnalité pour chronométrer notre programme, nous obtenons :
Starting...
okay
...
okay
Time to complete: 54.64
Ouah. 54,64 secondes, c'est assez long. Voyons si notre version avec multitraitement fait mieux :
Starting...
okay
...
okay
Time to complete: 6.24
Notre temps a été considérablement réduit. Nous sommes à environ 1/9e de notre temps d'origine.
Alors que se passerait-il si nous utilisions le threading pour cela à la place ?
Je suis sûr que vous pouvez deviner - ce ne serait pas beaucoup plus rapide que de le faire de manière synchrone. En fait, cela peut être plus lent car il faut encore un peu de temps et d'efforts pour créer de nouveaux threads. Mais ne me croyez pas sur parole, voici ce que nous obtenons lorsque nous remplaçons ProcessPoolExecutor()
par ThreadPoolExecutor()
:
Starting...
okay
...
okay
Time to complete: 53.83
Comme je l'ai mentionné précédemment, le threading permet à vos applications de se concentrer sur de nouvelles tâches pendant que d'autres attendent. Dans ce cas, nous ne restons jamais les bras croisés. Le multitraitement, d'autre part, crée des services totalement nouveaux, généralement sur des cœurs de processeur séparés, prêts à faire tout ce que vous lui demandez complètement en tandem avec tout ce que fait votre script. C'est pourquoi la version multitraitement prenant environ 1/9ème du temps a du sens - j'ai 8 cœurs dans mon CPU.
Maintenant que nous avons parlé de concurrence et de parallélisme en Python, nous pouvons enfin clarifier les termes. Si vous rencontrez des difficultés pour faire la distinction entre les termes, vous pouvez penser en toute sécurité et avec précision à nos définitions précédentes de "parallélisme" et de "concurrence" comme "concurrence parallèle" et "concurrence non parallèle" respectivement.
Real Python a un excellent article sur la concurrence vs le parallélisme .
Engineer Man a une bonne comparaison vidéo de threading vs multiprocessing .
Corey Schafer a également une bonne vidéo sur le multitraitement dans le même esprit que sa vidéo de threading.
Si vous ne regardez qu'une seule vidéo, regardez cette excellente conférence de Raymond Hettinger . Il fait un travail incroyable en expliquant les différences entre le multitraitement, le threading et l'asyncio.
Que se passe-t-il si j'ai besoin de combiner de nombreuses opérations d'E/S avec des calculs lourds ?
Nous pouvons le faire également. Supposons que vous deviez récupérer 100 pages Web pour une information spécifique, puis que vous deviez enregistrer cette information dans un fichier pour plus tard. Nous pouvons séparer la puissance de calcul entre chacun des cœurs de notre ordinateur en faisant en sorte que chaque processus gratte une fraction des pages.
Pour ce script, installons Beautiful Soup pour nous aider à gratter facilement nos pages : pip install beautifulsoup4
. Cette fois, nous avons en fait pas mal d'importations. Les voici, et voici pourquoi nous les utilisons :
import asyncio # Gives us async/await
import concurrent.futures # Allows creating new processes
import time
from math import floor # Helps divide up our requests evenly across our CPU cores
from multiprocessing import cpu_count # Returns our number of CPU cores
import aiofiles # For asynchronously performing file I/O operations
import aiohttp # For asynchronously making HTTP requests
from bs4 import BeautifulSoup # For easy webpage scraping
Vous pouvez trouver le code de cet exemple ici .
Tout d'abord, nous allons créer une fonction asynchrone qui demande à Wikipédia de récupérer des pages aléatoires. Nous gratterons chaque page que nous récupérons pour son titre en utilisant BeautifulSoup
, puis nous l'ajouterons à un fichier donné ; nous séparerons chaque titre par une tabulation. La fonction prendra deux arguments :
async def get_and_scrape_pages(num_pages: int, output_file: str):
"""
Makes {{ num_pages }} requests to Wikipedia to receive {{ num_pages }} random
articles, then scrapes each page for its title and appends it to {{ output_file }},
separating each title with a tab: "\\t"
#### Arguments
---
num_pages: int -
Number of random Wikipedia pages to request and scrape
output_file: str -
File to append titles to
"""
async with \
aiohttp.ClientSession() as client, \
aiofiles.open(output_file, "a+", encoding="utf-8") as f:
for _ in range(num_pages):
async with client.get("https://en.wikipedia.org/wiki/Special:Random") as response:
if response.status > 399:
# I was getting a 429 Too Many Requests at a higher volume of requests
response.raise_for_status()
page = await response.text()
soup = BeautifulSoup(page, features="html.parser")
title = soup.find("h1").text
await f.write(title + "\t")
await f.write("\n")
Nous ouvrons tous les deux de manière asynchrone un aiohttp ClientSession
et notre fichier de sortie. Le mode, a+
, signifie ajouter au fichier et le créer s'il n'existe pas déjà. L'encodage de nos chaînes en utf-8 garantit que nous n'obtenons pas d'erreur si nos titres contiennent des caractères internationaux. Si nous obtenons une réponse d'erreur, nous l'augmenterons au lieu de continuer (à des volumes de demandes élevés, j'obtenais un 429 Too Many Requests). Nous obtenons de manière asynchrone le texte de notre réponse, puis nous analysons le titre de manière asynchrone et l'ajoutons à notre fichier. Après avoir ajouté tous nos titres, nous ajoutons une nouvelle ligne : "\n".
Notre prochaine fonction est la fonction que nous allons démarrer avec chaque nouveau processus pour permettre son exécution asynchrone :
def start_scraping(num_pages: int, output_file: str, i: int):
""" Starts an async process for requesting and scraping Wikipedia pages """
print(f"Process {i} starting...")
asyncio.run(get_and_scrape_pages(num_pages, output_file))
print(f"Process {i} finished.")
Passons maintenant à notre fonction principale. Commençons par quelques constantes (et notre déclaration de fonction) :
def main():
NUM_PAGES = 100 # Number of pages to scrape altogether
NUM_CORES = cpu_count() # Our number of CPU cores (including logical cores)
OUTPUT_FILE = "./wiki_titles.tsv" # File to append our scraped titles to
PAGES_PER_CORE = floor(NUM_PAGES / NUM_CORES)
PAGES_FOR_FINAL_CORE = PAGES_PER_CORE + NUM_PAGES % PAGES_PER_CORE # For our final core
Et maintenant la logique :
futures = []
with concurrent.futures.ProcessPoolExecutor(NUM_CORES) as executor:
for i in range(NUM_CORES - 1):
new_future = executor.submit(
start_scraping, # Function to perform
# v Arguments v
num_pages=PAGES_PER_CORE,
output_file=OUTPUT_FILE,
i=i
)
futures.append(new_future)
futures.append(
executor.submit(
start_scraping,
PAGES_FOR_FINAL_CORE, OUTPUT_FILE, NUM_CORES-1
)
)
concurrent.futures.wait(futures)
Nous créons un tableau pour stocker nos futurs, puis nous créons un ProcessPoolExecutor
, en le définissant max_workers
comme égal à notre nombre de cœurs. Nous parcourons une plage égale à notre nombre de cœurs moins 1, en exécutant un nouveau processus avec notre start_scraping
fonction. Nous l'ajoutons ensuite à notre liste des contrats à terme. Notre noyau final aura potentiellement du travail supplémentaire à faire car il grattera un nombre de pages égal à chacun de nos autres noyaux, mais grattera en outre un nombre de pages égal au reste que nous avons obtenu en divisant notre nombre total de pages à gratter par notre nombre total de cœurs de processeur.
Assurez-vous d'exécuter réellement votre fonction principale :
if __name__ == "__main__":
start = time.time()
main()
print(f"Time to complete: {round(time.time() - start, 2)} seconds.")
Après avoir exécuté le programme avec mon processeur à 8 cœurs (avec le code d'analyse comparative) :
Cette version ( asyncio avec multitraitement ):
Time to complete: 5.65 seconds.
Time to complete: 8.87 seconds.
Time to complete: 47.92 seconds.
Time to complete: 88.86 seconds.
Je suis en fait assez surpris de voir que l'amélioration de l'asyncio avec le multitraitement par rapport au multitraitement n'était pas aussi grande que je le pensais.
async
/ await
et une syntaxe similaire existent également dans d'autres langages, et dans certains de ces langages, son implémentation peut différer considérablement.
Le premier langage de programmation (en 2007) à utiliser la async
syntaxe était le F# de Microsoft. Alors qu'il n'est pas exactement utilisé await
pour attendre un appel de fonction, il utilise une syntaxe spécifique comme let!
et do!
avec les Async
fonctions propriétaires incluses dans le System
module.
Vous pouvez en savoir plus sur la programmation asynchrone en F# dans la documentation F# de Microsoft .
Leur équipe C# s'est ensuite appuyée sur ce concept, et c'est là que sont nés les mots-clés async
/ await
que nous connaissons maintenant :
using System;
// Allows the "Task" return type
using System.Threading.Tasks;
public class Program
{
// Declare an async function with "async"
private static async Task<string> ReturnHello()
{
return "hello world";
}
// Main can be async -- no problem
public static async Task Main()
{
// await an async string
string result = await ReturnHello();
// Print the string we got asynchronously
Console.WriteLine(result);
}
}
On s'assure qu'on est using System.Threading.Tasks
tel qu'il inclut le Task
type, et, en général, le Task
type est nécessaire pour qu'une fonction asynchrone soit attendue. L'avantage de C# est que vous pouvez rendre votre fonction principale asynchrone simplement en la déclarant avec async
, et vous n'aurez aucun problème.
Si vous souhaitez en savoir plus sur
async
/await
dans C #, les documents C # de Microsoft contiennent une bonne page.
Introduite pour la première fois dans ES6, la syntaxe async
/ await
est essentiellement une abstraction des promesses JavaScript (qui sont similaires aux futures Python). Contrairement à Python, cependant, tant que vous n'attendez pas, vous pouvez appeler une fonction asynchrone normalement sans fonction spécifique comme celle de Python asyncio.start()
:
// Declare a function with async
async function returnHello(){
return "hello world";
}
async function printSomething(){
// await an async string
const result = await returnHello();
// print the string we got asynchronously
console.log(result);
}
// Run our async code
printSomething();
Voir MDN pour plus d'informations sur
async
/await
dans JavaScript .
Rust autorise désormais également l'utilisation de la syntaxe async
/ await
, et fonctionne de la même manière que Python, C# et JavaScript :
// Allows blocking synchronous code to run async code
use futures::executor::block_on;
// Declare an async function with "async"
async fn return_hello() -> String {
"hello world".to_string()
}
// Code that awaits must also be declared with "async"
async fn print_something(){
// await an async String
let result: String = return_hello().await;
// Print the string we got asynchronously
println!("{0}", result);
}
fn main() {
// Block the current synchronous execution to run our async code
block_on(print_something());
}
Pour utiliser les fonctions asynchrones, nous devons d'abord ajouter futures = "0.3"
à notre Cargo.toml . Nous importons ensuite la block_on
fonction avec use futures::executor::block_on
-- block_on
est nécessaire pour exécuter notre fonction asynchrone à partir de notre main
fonction synchrone.
Vous pouvez trouver plus d'informations sur
async
/await
dans Rust dans la documentation Rust.
Plutôt que la syntaxe async
/ traditionnelle await
inhérente à tous les langages précédents que nous avons couverts, Go utilise des "goroutines" et des "canaux". Vous pouvez considérer un canal comme étant similaire à un futur Python. Dans Go, vous envoyez généralement un canal comme argument à une fonction, puis utilisez go
pour exécuter la fonction simultanément. Chaque fois que vous devez vous assurer que la fonction est terminée, vous utilisez la <-
syntaxe, que vous pouvez considérer comme la await
syntaxe la plus courante. Si votre goroutine (la fonction que vous exécutez de manière asynchrone) a une valeur de retour, elle peut être saisie de cette façon.
package main
import "fmt"
// "chan" makes the return value a string channel instead of a string
func returnHello(result chan string){
// Gives our channel a value
result <- "hello world"
}
func main() {
// Creates a string channel
result := make(chan string)
// Starts execution of our goroutine
go returnHello(result)
// Awaits and prints our string
fmt.Println(<- result)
}
Exécutez-le dans le Go Playground
Pour plus d'informations sur la simultanéité dans Go, consultez An Introduction to Programming in Go par Caleb Doxsey.
Comme Python, Ruby a également la limitation Global Interpreter Lock. Ce qu'il n'a pas, c'est la simultanéité intégrée au langage. Cependant, il existe un joyau créé par la communauté qui permet la simultanéité dans Ruby, et vous pouvez trouver sa source sur GitHub .
Comme Ruby, Java n'a pas la syntaxe async
/ await
intégrée, mais il a des capacités de concurrence en utilisant le java.util.concurrent
module. Cependant, Electronic Arts a écrit une bibliothèque Async qui permet de l'utiliser await
comme méthode. Ce n'est pas exactement la même chose que Python/C#/JavaScript/Rust, mais cela vaut la peine d'être examiné si vous êtes un développeur Java et que vous êtes intéressé par ce type de fonctionnalité.
Bien que C++ n'ait pas non plus la syntaxe async
/ await
, il a la possibilité d'utiliser des contrats à terme pour exécuter du code simultanément à l'aide du futures
module :
#include <iostream>
#include <string>
// Necessary for futures
#include <future>
// No async declaration needed
std::string return_hello() {
return "hello world";
}
int main ()
{
// Declares a string future
std::future<std::string> fut = std::async(return_hello);
// Awaits the result of the future
std::string result = fut.get();
// Prints the string we got asynchronously
std::cout << result << '\n';
}
Il n'est pas nécessaire de déclarer une fonction avec un mot clé pour indiquer si elle peut et doit être exécutée de manière asynchrone. Au lieu de cela, vous déclarez votre futur initial chaque fois que vous en avez besoin avec std::future<{{ function return type }}>
et le définissez égal à std::async()
, y compris le nom de la fonction que vous souhaitez exécuter de manière asynchrone avec tous les arguments qu'elle prend - c'est-à-dire std::async(do_something, 1, 2, "string")
. Pour attendre la valeur du futur, utilisez la .get()
syntaxe dessus.
Vous pouvez trouver de la documentation pour async en C++ sur cplusplus.com.
Que vous travailliez avec des opérations de réseau ou de fichier asynchrones ou que vous effectuiez de nombreux calculs complexes, il existe plusieurs façons d'optimiser l'efficacité de votre code.
Si vous utilisez Python, vous pouvez utiliser asyncio
ou threading
pour tirer le meilleur parti des opérations d'E/S ou du multiprocessing
module pour le code gourmand en CPU.
Rappelez-vous également que le
concurrent.futures
module peut être utilisé à la place dethreading
oumultiprocessing
.
Si vous utilisez un autre langage de programmation, il y a de fortes chances qu'il y ait une implémentation de async
/ await
pour lui aussi.
Source : https://testdrive.io
1660190700
¿Qué son la concurrencia y el paralelismo, y cómo se aplican a Python?
Hay muchas razones por las que sus aplicaciones pueden ser lentas. A veces, esto se debe a un diseño algorítmico deficiente o a una elección incorrecta de la estructura de datos. A veces, sin embargo, se debe a fuerzas fuera de nuestro control, como restricciones de hardware o las peculiaridades de las redes. Ahí es donde encajan la concurrencia y el paralelismo. Permiten que sus programas hagan varias cosas a la vez, ya sea al mismo tiempo o perdiendo el menor tiempo posible esperando en tareas ocupadas.
Ya sea que esté tratando con recursos web externos, leyendo y escribiendo en varios archivos, o necesite usar una función de cálculo intensivo varias veces con diferentes parámetros, este artículo debería ayudarlo a maximizar la eficiencia y la velocidad de su código.
Primero, profundizaremos en qué son la simultaneidad y el paralelismo y cómo encajan en el ámbito de Python utilizando bibliotecas estándar como subprocesamiento, multiprocesamiento y asyncio. La última parte de este artículo comparará la implementación de async
/ de Python await
con la forma en que otros lenguajes la han implementado.
Puede encontrar todos los ejemplos de código de este artículo en el repositorio de concurrency-parallelism-and-asyncio en GitHub.
Para trabajar con los ejemplos de este artículo, ya debería saber cómo trabajar con solicitudes HTTP.
Al final de este artículo, debería poder responder las siguientes preguntas:
¿Qué es la concurrencia?
Una definición efectiva de concurrencia es "ser capaz de realizar múltiples tareas a la vez". Sin embargo, esto es un poco engañoso, ya que las tareas pueden o no realizarse exactamente al mismo tiempo. En cambio, un proceso podría comenzar, luego, una vez que está esperando que finalice una instrucción específica, cambiar a una nueva tarea, solo para regresar una vez que ya no esté esperando. Una vez que finaliza una tarea, cambia de nuevo a una tarea sin terminar hasta que se hayan realizado todas. Las tareas comienzan de forma asíncrona, se realizan de forma asíncrona y luego finalizan de forma asíncrona.
Si eso te resultó confuso, pensemos en una analogía: digamos que quieres hacer un BLT . Primero, querrás tirar el tocino en una sartén a fuego medio-bajo. Mientras se cocina el tocino, puedes sacar los tomates y la lechuga y comenzar a prepararlos (lavarlos y cortarlos). Mientras tanto, continúas revisando y ocasionalmente volteando tu tocino.
En este punto, ha comenzado una tarea y luego comenzó y completó dos más mientras tanto, todo mientras todavía está esperando la primera.
Eventualmente pones tu pan en una tostadora. Mientras se tuesta, continúas revisando tu tocino. A medida que se terminan las piezas, las saca y las coloca en un plato. Una vez que el pan haya terminado de tostarse, se le aplica la crema para untar de su elección, y luego puede comenzar a colocar capas sobre los tomates, la lechuga y luego, una vez que haya terminado de cocinarse, el tocino. Solo una vez que todo esté cocido, preparado y en capas, puede colocar la última tostada en su sándwich, cortarlo (opcional) y comerlo.
Debido a que requiere que realice varias tareas al mismo tiempo, hacer un BLT es inherentemente un proceso simultáneo, incluso si no está prestando toda su atención a cada una de esas tareas a la vez. Para todos los efectos, en la siguiente sección, nos referiremos a esta forma de concurrencia simplemente como "concurrencia". Lo diferenciaremos más adelante en este artículo.
Por esta razón, la simultaneidad es ideal para procesos intensivos de E/S, tareas que implican esperar solicitudes web u operaciones de lectura/escritura de archivos.
En Python, hay algunas formas diferentes de lograr la concurrencia. Lo primero que veremos es la biblioteca de subprocesos.
Para nuestros ejemplos en esta sección, vamos a construir un pequeño programa de Python que toma cinco veces un género musical aleatorio de la API Genrenator de Binary Jazz , imprime el género en la pantalla y coloca cada uno en su propio archivo.
Para trabajar con subprocesos en Python, la única importación que necesitará es threading
, pero para este ejemplo, también importé urllib
para trabajar con solicitudes HTTP, time
para determinar cuánto tardan las funciones en completarse y json
para convertir fácilmente los datos json devueltos. de la API de Genrenator.
Puede encontrar el código para este ejemplo aquí .
Comencemos con una función simple:
def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
req = Request("https://binaryjazz.us/wp-json/genrenator/v1/genre/", headers={"User-Agent": "Mozilla/5.0"})
genre = json.load(urlopen(req))
with open(file_name, "w") as new_file:
print(f"Writing '{genre}' to '{file_name}'...")
new_file.write(genre)
Examinando el código anterior, hacemos una solicitud a la API de Genrenator, cargamos su respuesta JSON (un género musical aleatorio), la imprimimos y luego la escribimos en un archivo.
Sin el encabezado "User-Agent", recibirá un 304.
Lo que realmente nos interesa es la siguiente sección, donde ocurre el enhebrado real:
threads = []
for i in range(5):
thread = threading.Thread(
target=write_genre,
args=[f"./threading/new_file{i}.txt"]
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Primero comenzamos con una lista. Luego procedemos a iterar cinco veces, creando un nuevo hilo cada vez. A continuación, comenzamos cada subproceso, lo agregamos a nuestra lista de "subprocesos" y luego iteramos sobre nuestra lista una última vez para unir cada subproceso.
Explicación: crear hilos en Python es fácil.
Para crear un hilo nuevo, utilice threading.Thread()
. Puede pasarle el kwarg (argumento de palabra clave) target
con un valor de cualquier función que le gustaría ejecutar en ese hilo. Pero solo pase el nombre de la función, no su valor (es decir, para nuestros propósitos, write_genre
y no write_genre()
). Para pasar argumentos, pase "kwargs" (que toma un dict de sus kwargs) o "args" (que toma un iterable que contiene sus argumentos, en este caso, una lista).
Sin embargo, crear un hilo no es lo mismo que iniciar un hilo. Para iniciar su hilo, utilice {the name of your thread}.start()
. Comenzar un hilo significa "comenzar su ejecución".
Por último, cuando unimos hilos con thread.join()
, todo lo que hacemos es asegurarnos de que el hilo haya terminado antes de continuar con nuestro código.
Pero, ¿qué es exactamente un hilo?
Un subproceso es una forma de permitir que su computadora divida un solo proceso/programa en muchas piezas livianas que se ejecutan en paralelo. De manera un tanto confusa, la implementación estándar de subprocesos de Python limita los subprocesos a solo poder ejecutar uno a la vez debido a algo llamado Bloqueo global de intérprete (GIL). El GIL es necesario porque la administración de memoria de CPython (la implementación predeterminada de Python) no es segura para subprocesos. Debido a esta limitación, los subprocesos en Python son concurrentes, pero no paralelos. Para evitar esto, Python tiene un multiprocessing
módulo separado no limitado por GIL que activa procesos separados, lo que permite la ejecución paralela de su código. El uso del multiprocessing
módulo es casi idéntico al uso del threading
módulo.
Puede encontrar más información sobre GIL de Python y la seguridad de subprocesos en Real Python y en los documentos oficiales de Python .
Echaremos un vistazo más profundo al multiprocesamiento en Python en breve.
Antes de mostrar la mejora potencial de la velocidad con respecto al código sin subprocesos, me tomé la libertad de crear también una versión sin subprocesos del mismo programa (nuevamente, disponible en GitHub ). En lugar de crear un nuevo subproceso y unir cada uno de ellos, llama write_genre
a un bucle for que itera cinco veces.
Para comparar puntos de referencia de velocidad, también importé la time
biblioteca para cronometrar la ejecución de nuestros scripts:
Starting...
Writing "binary indoremix" to "./sync/new_file0.txt"...
Writing "slavic aggro polka fusion" to "./sync/new_file1.txt"...
Writing "israeli new wave" to "./sync/new_file2.txt"...
Writing "byzantine motown" to "./sync/new_file3.txt"...
Writing "dutch hate industrialtune" to "./sync/new_file4.txt"...
Time to complete synchronous read/writes: 1.42 seconds
Al ejecutar el script, vemos que mi computadora tarda alrededor de 1,49 segundos (junto con géneros musicales clásicos como "Dutch Hat Industrialtune"). No está mal.
Ahora ejecutemos la versión que usa subprocesos:
Starting...
Writing "college k-dubstep" to "./threading/new_file2.txt"...
Writing "swiss dirt" to "./threading/new_file0.txt"...
Writing "bop idol alternative" to "./threading/new_file4.txt"...
Writing "ethertrio" to "./threading/new_file1.txt"...
Writing "beach aust shanty français" to "./threading/new_file3.txt"...
Time to complete threading read/writes: 0.77 seconds
Lo primero que le puede llamar la atención es que las funciones no se completan en orden: 2 - 0 - 4 - 1 - 3
Esto se debe a la naturaleza asincrónica de los subprocesos: mientras una función espera, comienza otra, y así sucesivamente. Debido a que podemos continuar realizando tareas mientras esperamos que otros terminen (ya sea debido a operaciones de red o de E/S de archivos), también puede haber notado que reducimos nuestro tiempo aproximadamente a la mitad: 0,77 segundos. Si bien esto puede no parecer mucho ahora, es fácil imaginar el caso real de crear una aplicación web que necesita escribir muchos más datos en un archivo o interactuar con servicios web mucho más complejos.
Entonces, si la creación de subprocesos es tan buena, ¿por qué no terminamos el artículo aquí?
Porque hay formas aún mejores de realizar tareas al mismo tiempo.
Echemos un vistazo a un ejemplo usando asyncio. Para este método, instalaremos aiohttp usando pip
. Esto nos permitirá realizar solicitudes sin bloqueo y recibir respuestas utilizando la sintaxis async
/ await
que se presentará en breve. También tiene el beneficio adicional de una función que convierte una respuesta JSON sin necesidad de importar la json
biblioteca. También instalaremos e importaremos aiofiles , lo que permite operaciones de archivo sin bloqueo. Aparte de aiohttp
and aiofiles
, import asyncio
, que viene con la biblioteca estándar de Python.
"Sin bloqueo" significa que un programa permitirá que otros subprocesos continúen ejecutándose mientras espera. Esto se opone al código de "bloqueo", que detiene la ejecución de su programa por completo. Las operaciones de E/S sincrónicas normales sufren esta limitación.
Puede encontrar el código para este ejemplo aquí .
Una vez que tengamos nuestras importaciones en su lugar, echemos un vistazo a la versión asíncrona de la write_genre
función de nuestro ejemplo asyncio:
async def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
async with aiohttp.ClientSession() as session:
async with session.get("https://binaryjazz.us/wp-json/genrenator/v1/genre/") as response:
genre = await response.json()
async with aiofiles.open(file_name, "w") as new_file:
print(f'Writing "{genre}" to "{file_name}"...')
await new_file.write(genre)
Para aquellos que no están familiarizados con la sintaxis async
/ await
que se puede encontrar en muchos otros lenguajes modernos, async
declara que una función, for
bucle o with
declaración debe usarse de forma asíncrona. Para llamar a una función asíncrona, debe usar la await
palabra clave de otra función asíncrona o llamar create_task()
directamente desde el bucle de eventos, que se puede obtener de asyncio.get_event_loop()
, es decir, loop = asyncio.get_event_loop()
.
Además:
async with
permite esperar respuestas asíncronas y operaciones de archivo.async for
(no se usa aquí) itera sobre una secuencia asíncrona .Los bucles de eventos son construcciones inherentes a la programación asíncrona que permiten realizar tareas de forma asíncrona. Mientras lee este artículo, puedo asumir con seguridad que probablemente no esté muy familiarizado con el concepto. Sin embargo, incluso si nunca ha escrito una aplicación asíncrona, tiene experiencia con bucles de eventos cada vez que usa una computadora. Ya sea que su computadora esté escuchando la entrada del teclado, esté jugando juegos multijugador en línea o esté navegando en Reddit mientras tiene archivos que se copian en segundo plano, un bucle de eventos es la fuerza impulsora que mantiene todo funcionando sin problemas y de manera eficiente. En su esencia más pura, un bucle de eventos es un proceso que espera desencadenantes y luego realiza acciones específicas (programadas) una vez que se cumplen esos desencadenantes. A menudo devuelven una "promesa" (sintaxis de JavaScript) o "futuro" (sintaxis de Python) de algún tipo para indicar que se ha agregado una tarea. Una vez que finaliza la tarea, la promesa o el futuro devuelve un valor pasado desde la función llamada (suponiendo que la función devuelva un valor).
La idea de realizar una función en respuesta a otra función se denomina "devolución de llamada".
Para otra versión de las devoluciones de llamada y los eventos, aquí hay una excelente respuesta en Stack Overflow .
Aquí hay un tutorial de nuestra función:
Estamos usando async with
para abrir nuestra sesión de cliente de forma asíncrona. La aiohttp.ClientSession()
clase es lo que nos permite realizar solicitudes HTTP y permanecer conectados a una fuente sin bloquear la ejecución de nuestro código. Luego hacemos una solicitud asíncrona a la API de Genrenator y esperamos la respuesta JSON (un género musical aleatorio). En la siguiente línea, usamos async with
nuevamente con la aiofiles
biblioteca para abrir de forma asincrónica un nuevo archivo para escribir nuestro nuevo género. Imprimimos el género, luego lo escribimos en el archivo.
A diferencia de las secuencias de comandos regulares de Python, la programación con asyncio prácticamente impone* el uso de algún tipo de función "principal".
* A menos que esté usando la sintaxis obsoleta de "rendimiento" con el decorador @asyncio.coroutine, que se eliminará en Python 3.10 .
Esto se debe a que necesita usar la palabra clave "async" para usar la sintaxis "await", y la sintaxis "await" es la única forma de ejecutar realmente otras funciones asíncronas.
Aquí está nuestra función principal:
async def main():
tasks = []
for i in range(5):
tasks.append(write_genre(f"./async/new_file{i}.txt"))
await asyncio.gather(*tasks)
Como puede ver, lo hemos declarado con "async". Luego creamos una lista vacía llamada "tareas" para albergar nuestras tareas asíncronas (llamadas a Genrenator y nuestro archivo I/O). Agregamos nuestras tareas a nuestra lista, pero en realidad aún no se ejecutan. En realidad, las llamadas no se realizan hasta que las programamos con await asyncio.gather(*tasks)
. Esto ejecuta todas las tareas de nuestra lista y espera a que finalicen antes de continuar con el resto de nuestro programa. Por último, usamos asyncio.run(main())
para ejecutar nuestra función "principal". La .run()
función es el punto de entrada de nuestro programa y, por lo general , solo debe llamarse una vez por proceso .
Para aquellos que no estén familiarizados, el
*
frente de las tareas se llama "desempaquetado de argumentos". Tal como suena, descomprime nuestra lista en una serie de argumentos para nuestra función. Nuestra función esasyncio.gather()
en este caso.
Y eso es todo lo que tenemos que hacer. Ahora, ejecutando nuestro programa (cuya fuente incluye la misma funcionalidad de sincronización de los ejemplos sincrónicos y de subprocesamiento)...
Writing "albuquerque fiddlehaus" to "./async/new_file1.txt"...
Writing "euroreggaebop" to "./async/new_file2.txt"...
Writing "shoedisco" to "./async/new_file0.txt"...
Writing "russiagaze" to "./async/new_file4.txt"...
Writing "alternative xylophone" to "./async/new_file3.txt"...
Time to complete asyncio read/writes: 0.71 seconds
... vemos que es aún más rápido aún. Y, en general, el método asyncio siempre será un poco más rápido que el método de subprocesamiento. Esto se debe a que cuando usamos la sintaxis "aguardar", esencialmente le decimos a nuestro programa "espere, vuelvo enseguida", pero nuestro programa realiza un seguimiento de cuánto tiempo nos lleva terminar lo que estamos haciendo. Una vez que hayamos terminado, nuestro programa lo sabrá y se reanudará tan pronto como sea posible. La creación de subprocesos en Python permite la asincronía, pero nuestro programa teóricamente podría omitir diferentes subprocesos que aún no estén listos, perdiendo el tiempo si hay subprocesos listos para continuar ejecutándose.
Entonces, ¿cuándo debo usar subprocesos y cuándo debo usar asyncio?
Cuando esté escribiendo código nuevo, use asyncio. Si necesita interactuar con bibliotecas más antiguas o aquellas que no son compatibles con asyncio, es posible que esté mejor con subprocesos.
Resulta que probar funciones asíncronas con pytest es tan fácil como probar funciones síncronas. Simplemente instale el paquete pytest-asyncio con pip
, marque sus pruebas con la async
palabra clave y aplique un decorador que permita pytest
saber que es asíncrono: @pytest.mark.asyncio
. Veamos un ejemplo.
Primero, escribamos una función asíncrona arbitraria en un archivo llamado hello_asyncio.py :
import asyncio
async def say_hello(name: str):
""" Sleeps for two seconds, then prints 'Hello, {{ name }}!' """
try:
if type(name) != str:
raise TypeError("'name' must be a string")
if name == "":
raise ValueError("'name' cannot be empty")
except (TypeError, ValueError):
raise
print("Sleeping...")
await asyncio.sleep(2)
print(f"Hello, {name}!")
La función toma un solo argumento de cadena: name
. Al asegurarnos de que name
es una cadena con una longitud superior a uno, nuestra función duerme de forma asíncrona durante dos segundos y luego se imprime "Hello, {name}!"
en la consola.
La diferencia entre
asyncio.sleep()
ytime.sleep()
es queasyncio.sleep()
no bloquea.
Ahora vamos a probarlo con pytest. En el mismo directorio que hello_asyncio.py, cree un archivo llamado test_hello_asyncio.py, luego ábralo en su editor de texto favorito.
Comencemos con nuestras importaciones:
import pytest # Note: pytest-asyncio does not require a separate import
from hello_asyncio import say_hello
Luego crearemos una prueba con la entrada adecuada:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
@pytest.mark.asyncio
async def test_say_hello(name):
await say_hello(name)
Cosas a tener en cuenta:
@pytest.mark.asyncio
decorador permite que pytest funcione de forma asíncrona.async
sintaxisawait
ejecutando nuestra función asíncrona como lo haríamos si la estuviéramos ejecutando fuera de una prueba-v
Ahora ejecutemos nuestra prueba con la opción detallada :
pytest -v
...
collected 3 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 33%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 66%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [100%]
Se ve bien. A continuación, escribiremos un par de pruebas con una entrada incorrecta. De vuelta dentro de test_hello_asyncio.py , creemos una clase llamada TestSayHelloThrowsExceptions
:
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
@pytest.mark.asyncio
async def test_say_hello_value_error(self, name):
with pytest.raises(ValueError):
await say_hello(name)
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
@pytest.mark.asyncio
async def test_say_hello_type_error(self, name):
with pytest.raises(TypeError):
await say_hello(name)
Nuevamente, decoramos nuestras pruebas con @pytest.mark.asyncio
, marcamos nuestras pruebas con la async
sintaxis, luego llamamos a nuestra función con await
.
Vuelva a ejecutar las pruebas:
pytest -v
...
collected 7 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 14%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 28%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [ 42%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_value_error[] PASSED [ 57%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[19] PASSED [ 71%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name1] PASSED [ 85%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name2] PASSED [100%]
Como alternativa a pytest-asyncio, puede crear un accesorio pytest que produzca un bucle de eventos asyncio:
import asyncio
import pytest
from hello_asyncio import say_hello
@pytest.fixture
def event_loop():
loop = asyncio.get_event_loop()
yield loop
Luego, en lugar de usar la sintaxis async
/ await
, crea sus pruebas como lo haría con las pruebas sincrónicas normales:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
def test_say_hello(event_loop, name):
event_loop.run_until_complete(say_hello(name))
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
def test_say_hello_value_error(self, event_loop, name):
with pytest.raises(ValueError):
event_loop.run_until_complete(say_hello(name))
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
def test_say_hello_type_error(self, event_loop, name):
with pytest.raises(TypeError):
event_loop.run_until_complete(say_hello(name))
If you're interested, here's a more advanced tutorial on asyncio testing.
If you want to learn more about what distinguishes Python's implementation of threading vs asyncio, here's a great article from Medium.
For even better examples and explanations of threading in Python, here's a video by Corey Schafer that goes more in-depth, including using the concurrent.futures
library.
Lastly, for a massive deep-dive into asyncio itself, here's an article from Real Python completely dedicated to it.
Bonus: One more library you might be interested in is called Unsync, especially if you want to easily convert your current synchronous code into asynchronous code. To use it, you install the library with pip, import it with from unsync import unsync
, then decorate whatever currently synchronous function with @unsync
to make it asynchronous. To await it and get its return value (which you can do anywhere -- it doesn't have to be in an async/unsync function), just call .result()
after the function call.
What is parallelism?
Parallelism is very-much related to concurrency. In fact, parallelism is a subset of concurrency: whereas a concurrent process performs multiple tasks at the same time whether they're being diverted total attention or not, a parallel process is physically performing multiple tasks all at the same time. A good example would be driving, listening to music, and eating the BLT we made in the last section at the same time.
Because they don't require a lot of intensive effort, you can do them all at once without having to wait on anything or divert your attention away.
Now let's take a look at how to implement this in Python. We could use the multiprocessing
library, but let's use the concurrent.futures
library instead -- it eliminates the need to manage the number of process manually. Because the major benefit of multiprocessing happens when you perform multiple cpu-heavy tasks, we're going to compute the squares of 1 million (1000000) to 1 million and 16 (1000016).
You can find the code for this example here.
The only import we'll need is concurrent.futures
:
import concurrent.futures
import time
if __name__ == "__main__":
pow_list = [i for i in range(1000000, 1000016)]
print("Starting...")
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
futures = [executor.submit(pow, i, i) for i in pow_list]
for f in concurrent.futures.as_completed(futures):
print("okay")
end = time.time()
print(f"Time to complete: {round(end - start, 2)}")
Because I'm developing on a Windows machine, I'm using
if __name__ == "main"
. This is necessary because Windows does not have thefork
system call inherent to Unix systems. Because Windows doesn't have this capability, it resorts to launching a new interpreter with each process that tries to import the main module. If the main module doesn't exist, it reruns your entire program, causing recursive chaos to ensue.
So taking a look at our main function, we use a list comprehension to create a list from 1 million to 1 million and 16, we open a ProcessPoolExecutor with concurrent.futures, and we use list comprehension and ProcessPoolExecutor().submit()
to start executing our processes and throwing them into a list called "futures."
We could also use
ThreadPoolExecutor()
if we wanted to use threads instead -- concurrent.futures is versatile.
And this is where the asynchronicity comes in: The "results" list does not actually contain the results from running our functions. Instead, it contains "futures" which are similar to the JavaScript idea of "promises." In order to allow our program to continue running, we get back these futures that represent a placeholder for a value. If we try to print the future, depending on whether it's finished running or not, we'll either get back a state of "pending" or "finished." Once it's finished we can get the return value (assuming there is one) using var.result()
. In this case, our var will be "result."
We then iterate through our list of futures, but instead of printing our values, we're simply printing out "okay." This is just because of how massive the resulting calculations come out to be.
Just as before, I built a comparison script that does this synchronously. And, just as before, you can find it on GitHub.
Running our control program, which also includes functionality for timing our program, we get:
Starting...
okay
...
okay
Time to complete: 54.64
Wow. 54.64 seconds is quite a long time. Let's see if our version with multiprocessing does any better:
Starting...
okay
...
okay
Time to complete: 6.24
Our time has been significantly reduced. We're at about 1/9th of our original time.
So what would happen if we used threading for this instead?
I'm sure you can guess -- it wouldn't be much faster than doing it synchronously. In fact, it might be slower because it still takes a little time and effort to spin up new threads. But don't take my word for it, here's what we get when we replace ProcessPoolExecutor()
with ThreadPoolExecutor()
:
Starting...
okay
...
okay
Time to complete: 53.83
As I mentioned earlier, threading allows your applications to focus on new tasks while others are waiting. In this case, we're never sitting idly by. Multiprocessing, on the other hand, spins up totally new services, usually on separate CPU cores, ready to do whatever you ask it completely in tandem with whatever else your script is doing. This is why the multiprocessing version taking roughly 1/9th of the time makes sense -- I have 8 cores in my CPU.
Now that we've talked about concurrency and parallelism in Python, we can finally set the terms straight. If you're having trouble distinguishing between the terms, you can safely and accurately think of our previous definitions of "parallelism" and "concurrency" as "parallel concurrency" and "non-parallel concurrency" respectively.
Real Python has a great article on concurrency vs parallelism.
Engineer Man has a good video comparison of threading vs multiprocessing.
Corey Schafer also has a good video on multiprocessing in the same spirit as his threading video.
If you only watch one video, watch this excellent talk by Raymond Hettinger. He does an amazing job explaining the differences between multiprocessing, threading, and asyncio.
What if I need to combine many I/O operations with heavy calculations?
We can do that too. Say you need to scrape 100 web pages for a specific piece of information, and then you need to save that piece of info in a file for later. We can separate the compute power across each of our computer's cores by making each process scrape a fraction of the pages.
For this script, let's install Beautiful Soup to help us easily scrape our pages: pip install beautifulsoup4
. This time we actually have quite a few imports. Here they are, and here's why we're using them:
import asyncio # Gives us async/await
import concurrent.futures # Allows creating new processes
import time
from math import floor # Helps divide up our requests evenly across our CPU cores
from multiprocessing import cpu_count # Returns our number of CPU cores
import aiofiles # For asynchronously performing file I/O operations
import aiohttp # For asynchronously making HTTP requests
from bs4 import BeautifulSoup # For easy webpage scraping
You can find the code for this example here.
First, we're going to create an async function that makes a request to Wikipedia to get back random pages. We'll scrape each page we get back for its title using BeautifulSoup
, and then we'll append it to a given file; we'll separate each title with a tab. The function will take two arguments:
async def get_and_scrape_pages(num_pages: int, output_file: str):
"""
Makes {{ num_pages }} requests to Wikipedia to receive {{ num_pages }} random
articles, then scrapes each page for its title and appends it to {{ output_file }},
separating each title with a tab: "\\t"
#### Arguments
---
num_pages: int -
Number of random Wikipedia pages to request and scrape
output_file: str -
File to append titles to
"""
async with \
aiohttp.ClientSession() as client, \
aiofiles.open(output_file, "a+", encoding="utf-8") as f:
for _ in range(num_pages):
async with client.get("https://en.wikipedia.org/wiki/Special:Random") as response:
if response.status > 399:
# I was getting a 429 Too Many Requests at a higher volume of requests
response.raise_for_status()
page = await response.text()
soup = BeautifulSoup(page, features="html.parser")
title = soup.find("h1").text
await f.write(title + "\t")
await f.write("\n")
Ambos estamos abriendo asíncronamente un aiohttp ClientSession
y nuestro archivo de salida. El modo, a+
significa agregar al archivo y crearlo si aún no existe. Codificar nuestras cadenas como utf-8 garantiza que no recibamos un error si nuestros títulos contienen caracteres internacionales. Si recibimos una respuesta de error, la generaremos en lugar de continuar (en grandes volúmenes de solicitudes recibía un 429 Demasiadas solicitudes). Obtenemos de forma asíncrona el texto de nuestra respuesta, luego analizamos el título y de forma asíncrona y lo agregamos a nuestro archivo. Después de agregar todos nuestros títulos, agregamos una nueva línea: "\n".
Nuestra próxima función es la función que comenzaremos con cada nuevo proceso para permitir ejecutarlo de forma asíncrona:
def start_scraping(num_pages: int, output_file: str, i: int):
""" Starts an async process for requesting and scraping Wikipedia pages """
print(f"Process {i} starting...")
asyncio.run(get_and_scrape_pages(num_pages, output_file))
print(f"Process {i} finished.")
Ahora nuestra función principal. Comencemos con algunas constantes (y nuestra declaración de función):
def main():
NUM_PAGES = 100 # Number of pages to scrape altogether
NUM_CORES = cpu_count() # Our number of CPU cores (including logical cores)
OUTPUT_FILE = "./wiki_titles.tsv" # File to append our scraped titles to
PAGES_PER_CORE = floor(NUM_PAGES / NUM_CORES)
PAGES_FOR_FINAL_CORE = PAGES_PER_CORE + NUM_PAGES % PAGES_PER_CORE # For our final core
Y ahora la lógica:
futures = []
with concurrent.futures.ProcessPoolExecutor(NUM_CORES) as executor:
for i in range(NUM_CORES - 1):
new_future = executor.submit(
start_scraping, # Function to perform
# v Arguments v
num_pages=PAGES_PER_CORE,
output_file=OUTPUT_FILE,
i=i
)
futures.append(new_future)
futures.append(
executor.submit(
start_scraping,
PAGES_FOR_FINAL_CORE, OUTPUT_FILE, NUM_CORES-1
)
)
concurrent.futures.wait(futures)
Creamos una matriz para almacenar nuestros futuros, luego creamos un ProcessPoolExecutor
, estableciendo su max_workers
igual a nuestra cantidad de núcleos. Iteramos sobre un rango igual a nuestro número de núcleos menos 1, ejecutando un nuevo proceso con nuestra start_scraping
función. Luego le agregamos nuestra lista de futuros. Nuestro núcleo final tendrá potencialmente trabajo adicional que hacer, ya que raspará una cantidad de páginas igual a cada uno de nuestros otros núcleos, pero también raspará una cantidad de páginas igual al resto que obtuvimos al dividir nuestro número total de páginas para raspar por nuestro número total de núcleos de CPU.
Asegúrate de ejecutar tu función principal:
if __name__ == "__main__":
start = time.time()
main()
print(f"Time to complete: {round(time.time() - start, 2)} seconds.")
Después de ejecutar el programa con mi CPU de 8 núcleos (junto con el código de evaluación comparativa):
Esta versión ( asyncio con multiprocesamiento ):
Time to complete: 5.65 seconds.
Time to complete: 8.87 seconds.
Time to complete: 47.92 seconds.
Time to complete: 88.86 seconds.
De hecho, estoy bastante sorprendido de ver que la mejora de asyncio con multiprocesamiento sobre solo multiprocesamiento no fue tan buena como pensé que sería.
async
/ await
y una sintaxis similar también existe en otros idiomas, y en algunos de esos idiomas, su implementación puede diferir drásticamente.
El primer lenguaje de programación (en 2007) que utilizó la async
sintaxis fue F# de Microsoft. Mientras que no suele await
esperar una llamada de función, utiliza una sintaxis específica como let!
y do!
junto con Async
funciones propietarias incluidas en el System
módulo.
Puede encontrar más información sobre la programación asíncrona en F# en los documentos de F# de Microsoft .
Luego, su equipo de C# se basó en este concepto, y ahí es donde nacieron las palabras clave async
/ await
con las que ahora estamos familiarizados:
using System;
// Allows the "Task" return type
using System.Threading.Tasks;
public class Program
{
// Declare an async function with "async"
private static async Task<string> ReturnHello()
{
return "hello world";
}
// Main can be async -- no problem
public static async Task Main()
{
// await an async string
string result = await ReturnHello();
// Print the string we got asynchronously
Console.WriteLine(result);
}
}
Nos aseguramos de que estemos using System.Threading.Tasks
ya que incluye el Task
tipo y, en general, el Task
tipo es necesario para que se espere una función asíncrona. Lo bueno de C# es que puede hacer que su función principal sea asíncrona simplemente declarándola con async
, y no tendrá ningún problema.
Si está interesado en obtener más información sobre
async
/await
en C#, los documentos de C# de Microsoft tienen una buena página al respecto.
Presentada por primera vez en ES6, la sintaxis async
/ await
es esencialmente una abstracción sobre las promesas de JavaScript (que son similares a los futuros de Python). Sin embargo, a diferencia de Python, siempre que no esté esperando, puede llamar a una función asíncrona normalmente sin una función específica como la de Python asyncio.start()
:
// Declare a function with async
async function returnHello(){
return "hello world";
}
async function printSomething(){
// await an async string
const result = await returnHello();
// print the string we got asynchronously
console.log(result);
}
// Run our async code
printSomething();
Consulte MDN para obtener más información sobre
async
/await
en JavaScript .
Rust ahora también permite el uso de la sintaxis async
/ await
y funciona de manera similar a Python, C# y JavaScript:
// Allows blocking synchronous code to run async code
use futures::executor::block_on;
// Declare an async function with "async"
async fn return_hello() -> String {
"hello world".to_string()
}
// Code that awaits must also be declared with "async"
async fn print_something(){
// await an async String
let result: String = return_hello().await;
// Print the string we got asynchronously
println!("{0}", result);
}
fn main() {
// Block the current synchronous execution to run our async code
block_on(print_something());
}
Para usar funciones asíncronas, primero debemos agregar futures = "0.3"
a nuestro Cargo.toml . Luego importamos la block_on
función con use futures::executor::block_on
-- block_on
es necesario para ejecutar nuestra función asíncrona desde nuestra main
función síncrona.
Puede encontrar más información sobre
async
/await
en Rust en los documentos de Rust.
async
En lugar de la sintaxis / tradicional await
inherente a todos los lenguajes anteriores que hemos cubierto, Go usa "goroutines" y "channels". Puede pensar en un canal como algo similar a un futuro de Python. En Go, generalmente envía un canal como argumento a una función y luego lo usa go
para ejecutar la función simultáneamente. Siempre que necesite asegurarse de que la función haya terminado de completarse, use la <-
sintaxis, que puede considerar como la await
sintaxis más común. Si su goroutine (la función que está ejecutando de forma asincrónica) tiene un valor de retorno, se puede obtener de esta manera.
package main
import "fmt"
// "chan" makes the return value a string channel instead of a string
func returnHello(result chan string){
// Gives our channel a value
result <- "hello world"
}
func main() {
// Creates a string channel
result := make(chan string)
// Starts execution of our goroutine
go returnHello(result)
// Awaits and prints our string
fmt.Println(<- result)
}
Ejecutarlo en el Go Playground
Para obtener más información sobre la simultaneidad en Go, consulte Introducción a la programación en Go de Caleb Doxsey.
De manera similar a Python, Ruby también tiene la limitación Global Interpreter Lock. Lo que no tiene es concurrencia integrada en el lenguaje. Sin embargo, existe una gema creada por la comunidad que permite la concurrencia en Ruby, y puede encontrar su fuente en GitHub .
Al igual que Ruby, Java no tiene la sintaxis async
/ await
incorporada, pero tiene capacidades de concurrencia usando el java.util.concurrent
módulo. Sin embargo, Electronic Arts escribió una biblioteca Async que permite su uso await
como método. No es exactamente lo mismo que Python/C#/JavaScript/Rust, pero vale la pena investigarlo si es un desarrollador de Java y está interesado en este tipo de funcionalidad.
Aunque C ++ tampoco tiene la sintaxis async
/ await
, tiene la capacidad de usar futuros para ejecutar código simultáneamente usando el futures
módulo:
#include <iostream>
#include <string>
// Necessary for futures
#include <future>
// No async declaration needed
std::string return_hello() {
return "hello world";
}
int main ()
{
// Declares a string future
std::future<std::string> fut = std::async(return_hello);
// Awaits the result of the future
std::string result = fut.get();
// Prints the string we got asynchronously
std::cout << result << '\n';
}
No es necesario declarar una función con ninguna palabra clave para indicar si puede y debe ejecutarse de forma asíncrona o no. En su lugar, declaras tu futuro inicial siempre que lo necesites std::future<{{ function return type }}>
y lo estableces igual a std::async()
, incluido el nombre de la función que deseas realizar de forma asíncrona junto con los argumentos necesarios, es decir, std::async(do_something, 1, 2, "string")
. Para esperar el valor del futuro, use la .get()
sintaxis en él.
Puede encontrar documentación para async en C++ en cplusplus.com.
Ya sea que esté trabajando con operaciones de archivo o de red asincrónicas o esté realizando numerosos cálculos complejos, hay algunas formas diferentes de maximizar la eficiencia de su código.
Si usa Python, puede usar asyncio
o threading
para aprovechar al máximo las operaciones de E/S o el multiprocessing
módulo para código con uso intensivo de CPU.
Recuerde también que el
concurrent.futures
módulo se puede usar en lugar dethreading
omultiprocessing
.
Si está utilizando otro lenguaje de programación, es probable que también haya una implementación de async
/ await
para él.
Fuente: https://testdriven.io
1660183260
O que são simultaneidade e paralelismo e como eles se aplicam ao Python?
Há muitas razões pelas quais seus aplicativos podem ser lentos. Às vezes, isso se deve ao design algorítmico ruim ou à escolha errada da estrutura de dados. Às vezes, no entanto, é devido a forças fora de nosso controle, como restrições de hardware ou peculiaridades da rede. É aí que a simultaneidade e o paralelismo se encaixam. Eles permitem que seus programas façam várias coisas ao mesmo tempo, seja ao mesmo tempo ou desperdiçando o mínimo de tempo possível esperando em tarefas ocupadas.
Não importa se você está lidando com recursos da Web externos, lendo e gravando em vários arquivos ou precisa usar uma função de cálculo intensivo várias vezes com parâmetros diferentes, este artigo deve ajudá-lo a maximizar a eficiência e a velocidade do seu código.
Primeiro, vamos nos aprofundar no que são simultaneidade e paralelismo e como eles se encaixam no reino do Python usando bibliotecas padrão como threading, multiprocessing e assíncrono. A última parte deste artigo irá comparar a implementação de async
/ do Python await
com como outras linguagens as implementaram.
Você pode encontrar todos os exemplos de código deste artigo no repositório concurrency-parallelism-and-asyncio no GitHub.
Para trabalhar com os exemplos deste artigo, você já deve saber como trabalhar com solicitações HTTP.
Ao final deste artigo, você deverá ser capaz de responder às seguintes perguntas:
O que é simultaneidade?
Uma definição eficaz para simultaneidade é "ser capaz de executar várias tarefas ao mesmo tempo". Isso é um pouco enganador, pois as tarefas podem ou não ser executadas exatamente ao mesmo tempo. Em vez disso, um processo pode iniciar e, quando estiver aguardando uma instrução específica para terminar, mudar para uma nova tarefa, apenas para voltar quando não estiver mais esperando. Quando uma tarefa é concluída, ela alterna novamente para uma tarefa inacabada até que todas tenham sido executadas. As tarefas começam de forma assíncrona, são executadas de forma assíncrona e terminam de forma assíncrona.
Se isso foi confuso para você, vamos pensar em uma analogia: digamos que você queira fazer um BLT . Primeiro, você vai querer jogar o bacon em uma panela em fogo médio-baixo. Enquanto o bacon cozinha, você pode pegar seus tomates e alface e começar a prepará-los (lavar e cortar). O tempo todo, você continua verificando e ocasionalmente virando seu bacon.
Neste ponto, você iniciou uma tarefa e, em seguida, iniciou e concluiu mais duas, enquanto ainda espera pela primeira.
Eventualmente você coloca seu pão em uma torradeira. Enquanto está torrando, você continua verificando seu bacon. À medida que as peças terminam, você as retira e as coloca em um prato. Depois que o pão estiver tostado, você aplica nele a pasta de sanduíche de sua escolha e, em seguida, pode começar a colocar camadas de tomate, alface e, quando terminar de cozinhar, o bacon. Somente depois que tudo estiver cozido, preparado e em camadas, você pode colocar o último pedaço de torrada no sanduíche, cortá-lo (opcional) e comê-lo.
Como exige que você execute várias tarefas ao mesmo tempo, fazer um BLT é inerentemente um processo simultâneo, mesmo que você não esteja dando atenção total a cada uma dessas tarefas de uma só vez. Para todos os efeitos, na próxima seção, nos referiremos a essa forma de simultaneidade apenas como "simultaneidade". Vamos diferenciá-lo mais adiante neste artigo.
Por esse motivo, a simultaneidade é ótima para processos com uso intenso de E/S -- tarefas que envolvem aguardar solicitações da Web ou operações de leitura/gravação de arquivos.
Em Python, existem algumas maneiras diferentes de alcançar a simultaneidade. A primeira que veremos é a biblioteca de encadeamento.
Para nossos exemplos nesta seção, vamos construir um pequeno programa Python que pega um gênero de música aleatório da API Genrenator da Binary Jazz cinco vezes, imprime o gênero na tela e coloca cada um em seu próprio arquivo.
Para trabalhar com encadeamento em Python, a única importação necessária é threading
, mas para este exemplo, também importei urllib
para trabalhar com solicitações HTTP, time
para determinar quanto tempo as funções levam para serem concluídas e json
para converter facilmente os dados json retornados da API do gerador.
Você pode encontrar o código para este exemplo aqui .
Vamos começar com uma função simples:
def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
req = Request("https://binaryjazz.us/wp-json/genrenator/v1/genre/", headers={"User-Agent": "Mozilla/5.0"})
genre = json.load(urlopen(req))
with open(file_name, "w") as new_file:
print(f"Writing '{genre}' to '{file_name}'...")
new_file.write(genre)
Examinando o código acima, estamos fazendo uma solicitação à API do Genrenator, carregando sua resposta JSON (um gênero musical aleatório), imprimindo-a e gravando-a em um arquivo.
Sem o cabeçalho "User-Agent", você receberá um 304.
O que realmente nos interessa é a próxima seção, onde o encadeamento real acontece:
threads = []
for i in range(5):
thread = threading.Thread(
target=write_genre,
args=[f"./threading/new_file{i}.txt"]
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Começamos com uma lista. Em seguida, continuamos a iterar cinco vezes, criando um novo encadeamento a cada vez. Em seguida, iniciamos cada thread, anexamos à nossa lista de "threads" e, em seguida, iteramos nossa lista uma última vez para ingressar em cada thread.
Explicação: Criar threads em Python é fácil.
Para criar um novo tópico, use threading.Thread()
. Você pode passar para ele o kwarg (argumento de palavra-chave) target
com um valor de qualquer função que você gostaria de executar nesse thread. Mas passe apenas o nome da função, não seu valor (ou seja, para nossos propósitos, write_genre
e não write_genre()
). Para passar argumentos, passe "kwargs" (que recebe um dict de seus kwargs) ou "args" (que recebe um iterável contendo seus argumentos -- neste caso, uma lista).
No entanto, criar um thread não é o mesmo que iniciar um thread. Para iniciar seu tópico, use {the name of your thread}.start()
. Iniciar um thread significa "iniciar sua execução".
Por fim, quando juntamos threads com thread.join()
, tudo o que estamos fazendo é garantir que o thread tenha terminado antes de continuar com nosso código.
Mas o que exatamente é um fio?
Um thread é uma maneira de permitir que seu computador divida um único processo/programa em muitas partes leves que são executadas em paralelo. Um tanto confuso, a implementação padrão de encadeamento do Python limita os encadeamentos a apenas serem capazes de executar um de cada vez devido a algo chamado Global Interpreter Lock (GIL). O GIL é necessário porque o gerenciamento de memória do CPython (implementação padrão do Python) não é thread-safe. Devido a essa limitação, o encadeamento em Python é simultâneo, mas não paralelo. Para contornar isso, o Python tem um multiprocessing
módulo separado não limitado pelo GIL que ativa processos separados, permitindo a execução paralela do seu código. O uso do multiprocessing
módulo é quase idêntico ao uso do threading
módulo.
Mais informações sobre a segurança de thread e GIL do Python podem ser encontradas em Real Python e nos documentos oficiais do Python .
Daremos uma olhada mais detalhada no multiprocessamento em Python em breve.
Antes de mostrarmos a potencial melhoria de velocidade em relação ao código não encadeado, tomei a liberdade de também criar uma versão não encadeada do mesmo programa (novamente, disponível no GitHub ). Em vez de criar um novo thread e unir cada um, ele chama write_genre
um loop for que itera cinco vezes.
Para comparar benchmarks de velocidade, também importei a time
biblioteca para cronometrar a execução de nossos scripts:
Starting...
Writing "binary indoremix" to "./sync/new_file0.txt"...
Writing "slavic aggro polka fusion" to "./sync/new_file1.txt"...
Writing "israeli new wave" to "./sync/new_file2.txt"...
Writing "byzantine motown" to "./sync/new_file3.txt"...
Writing "dutch hate industrialtune" to "./sync/new_file4.txt"...
Time to complete synchronous read/writes: 1.42 seconds
Ao executar o script, vemos que meu computador leva cerca de 1,49 segundo (junto com gêneros de música clássica como "dutch hate industrialtune"). Não é tão ruim.
Agora vamos executar a versão que usa threading:
Starting...
Writing "college k-dubstep" to "./threading/new_file2.txt"...
Writing "swiss dirt" to "./threading/new_file0.txt"...
Writing "bop idol alternative" to "./threading/new_file4.txt"...
Writing "ethertrio" to "./threading/new_file1.txt"...
Writing "beach aust shanty français" to "./threading/new_file3.txt"...
Time to complete threading read/writes: 0.77 seconds
A primeira coisa que pode se destacar para você é que as funções não estão sendo concluídas na ordem: 2 - 0 - 4 - 1 - 3
Isso ocorre devido à natureza assíncrona do encadeamento: enquanto uma função espera, outra começa e assim por diante. Como podemos continuar executando tarefas enquanto esperamos que os outros terminem (seja devido a operações de rede ou de E/S de arquivo), você também deve ter notado que reduzimos nosso tempo aproximadamente pela metade: 0,77 segundos. Embora isso possa não parecer muito agora, é fácil imaginar o caso real de construir um aplicativo da Web que precisa gravar muito mais dados em um arquivo ou interagir com serviços da Web muito mais complexos.
Então, se o encadeamento é tão bom, por que não terminamos o artigo aqui?
Porque existem maneiras ainda melhores de executar tarefas simultaneamente.
Vamos dar uma olhada em um exemplo usando assíncrono. Para este método, vamos instalar o aiohttp usando pip
. Isso nos permitirá fazer solicitações sem bloqueio e receber respostas usando a sintaxe async
/ await
que será introduzida em breve. Ele também tem o benefício extra de uma função que converte uma resposta JSON sem precisar importar a json
biblioteca. Também instalaremos e importaremos aiofiles , que permite operações de arquivo sem bloqueio. Além de aiohttp
e aiofiles
, import asyncio
, que vem com a biblioteca padrão do Python.
"Sem bloqueio" significa que um programa permitirá que outros threads continuem em execução enquanto aguardam. Isso se opõe ao código de "bloqueio", que interrompe completamente a execução do seu programa. As operações de E/S normais e síncronas sofrem com essa limitação.
Você pode encontrar o código para este exemplo aqui .
Assim que tivermos nossas importações em vigor, vamos dar uma olhada na versão assíncrona da write_genre
função do nosso exemplo assíncrono:
async def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
async with aiohttp.ClientSession() as session:
async with session.get("https://binaryjazz.us/wp-json/genrenator/v1/genre/") as response:
genre = await response.json()
async with aiofiles.open(file_name, "w") as new_file:
print(f'Writing "{genre}" to "{file_name}"...')
await new_file.write(genre)
Para aqueles que não estão familiarizados com a sintaxe async
/ await
que pode ser encontrada em muitas outras linguagens modernas, async
declara que uma função, for
loop ou with
instrução deve ser usada de forma assíncrona. Para chamar uma função assíncrona, você deve usar a await
palavra-chave de outra função assíncrona ou chamar create_task()
diretamente do loop de eventos, que pode ser obtido de asyncio.get_event_loop()
-- ou seja, loop = asyncio.get_event_loop()
.
Adicionalmente:
async with
permite aguardar respostas assíncronas e operações de arquivo.async for
(não usado aqui) itera em um fluxo assíncrono .Os loops de eventos são construções inerentes à programação assíncrona que permitem realizar tarefas de forma assíncrona. Enquanto você está lendo este artigo, posso assumir com segurança que você provavelmente não está muito familiarizado com o conceito. No entanto, mesmo que você nunca tenha escrito um aplicativo assíncrono, você tem experiência com loops de eventos toda vez que usa um computador. Esteja seu computador ouvindo a entrada do teclado, você jogando jogos multiplayer online ou navegando no Reddit enquanto os arquivos são copiados em segundo plano, um loop de eventos é a força motriz que mantém tudo funcionando de forma suave e eficiente. Em sua essência mais pura, um loop de eventos é um processo que espera por gatilhos e, em seguida, executa ações específicas (programadas) quando esses gatilhos são atendidos. Eles geralmente retornam uma "promessa" (sintaxe JavaScript) ou "futuro" (Sintaxe Python) de algum tipo para denotar que uma tarefa foi adicionada. Depois que a tarefa é concluída, a promessa ou o futuro retorna um valor passado da função chamada (supondo que a função retorne um valor).
A ideia de executar uma função em resposta a outra função é chamada de "retorno de chamada".
Para outra abordagem sobre retornos de chamada e eventos, aqui está uma ótima resposta no Stack Overflow .
Aqui está um passo a passo da nossa função:
Estamos usando async with
para abrir nossa sessão do cliente de forma assíncrona. A aiohttp.ClientSession()
classe é o que nos permite fazer requisições HTTP e permanecermos conectados a uma fonte sem bloquear a execução do nosso código. Em seguida, fazemos uma solicitação assíncrona à API do Genrenator e aguardamos a resposta JSON (um gênero musical aleatório). Na próxima linha, usamos async with
novamente com a aiofiles
biblioteca para abrir de forma assíncrona um novo arquivo para gravar nosso novo gênero. Imprimimos o gênero e depois gravamos no arquivo.
Ao contrário dos scripts Python regulares, a programação com assíncrono praticamente impõe* o uso de algum tipo de função "principal".
*A menos que você esteja usando a sintaxe obsoleta "yield" com o decorador @asyncio.coroutine, que será removido no Python 3.10 .
Isso ocorre porque você precisa usar a palavra-chave "async" para usar a sintaxe "await", e a sintaxe "await" é a única maneira de realmente executar outras funções assíncronas.
Aqui está nossa função principal:
async def main():
tasks = []
for i in range(5):
tasks.append(write_genre(f"./async/new_file{i}.txt"))
await asyncio.gather(*tasks)
Como você pode ver, nós o declaramos com "async". Em seguida, criamos uma lista vazia chamada "tasks" para abrigar nossas tarefas assíncronas (chamadas para Genrenator e nosso arquivo I/O). Anexamos nossas tarefas à nossa lista, mas elas ainda não foram executadas. As chamadas não são feitas até que as agendemos com await asyncio.gather(*tasks)
. Isso executa todas as tarefas em nossa lista e espera que elas terminem antes de continuar com o restante do nosso programa. Por fim, usamos asyncio.run(main())
para executar nossa função "main". A .run()
função é o ponto de entrada para nosso programa e geralmente deve ser chamada apenas uma vez por processo .
Para quem não conhece, a
*
frente das tarefas é chamada de "descompactação de argumentos". Assim como parece, ele descompacta nossa lista em uma série de argumentos para nossa função. Nossa função éasyncio.gather()
neste caso.
E isso é tudo que precisamos fazer. Agora, executando nosso programa (cuja fonte inclui a mesma funcionalidade de tempo dos exemplos síncrono e de encadeamento)...
Writing "albuquerque fiddlehaus" to "./async/new_file1.txt"...
Writing "euroreggaebop" to "./async/new_file2.txt"...
Writing "shoedisco" to "./async/new_file0.txt"...
Writing "russiagaze" to "./async/new_file4.txt"...
Writing "alternative xylophone" to "./async/new_file3.txt"...
Time to complete asyncio read/writes: 0.71 seconds
...vemos que é ainda mais rápido. E, em geral, o método assíncrono sempre será um pouco mais rápido que o método de encadeamento. Isso ocorre porque quando usamos a sintaxe "await", basicamente dizemos ao nosso programa "espere, já volto", mas nosso programa registra quanto tempo levamos para terminar o que estamos fazendo. Quando terminarmos, nosso programa saberá e voltará a funcionar assim que possível. Threading em Python permite assincronia, mas nosso programa teoricamente poderia pular diferentes threads que podem ainda não estar prontos, perdendo tempo se houver threads prontos para continuar rodando.
Então, quando devo usar o encadeamento e quando devo usar o assíncrono?
Quando você estiver escrevendo um novo código, use assíncrono. Se você precisar fazer interface com bibliotecas mais antigas ou que não suportam assíncrono, talvez seja melhor usar o threading.
Acontece que testar funções assíncronas com pytest é tão fácil quanto testar funções síncronas. Basta instalar o pacote pytest-asyncio com pip
, marcar seus testes com a palavra- async
chave e aplicar um decorador que pytest
informe que é assíncrono: @pytest.mark.asyncio
. Vejamos um exemplo.
Primeiro, vamos escrever uma função assíncrona arbitrária em um arquivo chamado hello_asyncio.py :
import asyncio
async def say_hello(name: str):
""" Sleeps for two seconds, then prints 'Hello, {{ name }}!' """
try:
if type(name) != str:
raise TypeError("'name' must be a string")
if name == "":
raise ValueError("'name' cannot be empty")
except (TypeError, ValueError):
raise
print("Sleeping...")
await asyncio.sleep(2)
print(f"Hello, {name}!")
A função recebe um único argumento de string: name
. Ao garantir que name
é uma string com um comprimento maior que um, nossa função dorme de forma assíncrona por dois segundos e depois imprime "Hello, {name}!"
no console.
A diferença entre
asyncio.sleep()
etime.sleep()
é queasyncio.sleep()
não é bloqueante.
Agora vamos testá-lo com pytest. No mesmo diretório que hello_asyncio.py, crie um arquivo chamado test_hello_asyncio.py e abra-o em seu editor de texto favorito.
Vamos começar com nossas importações:
import pytest # Note: pytest-asyncio does not require a separate import
from hello_asyncio import say_hello
Em seguida, criaremos um teste com entrada adequada:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
@pytest.mark.asyncio
async def test_say_hello(name):
await say_hello(name)
Coisas a observar:
@pytest.mark.asyncio
decorador permite que o pytest funcione de forma assíncronaasync
sintaxeawait
executando nossa função assíncrona como faríamos se a estivéssemos executando fora de um testeAgora vamos executar nosso teste com a -v
opção verbose:
pytest -v
...
collected 3 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 33%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 66%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [100%]
Parece bom. Em seguida, escreveremos alguns testes com entrada ruim. De volta ao test_hello_asyncio.py , vamos criar uma classe chamada TestSayHelloThrowsExceptions
:
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
@pytest.mark.asyncio
async def test_say_hello_value_error(self, name):
with pytest.raises(ValueError):
await say_hello(name)
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
@pytest.mark.asyncio
async def test_say_hello_type_error(self, name):
with pytest.raises(TypeError):
await say_hello(name)
Novamente, decoramos nossos testes com @pytest.mark.asyncio
, marcamos nossos testes com a async
sintaxe e chamamos nossa função com await
.
Execute os testes novamente:
pytest -v
...
collected 7 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 14%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 28%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [ 42%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_value_error[] PASSED [ 57%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[19] PASSED [ 71%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name1] PASSED [ 85%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name2] PASSED [100%]
Alternativamente ao pytest-asyncio, você pode criar um acessório pytest que produz um loop de eventos assíncrono:
import asyncio
import pytest
from hello_asyncio import say_hello
@pytest.fixture
def event_loop():
loop = asyncio.get_event_loop()
yield loop
Então, em vez de usar a sintaxe async
/ await
, você cria seus testes como faria com testes normais e síncronos:
@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
def test_say_hello(event_loop, name):
event_loop.run_until_complete(say_hello(name))
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
def test_say_hello_value_error(self, event_loop, name):
with pytest.raises(ValueError):
event_loop.run_until_complete(say_hello(name))
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
def test_say_hello_type_error(self, event_loop, name):
with pytest.raises(TypeError):
event_loop.run_until_complete(say_hello(name))
Se você estiver interessado, aqui está um tutorial mais avançado sobre testes assíncronos .
Se você quiser saber mais sobre o que distingue a implementação de threading versus assíncrona do Python, aqui está um ótimo artigo do Medium .
Para exemplos e explicações ainda melhores de encadeamento em Python, aqui está um vídeo de Corey Schafer que é mais aprofundado, incluindo o uso da concurrent.futures
biblioteca.
Por fim, para um mergulho profundo no próprio assíncrono, aqui está um artigo do Real Python completamente dedicado a ele.
Bônus : mais uma biblioteca na qual você pode estar interessado é chamada Unsync , especialmente se você deseja converter facilmente seu código síncrono atual em código assíncrono. Para usá-lo, você instala a biblioteca com pip, importa com from unsync import unsync
, depois decora qualquer função atualmente síncrona @unsync
para torná-la assíncrona. Para aguardá-lo e obter seu valor de retorno (o que você pode fazer em qualquer lugar - não precisa estar em uma função assíncrona/dessincronizada), basta chamar .result()
após a chamada da função.
O que é paralelismo?
O paralelismo está muito relacionado à simultaneidade. Na verdade, o paralelismo é um subconjunto de simultaneidade: enquanto um processo simultâneo executa várias tarefas ao mesmo tempo, independentemente de estarem desviando a atenção total ou não, um processo paralelo está executando fisicamente várias tarefas ao mesmo tempo. Um bom exemplo seria dirigir, ouvir música e comer o BLT que fizemos na última seção ao mesmo tempo.
Como eles não exigem muito esforço intenso, você pode fazê-los todos de uma vez sem ter que esperar por nada ou desviar sua atenção.
Agora vamos dar uma olhada em como implementar isso em Python. Poderíamos usar a multiprocessing
biblioteca, mas vamos usar a concurrent.futures
biblioteca - elimina a necessidade de gerenciar o número de processos manualmente. Como o principal benefício do multiprocessamento acontece quando você executa várias tarefas com muita CPU, vamos calcular os quadrados de 1 milhão (1000000) a 1 milhão e 16 (1000016).
Você pode encontrar o código para este exemplo aqui .
A única importação que precisamos é concurrent.futures
:
import concurrent.futures
import time
if __name__ == "__main__":
pow_list = [i for i in range(1000000, 1000016)]
print("Starting...")
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
futures = [executor.submit(pow, i, i) for i in pow_list]
for f in concurrent.futures.as_completed(futures):
print("okay")
end = time.time()
print(f"Time to complete: {round(end - start, 2)}")
Como estou desenvolvendo em uma máquina Windows, estou usando o
if __name__ == "main"
. Isso é necessário porque o Windows não possui afork
chamada de sistema inerente aos sistemas Unix . Como o Windows não possui esse recurso, ele lança um novo interpretador a cada processo que tenta importar o módulo principal. Se o módulo principal não existir, ele executará novamente todo o seu programa, causando um caos recursivo.
Então, dando uma olhada em nossa função principal, usamos uma compreensão de lista para criar uma lista de 1 milhão a 1 milhão e 16, abrimos um ProcessPoolExecutor com concurrent.futures e usamos compreensão de lista e ProcessPoolExecutor().submit()
começamos a executar nossos processos e jogá-los em uma lista chamada "futuros".
Também poderíamos usar
ThreadPoolExecutor()
se quiséssemos usar threads -- concurrent.futures é versátil.
E é aí que entra a assincronia: a lista de "resultados" não contém os resultados da execução de nossas funções. Em vez disso, ele contém "futuros" que são semelhantes à ideia JavaScript de "promessas". Para permitir que nosso programa continue em execução, recuperamos esses futuros que representam um espaço reservado para um valor. Se tentarmos imprimir o futuro, dependendo se ele terminou de ser executado ou não, retornaremos um estado de "pendente" ou "concluído". Uma vez terminado, podemos obter o valor de retorno (supondo que haja um) usando var.result()
. Nesse caso, nosso var será "resultado".
Em seguida, iteramos nossa lista de futuros, mas em vez de imprimir nossos valores, estamos simplesmente imprimindo "ok". Isso é apenas por causa de quão massivos os cálculos resultantes se tornam.
Assim como antes, criei um script de comparação que faz isso de forma síncrona. E, assim como antes, você pode encontrá-lo no GitHub .
Executando nosso programa de controle, que também inclui funcionalidade para cronometrar nosso programa, obtemos:
Starting...
okay
...
okay
Time to complete: 54.64
Uau. 54,64 segundos é bastante tempo. Vamos ver se nossa versão com multiprocessamento se sai melhor:
Starting...
okay
...
okay
Time to complete: 6.24
Nosso tempo foi significativamente reduzido. Estamos em cerca de 1/9 do nosso tempo original.
Então, o que aconteceria se usássemos threading para isso?
Tenho certeza que você pode adivinhar - não seria muito mais rápido do que fazê-lo de forma síncrona. Na verdade, pode ser mais lento porque ainda leva um pouco de tempo e esforço para criar novos tópicos. Mas não acredite na minha palavra, aqui está o que obtemos quando substituímos ProcessPoolExecutor()
por ThreadPoolExecutor()
:
Starting...
okay
...
okay
Time to complete: 53.83
Como mencionei anteriormente, o encadeamento permite que seus aplicativos se concentrem em novas tarefas enquanto outros estão esperando. Neste caso, nunca estamos sentados de braços cruzados. O multiprocessamento, por outro lado, gera serviços totalmente novos, geralmente em núcleos de CPU separados, prontos para fazer o que você pedir, completamente em conjunto com o que mais seu script estiver fazendo. É por isso que a versão de multiprocessamento que ocupa cerca de 1/9 do tempo faz sentido - eu tenho 8 núcleos na minha CPU.
Agora que falamos sobre simultaneidade e paralelismo em Python, podemos finalmente esclarecer os termos. Se você está tendo problemas para distinguir entre os termos, você pode pensar com segurança e precisão em nossas definições anteriores de "paralelismo" e "simultaneidade" como "simultaneidade paralela" e "simultaneidade não paralela", respectivamente.
Real Python tem um ótimo artigo sobre simultaneidade vs paralelismo .
Engineer Man tem uma boa comparação de vídeo de threading vs multiprocessamento .
Corey Schafer também tem um bom vídeo sobre multiprocessamento no mesmo espírito de seu vídeo de threading.
Se você assistir apenas a um vídeo, assista a esta excelente palestra de Raymond Hettinger . Ele faz um trabalho incrível explicando as diferenças entre multiprocessamento, threading e assíncrono.
E se eu precisar combinar muitas operações de E/S com cálculos pesados?
Podemos fazer isso também. Digamos que você precise extrair 100 páginas da Web para obter uma informação específica e, em seguida, salve essa informação em um arquivo para mais tarde. Podemos separar o poder de computação em cada um dos núcleos de nosso computador, fazendo com que cada processo raspe uma fração das páginas.
Para este script, vamos instalar o Beautiful Soup para nos ajudar a raspar facilmente nossas páginas: pip install beautifulsoup4
. Desta vez, temos realmente algumas importações. Aqui estão eles, e é por isso que os estamos usando:
import asyncio # Gives us async/await
import concurrent.futures # Allows creating new processes
import time
from math import floor # Helps divide up our requests evenly across our CPU cores
from multiprocessing import cpu_count # Returns our number of CPU cores
import aiofiles # For asynchronously performing file I/O operations
import aiohttp # For asynchronously making HTTP requests
from bs4 import BeautifulSoup # For easy webpage scraping
Você pode encontrar o código para este exemplo aqui .
Primeiro, vamos criar uma função assíncrona que faz uma solicitação à Wikipedia para recuperar páginas aleatórias. Vamos raspar cada página que obtivermos para seu título usando BeautifulSoup
, e então vamos anexá-la a um determinado arquivo; vamos separar cada título com uma tabulação. A função terá dois argumentos:
async def get_and_scrape_pages(num_pages: int, output_file: str):
"""
Makes {{ num_pages }} requests to Wikipedia to receive {{ num_pages }} random
articles, then scrapes each page for its title and appends it to {{ output_file }},
separating each title with a tab: "\\t"
#### Arguments
---
num_pages: int -
Number of random Wikipedia pages to request and scrape
output_file: str -
File to append titles to
"""
async with \
aiohttp.ClientSession() as client, \
aiofiles.open(output_file, "a+", encoding="utf-8") as f:
for _ in range(num_pages):
async with client.get("https://en.wikipedia.org/wiki/Special:Random") as response:
if response.status > 399:
# I was getting a 429 Too Many Requests at a higher volume of requests
response.raise_for_status()
page = await response.text()
soup = BeautifulSoup(page, features="html.parser")
title = soup.find("h1").text
await f.write(title + "\t")
await f.write("\n")
Ambos estamos abrindo de forma assíncrona um aiohttp ClientSession
e nosso arquivo de saída. O modo, a+
, significa anexar ao arquivo e criá-lo se ele ainda não existir. Codificar nossas strings como utf-8 garante que não receberemos um erro se nossos títulos contiverem caracteres internacionais. Se recebermos uma resposta de erro, vamos aumentá-la em vez de continuar (em grandes volumes de solicitações, eu estava recebendo 429 Too Many Requests). Obtemos de forma assíncrona o texto de nossa resposta, analisamos o título e o anexamos de forma assíncrona ao nosso arquivo. Depois de anexarmos todos os nossos títulos, anexamos uma nova linha: "\n".
Nossa próxima função é a função que iniciaremos com cada novo processo para permitir executá-lo de forma assíncrona:
def start_scraping(num_pages: int, output_file: str, i: int):
""" Starts an async process for requesting and scraping Wikipedia pages """
print(f"Process {i} starting...")
asyncio.run(get_and_scrape_pages(num_pages, output_file))
print(f"Process {i} finished.")
Agora para a nossa função principal. Vamos começar com algumas constantes (e nossa declaração de função):
def main():
NUM_PAGES = 100 # Number of pages to scrape altogether
NUM_CORES = cpu_count() # Our number of CPU cores (including logical cores)
OUTPUT_FILE = "./wiki_titles.tsv" # File to append our scraped titles to
PAGES_PER_CORE = floor(NUM_PAGES / NUM_CORES)
PAGES_FOR_FINAL_CORE = PAGES_PER_CORE + NUM_PAGES % PAGES_PER_CORE # For our final core
E agora a lógica:
futures = []
with concurrent.futures.ProcessPoolExecutor(NUM_CORES) as executor:
for i in range(NUM_CORES - 1):
new_future = executor.submit(
start_scraping, # Function to perform
# v Arguments v
num_pages=PAGES_PER_CORE,
output_file=OUTPUT_FILE,
i=i
)
futures.append(new_future)
futures.append(
executor.submit(
start_scraping,
PAGES_FOR_FINAL_CORE, OUTPUT_FILE, NUM_CORES-1
)
)
concurrent.futures.wait(futures)
Criamos um array para armazenar nossos futuros, depois criamos um ProcessPoolExecutor
, definindo seu max_workers
igual ao nosso número de núcleos. Nós iteramos em um intervalo igual ao nosso número de núcleos menos 1, executando um novo processo com nossa start_scraping
função. Em seguida, anexamos nossa lista de futuros. Nosso núcleo final terá potencialmente trabalho extra a fazer, pois irá extrair um número de páginas igual a cada um de nossos outros núcleos, mas também irá extrair um número de páginas igual ao restante que obtivemos ao dividir nosso número total de páginas a serem coletadas pelo nosso número total de núcleos de CPU.
Certifique-se de realmente executar sua função principal:
if __name__ == "__main__":
start = time.time()
main()
print(f"Time to complete: {round(time.time() - start, 2)} seconds.")
Depois de executar o programa com minha CPU de 8 núcleos (junto com o código de benchmark):
Esta versão ( assíncrona com multiprocessamento ):
Time to complete: 5.65 seconds.
Time to complete: 8.87 seconds.
Time to complete: 47.92 seconds.
Time to complete: 88.86 seconds.
Na verdade, estou bastante surpreso ao ver que a melhoria do assíncrono com multiprocessamento sobre apenas multiprocessamento não foi tão grande quanto eu pensei que seria.
async
/ await
e sintaxe semelhante também existem em outras linguagens e, em algumas dessas linguagens, sua implementação pode diferir drasticamente.
A primeira linguagem de programação (em 2007) a usar a async
sintaxe foi o F# da Microsoft. Considerando que ele não usa exatamente await
para esperar em uma chamada de função, ele usa uma sintaxe específica como let!
e do!
junto com Async
funções proprietárias incluídas no System
módulo.
Você pode encontrar mais informações sobre programação assíncrona em F# em F# docs da Microsoft .
A equipe de C# deles construiu esse conceito, e é aí que as palavras-chave async
/ com as await
quais estamos familiarizados nasceram:
using System;
// Allows the "Task" return type
using System.Threading.Tasks;
public class Program
{
// Declare an async function with "async"
private static async Task<string> ReturnHello()
{
return "hello world";
}
// Main can be async -- no problem
public static async Task Main()
{
// await an async string
string result = await ReturnHello();
// Print the string we got asynchronously
Console.WriteLine(result);
}
}
Garantimos que estamos using System.Threading.Tasks
como inclui o Task
tipo e, em geral, o Task
tipo é necessário para que uma função assíncrona seja aguardada. O legal do C# é que você pode tornar sua função principal assíncrona apenas declarando-a com async
, e você não terá problemas.
Se você estiver interessado em aprender mais sobre
async
/await
em C#, os documentos C# da Microsoft têm uma boa página.
Introduzido pela primeira vez no ES6, a sintaxe async
/ await
é essencialmente uma abstração sobre as promessas do JavaScript (que são semelhantes aos futuros do Python). Ao contrário do Python, no entanto, desde que você não esteja aguardando, você pode chamar uma função assíncrona normalmente sem uma função específica como a do Python asyncio.start()
:
// Declare a function with async
async function returnHello(){
return "hello world";
}
async function printSomething(){
// await an async string
const result = await returnHello();
// print the string we got asynchronously
console.log(result);
}
// Run our async code
printSomething();
Consulte MDN para obter mais informações sobre
async
/await
em JavaScript .
Rust agora também permite o uso da sintaxe async
/ await
e funciona de maneira semelhante ao Python, C# e JavaScript:
// Allows blocking synchronous code to run async code
use futures::executor::block_on;
// Declare an async function with "async"
async fn return_hello() -> String {
"hello world".to_string()
}
// Code that awaits must also be declared with "async"
async fn print_something(){
// await an async String
let result: String = return_hello().await;
// Print the string we got asynchronously
println!("{0}", result);
}
fn main() {
// Block the current synchronous execution to run our async code
block_on(print_something());
}
Para usar funções assíncronas, devemos primeiro adicionar futures = "0.3"
ao nosso Cargo.toml . Em seguida, importamos a block_on
função com use futures::executor::block_on
-- block_on
é necessário para executar nossa função assíncrona de nossa main
função síncrona.
Você pode encontrar mais informações sobre
async
/await
em Rust nos documentos do Rust.
Em vez da sintaxe async
/ tradicional await
inerente a todas as linguagens anteriores que abordamos, o Go usa "goroutines" e "channels". Você pode pensar em um canal como sendo semelhante a um futuro Python. Em Go, você geralmente envia um canal como um argumento para uma função e, em seguida, usa go
para executar a função simultaneamente. Sempre que precisar garantir que a função foi concluída, use a <-
sintaxe, que pode ser considerada a await
sintaxe mais comum. Se sua goroutine (a função que você está executando de forma assíncrona) tiver um valor de retorno, ela poderá ser capturada dessa maneira.
package main
import "fmt"
// "chan" makes the return value a string channel instead of a string
func returnHello(result chan string){
// Gives our channel a value
result <- "hello world"
}
func main() {
// Creates a string channel
result := make(chan string)
// Starts execution of our goroutine
go returnHello(result)
// Awaits and prints our string
fmt.Println(<- result)
}
Para obter mais informações sobre simultaneidade em Go, consulte An Introduction to Programming in Go por Caleb Doxsey.
Da mesma forma que o Python, o Ruby também possui a limitação Global Interpreter Lock. O que não tem é simultaneidade embutida na linguagem. No entanto, existe uma gem criada pela comunidade que permite simultaneidade em Ruby, e você pode encontrar sua fonte no GitHub .
Como Ruby, Java não possui a sintaxe async
/ await
incorporada, mas possui recursos de simultaneidade usando o java.util.concurrent
módulo. No entanto, a Electronic Arts escreveu uma biblioteca Async que permite o uso await
como método. Não é exatamente o mesmo que Python/C#/JavaScript/Rust, mas vale a pena dar uma olhada se você for um desenvolvedor Java e estiver interessado nesse tipo de funcionalidade.
Embora o C++ também não tenha a sintaxe async
/ await
, ele tem a capacidade de usar futuros para executar código simultaneamente usando o futures
módulo:
#include <iostream>
#include <string>
// Necessary for futures
#include <future>
// No async declaration needed
std::string return_hello() {
return "hello world";
}
int main ()
{
// Declares a string future
std::future<std::string> fut = std::async(return_hello);
// Awaits the result of the future
std::string result = fut.get();
// Prints the string we got asynchronously
std::cout << result << '\n';
}
Não há necessidade de declarar uma função com qualquer palavra-chave para indicar se ela pode ou não ser executada de forma assíncrona. Em vez disso, você declara seu futuro inicial sempre que precisar std::future<{{ function return type }}>
e o define como std::async()
, incluindo o nome da função que deseja executar de forma assíncrona junto com quaisquer argumentos necessários - ou seja, std::async(do_something, 1, 2, "string")
. Para aguardar o valor do futuro, use a .get()
sintaxe nele.
Você pode encontrar documentação para async em C++ em cplusplus.com.
Esteja você trabalhando com rede assíncrona ou operações de arquivo ou executando vários cálculos complexos, existem algumas maneiras diferentes de maximizar a eficiência do seu código.
Se estiver usando Python, você pode usar asyncio
ou threading
para aproveitar ao máximo as operações de E/S ou o multiprocessing
módulo para código com uso intensivo de CPU.
Lembre-se também de que o
concurrent.futures
módulo pode ser usado no lugar dethreading
oumultiprocessing
.
Se você estiver usando outra linguagem de programação, é provável que haja uma implementação de async
/ await
para ela também.
Fonte: https://testdrive.io
1659899640
This tutorial looks at how to speed up CPU-bound and IO-bound operations with multiprocessing, threading, and AsyncIO.
Source: https://testdriven.io
1659892380
Hướng dẫn này xem xét cách tăng tốc các hoạt động liên kết CPU và IO với đa xử lý, phân luồng và AsyncIO.
Đồng thời và song song là những thuật ngữ tương tự nhau, nhưng chúng không giống nhau.
Đồng thời là khả năng chạy nhiều tác vụ trên CPU cùng một lúc. Các tác vụ có thể bắt đầu, chạy và hoàn thành trong các khoảng thời gian trùng lặp. Trong trường hợp của một CPU, nhiều tác vụ được chạy với sự trợ giúp của chuyển đổi ngữ cảnh , nơi trạng thái của một tiến trình được lưu trữ để nó có thể được gọi và thực thi sau này.
Song song, trong khi đó, là khả năng chạy nhiều tác vụ cùng lúc trên nhiều lõi CPU.
Mặc dù chúng có thể tăng tốc độ ứng dụng của bạn, nhưng không nên sử dụng đồng thời và song song ở mọi nơi. Trường hợp sử dụng phụ thuộc vào việc tác vụ bị ràng buộc CPU hay IO.
Các tác vụ bị giới hạn bởi CPU là các tác vụ bị ràng buộc bởi CPU. Ví dụ, các phép tính toán học bị ràng buộc bởi CPU vì sức mạnh tính toán tăng lên khi số lượng bộ xử lý máy tính tăng lên. Song song dành cho các tác vụ ràng buộc CPU. Về lý thuyết, Nếu một nhiệm vụ được chia thành n-nhiệm vụ con, mỗi n-nhiệm vụ này có thể chạy song song để giảm hiệu quả thời gian xuống còn 1 / n của nhiệm vụ không song song ban đầu. Đồng thời được ưu tiên cho các nhiệm vụ ràng buộc IO, vì bạn có thể làm việc khác trong khi các tài nguyên IO đang được tìm nạp.
Ví dụ tốt nhất về các tác vụ ràng buộc CPU là trong khoa học dữ liệu. Các nhà khoa học dữ liệu xử lý những khối dữ liệu khổng lồ. Đối với xử lý trước dữ liệu, họ có thể chia dữ liệu thành nhiều lô và chạy chúng song song, giảm tổng thời gian xử lý một cách hiệu quả. Tăng số lượng lõi giúp xử lý nhanh hơn.
Việc tìm kiếm trên web bị ràng buộc IO. Bởi vì tác vụ có rất ít ảnh hưởng đến CPU vì phần lớn thời gian được dành cho việc đọc và ghi vào mạng. Các tác vụ liên kết IO phổ biến khác bao gồm các lệnh gọi cơ sở dữ liệu và đọc và ghi tệp vào đĩa. Các ứng dụng web, như Django và Flask, là các ứng dụng bị ràng buộc bởi IO.
Nếu bạn muốn tìm hiểu thêm về sự khác biệt giữa các luồng, đa xử lý và không đồng bộ trong Python, hãy xem bài viết Tăng tốc Python với Đồng tiền, Song song và asyncio .
Cùng với đó, chúng ta hãy xem cách tăng tốc các tác vụ sau:
# 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
Tất cả các ví dụ mã trong hướng dẫn này có thể được tìm thấy trong repo song song-đồng thời-ví dụ-python .
Ghi chú:
make_request
thực hiện một yêu cầu HTTP tới https://httpbin.org/ip X số lần.make_request_async
làm cho cùng một yêu cầu HTTP không đồng bộ với HTTPX .get_prime_numbers
tính các số nguyên tố, thông qua phương pháp Sieve of Eratosthenes , từ hai đến giới hạn đã cho.Chúng tôi sẽ sử dụng các thư viện sau từ thư viện chuẩn để tăng tốc các tác vụ trên:
Thư viện | Lớp / Phương pháp | Loại chế biến |
---|---|---|
xâu chuỗi | Chủ đề | đồng thời |
concurrent.futures | ThreadPoolExecutor | đồng thời |
asyncio | tụ họp | đồng thời (qua coroutines) |
đa chế biến | Hồ bơi | song song |
concurrent.futures | ProcessPoolExecutor | song song |
Một lần nữa, các tác vụ ràng buộc IO dành nhiều thời gian trên IO hơn là trên CPU.
Vì việc tìm kiếm web bị ràng buộc IO, chúng ta nên sử dụng phân luồng để tăng tốc quá trình xử lý vì việc truy xuất HTML (IO) chậm hơn so với phân tích cú pháp nó (CPU).
Tình huống: Làm cách nào để tăng tốc tập lệnh thu thập dữ liệu và thu thập dữ liệu web dựa trên Python?
Hãy bắt đầu với một điểm chuẩn.
# 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.")
Ở đây, chúng tôi đã thực hiện 100 yêu cầu HTTP bằng cách sử dụng make_request
hàm. Vì các yêu cầu xảy ra đồng bộ, mỗi tác vụ được thực hiện tuần tự.
Elapsed run time: 15.710984757 seconds.
Vì vậy, đó là khoảng 0,16 giây cho mỗi yêu cầu.
# 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.")
Ở đây, cùng một make_request
hàm được gọi 100 lần. Lần này threading
thư viện được sử dụng để tạo một luồng cho mỗi yêu cầu.
Elapsed run time: 1.020112515 seconds.
Tổng thời gian giảm từ ~ 16 giây xuống ~ 1 giây.
Vì chúng tôi đang sử dụng các chuỗi riêng biệt cho từng yêu cầu, bạn có thể tự hỏi tại sao toàn bộ không mất ~ 0,16 giây để hoàn thành. Thời gian thêm này là chi phí quản lý chủ đề. Khóa thông dịch viên toàn cầu (GIL) trong Python đảm bảo rằng chỉ một luồng sử dụng mã bytecode Python tại một thời điểm.
# 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.")
Ở đây chúng tôi đã sử dụng concurrent.futures.ThreadPoolExecutor
để đạt được đa luồng. Sau khi tất cả các tương lai / hứa hẹn được tạo, chúng tôi thường wait
đợi tất cả chúng hoàn thành.
Elapsed run time: 1.340592231 seconds
concurrent.futures.ThreadPoolExecutor
thực sự là một phần trừu tượng xung quanh multithreading
thư viện, giúp dễ sử dụng hơn. Trong ví dụ trước, chúng tôi đã gán mỗi yêu cầu cho một luồng và tổng số 100 luồng đã được sử dụng. Nhưng ThreadPoolExecutor
mặc định số lượng luồng công nhân là min(32, os.cpu_count() + 4)
. ThreadPoolExecutor tồn tại để dễ dàng quá trình đạt được đa luồng. Nếu bạn muốn kiểm soát nhiều hơn đối với đa luồng, hãy sử dụng multithreading
thư viện để thay thế.
# 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")
httpx
được sử dụng ở đây vìrequests
không hỗ trợ các hoạt động không đồng bộ.
Ở đây, chúng tôi đã sử dụng asyncio
để đạt được đồng thời.
Elapsed run time: 0.553961068 seconds
asyncio
nhanh hơn các phương pháp khác, vì threading
sử dụng các luồng Hệ điều hành (Hệ điều hành). Vì vậy, các luồng được quản lý bởi Hệ điều hành, nơi chuyển đổi luồng được ưu tiên bởi Hệ điều hành. asyncio
sử dụng coroutines, được định nghĩa bởi trình thông dịch Python. Với coroutines, chương trình quyết định thời điểm chuyển đổi nhiệm vụ một cách tối ưu. Điều này được xử lý bởi even_loop
asyncio.
Kịch bản: Làm thế nào để tăng tốc một tập lệnh xử lý dữ liệu đơn giản?
Một lần nữa, hãy bắt đầu với một điểm chuẩn.
# 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.")
Ở đây, chúng tôi đã thực thi get_prime_numbers
hàm cho các số từ 1000 đến 16000.
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.")
Ở đây, chúng tôi đã sử dụng multiprocessing
để tính các số nguyên tố.
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.")
Ở đây, chúng tôi đã đạt được đa xử lý bằng cách sử dụng concurrent.futures.ProcessPoolExecutor
. Sau khi các công việc được thêm vào tương lai, hãy wait(futures)
đợi chúng hoàn thành.
Elapsed run time: 4.452427557 seconds.
concurrent.futures.ProcessPoolExecutor
là một lớp bao bọc xung quanh multiprocessing.Pool
. Nó có những hạn chế tương tự như ThreadPoolExecutor
. Nếu bạn muốn kiểm soát nhiều hơn quá trình đa xử lý, hãy sử dụng multiprocessing.Pool
. concurrent.futures
cung cấp sự trừu tượng về cả đa xử lý và phân luồng, giúp dễ dàng chuyển đổi giữa hai quy trình này.
Cần lưu ý rằng sử dụng đa xử lý để thực thi make_request
chức năng sẽ chậm hơn nhiều so với hương vị luồng vì các quy trình sẽ cần phải đợi IO. Tuy nhiên, cách tiếp cận đa xử lý sẽ nhanh hơn so với cách tiếp cận đồng bộ.
Tương tự như vậy, sử dụng đồng thời cho các tác vụ ràng buộc CPU không đáng để nỗ lực khi so sánh với song song.
Điều đó đang được nói, sử dụng đồng thời hoặc song song để thực thi các tập lệnh của bạn sẽ làm tăng thêm độ phức tạp. Mã của bạn nói chung sẽ khó đọc, kiểm tra và gỡ lỗi hơn, vì vậy chỉ sử dụng chúng khi thực sự cần thiết cho các tập lệnh chạy dài.
concurrent.futures
là nơi tôi thường bắt đầu kể từ đó-
requests
vs httpx
)Lấy mã từ kho lưu trữ song song-đồng thời-ví dụ-python trên GitHub.
Nguồn: https://testdriven.io
1659885060
В этом руководстве рассматривается, как ускорить операции, связанные с процессором и вводом-выводом, с помощью многопроцессорной обработки, многопоточности и AsyncIO.
Параллелизм и параллелизм — похожие термины, но это не одно и то же.
Параллелизм — это возможность одновременного запуска нескольких задач на ЦП. Задачи могут запускаться, выполняться и завершаться в перекрывающиеся периоды времени. В случае одного процессора несколько задач запускаются с помощью переключения контекста , при этом состояние процесса сохраняется, чтобы его можно было вызвать и выполнить позже.
Между тем, параллелизм — это возможность одновременного запуска нескольких задач на нескольких ядрах ЦП.
Хотя они могут увеличить скорость вашего приложения, параллельность и параллелизм не должны использоваться повсеместно. Вариант использования зависит от того, привязана ли задача к процессору или к вводу-выводу.
Задачи, которые ограничены ЦП, привязаны к ЦП. Например, математические вычисления связаны с процессором, поскольку вычислительная мощность увеличивается по мере увеличения количества компьютерных процессоров. Параллелизм предназначен для задач, связанных с процессором. Теоретически, если задача разделена на n подзадач, каждая из этих n задач может выполняться параллельно, чтобы эффективно сократить время исходной непараллельной задачи до 1/n. Параллелизм предпочтительнее для задач, связанных с вводом-выводом, поскольку вы можете делать что-то еще, пока ресурсы ввода-вывода извлекаются.
Лучший пример задач, связанных с процессором, — наука о данных. Специалисты по данным имеют дело с огромными массивами данных. Для предварительной обработки данных они могут разделить данные на несколько пакетов и запускать их параллельно, эффективно сокращая общее время обработки. Увеличение количества ядер приводит к более быстрой обработке.
Веб-скрапинг связан с вводом-выводом. Потому что задача мало влияет на ЦП, так как большая часть времени тратится на чтение и запись в сеть. Другие распространенные задачи, связанные с вводом-выводом, включают вызовы базы данных, а также чтение и запись файлов на диск. Веб-приложения, такие как Django и Flask, являются приложениями, связанными с вводом-выводом.
Если вам интересно узнать больше о различиях между потоками, многопроцессорностью и асинхронностью в 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
отправляет HTTP-запрос на https://httpbin.org/ip X раз.make_request_async
делает тот же HTTP-запрос асинхронно с HTTPX .get_prime_numbers
вычисляет простые числа с помощью метода решета Эратосфена от двух до указанного предела.Мы будем использовать следующие библиотеки из стандартной библиотеки для ускорения вышеуказанных задач:
Библиотека | Класс/метод | Тип обработки |
---|---|---|
резьба | Нить | одновременный |
параллельные.фьючерсы | ThreadPoolExecutor | одновременный |
асинцио | собирать | одновременно (через сопрограммы) |
многопроцессорность | Бассейн | параллельно |
параллельные.фьючерсы | ProcessPoolExecutor | параллельно |
Опять же, задачи, связанные с вводом-выводом, тратят больше времени на ввод-вывод, чем на ЦП.
Поскольку веб-скрапинг связан с вводом-выводом, мы должны использовать многопоточность для ускорения обработки, поскольку извлечение 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.")
Здесь мы сделали 100 HTTP-запросов с помощью make_request
функции. Поскольку запросы происходят синхронно, каждая задача выполняется последовательно.
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.
Общее время уменьшается с ~16 с до ~1 с.
Поскольку мы используем отдельные потоки для каждого запроса, вам может быть интересно, почему все это не заняло ~ 0,16 с. Это дополнительное время является накладными расходами на управление потоками. Глобальная блокировка интерпретатора (GIL) в Python гарантирует, что только один поток одновременно использует байт-код 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")
httpx
используется здесь, такrequests
как не поддерживает асинхронные операции.
Здесь мы использовали asyncio
для достижения параллелизма.
Elapsed run time: 0.553961068 seconds
asyncio
быстрее, чем другие методы, поскольку threading
использует потоки ОС (операционной системы). Таким образом, потоки управляются ОС, где переключение потоков вытесняется ОС. 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.")
Здесь мы выполнили get_prime_numbers
функцию для чисел от 1000 до 16000.
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
функции будет намного медленнее, чем многопоточность, поскольку процессам придется ждать ввода-вывода. Однако подход с многопроцессорной обработкой будет быстрее, чем подход с синхронизацией.
Точно так же использование параллелизма для задач, связанных с ЦП, не стоит усилий по сравнению с параллелизмом.
При этом использование параллелизма или параллелизма для выполнения ваших скриптов добавляет сложности. Ваш код, как правило, будет сложнее читать, тестировать и отлаживать, поэтому используйте их только в случае крайней необходимости для долго выполняющихся сценариев.
concurrent.futures
с чего я обычно начинаю, так как-
requests
vs httpx
)Возьмите код из репозитория parallel-concurrent-examples-python на GitHub.
Источник: https://testdriven.io
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
)1659869820
Ce didacticiel explique comment accélérer les opérations liées au CPU et aux E/S avec le multitraitement, le threading et AsyncIO.
La concurrence et le parallélisme sont des termes similaires, mais ils ne sont pas la même chose.
La simultanéité est la possibilité d'exécuter plusieurs tâches sur le processeur en même temps. Les tâches peuvent démarrer, s'exécuter et se terminer dans des périodes qui se chevauchent. Dans le cas d'un seul processeur, plusieurs tâches sont exécutées à l'aide de la commutation de contexte , où l'état d'un processus est stocké afin qu'il puisse être appelé et exécuté ultérieurement.
Le parallélisme, quant à lui, est la capacité d'exécuter plusieurs tâches en même temps sur plusieurs cœurs de processeur.
Bien qu'ils puissent augmenter la vitesse de votre application, la concurrence et le parallélisme ne doivent pas être utilisés partout. Le cas d'utilisation varie selon que la tâche est liée au processeur ou liée aux E/S.
Les tâches limitées par le processeur sont liées au processeur. Par exemple, les calculs mathématiques sont liés au processeur puisque la puissance de calcul augmente à mesure que le nombre de processeurs informatiques augmente. Le parallélisme concerne les tâches liées au processeur. En théorie, si une tâche est divisée en n-sous-tâches, chacune de ces n-tâches peut s'exécuter en parallèle pour réduire efficacement le temps à 1/n de la tâche non parallèle d'origine. La simultanéité est préférée pour les tâches liées aux E/S, car vous pouvez faire autre chose pendant que les ressources d'E/S sont récupérées.
Le meilleur exemple de tâches liées au processeur se trouve dans la science des données. Les data scientists traitent d'énormes volumes de données. Pour le prétraitement des données, ils peuvent diviser les données en plusieurs lots et les exécuter en parallèle, ce qui réduit efficacement le temps total de traitement. L'augmentation du nombre de cœurs accélère le traitement.
Le scraping Web est lié aux E/S. Parce que la tâche a peu d'effet sur le processeur puisque la plupart du temps est consacré à la lecture et à l'écriture sur le réseau. Les autres tâches courantes liées aux E/S incluent les appels de base de données et la lecture et l'écriture de fichiers sur le disque. Les applications Web, comme Django et Flask, sont des applications liées aux E/S.
Si vous souhaitez en savoir plus sur les différences entre les threads, le multitraitement et l'asynchronisme dans Python, consultez l' article Accélérer Python avec la concurrence, le parallélisme et l'asynchronie .
Sur ce, voyons comment accélérer les tâches suivantes :
# 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
Tous les exemples de code de ce didacticiel se trouvent dans le référentiel parallel-concurrent-examples-python .
Remarques:
make_request
fait une requête HTTP à https://httpbin.org/ip X nombre de fois.make_request_async
fait la même requête HTTP de manière asynchrone avec HTTPX .get_prime_numbers
calcule les nombres premiers, via la méthode du crible d'Eratosthène , de deux à la limite prévue.Nous utiliserons les bibliothèques suivantes de la bibliothèque standard pour accélérer les tâches ci-dessus :
Bibliothèque | Classe/Méthode | Type de traitement |
---|---|---|
enfilage | Fil de discussion | concurrent |
contrats à terme concurrents | ThreadPoolExecutor | concurrent |
asynchrone | rassembler | concurrent (via des coroutines) |
multitraitement | Piscine | parallèle |
contrats à terme concurrents | ProcessPoolExecutorProcessPoolExecutor | parallèle |
Encore une fois, les tâches liées aux E/S passent plus de temps sur les E/S que sur le CPU.
Étant donné que le web scraping est lié aux IO, nous devrions utiliser le threading pour accélérer le traitement car la récupération du HTML (IO) est plus lente que son analyse (CPU).
Scénario : Comment accélérer un script de grattage et d'exploration Web basé sur Python ?
Commençons par un benchmark.
# 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.")
Ici, nous avons fait 100 requêtes HTTP en utilisant la make_request
fonction. Étant donné que les demandes se produisent de manière synchrone, chaque tâche est exécutée de manière séquentielle.
Elapsed run time: 15.710984757 seconds.
Donc, c'est environ 0,16 seconde par requête.
# 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.")
Ici, la même make_request
fonction est appelée 100 fois. Cette fois, la threading
bibliothèque est utilisée pour créer un fil pour chaque requête.
Elapsed run time: 1.020112515 seconds.
Le temps total passe de ~16s à ~1s.
Étant donné que nous utilisons des threads séparés pour chaque requête, vous vous demandez peut-être pourquoi tout cela n'a pas pris environ 0,16 s pour se terminer. Ce temps supplémentaire correspond à la surcharge de gestion des threads. Le Global Interpreter Lock (GIL) en Python garantit qu'un seul thread utilise le bytecode Python à la fois.
# 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.")
Ici, nous avons utilisé concurrent.futures.ThreadPoolExecutor
pour réaliser le multithreading. Une fois que tous les contrats à terme/promesses ont été créés, nous avions l'habitude wait
d'attendre qu'ils soient tous terminés.
Elapsed run time: 1.340592231 seconds
concurrent.futures.ThreadPoolExecutor
est en fait une abstraction autour de la multithreading
bibliothèque, ce qui la rend plus facile à utiliser. Dans l'exemple précédent, nous avons attribué chaque requête à un thread et au total 100 threads ont été utilisés. Mais ThreadPoolExecutor
le nombre de threads de travail par défaut est min(32, os.cpu_count() + 4)
. ThreadPoolExecutor existe pour faciliter le processus de réalisation du multithreading. Si vous voulez plus de contrôle sur le multithreading, utilisez multithreading
plutôt la bibliothèque.
# 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")
httpx
est utilisé ici carrequests
ne prend pas en charge les opérations asynchrones.
Ici, nous avions l'habitude asyncio
d'obtenir la concurrence.
Elapsed run time: 0.553961068 seconds
asyncio
est plus rapide que les autres méthodes, car threading
utilise les threads du système d'exploitation (système d'exploitation). Ainsi, les threads sont gérés par le système d'exploitation, où la commutation de threads est anticipée par le système d'exploitation. asyncio
utilise des coroutines, qui sont définies par l'interpréteur Python. Avec les coroutines, le programme décide quand changer de tâche de manière optimale. Ceci est géré par le even_loop
in asyncio.
Scénario : Comment accélérer un script de traitement de données simple ?
Encore une fois, commençons par un point de repère.
# 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.")
Ici, nous avons exécuté la get_prime_numbers
fonction pour les nombres de 1000 à 16000.
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.")
Ici, nous avons utilisé multiprocessing
pour calculer les nombres premiers.
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.")
Ici, nous avons réalisé le multitraitement en utilisant concurrent.futures.ProcessPoolExecutor
. Une fois les travaux ajoutés aux contrats à terme, wait(futures)
attend qu'ils se terminent.
Elapsed run time: 4.452427557 seconds.
concurrent.futures.ProcessPoolExecutor
est un emballage autour multiprocessing.Pool
. Il a les mêmes limites que le ThreadPoolExecutor
. Si vous voulez plus de contrôle sur le multitraitement, utilisez multiprocessing.Pool
. concurrent.futures
fournit une abstraction à la fois du multitraitement et du threading, ce qui facilite le basculement entre les deux.
Il convient de noter que l'utilisation du multitraitement pour exécuter la make_request
fonction sera beaucoup plus lente que la saveur de threading car les processus devront attendre l'IO. L'approche multitraitement sera cependant plus rapide que l'approche de synchronisation.
De même, l'utilisation de la simultanéité pour les tâches liées au processeur ne vaut pas la peine par rapport au parallélisme.
Cela étant dit, l'utilisation de la concurrence ou du parallélisme pour exécuter vos scripts ajoute de la complexité. Votre code sera généralement plus difficile à lire, à tester et à déboguer, donc utilisez-les uniquement lorsque cela est absolument nécessaire pour les scripts de longue durée.
concurrent.futures
c'est là que je commence généralement depuis-
requests
vs httpx
)Récupérez le code du dépôt parallel-concurrent-examples-python sur GitHub.
Source : https://testdrive.io
1659862320
Este tutorial analiza cómo acelerar las operaciones vinculadas a la CPU y a E/S con multiprocesamiento, subprocesos y AsyncIO.
Concurrencia y paralelismo son términos similares, pero no son lo mismo.
La concurrencia es la capacidad de ejecutar múltiples tareas en la CPU al mismo tiempo. Las tareas pueden comenzar, ejecutarse y completarse en períodos de tiempo superpuestos. En el caso de una sola CPU, se ejecutan varias tareas con la ayuda del cambio de contexto , donde se almacena el estado de un proceso para que pueda llamarse y ejecutarse más tarde.
Mientras tanto, el paralelismo es la capacidad de ejecutar múltiples tareas al mismo tiempo en múltiples núcleos de CPU.
Aunque pueden aumentar la velocidad de su aplicación, la concurrencia y el paralelismo no deben usarse en todas partes. El caso de uso depende de si la tarea está vinculada a la CPU oa la E/S.
Las tareas que están limitadas por la CPU están vinculadas a la CPU. Por ejemplo, los cálculos matemáticos están vinculados a la CPU, ya que la potencia computacional aumenta a medida que aumenta la cantidad de procesadores de la computadora. El paralelismo es para tareas vinculadas a la CPU. En teoría, si una tarea se divide en n subtareas, cada una de estas n tareas puede ejecutarse en paralelo para reducir efectivamente el tiempo a 1/n de la tarea original no paralela. Se prefiere la simultaneidad para las tareas vinculadas a IO, ya que puede hacer otra cosa mientras se recuperan los recursos de IO.
El mejor ejemplo de tareas vinculadas a la CPU está en la ciencia de datos. Los científicos de datos manejan grandes cantidades de datos. Para el preprocesamiento de datos, pueden dividir los datos en varios lotes y ejecutarlos en paralelo, lo que reduce efectivamente el tiempo total de procesamiento. Aumentar el número de núcleos da como resultado un procesamiento más rápido.
Web scraping está vinculado a IO. Porque la tarea tiene poco efecto en la CPU ya que la mayor parte del tiempo se dedica a leer y escribir en la red. Otras tareas comunes vinculadas a IO incluyen llamadas a bases de datos y lectura y escritura de archivos en el disco. Las aplicaciones web, como Django y Flask, son aplicaciones vinculadas a IO.
Si está interesado en obtener más información sobre las diferencias entre subprocesos, multiprocesamiento y async en Python, consulte el artículo Aceleración de Python con concurrencia, paralelismo y asyncio .
Con eso, echemos un vistazo a cómo acelerar las siguientes tareas:
# 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
Todos los ejemplos de código en este tutorial se pueden encontrar en el repositorio paralelo-concurrent-examples-python .
Notas:
make_request
realiza una solicitud HTTP a https://httpbin.org/ip X número de veces.make_request_async
realiza la misma solicitud HTTP de forma asíncrona con HTTPX .get_prime_numbers
calcula los números primos, a través del método de la criba de Eratóstenes , desde dos hasta el límite proporcionado.Usaremos las siguientes bibliotecas de la biblioteca estándar para acelerar las tareas anteriores:
Biblioteca | Clase/Método | Tipo de procesamiento |
---|---|---|
enhebrar | Hilo | concurrente |
futuros concurrentes | ThreadPoolExecutor | concurrente |
asíncio | recolectar | concurrente (a través de coroutines) |
multiprocesamiento | Piscina | paralela |
futuros concurrentes | ProcessPoolExecutorProcessPoolExecutor | paralela |
Nuevamente, las tareas vinculadas a IO pasan más tiempo en IO que en la CPU.
Dado que el raspado web está vinculado a IO, debemos usar subprocesos para acelerar el procesamiento, ya que la recuperación del HTML (IO) es más lenta que su análisis (CPU).
Escenario: ¿Cómo acelerar un script de raspado y rastreo web basado en Python?
Comencemos con un punto de referencia.
# 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.")
Aquí, hicimos 100 solicitudes HTTP usando la make_request
función. Dado que las solicitudes ocurren sincrónicamente, cada tarea se ejecuta secuencialmente.
Elapsed run time: 15.710984757 seconds.
Entonces, eso es aproximadamente 0,16 segundos por solicitud.
# 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.")
Aquí, la misma make_request
función se llama 100 veces. Esta vez, la threading
biblioteca se usa para crear un hilo para cada solicitud.
Elapsed run time: 1.020112515 seconds.
El tiempo total disminuye de ~16 s a ~1 s.
Dado que estamos usando subprocesos separados para cada solicitud, es posible que se pregunte por qué no tardó ~0.16 s en completarse todo. Este tiempo adicional es la sobrecarga para administrar subprocesos. El bloqueo de intérprete global (GIL) en Python se asegura de que solo un subproceso use el código de bytes de Python a la vez.
# 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.")
Aquí solíamos concurrent.futures.ThreadPoolExecutor
lograr subprocesos múltiples. Después de crear todos los futuros/promesas, solíamos wait
esperar a que se completaran todos.
Elapsed run time: 1.340592231 seconds
concurrent.futures.ThreadPoolExecutor
es en realidad una abstracción alrededor de la multithreading
biblioteca, lo que hace que sea más fácil de usar. En el ejemplo anterior, asignamos cada solicitud a un hilo y en total se usaron 100 hilos. Pero ThreadPoolExecutor
el número predeterminado de subprocesos de trabajo es min(32, os.cpu_count() + 4)
. ThreadPoolExecutor existe para facilitar el proceso de lograr subprocesos múltiples. Si desea tener más control sobre los subprocesos múltiples, use la multithreading
biblioteca en su lugar.
# 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")
httpx
se usa aquí yarequests
que no admite operaciones asíncronas.
Aquí, solíamos asyncio
lograr la concurrencia.
Elapsed run time: 0.553961068 seconds
asyncio
es más rápido que los otros métodos, porque threading
utiliza subprocesos del sistema operativo (OS). Entonces, los subprocesos son administrados por el sistema operativo, donde el sistema operativo se adelanta al cambio de subprocesos. asyncio
utiliza rutinas, que están definidas por el intérprete de Python. Con las corrutinas, el programa decide cuándo cambiar de tarea de manera óptima. Esto es manejado por even_loop
in asyncio.
Escenario: ¿Cómo acelerar un simple script de procesamiento de datos?
Una vez más, comencemos con un punto de referencia.
# 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.")
Aquí, ejecutamos la get_prime_numbers
función para los números del 1000 al 16000.
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.")
Aquí, usamos multiprocessing
para calcular los números primos.
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.")
Aquí, logramos el multiprocesamiento usando concurrent.futures.ProcessPoolExecutor
. Una vez que los trabajos se agregan a los futuros, wait(futures)
espera a que finalicen.
Elapsed run time: 4.452427557 seconds.
concurrent.futures.ProcessPoolExecutor
es un envoltorio alrededor multiprocessing.Pool
. Tiene las mismas limitaciones que el ThreadPoolExecutor
. Si desea tener más control sobre el multiprocesamiento, use multiprocessing.Pool
. concurrent.futures
proporciona una abstracción sobre el multiprocesamiento y los subprocesos, lo que facilita el cambio entre los dos.
Vale la pena señalar que el uso de multiprocesamiento para ejecutar la make_request
función será mucho más lento que el tipo de subprocesamiento, ya que los procesos deberán esperar el IO. Sin embargo, el enfoque de multiprocesamiento será más rápido que el enfoque de sincronización.
Del mismo modo, el uso de la simultaneidad para tareas vinculadas a la CPU no vale la pena en comparación con el paralelismo.
Dicho esto, el uso de concurrencia o paralelismo para ejecutar sus scripts agrega complejidad. Su código generalmente será más difícil de leer, probar y depurar, así que utilícelos solo cuando sea absolutamente necesario para secuencias de comandos de ejecución prolongada.
concurrent.futures
es donde generalmente empiezo desde-
requests
vs httpx
)Tome el código del repositorio paralelo-concurrent-examples-python en GitHub.
Fuente: https://testdriven.io