Python tutorials > Advanced Python Concepts > Concurrency and Parallelism > What is concurrency/parallelism?
What is concurrency/parallelism?
Concurrency and parallelism are essential concepts in modern programming, especially when dealing with tasks that can be broken down into smaller, independent units. Understanding the difference between them and when to use each approach can significantly improve the performance and responsiveness of your Python applications. This tutorial explains concurrency and parallelism, highlighting their distinctions and use cases with practical examples.
Concurrency vs. Parallelism: The Key Difference
Concurrency is about dealing with multiple tasks at the same time. A concurrent system can switch between tasks, giving the illusion that they are running simultaneously. This is often achieved through techniques like threading or asynchronous programming. Parallelism, on the other hand, is about executing multiple tasks simultaneously. This requires multiple processing units (cores) and allows for true simultaneous execution. Parallelism relies on concurrency. Think of it this way: Concurrency is like a single chef juggling multiple orders, switching between them to keep everything moving. Parallelism is like having multiple chefs, each working on a separate order simultaneously.
Concurrency with Threads (Illustrative Example)
This example demonstrates concurrency using threads. Two threads, 'A' and 'B', are created and started. Each thread executes the `task` function, which simulates a time-consuming operation using `time.sleep()`. While the threads appear to run simultaneously, they are actually sharing a single CPU core and switching between each other. Important note: Due to the Global Interpreter Lock (GIL) in standard Python (CPython), true parallelism is limited when using threads for CPU-bound tasks. The GIL allows only one thread to hold control of the Python interpreter at any time. For CPU-bound tasks, consider using multiprocessing for true parallelism.
import threading
import time
def task(name):
print(f'Task {name}: Starting')
time.sleep(2)
print(f'Task {name}: Finishing')
# Create threads
t1 = threading.Thread(target=task, args=('A',))
t2 = threading.Thread(target=task, args=('B',))
# Start threads
t1.start()
t2.start()
# Wait for threads to finish
t1.join()
t2.join()
print('All tasks complete.')
Parallelism with Multiprocessing
This example demonstrates parallelism using the `multiprocessing` module. Two processes, 'A' and 'B', are created and started. Each process executes the `task` function, similar to the threading example. However, since each process has its own Python interpreter, they can truly run in parallel on multiple CPU cores, bypassing the GIL limitation. The `if __name__ == '__main__':` guard is crucial, especially on Windows, to prevent recursive process creation.
import multiprocessing
import time
def task(name):
print(f'Task {name}: Starting')
time.sleep(2)
print(f'Task {name}: Finishing')
if __name__ == '__main__':
# Create processes
p1 = multiprocessing.Process(target=task, args=('A',))
p2 = multiprocessing.Process(target=task, args=('B',))
# Start processes
p1.start()
p2.start()
# Wait for processes to finish
p1.join()
p2.join()
print('All tasks complete.')
Asynchronous Programming (Concurrency)
This example shows concurrency using `asyncio`. The `task` function is defined as an asynchronous coroutine. `asyncio.sleep()` allows the event loop to switch to other tasks while waiting, effectively achieving concurrency. The `asyncio.gather()` function runs multiple tasks concurrently. Asynchronous programming is well-suited for I/O-bound tasks like network requests, where the program spends much of its time waiting for external resources.
import asyncio
import time
async def task(name):
print(f'Task {name}: Starting')
await asyncio.sleep(2)
print(f'Task {name}: Finishing')
async def main():
await asyncio.gather(task('A'), task('B'))
if __name__ == '__main__':
asyncio.run(main())
Real-Life Use Case: Web Scraping
Web scraping often involves making multiple HTTP requests, which can be slow. Using concurrency (e.g., with threads or `asyncio`) can significantly speed up the scraping process by making requests concurrently. This example uses threads to scrape multiple URLs simultaneously. Each thread fetches a different URL, reducing the overall time taken to scrape all the websites.
import requests
import threading
import time
def scrape_url(url):
try:
response = requests.get(url)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
print(f'Successfully scraped {url}')
# Process the content of the page here (e.g., extract data)
except requests.exceptions.RequestException as e:
print(f'Error scraping {url}: {e}')
urls = [
'https://www.example.com',
'https://www.google.com',
'https://www.wikipedia.org'
]
threads = []
start_time = time.time()
for url in urls:
t = threading.Thread(target=scrape_url, args=(url,))
threads.append(t)
t.start()
for t in threads:
t.join()
end_time = time.time()
print(f'Total time taken: {end_time - start_time:.2f} seconds')
When to Use Concurrency vs. Parallelism
Best Practices
Memory Footprint
Threads: Threads typically have a smaller memory footprint than processes because they share the same memory space. However, this shared memory space requires careful synchronization to avoid data corruption. Processes: Processes have a larger memory footprint because each process has its own memory space. This isolation can be beneficial for stability but increases memory usage.
Alternatives
Greenlets: Greenlets are lightweight, user-space threads that provide concurrency without the overhead of operating system threads. They are often used in conjunction with asynchronous frameworks like Gevent. Celery: Celery is a distributed task queue that allows you to distribute tasks across multiple machines, providing both concurrency and parallelism at scale. Dask: Dask is a library for parallel computing in Python that provides a high-level interface for parallelizing NumPy, Pandas, and other data science workloads.
Pros and Cons of using threads
Pros and Cons of using multiprocessing
Interview Tip
When discussing concurrency and parallelism in interviews, be sure to emphasize the difference between them, their respective use cases, and the limitations of threads due to the GIL in Python. Also, be prepared to discuss strategies for avoiding race conditions and handling errors in concurrent and parallel programs. Familiarity with `asyncio` and `multiprocessing` is highly valued.
FAQ
-
What is the Global Interpreter Lock (GIL)?
The Global Interpreter Lock (GIL) is a mutex that allows only one thread to hold control of the Python interpreter at any given time. This prevents multiple native threads from executing Python bytecode in parallel within a single process. The GIL primarily affects CPU-bound tasks. For I/O bound tasks, the GIL is often released while waiting for I/O, allowing other threads to run.
-
How can I avoid race conditions in concurrent programs?
Race conditions can be avoided by minimizing shared state between threads or processes and using appropriate synchronization mechanisms like locks (e.g., `threading.Lock`) to protect critical sections of code that access shared resources. Also, consider using thread-safe data structures and queues for communication between threads.
-
Is `asyncio` truly parallel?
No, `asyncio` is not truly parallel. It is a form of concurrency that allows multiple tasks to make progress without blocking each other. It achieves this through cooperative multitasking, where tasks voluntarily yield control to the event loop, allowing other tasks to run. `asyncio` is well-suited for I/O-bound tasks but does not provide true parallelism for CPU-bound tasks.