Skip to content

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 → respond

Iterators 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 3

Generators

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))   # 1

yield 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 = 30

yield 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)
Use 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:

ParameterDescription
urlRequest URL
methodHTTP method (default GET)
headersRequest headers dictionary
bodyRequest body (used for POST/PUT)
connect_timeoutConnection timeout in seconds (default 20)
request_timeoutTotal request timeout in seconds (default 20)
follow_redirectsWhether to follow redirects (default True)
validate_certWhether 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})
In production, prefer native async drivers for database operations: use aiomysql for MySQL, motor for MongoDB, and aioredis for Redis. Running synchronous I/O through a thread pool should only be a transitional solution.
Last updated on