Concurrency with asyncio
Notes based on Fluent Python, summarized and rephrased in my own words.[file:26]
Threads vs coroutines
asyncio provides a framework for single-threaded, cooperative concurrency using async/await. To see the contrast, the book compares a classic threaded spinner with an asyncio-based version.[file:26]
Thread-based spinner
import threading
import itertools
import time
import sys
class Signal:
go = True
def spin(msg, signal):
write, flush = sys.stdout.write, sys.stdout.flush
for char in itertools.cycle('|/-\'):
status = char + ' ' + msg
write(status)
flush()
write('' * len(status))
time.sleep(.1)
if not signal.go:
break
write(' ' * len(status) + '' * len(status))
def slow_function():
# pretend to wait for I/O
time.sleep(3)
return 42
def supervisor():
signal = Signal()
spinner = threading.Thread(target=spin,
args=('thinking!', signal))
print('spinner object:', spinner)
spinner.start()
result = slow_function()
signal.go = False
spinner.join()
return result
def main():
result = supervisor()
print('Answer:', result)
if __name__ == '__main__':
main()Here:[file:26]
spinruns in a separate OS thread and updates a text spinner.slow_functionsimulates blocking I/O withtime.sleep.- The main thread waits for
slow_function, while the spinner thread keeps updating untilsignal.gois set toFalse.
Coroutine-based spinner with asyncio
The asyncio version uses coroutines and the event loop instead of OS threads:[file:26]
import asyncio
import itertools
import sys
async def spin(msg):
write, flush = sys.stdout.write, sys.stdout.flush
for char in itertools.cycle('|/-\'):
status = char + ' ' + msg
write(status)
flush()
write('' * len(status))
try:
await asyncio.sleep(.1)
except asyncio.CancelledError:
break
write(' ' * len(status) + '' * len(status))
async def slow_function():
# pretend to wait for I/O
await asyncio.sleep(3)
return 42
async def supervisor():
spinner = asyncio.create_task(spin('thinking!'))
print('spinner object:', spinner)
result = await slow_function()
spinner.cancel()
return result
def main():
loop = asyncio.get_event_loop()
result = loop.run_until_complete(supervisor())
loop.close()
print('Answer:', result)
if __name__ == '__main__':
main()Key differences:[file:26]
async defdefines coroutines, andawaitmarks suspension points.asyncio.create_taskschedulesspinto run concurrently within the event loop.slow_functionyields control withawait asyncio.sleep, allowing the spinner to advance.- Cancellation is explicit with
spinner.cancel()and handled viaasyncio.CancelledErrorinspin.
Threads rely on the OS scheduler and can run in parallel on multiple cores; asyncio coroutines run cooperatively in a single thread, switching only at await points.
Core ideas of asyncio
asyncio is built around three main concepts:
- Event loop – drives the execution of tasks and callbacks, monitors I/O, and schedules coroutines.
- Coroutines (
async def) – units of cooperative work that yield control atawait. - Tasks – wrappers around coroutines that the event loop can schedule and manage like futures.
Typical flow:[file:26]
- You define
async defcoroutines that useawaiton I/O-bound or blocking operations. - You schedule them as tasks with
asyncio.create_taskor useasyncio.gatherto run several concurrently. - You start the event loop (in modern code, usually with
asyncio.run(main())).
await is the key: it pauses the current coroutine while another can run, without blocking the whole thread.
Comparing threading and asyncio for I/O-bound work
Both threads and asyncio can improve throughput for I/O-bound workloads, but they do so differently:[file:26]
Threads
- Each thread can block independently on I/O.
- Blocking calls (e.g.
requests.get,time.sleep) release the GIL, letting other threads run. - Simpler mental model for some problems, but you must watch out for shared-state bugs and need synchronization primitives.
asyncio- Single-threaded by default; I/O operations must be non-blocking and integrated with the event loop.
- Concurrency is explicit via
awaitpoints; no preemptive thread switching. - Avoids many traditional race conditions and locking issues, but requires using async-aware libraries (e.g.
aiohttpinstead ofrequests).
For CPU-bound tasks, neither plain threads nor plain asyncio coroutines can bypass the GIL; you’d generally use processes (ProcessPoolExecutor) or native extensions.
Patterns shown by the spinner example
The spinner example illustrates several important asyncio patterns:[file:26]
- Background task:
spinruns in the background as a task whileslow_functionsimulates work. - Cancellation: tasks should be written to catch
asyncio.CancelledErrorat suspension points (likeawait asyncio.sleep) so they can cleanly exit. - Structured concurrency:
supervisoris anasyncfunction that:- starts the spinner,
- awaits the main operation,
- then cancels the spinner and returns the result.
In modern Python (3.7+), this is often wrapped with asyncio.run(supervisor()) instead of manual event loop management, but the core idea remains the same.
When to choose asyncio
asyncio tends to be a good fit when:[file:26]
- You have many concurrent I/O-bound operations (e.g. many HTTP requests, websockets, database queries).
- You want to minimize thread usage and avoid shared-state synchronization.
- You can use async-compatible libraries for your I/O.
Threads may be simpler if:
- You are integrating with existing blocking APIs that don’t have async versions.
- The number of concurrent operations is modest and you don’t want to restructure code as
async/await.
In practice, large-scale network servers, crawlers, and chat systems often benefit from asyncio, while smaller scripts and mixed workloads sometimes remain simpler with concurrent.futures.ThreadPoolExecutor.