Queues & Locks
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Tornado 6.5.10 (released 2026-09-15) on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as the alpha 6.6a1 at that date, so everything about 6.6 here (such as its higher minimum Python version) is upcoming rather than released. All code runs on Tornado’s single event loop (an asyncio loop since Tornado 5), which is why the primitives described here exist: they let coroutines wait for each other without blocking the loop. The running scenario is Bookshelf Live, where a bounded pool of workers fetches cover metadata from an external service.
Why Coroutines Need Their Own Primitives
A coroutine that waits has to hand control back to the event loop, otherwise nothing else, including every other
request, can run. The classic blocking tools (queue.Queue.get, threading.Lock.acquire) park the whole thread, and
in Tornado there is only one thread serving all connections. tornado.queues and tornado.locks provide the same
ideas (a producer/consumer queue, mutual exclusion, signalling, rate limiting) as awaitables: await queue.get() or
async with lock: suspends only the current coroutine.
Typical uses in a service like Bookshelf Live:
-
Back-pressure and background work. A request handler accepts a job and a bounded pool of worker coroutines drains it at a controlled rate, instead of starting an unlimited number of outbound calls.
-
Mutual exclusion across
awaitpoints. Between twoawaitexpressions nothing else can run, so plain Python code is already atomic; but a read-modify-write that spans anawait(for example "check the cache, fetch, store") can interleave with another coroutine, and needs aLock. -
Signalling. One coroutine announces "the cache is warm", "shutdown requested" or "a review arrived" and others wake up (
Event,Condition). -
Concurrency limits. At most N simultaneous calls to an upstream service (
Semaphore).
and startup code
(producers)"] -- "put / put_nowait" --> Q[("Queue
maxsize = 100")] Q -- "get" --> W1["Worker 1"] Q -- "get" --> W2["Worker 2"] Q -- "get" --> W3["Worker N"] W1 --> S{{"Semaphore
upstream limit"}} W2 --> S W3 --> S S --> U["Metadata service
(AsyncHTTPClient)"] W1 -. "task_done" .-> Q W2 -. "task_done" .-> Q W3 -. "task_done" .-> Q J["await queue.join()"] -. "resumes when every put
has a matching task_done" .-> Q
tornado.queues
The module provides a Queue base class (first-in first-out) and two subclasses, plus two exceptions. A queue is
unbounded when maxsize is 0 (the default); with a positive maxsize, put waits while the queue is full, which is
the built-in back-pressure mechanism.
| Name | Behaviour |
|---|---|
|
FIFO queue. |
|
Retrieves entries in priority order, lowest first. Entries are typically |
|
Retrieves the most recently put item first (a stack). |
|
Raised by |
| Method or property | Behaviour |
|---|---|
|
Returns a future that completes when the item is queued; waits while the queue is full. Raises
|
|
Queues the item or raises |
|
Returns an awaitable resolving to the next item; raises |
|
Returns an item or raises |
|
Called by a consumer after it has finished processing an item it received with |
|
Awaitable that completes when every |
|
Inspection helpers. |
|
Repeatedly awaits |
A Bounded Producer and Consumers
The next example is the shape of the official Queue example: a producer fills a bounded queue, consumers iterate
over it with async for, call task_done() in a finally block, and the main coroutine waits on join() before
cancelling the workers (which loop forever). The finally matters: if processing raises and task_done() is skipped,
join() never completes.
import asyncio
from tornado.queues import Queue
async def demo_basic():
shelf_jobs = Queue(maxsize=2) # at most 2 waiting items
done = []
async def worker(name):
async for title in shelf_jobs: # never ends by itself
try:
await asyncio.sleep(0.01) # pretend to do I/O
done.append((name, title))
finally:
shelf_jobs.task_done() # always, even on error
workers = [asyncio.create_task(worker(f"w{i}")) for i in range(3)]
for title in ["Dune", "Emma", "Ulysses", "Kim", "Ivanhoe"]:
await shelf_jobs.put(title) # waits whenever 2 are queued
await shelf_jobs.join(timeout=None) # every put has a task_done
for w in workers: # workers loop forever: stop them
w.cancel()
await asyncio.gather(*workers, return_exceptions=True)
return sorted(t for _, t in done)
assert asyncio.run(demo_basic()) == ["Dune", "Emma", "Ivanhoe", "Kim", "Ulysses"]
print("processed all five titles")
Source: Tornado: tornado.queues.Queue
Several workers consume the same queue and each item is delivered to exactly one of them; with three workers the five titles above may finish in any order, so the example compares a sorted list.
Priority and LIFO Queues
PriorityQueue suits jobs of different urgency, such as a reader’s own "refresh my cover" action versus a bulk
re-index. LifoQueue serves the newest item first, useful when stale work is worth less than fresh work.
import asyncio
from tornado.queues import LifoQueue, PriorityQueue
async def demo_ordering():
pq = PriorityQueue()
pq.put_nowait((10, "bulk re-index"))
pq.put_nowait((0, "reader refresh"))
pq.put_nowait((1, "new book"))
by_priority = [(await pq.get())[1] for _ in range(3)]
lq = LifoQueue()
for n in (3, 2, 1):
lq.put_nowait(n)
newest_first = [await lq.get() for _ in range(3)]
return by_priority, newest_first
by_priority, newest_first = asyncio.run(demo_ordering())
assert by_priority == ["reader refresh", "new book", "bulk re-index"]
assert newest_first == [1, 2, 3]
print(by_priority, newest_first)
A PriorityQueue is a binary heap. Two entries with equal priority are compared by their second element, so a
dictionary or a custom object as data raises TypeError on a tie. Add a monotonically increasing counter as a tie
breaker ((priority, next(counter), data)), which also keeps equal-priority jobs in arrival order.
Non-Blocking Calls and Timeouts
put_nowait and get_nowait never wait. A handler that must answer immediately uses put_nowait and turns
QueueFull into a 503 (or 429) response instead of letting requests pile up behind a full queue.
Every waiting method also takes a timeout. Its unit is a Tornado convention that differs from the standard library:
a plain number is an absolute deadline on the IOLoop.time clock (usually time.time), and a relative timeout must be
a datetime.timedelta. Passing 5 therefore means "five seconds after the epoch" and times out at once. The
timeout error is tornado.util.TimeoutError, which on current Python versions is the built-in TimeoutError.
import asyncio
import datetime
from tornado.queues import Queue, QueueEmpty, QueueFull
from tornado.util import TimeoutError as TornadoTimeoutError
async def demo_timeouts():
q = Queue(maxsize=1)
q.put_nowait("first")
try:
q.put_nowait("second") # full: fails immediately
except QueueFull:
outcome_full = "QueueFull"
try:
await q.put("second", timeout=datetime.timedelta(milliseconds=50))
except TornadoTimeoutError:
outcome_put = "put timed out"
assert q.get_nowait() == "first"
try:
q.get_nowait()
except QueueEmpty:
outcome_empty = "QueueEmpty"
try:
await q.get(timeout=datetime.timedelta(milliseconds=50))
except TornadoTimeoutError:
outcome_get = "get timed out"
return outcome_full, outcome_put, outcome_empty, outcome_get
print(asyncio.run(demo_timeouts()))
assert TornadoTimeoutError is TimeoutError
Source: Tornado: Queue.get
The Bookshelf Live Cover Fetcher
Bookshelf Live fetches cover metadata for newly added books from an external service. Starting one request per book
would overload the service and the process, so the app uses a bounded queue and a fixed number of workers, with a
Semaphore as a second, global limit. The metadata service is faked by a local Tornado server on a loopback port, so
the example is self-contained. AsyncHTTPClient is described fully in Async HTTP Client; here it is just
the awaitable HTTP client.
import asyncio
import json
from tornado.httpclient import AsyncHTTPClient
from tornado.httpserver import HTTPServer
from tornado.locks import Lock, Semaphore
from tornado.netutil import bind_sockets
from tornado.queues import Queue
from tornado.web import Application, RequestHandler
class FakeMetadata(RequestHandler):
calls = 0
active = 0
peak = 0
async def get(self, isbn):
cls = type(self)
cls.calls += 1
cls.active += 1
cls.peak = max(cls.peak, cls.active)
await asyncio.sleep(0.02) # upstream latency
cls.active -= 1
self.write(json.dumps({"isbn": isbn, "cover": f"/covers/{isbn}.jpg"}))
class CoverFetcher:
"""Bounded queue + N workers + a global limit on upstream calls."""
def __init__(self, base_url, workers=4, queue_size=50, upstream_limit=2):
self.base_url = base_url
self.queue = Queue(maxsize=queue_size)
self.upstream = Semaphore(upstream_limit)
self.cache_lock = Lock()
self.covers = {}
self.errors = []
self._n_workers = workers
self._tasks = []
def start(self):
self._tasks = [asyncio.create_task(self._worker())
for _ in range(self._n_workers)]
async def stop(self):
await self.queue.join() # finish what was accepted
for task in self._tasks:
task.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
def submit(self, isbn):
"""Called from a handler: never waits; raises QueueFull when saturated."""
self.queue.put_nowait(isbn)
async def _worker(self):
client = AsyncHTTPClient()
async for isbn in self.queue:
try:
async with self.cache_lock: # check-fetch-store is atomic
if isbn in self.covers:
continue # finally still runs
self.covers[isbn] = None # reserve the slot
async with self.upstream: # at most N upstream calls
response = await client.fetch(f"{self.base_url}/meta/{isbn}")
self.covers[isbn] = json.loads(response.body)["cover"]
except Exception as exc: # a worker must not die
self.errors.append((isbn, repr(exc)))
finally:
self.queue.task_done()
async def demo_fetcher():
sockets = bind_sockets(0, "127.0.0.1")
port = sockets[0].getsockname()[1]
server = HTTPServer(Application([(r"/meta/(\d+)", FakeMetadata)]))
server.add_sockets(sockets)
fetcher = CoverFetcher(f"http://127.0.0.1:{port}", workers=4, upstream_limit=2)
fetcher.start()
isbns = [str(9780000000000 + n) for n in range(12)]
for isbn in isbns + isbns[:3]: # three duplicates
fetcher.submit(isbn)
await fetcher.stop()
server.stop()
await server.close_all_connections()
return fetcher
fetcher = asyncio.run(demo_fetcher())
assert len(fetcher.covers) == 12 and all(fetcher.covers.values())
assert not fetcher.errors, fetcher.errors
assert FakeMetadata.calls == 12 # duplicates never hit upstream
assert FakeMetadata.peak <= 2 # the Semaphore held
print("fetched", len(fetcher.covers), "covers; upstream peak =", FakeMetadata.peak)
Source: Tornado: tornado.queues.Queue
Points to notice in the worker:
-
continueinsidetrystill runs thefinally, sotask_done()is called for every item, including skipped duplicates. Callingtask_done()twice for one item raisesValueError; never calling it hangsjoin(). -
The
Exceptionhandler keeps the worker alive. An uncaught exception ends the task silently (it is only logged when the task is garbage collected), and the pool shrinks one worker at a time. -
The
Lockis held only for the in-memory check, not across the network call, so the lock is a few microseconds and does not serialise the workers. TheSemaphoreis what limits the upstream concurrency. -
submitusesput_nowait. A handler would catchQueueFulland answer503 Service Unavailable, rather than suspending the request indefinitely.
Starting Workers with the Application
In a real service the pool is created next to the Application and lives for the process. Start it from the async
entry point after the event loop is running (not at import time), keep references to the tasks so they are not
garbage collected, and stop it after the server stops listening, as the example above does with stop(). A handler
reaches the pool through an application setting, so AsyncHTTPTestCase can build the same wiring, as in the test
below. Application and handler details, and testing conventions, are in Applications & Request Handlers and Testing.
import json
import unittest
from tornado.queues import Queue, QueueFull
from tornado.testing import AsyncHTTPTestCase
from tornado.web import Application, RequestHandler
class AddBookHandler(RequestHandler):
def post(self):
jobs = self.application.settings["jobs"]
try:
jobs.put_nowait(self.get_argument("isbn"))
except QueueFull:
self.set_status(503)
self.set_header("Retry-After", "5")
self.write({"error": "busy"})
return
self.set_status(202)
self.write({"queued": jobs.qsize()})
class AddBookTest(AsyncHTTPTestCase):
def get_app(self):
self.jobs = Queue(maxsize=2)
return Application([(r"/books", AddBookHandler)], jobs=self.jobs)
def test_accepts_then_pushes_back(self):
for n in (1, 2):
r = self.fetch("/books", method="POST", body=f"isbn=97800{n}")
self.assertEqual(r.code, 202)
r = self.fetch("/books", method="POST", body="isbn=978003")
self.assertEqual(r.code, 503)
self.assertEqual(r.headers["Retry-After"], "5")
self.assertEqual(self.jobs.qsize(), 2)
result = unittest.TextTestRunner(verbosity=0).run(
unittest.defaultTestLoader.loadTestsFromTestCase(AddBookTest))
assert result.wasSuccessful()
tornado.locks
The module mirrors the primitives of threading, adapted to coroutines.
| Class | Purpose | Waiting method and timeout result |
|---|---|---|
|
Mutual exclusion. Starts unlocked; |
|
|
A counter: |
|
|
A |
|
|
A boolean flag. |
|
|
Waiters wait for a notification; |
|
As with the queue methods, timeout is an absolute deadline unless given as a datetime.timedelta. A plain with
statement is rejected: with lock:, with semaphore: and the like raise RuntimeError with a message telling you to use async with,
because acquiring is asynchronous.
Source: Tornado: tornado.locks - Synchronization primitives
Lock: Atomic Sections that Contain an await
Without a Lock, two coroutines can both see "no cached value", both fetch, and both write. The counter below
makes the race visible: the unlocked version loses updates because each coroutine reads, awaits and then writes.
import asyncio
from tornado.locks import Lock
async def demo_lock():
counter = {"unlocked": 0, "locked": 0}
lock = Lock()
async def bump_unlocked():
value = counter["unlocked"]
await asyncio.sleep(0) # another coroutine runs here
counter["unlocked"] = value + 1
async def bump_locked():
async with lock:
value = counter["locked"]
await asyncio.sleep(0)
counter["locked"] = value + 1
await asyncio.gather(*[bump_unlocked() for _ in range(10)])
await asyncio.gather(*[bump_locked() for _ in range(10)])
return counter
print(counter := asyncio.run(demo_lock()))
assert counter["unlocked"] < 10 and counter["locked"] == 10
Source: Tornado: tornado.locks.Lock
Prefer to put as little as possible, and never a long network call, inside a held lock. When you only need to bound
concurrency rather than exclude, use a Semaphore.
Event: A One-Shot Announcement
An Event is the natural "ready" or "stopping" flag. In Bookshelf Live a background task warms the genre cache and
request handlers that need it wait on the event, with a deadline so a failed warm-up cannot hang them.
import asyncio
import datetime
from tornado.locks import Event
from tornado.util import TimeoutError as TornadoTimeoutError
async def demo_event():
cache_ready = Event()
async def warm_cache():
await asyncio.sleep(0.02)
cache_ready.set() # wakes every waiter at once
async def handler(n):
await cache_ready.wait(timeout=datetime.timedelta(seconds=1))
return n
asyncio.create_task(warm_cache())
served = await asyncio.gather(*[handler(n) for n in range(3)])
never = Event()
try:
await never.wait(timeout=datetime.timedelta(milliseconds=30))
timed_out = False
except TornadoTimeoutError:
timed_out = True
return served, cache_ready.is_set(), timed_out
served, is_set, timed_out = asyncio.run(demo_event())
assert served == [0, 1, 2] and is_set and timed_out
print(served, is_set, timed_out)
Source: Tornado: tornado.locks.Event
Condition: "Something Changed"
A Condition suits repeating notifications, such as the "new reviews" feed: each long-polling client waits, and
the code that saves a review calls notify_all(). Unlike an Event, a notification is not remembered: a coroutine
that calls wait() after notify() waits for the next one, so check the data you care about in a loop, and use the
return value to distinguish a timeout.
import asyncio
import datetime
from tornado.locks import Condition
async def demo_condition():
new_reviews = Condition()
reviews = []
async def long_poll(seen):
while len(reviews) <= seen:
notified = await new_reviews.wait(
timeout=datetime.timedelta(milliseconds=500))
if not notified:
return "timeout" # wait() returns False, no exception
return reviews[seen]
pollers = [asyncio.create_task(long_poll(0)) for _ in range(3)]
await asyncio.sleep(0.02)
reviews.append("Great book")
new_reviews.notify_all()
got = await asyncio.gather(*pollers)
quiet = await long_poll(1) # no review arrives
return got, quiet
print(asyncio.run(demo_condition()))
Source: Tornado: tornado.locks.Condition
Since Tornado 5.0 a notified waiter always resumes on the next iteration of the IOLoop, never synchronously inside
notify().
Semaphore and BoundedSemaphore
A Semaphore(2) lets two coroutines in at once. BoundedSemaphore additionally protects against unbalanced
releases; the plain Semaphore silently accepts them, which quietly raises your concurrency limit.
import asyncio
import datetime
from tornado.locks import BoundedSemaphore, Lock, Semaphore
from tornado.util import TimeoutError as TornadoTimeoutError
async def demo_semaphore():
sem = Semaphore(2)
running = peak = 0
async def job():
nonlocal running, peak
async with sem:
running += 1
peak = max(peak, running)
await asyncio.sleep(0.01)
running -= 1
await asyncio.gather(*[job() for _ in range(8)])
leaky = Semaphore(1)
leaky.release() # accepted: limit is now 2
bounded = BoundedSemaphore(1)
try:
bounded.release() # more releases than acquires
except ValueError as exc:
bounded_error = str(exc)
lock = Lock()
try:
lock.release() # not locked
except RuntimeError:
lock_error = "RuntimeError"
await lock.acquire()
try:
await lock.acquire(timeout=datetime.timedelta(milliseconds=30))
except TornadoTimeoutError:
reacquire = "timed out (locks are not re-entrant)"
try:
with lock: # wrong: must be `async with`
pass
except RuntimeError:
sync_with = "RuntimeError"
return peak, bounded_error, lock_error, reacquire, sync_with
result = asyncio.run(demo_semaphore())
assert result[0] == 2
print(result)
Choosing a Primitive
| Need | Use |
|---|---|
Hand work from handlers to a pool and slow producers down when workers fall behind |
|
Urgent work first / newest work first |
|
Only one coroutine at a time inside a section that awaits |
|
At most N coroutines at a time (upstream limit, connection budget) |
|
A one-time "ready" or "stop" signal remembered for late arrivals |
|
Repeated "state changed, re-check" wake-ups |
|
Wait for all submitted work to finish |
|
Coroutine-Safe, Not Thread-Safe
These classes are not thread-safe. They are meant to coordinate coroutines in a single-threaded application, not to
protect shared data in a multi-threaded one and not to replace the threading module’s primitives. They keep plain
deque and set structures and complete futures directly, so calling queue.put_nowait(item) from another thread
can corrupt the queue state or, worse, leave the event loop asleep with the new item never noticed.
The safe pattern is to schedule the call onto the loop’s thread with IOLoop.add_callback (documented as safe to call
from other threads) or with asyncio's loop.call_soon_threadsafe:
import asyncio
import threading
from tornado.ioloop import IOLoop
from tornado.queues import Queue
async def demo_threads():
q = Queue()
loop_thread = threading.get_ident()
io_loop = IOLoop.current()
def blocking_library():
# Runs in a worker thread (for example a CPU-bound or blocking call).
# WRONG: q.put_nowait("from a thread")
# RIGHT: hand the call to the event loop thread.
io_loop.add_callback(q.put_nowait, "from a thread")
return threading.get_ident()
worker_thread = await asyncio.to_thread(blocking_library)
item = await q.get()
return item, worker_thread != loop_thread
print(asyncio.run(demo_threads()))
Source: Tornado: IOLoop.add_callback
Two consequences for deployment follow. First, a tornado.queues.Queue lives in one process; Tornado is usually run as one
process per CPU, so each process has its own queue and its own workers, and a job queued in one process is invisible
to the others. Second, when jobs must survive a restart, be shared across processes or run on other machines, use
a broker-backed task queue; the Flask section’s
Background Jobs, Celery & Email page shows that model, and
the message-broker material under Messaging applies equally to Tornado. In-process
queues are for short-lived, loss-tolerant work such as fetching a cover image.
The asyncio Equivalents
Since Tornado 5 the IOLoop is a thin wrapper over the asyncio event loop, and Tornado futures are asyncio futures,
so Tornado and asyncio primitives can be mixed in one program. The Tornado classes predate asyncio’s and differ in a few
details, which matter when you copy code between the two or read older examples.
| Tornado | asyncio | Differences |
|---|---|---|
|
|
Both have |
|
|
Both are used with |
|
|
Same semantics. Tornado accepts |
|
|
|
|
|
Tornado’s has no underlying lock and |
New code written for Tornado can use either family, and plain asyncio primitives are fine inside a Tornado
application. Prefer Tornado’s classes when you want the built-in timeout argument and async for on the queue;
prefer asyncio’s when the code must also run outside Tornado. The following example uses the asyncio queue and the
asyncio.timeout() wrapper inside a Tornado request handler test, to show they coexist:
import asyncio
import json
from tornado.testing import AsyncHTTPTestCase
from tornado.web import Application, RequestHandler
import unittest
import tornado.concurrent
class ReviewFeed(RequestHandler):
async def get(self):
q = self.application.settings["feed"] # an asyncio.Queue
try:
async with asyncio.timeout(1):
review = await q.get()
except TimeoutError:
self.set_status(204)
return
self.write(json.dumps({"review": review}))
class FeedTest(AsyncHTTPTestCase):
def get_app(self):
self.feed = asyncio.Queue()
return Application([(r"/feed", ReviewFeed)], feed=self.feed)
def test_mixed_primitives(self):
self.feed.put_nowait("Loved it")
r = self.fetch("/feed")
self.assertEqual(json.loads(r.body), {"review": "Loved it"})
assert tornado.concurrent.Future is asyncio.Future
result = unittest.TextTestRunner(verbosity=0).run(
unittest.defaultTestLoader.loadTestsFromTestCase(FeedTest))
assert result.wasSuccessful()
Source: Python: asyncio queues
Pitfalls
-
Forgotten or doubled
task_done(). Never calling it makesjoin()hang; calling it more often than items were put raisesValueError. Put it in afinally. -
Workers that die. An unhandled exception ends the worker task and, because nobody awaits it, goes unnoticed. Catch
Exceptionin the loop body, log it, and still calltask_done(). -
Workers never stop.
async for item in queueandwhile Trueloops run forever; stop them by cancelling their tasks afterjoin(), or enqueue one sentinel per worker, and await them so shutdown is clean. -
Unbounded queues.
Queue()withoutmaxsizeturns a slow worker pool into unbounded memory growth. Pick amaxsize, and decide whether producers wait (put), time out (put(timeout=...)) or are rejected with503(put_nowaitplusQueueFull). -
Absolute versus relative timeouts.
timeout=5is a point in time, not five seconds. Usedatetime.timedelta(seconds=5). This is the most common mistake when porting fromqueue.Queueor asyncio. -
Two timeout conventions.
Condition.waitreturnsFalse; every other waiting method raisestornado.util.TimeoutError. -
Holding a
Lockacross slow I/O. Serialises every coroutine behind it; lock the check and the store, not the fetch, or use a per-key lock or aSemaphore. -
Re-entrant locking. A
Lockis not re-entrant; a coroutine that acquires it twice deadlocks itself. A timeout onacquireis the safety net. -
Using
withinstead ofasync with. RaisesRuntimeErrorforLockandSemaphore. -
Blocking the loop inside a worker. A synchronous HTTP library or
time.sleepinside a worker stalls every request of the process. UseAsyncHTTPClient, or push the blocking call to a thread withasyncio.to_threador an executor. -
Using the primitives from threads. They are not thread-safe; hop onto the loop with
IOLoop.add_callback. -
Per-process state. With several Tornado processes behind a proxy, each has its own queue and locks; a lock does not exclude coroutines in another process. Use a database constraint, a Redis lock or a broker for cross-process coordination.
-
Fire-and-forget tasks. Keep a reference to every task you create (the pool keeps its task list); the event loop only holds weak references to tasks.
Recent Changes and Version Notes
| Version | Change |
|---|---|
Tornado 4.3 |
|
Tornado 5.0 |
Tornado runs on the |
Tornado 6.6 (alpha only) |
Upcoming, not released: Python 3.11 becomes the minimum version, and Python 3.15 and free-threaded builds are supported. Treat the queues and locks as single-event-loop primitives regardless of the Python build. |
Source: Tornado: Release notes
Related Reading
-
The Flask section’s Async, Gevent & Quart page compares how a WSGI framework tries to wait on I/O, which is the problem these primitives solve natively.
-
The FastAPI section’s Async & Concurrency page covers the
asyncioside of the same ideas, including task groups and the thread pool. -
The Python Reference’s Python section covers
asyncioitself. -
Applications & Request Handlers, Async HTTP Client, WebSockets and Streaming, Long Polling & SSE build on the queue and event patterns above.