Coroutines and Async I/O
Tornado’s core advantage lies in its event-loop-based asynchronous I/O model. To understand this model, you need to start with Python’s iterators and generators and progressively build a mental model of coroutines and async/await. This article moves from concepts to practice, covering common scenarios such as asynchronous HTTP requests and concurrent calls.
Synchronous vs. Asynchronous
In the synchronous model, every I/O operation (network request, database query, file read/write) blocks the current thread until a result is returned before execution can continue. Under high concurrency, large numbers of threads sit idle, incurring enormous memory and context-switching overhead.
In the asynchronous model, an I/O operation does not block. Instead, the current task is suspended and the event loop switches to other tasks that are ready to run. When the I/O completes, execution resumes. A single thread can handle a large number of concurrent connections with minimal overhead.
Synchronous (each request occupies a thread):
Request 1 → [wait for I/O] → process → respond
Request 2 → wait for a free thread → [wait for I/O] → process → respond
Asynchronous (single-thread event loop):
Request 1 → [initiate I/O] → suspend
Request 2 → [initiate I/O] → suspend
[I/O completion notification] → resume request 1 → process → respond
[I/O completion notification] → resume request 2 → process → respondIterators and Generators (Foundational Concepts)
Understanding coroutines requires first mastering iterators and generators.
Iterators
An object that implements __iter__() and __next__() is an iterator:
class Counter:
def __init__(self, n):
self.n = n
self.i = 0
def __iter__(self):
return self
def __next__(self):
if self.i >= self.n:
raise StopIteration
self.i += 1
return self.i
for x in Counter(3):
print(x) # 1 2 3Generators
When a function contains a yield statement, calling it returns a generator object. The function body runs up to the next yield each time next() is called:
def countdown(n):
while n > 0:
yield n
n -= 1
gen = countdown(3)
print(next(gen)) # 3
print(next(gen)) # 2
print(next(gen)) # 1yield can also receive values via send(), enabling two-way communication:
def accumulator():
total = 0
while True:
value = yield total # Suspend and receive a value from the caller
if value is None:
break
total += value
acc = accumulator()
next(acc) # Initialize (run to the first yield)
acc.send(10) # → total = 10
acc.send(20) # → total = 30yield from
yield from delegates to a sub-generator, transparently passing yield values from the sub-generator to the outer caller, and passing values sent by the outer caller into the sub-generator:
def inner():
yield 1
yield 2
def outer():
yield from inner()
yield 3
list(outer()) # [1, 2, 3]This delegation mechanism is the foundation of early coroutines.
Tornado Coroutines: async/await
Tornado 6.x is fully based on Python 3 native async/await syntax, which is the recommended approach.
Basic Pattern
import tornado.web
import tornado.httpclient
class AsyncHandler(tornado.web.RequestHandler):
async def get(self):
# await suspends the current coroutine; the event loop continues processing other requests
client = tornado.httpclient.AsyncHTTPClient()
response = await client.fetch("https://httpbin.org/get")
self.write(response.body)async def defines a coroutine function; await waits for an awaitable object (coroutine, Future, or Task) to complete. The key difference from ordinary synchronous functions: while await is waiting, the event loop is not blocked — other requests can continue to be processed.
@gen.coroutine (Legacy Style)
Tornado 5.x and earlier used the @gen.coroutine decorator with yield to implement coroutines. You may encounter this in older codebases:
from tornado import gen
class OldStyleHandler(tornado.web.RequestHandler):
@gen.coroutine
def get(self):
client = tornado.httpclient.AsyncHTTPClient()
response = yield client.fetch("https://httpbin.org/get")
self.write(response.body)async/await for all new code. @gen.coroutine and async def can await/yield each other, so mixing them in the same codebase is fine.Async HTTP Requests
AsyncHTTPClient is Tornado’s built-in asynchronous HTTP client for making non-blocking external requests:
import json
import tornado.httpclient
class WeatherHandler(tornado.web.RequestHandler):
async def get(self):
city = self.get_argument("city", "Beijing")
client = tornado.httpclient.AsyncHTTPClient()
# Build the request (can customize headers, method, body, timeout, etc.)
request = tornado.httpclient.HTTPRequest(
url=f"https://api.example.com/weather?city={city}",
method="GET",
headers={"Authorization": "Bearer token123"},
connect_timeout=5,
request_timeout=10,
)
try:
response = await client.fetch(request)
data = json.loads(response.body)
self.write(data)
except tornado.httpclient.HTTPError as e:
self.set_status(e.code)
self.write({"error": str(e)})Common HTTPRequest parameters:
| Parameter | Description |
|---|---|
url | Request URL |
method | HTTP method (default GET) |
headers | Request headers dictionary |
body | Request body (used for POST/PUT) |
connect_timeout | Connection timeout in seconds (default 20) |
request_timeout | Total request timeout in seconds (default 20) |
follow_redirects | Whether to follow redirects (default True) |
validate_cert | Whether to validate SSL certificates (default True) |
Parallel Coroutines
When multiple async tasks are independent of each other, running them in parallel rather than awaiting them sequentially can dramatically reduce total execution time.
Using asyncio.gather
import asyncio
import tornado.httpclient
class ParallelHandler(tornado.web.RequestHandler):
async def get(self):
client = tornado.httpclient.AsyncHTTPClient()
# Create multiple coroutine tasks and launch requests in parallel
results = await asyncio.gather(
client.fetch("https://httpbin.org/get"),
client.fetch("https://httpbin.org/ip"),
client.fetch("https://httpbin.org/headers"),
)
self.write({
"task1": results[0].body.decode(),
"task2": results[1].body.decode(),
"task3": results[2].body.decode(),
})Using a yield List (gen.coroutine Style)
In the legacy coroutine style, yielding a list or dictionary runs tasks in parallel:
from tornado import gen
class ParallelHandler(tornado.web.RequestHandler):
@gen.coroutine
def get(self):
client = tornado.httpclient.AsyncHTTPClient()
# yield a list: runs in parallel, returns a list of results
r1, r2 = yield [
client.fetch("https://httpbin.org/get"),
client.fetch("https://httpbin.org/ip"),
]
# yield a dict: runs in parallel, returns a dict with the same keys
results = yield {
"info": client.fetch("https://httpbin.org/get"),
"ip": client.fetch("https://httpbin.org/ip"),
}
self.write(results["ip"].body)Running Synchronous Blocking Code in Tornado
If you must call a synchronous blocking function (such as legacy pymysql), use loop.run_in_executor() to run it in a thread pool, keeping the event loop unblocked:
import asyncio
import concurrent.futures
executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
class DbHandler(tornado.web.RequestHandler):
async def get(self):
loop = asyncio.get_event_loop()
# Run the blocking function in a thread pool
result = await loop.run_in_executor(executor, blocking_db_query, user_id)
self.write({"result": result})aiomysql for MySQL, motor for MongoDB, and aioredis for Redis. Running synchronous I/O through a thread pool should only be a transitional solution.