Harnessing Concurrency in Python: A Beginner's Guide
Written on
Understanding Concurrency in Python
This guide is tailored for beginners looking to leverage concurrent execution to boost the performance of their Python applications.
Gordon Moore, in 1965, proposed what is now referred to as Moore's Law, predicting that the number of transistors on microchips would double approximately every two years. Furthermore, this law implies that the cost of computing hardware would decrease by half in the same timeframe.
In our current technological environment, it is commonplace for computer systems to feature multi-core CPUs or multiple processors. As developers, it is essential to write code that takes full advantage of these hardware capabilities to provide optimized solutions for users.
What is Concurrency?
Concurrency refers to the simultaneous execution of multiple instruction sequences. For instance, if a system is equipped with a dual-core CPU, running code without concurrency would only utilize one core, leaving the other core inactive. By implementing concurrency, we can run tasks simultaneously on both cores, resulting in enhanced performance and reduced wait times.
However, a significant downside to concurrency is the unpredictability of task execution order. Therefore, it is crucial that the execution sequence does not affect the results, and tasks should ideally share minimal resources. To address this, coordination of shared resources is required, which adds complexity to the process. The greater the number of shared resources, the more intricate the system becomes.
Types of Concurrency
Parallel Programming
Parallel programming involves dividing a primary task into smaller sub-tasks. Each of these sub-tasks can then be assigned to different threads or processes, allowing them to be executed simultaneously on multiple cores. In contrast, single-core programming only utilizes one core for task execution, which may lead to inefficient resource use.
By adopting parallel programming, all available cores can be utilized, thereby boosting overall performance. It is particularly effective for CPU-bound tasks—tasks that can only be accelerated by adding more processors. Examples of such tasks include mathematical computations and search algorithms.
Asynchronous Programming
Asynchronous programming, also known as multi-threading, allows the main thread to dispatch a sub-task to another thread or actor. Instead of waiting for the sub-task to complete, the main thread continues executing other work. Once the sub-task finishes, the actor notifies the main thread, triggering a callback function to process the results.
In Python, we use an object called a "future" to represent the result of an operation that has not yet completed. Depending on the program's structure, the main thread may either wait for the sub-task(s) to finish or check back later. Asynchronous programming is particularly useful for I/O-bound tasks, which rely on external factors like disk access or network communication.
Parallel vs. Asynchronous Programming
At any given moment:
- Parallel Programming: Aim to execute tasks more quickly.
- Asynchronous Programming: Focus on managing more tasks concurrently.
Multi-Threading in Python
A classic example illustrating the advantages of multi-threading is the concurrent downloading of multiple images from the internet. This example helps to understand how effectively multi-threading can be utilized and how it can be implemented in Python.
import os
import time
from urllib.request import urlretrieve
from typing import List
IMGS_URL_LIST = [
# ... more image URLs ...
]
def download_images(img_url_list: List[str]) -> None:
if not img_url_list:
returnos.makedirs('images', exist_ok=True)
start_time = time.perf_counter()
for img_num, url in enumerate(img_url_list):
urlretrieve(url, f'images{os.path.sep}{img_num + 1}')
print(f"Downloaded {len(img_url_list)} images in {round(time.perf_counter() - start_time, 2)} seconds")
download_images(IMGS_URL_LIST)
In this single-threaded script, it took approximately 22.06 seconds to download 26 images. To enhance this, we can adopt a concurrent programming approach.
Instead of looping through each URL sequentially, we can separate the logic into two functions: the target function, which handles the downloading of a single image, and the runner function that triggers threads for each URL.
Multi-Threading Implementation
To implement multi-threading, we can modify our script like this:
from threading import Thread
def download_image(url: str, img_num: int) -> None:
urlretrieve(url, f'images{os.path.sep}{img_num + 1}')
def download_images_concurrent(img_url_list: List[str]) -> None:
if not img_url_list:
returnos.makedirs('images', exist_ok=True)
start_time = time.perf_counter()
threads = []
for img_num, url in enumerate(img_url_list):
t = Thread(target=download_image, args=(url, img_num))
t.start()
threads.append(t)
for thread in threads:
thread.join()
print(f"Downloaded {len(img_url_list)} images in {round(time.perf_counter() - start_time, 2)} seconds")
download_images_concurrent(IMGS_URL_LIST)
This revised script retrieves the same 26 images in just 2.92 seconds, a significant improvement in efficiency!
Understanding Execution Flow
To grasp the execution flow, we must first familiarize ourselves with various thread states:
- New: A thread that has just been created.
- Ready: A thread that is prepared to execute.
- Running: A thread that is currently executing.
- Blocked: A thread that is waiting for resources.
- Finished: A thread that has completed execution.
Initially, only the main thread is active. As the main thread spawns new threads, it transitions to a blocked state while waiting for the child threads to finish.
In complex scenarios where threads access shared resources, it is essential to manage which thread can access a resource at any given time. This can be achieved using semaphores and locks.
Multi-Processing in Python
Creating processes in Python is straightforward and resembles thread usage. We can initiate a new process using the Process function from the multiprocessing module. The process is started with the start method, and we can utilize join to ensure the main process waits for all child processes to complete.
import multiprocessing
def target_function(num: int) -> int:
return num ** 2
pool_size = multiprocessing.cpu_count()
pool = multiprocessing.Pool(processes=pool_size)
result = pool.map(target_function, range(200))
pool.close()
pool.join()
print(result)
However, for simple tasks, using multiprocessing may not always yield better performance due to the overhead associated with inter-process communication.
Conclusion
Concurrency is a programming paradigm that enhances the ability of a program to perform multiple tasks simultaneously. This guide has explored various concurrency types and provided practical examples to help you start using concurrency in Python.
It's advisable to experiment with these examples in your own scripts to appreciate how much more efficient your applications can become. While there are numerous advanced concepts to delve into regarding concurrency, those discussions can wait for another time!
If you found this post helpful, consider becoming a member for just $5/month to unlock unlimited access to content and support your favorite writers.
Want to Connect?
I would love to hear your feedback on this topic or any thoughts related to AI and Data. Feel free to reach out at [email protected].
In this video, "Concurrency Concepts in Python," you can explore the foundational ideas of concurrency in Python in more detail.
Check out the tutorial "Santiago Basulto - Python Concurrency: from beginner to pro," which provides an in-depth look at concurrency techniques in Python.