Python’s concurrency story is a bit like a three-ring circus, and picking the right act depends entirely on what kind of performance you need.

Let’s see asyncio in action with a simple web server that handles multiple requests concurrently without blocking.

import asyncio
import time

async def handle_request(reader, writer):
    addr = writer.get_extra_info('peername')
    print(f"Connection from {addr}")

    request_data = await reader.read(100)
    message = request_data.decode()
    print(f"Received: {message!r} from {addr}")

    print(f"Sending: Hello from server! from {addr}")
    writer.write(b"Hello from server!\n")
    await writer.drain()

    print(f"Close the connection from {addr}")
    writer.close()

async def main():
    server = await asyncio.start_server(
        handle_request, '127.0.0.1', 8888)

    addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets)
    print(f'Serving on {addrs}')

    async with server:
        await server.serve_forever()

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("Server stopped.")

If you run this server and then open two terminal windows to connect to it using telnet or nc:

# Terminal 1
telnet 127.0.0.1 8888
GET request 1
# Terminal 2
telnet 127.0.0.1 8888
GET request 2

You’ll notice that both requests are handled. The server doesn’t wait for request 1 to finish before starting to process request 2. This is because asyncio uses a single thread and an event loop to manage these concurrent operations. When handle_request encounters an await (like waiting for data to be read from the network), it yields control back to the event loop, which can then switch to another ready task, such as processing the incoming data for the second connection.

The core problem asyncio solves is I/O-bound concurrency within a single thread. Imagine a web server needing to fetch data from three different databases. A traditional, synchronous approach would be to query database 1, wait for it, query database 2, wait, query database 3, wait. This is incredibly inefficient if the databases are slow. asyncio lets you initiate all three queries almost simultaneously. While waiting for database 1’s response, it can process incoming data for database 2, or start processing database 3’s query. It’s cooperative multitasking: tasks explicitly give up control when they would otherwise block.

Under the hood, asyncio relies on coroutines, which are functions defined with async def. These coroutines can be paused and resumed. The event loop is the conductor, constantly checking which coroutines are ready to run and which are waiting on I/O. When a coroutine awaits something, it tells the event loop, "I’m going to be busy waiting for this. Wake me up when it’s done." The event loop then looks for other coroutines that are ready to execute. This single-threaded, cooperative model is extremely efficient for I/O-bound tasks because it avoids the overhead of creating and switching between multiple threads or processes.

The key levers you control in asyncio are:

  • Coroutines (async def): These are the basic building blocks.
  • await keyword: This is how coroutines yield control and wait for other asynchronous operations to complete.
  • Event Loop: This is managed by asyncio.run() and asyncio.get_event_loop(), and it orchestrates the execution of coroutines.
  • Tasks: asyncio.create_task() wraps a coroutine into a Task object, which is scheduled to run by the event loop.

The real magic of asyncio is that while one coroutine is waiting for a network response, or a database query, or a file read, the same thread can be executing other coroutines. It’s not about doing things in parallel (like multiprocessing) or even true concurrency in the sense of multiple threads executing code simultaneously (like threading on multi-core CPUs). It’s about maximizing the utilization of a single thread by rapidly switching between tasks that are waiting for external operations to complete. This is why it excels at I/O-bound workloads where the CPU spends most of its time idle, waiting for data.

Most people think of asyncio as just being about await, but the async keyword itself is crucial. It signals to Python that this function is a coroutine and can be paused. Without async, you can’t use await inside it, and without await, you can’t pause execution to let other coroutines run. The event loop itself is what makes this work; it’s the scheduler that keeps track of all the paused coroutines and resumes them when their awaited operations are ready.

The next hurdle is managing complex dependencies between asynchronous tasks and handling exceptions across them.

Want structured learning?

Take the full Python course →