Performance and internals

aiohttp · Expert · 12 min

Expert-level performance work means knowing which layer costs what and how the event loop behaves under load.

aiohttp's speed comes from a few components:

  • HTTP parsing uses a C extension built on llhttp (Node.js's parser), with a pure-Python fallback. Setting AIOHTTP_NO_EXTENSIONS=1 forces the Python implementation, useful when debugging but much slower.
  • multidict provides the case-insensitive, multi-value header and query containers, also in C.
  • yarl implements the immutable URL type; building URLs with yarl (URL(base) / "path" % {"q": 1}) avoids string bugs.
  • frozenlist, aiosignal and propcache support signals and cached properties.

Each process runs one event loop on one thread. Throughput depends on never blocking it:

  • Any synchronous call that takes milliseconds (a blocking database driver, json.dumps of a huge object, image processing, time.sleep) delays every connection. Offload with await asyncio.to_thread(fn, ...) or a process pool.
  • Run with PYTHONASYNCIODEBUG=1 or loop.set_debug(True): asyncio logs callbacks that take longer than loop.slow_callback_duration (100 ms by default).
  • Profile with py-spy against the live process, or yappi for coroutine-aware profiling.

Other levers: uvloop as the event loop, orjson for JSON (json_response(dumps=...) and resp.json(loads=...) accept custom functions), connection reuse, and response streaming (web.StreamResponse) for large payloads instead of building them in memory. Scale across cores by running several processes, since one loop uses one core.

Example

import asyncio
import time
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer

def blocking_work():
    time.sleep(0.2)              # stands in for a sync DB call or heavy CPU
    return "done"

async def bad(request):
    return web.Response(text=blocking_work())          # blocks the loop

async def good(request):
    return web.Response(text=await asyncio.to_thread(blocking_work))
async def main():
    app = web.Application()
    app.add_routes([web.get("/bad", bad), web.get("/good", good)])
    async with TestClient(TestServer(app)) as c:
        for path in ("/bad", "/good"):
            t = time.perf_counter()
            await asyncio.gather(*(c.get(path) for _ in range(5)))
            print(path, round(time.perf_counter() - t, 1))
# /bad 1.0   (five requests run one after another)
# /good 0.2  (threads overlap)
asyncio.run(main())

Remember

  • C extensions (llhttp parser, multidict, yarl) make aiohttp fast
  • One loop per process: any blocking call stalls all connections
  • Use to_thread or process pools, asyncio debug mode and py-spy to find stalls

Watch out for

  • Using a synchronous database driver inside handlers.

Check yourself

  1. Five concurrent requests hit a handler that calls time.sleep(0.2). Roughly how long until all finish?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.aiohttp.org/en/stable/web_advanced.html