Concurrency with Futures
Notes based on Fluent Python, summarized and rephrased in my own words.[file:25]
Why futures matter for concurrency
For most application-level Python programmers, concurrency often boils down to: "spawn a bunch of independent tasks and collect the results".[file:25] The concurrent.futures module provides a high-level way to do this using Future objects and thread or process pools.
A Future represents the result of an asynchronous operation that may not have completed yet. You submit work, get a Future, and later retrieve the outcome (or exception) when it is ready.[file:25]
Conceptually, Python’s Future is similar to JavaScript’s Promise:
- Both represent an asynchronous computation.
- Both have a notion of pending and completed (success or failure).
- Both provide an API to obtain the result or propagate errors once the operation finishes.[file:25]
In Python, futures are typically created for you by executors (ThreadPoolExecutor, ProcessPoolExecutor), not instantiated directly.[file:25]
Sequential flag download example
We start with a simple script that downloads flag images for the 20 most populous countries sequentially:[file:25]
import os
import time
import sys
import requests
POP20_CC = (
'CN IN US ID BR PK NG BD RU JP '
'MX PH VN ET EG DE IR TR CD FR'
).split()
BASE_URL = 'http://flupy.org/data/flags'
DEST_DIR = 'downloads/'
def save_flag(img, filename):
path = os.path.join(DEST_DIR, filename)
with open(path, 'wb') as fp:
fp.write(img)
def get_flag(cc):
url = '{}/{cc}/{cc}.gif'.format(BASE_URL, cc=cc.lower())
resp = requests.get(url)
return resp.content
def show(text):
print(text, end=' ')
sys.stdout.flush()
def download_many(cc_list):
for cc in sorted(cc_list):
image = get_flag(cc)
show(cc)
save_flag(image, cc.lower() + '.gif')
return len(cc_list)
def main(download_many):
t0 = time.time()
count = download_many(POP20_CC)
elapsed = time.time() - t0
msg = '
{} flags downloaded in {:.2f}s'
print(msg.format(count, elapsed))
if __name__ == '__main__':
main(download_many)This version:[file:25]
- Downloads each flag one after another.
- Is simple but potentially slow, because each HTTP request blocks while waiting for network I/O.
Concurrent downloads with ThreadPoolExecutor
For I/O-bound tasks like network requests, threads can speed things up because blocking I/O operations release the GIL (Global Interpreter Lock) in CPython.[file:25]
We can make downloads concurrent with a thread pool:[file:25]
from concurrent import futures
MAX_WORKERS = 20
def download_one(cc):
image = get_flag(cc)
show(cc)
save_flag(image, cc.lower() + '.gif')
return cc
def download_many(cc_list):
workers = min(MAX_WORKERS, len(cc_list))
with futures.ThreadPoolExecutor(workers) as executor:
# executor.map schedules download_one for each country code
res = executor.map(download_one, sorted(cc_list))
# Force evaluation of the iterator to ensure all tasks complete
return len(list(res))
if __name__ == '__main__':
main(download_many)Here:[file:25]
ThreadPoolExecutormanages a pool of worker threads.executor.mapschedulesdownload_onefor each country code and returns an iterator over results in order.- I/O waits in
requests.getrelease the GIL, allowing other threads to run while one is blocked.[file:25]
Futures vs Promises (conceptual)
Python
Future(concurrent.futures)- Represents a computation that runs in a separate thread or process.
- Provides methods to check completion, get results, or capture exceptions.
- Usually obtained from an executor rather than instantiated directly.[file:25]
JavaScript
Promise- Represents an asynchronous operation with states
pending,fulfilled,rejected. - Uses
.then(),.catch(),.finally()for result/exception handling. - Widely used with async Web APIs, timers, etc.[file:25]
- Represents an asynchronous operation with states
They serve similar roles in their respective ecosystems, but are tied to different concurrency models and runtimes.
Blocking I/O, the GIL, and when threads help
In CPython:
- The Global Interpreter Lock (GIL) ensures that only one thread executes Python bytecode at a time, so a pure Python process typically cannot run CPU-bound Python code truly in parallel on multiple cores.[file:25]
- Some C extensions and built-in operations explicitly release the GIL when performing long-running, low-level work.
For I/O-bound tasks:
- Standard library functions that perform blocking I/O (network, file I/O, etc.) release the GIL while waiting for the OS.[file:25]
- This allows other Python threads to make progress during those waits.
time.sleep()also releases the GIL.[file:25]
So, despite the GIL, threads are useful for I/O-bound workloads in Python. You can spawn multiple threads, each waiting on I/O, and keep resources like network connections and disks busy.[file:25]
For CPU-bound workloads, threads usually do not provide real parallelism because of the GIL; using processes is better.[file:25]
Using ProcessPoolExecutor to bypass the GIL for CPU-bound tasks
For CPU-intensive tasks (e.g. large numeric computations, heavy data processing), you can use processes instead of threads:[file:25]
from concurrent import futures
def download_many(cc_list):
with futures.ProcessPoolExecutor() as executor:
# submit CPU-bound work here instead of I/O-bound get_flag
...Key points:[file:25]
ProcessPoolExecutorruns each submitted function in a separate process, giving true parallelism across CPU cores.- Unlike
ThreadPoolExecutor.__init__, themax_workersargument forProcessPoolExecutoris optional and defaults toos.cpu_count(), which is usually appropriate for CPU-bound tasks.[file:25] - For I/O-bound tasks, you might use many more threads (e.g. tens or hundreds) depending on your workload and memory.
In a typical migration from threads to processes, the structural change can be as small as replacing:[file:25]
with futures.ThreadPoolExecutor(workers) as executor:
...with:
with futures.ProcessPoolExecutor() as executor:
...Of course, the actual function you submit should be CPU-heavy and pickleable (so it can be sent to worker processes), and you need to design around inter-process data transfer costs.
Summary
- Use
ThreadPoolExecutorwith futures for I/O-bound concurrency; blocking I/O releases the GIL so threads can overlap work.[file:25] - Use
ProcessPoolExecutorwith futures for CPU-bound concurrency when you want to exploit multiple cores and avoid the GIL.[file:25] - Treat
Futureas a handle to an asynchronous computation: you schedule work, continue doing other things, and later collect results or errors when they’re ready.[file:25]