Before We Begin: What This Article Is Trying to Solve

Many Python async tutorials introduce async def, await, Task, and Future immediately, without first explaining how they relate to one another. Readers can often imitate the code but still struggle to answer questions such as:

  • Why does calling an async def function not execute it immediately?
  • What exactly can appear after await?
  • Why is await time.sleep(5) not asynchronous, and why does it eventually raise an error?
  • While await fetch_a() is waiting, why can the event loop not simply move to the next line and execute fetch_b()?
  • What is the difference between a coroutine, a Task, and a Future?
  • Which protocols power async for and async with?
  • Why do asynchronous programs still need thread pools?
  • Can asyncio.to_thread() solve CPU-bound computation?
  • How should these capabilities be used in FastAPI, LLM streaming, and AI Agent tool calls?

This article starts with synchronous iteration, builds a complete mental model step by step, and then moves into coroutines, task scheduling, and engineering practice.


Five Things to Remember First

If you do not want to read the entire article yet, remember these five points:

  1. Calling an ordinary function executes it immediately; calling a coroutine function only creates a coroutine object and does not run it automatically.
  2. The event loop schedules Tasks and callbacks that already exist. It does not jump to a line in the current coroutine that has not executed yet.
  3. await can wait only for an awaitable. The most common awaitables are coroutine objects, Tasks, and Futures.
  4. Consecutive direct await expressions normally run sequentially. To run multiple coroutines concurrently, schedule all of them as Tasks first, or pass them to TaskGroup or gather().
  5. async def does not automatically turn synchronous blocking code into non-blocking code.

Part 1: Build the Right Concurrency Mental Model

1. The Restaurant Kitchen Metaphor

Imagine a restaurant with only one chef.

The chef can genuinely do only one thing at a time:

chop ingredients
stir a pan
plate a dish

But after putting soup on the stove and waiting for it to boil, the chef does not have to stand still. They can work on another dish and return when the stove signals that the soup is ready.

This resembles the typical way asyncio works:

one event-loop thread
+
multiple Tasks that can pause and resume
+
I/O completion notifications

This produces concurrency:

Multiple pieces of work make alternating progress during the same period.

It does not necessarily produce parallelism:

Multiple pieces of work execute at the exact same moment on multiple CPU cores.


2. Where the Chef Metaphor Breaks Down

The metaphor is useful, but it needs two important corrections.

The Event Loop Is Not an Intelligent Chef That Reads Source Code

The event loop does not see:

await fetch_a()
await fetch_b()

and decide on its own:

“A is waiting, so I will jump to the next line and execute B.”

It can run only Tasks that have already been created and scheduled, registered callbacks, and ready I/O events.

If fetch_b() has not been called yet, then:

  • there is no fetch_b coroutine object;
  • there is certainly no corresponding Task;
  • the event loop does not even know that B exists.

Cooperative Scheduling Is Not Preemptive Scheduling

asyncio primarily uses cooperative scheduling. While a Task is running, the event loop does not forcibly interrupt it at arbitrary points. A Task usually has to run until it:

  • reaches an await that is not yet complete;
  • yields control voluntarily; or
  • finishes execution;

before the event loop can run another Task.

The Python documentation describes this behavior as follows: the event loop runs all callbacks and Tasks in one thread; when the running Task reaches an await and is suspended, the event loop can execute the next Task.1


3. Four Pairs of Easily Confused Concepts

Concurrency and Parallelism

Concurrency:
multiple tasks make alternating progress

Parallelism:
multiple tasks execute simultaneously on multiple execution units

asyncio mainly addresses I/O concurrency. CPU parallelism usually requires multiple processes, multiple interpreters, or native extensions that release the GIL.

Blocking and Non-Blocking

Blocking:
the caller must stop here until the operation finishes

Non-blocking:
the caller can handle other work while the operation is incomplete

time.sleep(5) blocks the current thread. await asyncio.sleep(5) suspends the current Task and lets the event loop run other Tasks.

Synchronous and Asynchronous

Synchronous interface:
the result is normally available when the current call returns

Asynchronous interface:
the call first returns an object representing a future result,
which can be awaited later

I/O-Bound and CPU-Bound

I/O-bound:
most time is spent waiting for networks, databases, disks,
or external services

CPU-bound:
most time is spent performing Python computation

Async programming is particularly suitable for:

  • LLM API requests;
  • vector database queries;
  • HTTP tool calls;
  • database requests;
  • message queues;
  • streaming responses.

It does not automatically accelerate:

  • large matrix computations;
  • text compression;
  • image processing;
  • complex loops written in pure Python.

Part 2: The Synchronous Iteration System

Before learning async for, first understand the protocol behind an ordinary for loop.

1. Iterable

An Iterable is:

An object from which iter(obj) can obtain an iterator.

Common Iterables include:

list
tuple
dict
set
str
range

For example:

numbers = [10, 20, 30]
iterator = iter(numbers)

More precisely, an Iterable is a source of iterators. Many containers that can be traversed repeatedly return a new independent iterator each time iter() is called, but the protocol itself does not require every Iterable to be repeatable.


2. Iterator

An Iterator is the object that actually maintains traversal state.

It must support:

__iter__()
__next__()

Here:

  • __iter__() returns the iterator itself;
  • __next__() returns the next item;
  • when no items remain, it raises StopIteration.

For example:

numbers = [10, 20, 30]

it1 = iter(numbers)
it2 = iter(numbers)

next(it1)  # 10
next(it1)  # 20

next(it2)  # 10

it1 and it2 each maintain their own current position.

Why Calling iter() on an Iterator Returns Itself

it = iter([1, 2, 3])

iter(it) is it  # True

It is already an iterator, so there is no need to create another wrapper.

Do not describe this as a factory in the full sense of the word. It returns itself only to satisfy the iteration protocol; unlike a list, it normally does not create a fresh independent cursor.

Why an Iterator Is Usually Single-Use

it = iter([1, 2, 3])

list(it)  # [1, 2, 3]
list(it)  # []

The first call has already advanced the cursor to the end, so the second call has nothing left to consume.


3. Generator

A generator is a special Iterator written with yield.

def countdown(n: int):
    while n > 0:
        yield n
        n -= 1

Calling the generator function:

generator = countdown(3)

does not execute the entire function immediately. It returns a generator object.

Each call to:

next(generator)

resumes the function from the position after the previous yield.

The main benefits of generators are:

  • lazy computation;
  • incremental output;
  • lower peak memory use;
  • a natural way to express data streams.

Remember the relationship as:

A Generator is a kind of Iterator
An Iterator is also an Iterable

4. What a for Loop Really Does

This code:

for item in source:
    consume(item)

is conceptually close to:

iterator = iter(source)

while True:
    try:
        item = next(iterator)
    except StopIteration:
        break

    consume(item)

for is syntactic sugar for the iteration protocol.


Part 3: Four Function Forms

def versus async def, and return versus yield, are two separate dimensions.

DefinitionWhat calling it returnsTypical consumption
def + returnAn ordinary return valueUse it directly
def + yieldA Generatorfor
async def + returnA Coroutine objectawait
async def + yieldAn Async generatorasync for

1. Ordinary Function

def add(a: int, b: int) -> int:
    return a + b

It executes immediately when called:

result = add(1, 2)

2. Synchronous Generator

def generate_numbers():
    yield 1
    yield 2

Calling it returns a generator:

generator = generate_numbers()

3. Coroutine Function

async def fetch_data() -> str:
    return "done"

Calling it returns a coroutine object:

coroutine = fetch_data()

At this point, the function body normally has not executed.


4. Asynchronous Generator

async def stream_tokens():
    yield "A"
    yield "B"

Calling it returns an asynchronous generator:

stream = stream_tokens()

Consume it with:

async for token in stream:
    print(token)

An asynchronous generator can use a value-less return to end, but it cannot use return some_value the way an ordinary coroutine can.


Part 4: Coroutine Functions and Coroutine Objects

This is one of the most important distinctions in async programming.

1. Coroutine Function

async def fetch_data() -> str:
    return "done"

fetch_data is a coroutine function.

You can inspect it with:

import inspect

inspect.iscoroutinefunction(fetch_data)  # True

2. Coroutine Object

Calling it:

coroutine = fetch_data()

returns a coroutine object.

inspect.iscoroutine(coroutine)  # True
inspect.isawaitable(coroutine)  # True

inspect is a Python standard-library module for examining live objects. inspect.isawaitable(obj) determines whether an object can be used in an await expression.2


3. Why Calling a Coroutine Function Does Not Execute It Immediately

An ordinary function:

def normal():
    print("running")

normal()
# 立即打印

A coroutine function:

async def async_function():
    print("running")

coroutine = async_function()
# 通常还没有打印

Calling a coroutine function mainly does the following:

creates a coroutine object
stores its local execution state
waits for it to be awaited or scheduled later

If you neither use await on it nor schedule it:

async_function()

you will normally see:

RuntimeWarning: coroutine was never awaited

The Python documentation explicitly states that merely calling a coroutine function does not schedule it to run.3


Part 5: What Exactly Is an Awaitable?

1. Definition

An Awaitable is:

An object that can appear after await and eventually produce a result, an exception, or a cancellation state.

result = await awaitable

The three most common kinds of awaitable in Python are:

Coroutine
Task
Future

The Python data model specifies that a general custom awaitable is normally implemented through the __await__() protocol, and __await__() must return an iterator.4


2. Coroutine: A Description of Asynchronous Execution

async def load_user() -> dict:
    ...

Calling it:

coroutine = load_user()

describes:

How this asynchronous code should execute.

When you directly await a coroutine, Python normally does not create a separate independent Task. The current Task enters and drives that coroutine until it completes or reaches an operation that must wait.


3. Task: A Coroutine Scheduled by the Event Loop

A Task is a scheduling wrapper around a coroutine.

task = asyncio.create_task(load_user())

This means:

Register this coroutine with the current event loop so that it can run independently as a Task as soon as possible.

A Task also provides:

task.cancel()
task.done()
task.result()
task.exception()

A Task is itself awaitable:

user = await task

4. Future: A Placeholder for a Future Result

A Future represents:

An asynchronous operation that will complete later, although its result is not ready yet.

PENDING
→ FINISHED / CANCELLED

A Future can eventually hold:

  • a return value;
  • an exception;
  • a cancellation state.

Low-level libraries often use Futures to connect callback-based I/O to the async / await model.

Application code normally does not need to create Futures directly. The Python documentation also describes Future as a low-level awaitable.3

The Relationship Between Task and Future

Think of them this way:

Future:
represents “a result will exist in the future”

Task:
represents “run a coroutine and expose its final result
through a Future-like object”

Do not confuse:

asyncio.Future
concurrent.futures.Future

They belong to different concurrency systems.5


Part 6: What Does await Actually Do?

1. Evaluate the Expression on the Right First

In this code:

result = await operation()

the first step is not “yield control to the event loop immediately.” Python first calls:

operation()

and then checks whether its return value is awaitable.

This is the key to understanding await time.sleep(5).


2. When the Awaitable Is Not Complete

The conceptual process is:

the current Task executes await
→ the awaitable is not complete
→ the current Task is suspended
→ the event loop runs other ready Tasks
→ the awaitable completes
→ the current Task resumes
→ the await expression produces a result

3. When the Awaitable Is Already Complete

If the object being awaited has already completed, await may retrieve its result and continue immediately, without an observable task switch.

It is therefore more accurate to say:

await provides an opportunity to suspend, but not every await necessarily causes a scheduling switch.

asyncio.sleep() is a special and explicit example: the official documentation says that it always suspends the current Task so other Tasks can run.3


4. await Does Not Create a Thread Automatically

This code does not automatically send a synchronous function to the background:

await blocking_function()

Python first executes blocking_function() synchronously.

Only if the return value is awaitable can await continue working.


Part 7: Why await time.sleep(5) Does Not Work

Incorrect code:

import time

async def example() -> str:
    await time.sleep(5)
    return "done"

Its actual execution order is:

1. Call time.sleep(5)
2. Block the event-loop thread synchronously for 5 seconds
3. time.sleep() returns None
4. Python attempts to execute await None
5. Raise TypeError

The error resembles:

TypeError: object NoneType can't be used in 'await' expression

because:

time.sleep(5) is None

The correct version is:

import asyncio

async def example() -> str:
    await asyncio.sleep(5)
    return "done"

The difference is:

time.sleep(5)
= block the current thread

await asyncio.sleep(5)
= suspend the current Task while the event loop continues running other Tasks

If you must call a synchronous blocking function such as time.sleep(), you can place it in a worker thread:

import asyncio
import time

async def example() -> str:
    await asyncio.to_thread(time.sleep, 5)
    return "done"

Part 8: How the Event Loop, Coroutines, and Tasks Work Together

1. What the Event Loop Schedules

The event loop mainly handles:

scheduled Tasks
registered callbacks
ready I/O events
timers

It does not schedule “function names appearing in source code.”


2. What Is the Current Task?

For example:

async def main():
    await fetch_a()
    await fetch_b()

When asyncio.run(main()) starts, main() runs inside a Task.

That Task follows this execution path:

enter main
→ call fetch_a()
→ drive the fetch_a coroutine
→ wait for A
→ A completes
→ return to the next line in main
→ call fetch_b()

fetch_b() has not even been called before the previous line finishes.


3. One Task Has Only One Instruction Position at a Time

The current Task cannot be paused at:

await fetch_a()

while also continuing to execute its own next line:

await fetch_b()

because a coroutine object maintains only one current execution position.

To let A and B progress at the same time, split them into two independent Tasks.


Part 9: Why Consecutive Direct Awaits Run Sequentially

Consider:

result_a = await fetch_a()
result_b = await fetch_b()

The timeline is:

main Task
  |
  |-- call fetch_a()
  |-- wait for A
  |   main Task is suspended
  |
  |   the event loop can run other Tasks that already exist
  |   but B has not been called and has no Task
  |
  |-- A completes
  |-- main Task resumes
  |-- call fetch_b()
  |-- wait for B

The order is therefore always:

A completes
→ only then does B begin

Why Can the Event Loop Not Jump to the Next Line?

Because the next line still belongs to the same suspended main Task.

The event loop can switch to:

other Tasks that have already been scheduled

not to:

a line in the current Task that has not executed yet

Part 10: Why create_task() Enables Concurrency

task_a = asyncio.create_task(fetch_a())
task_b = asyncio.create_task(fetch_b())

result_a = await task_a
result_b = await task_b

The first two lines create two independent Tasks:

Task A
Task B

When the main Task executes:

await task_a

and is suspended, the event loop can run:

Task A
Task B

Therefore, even if the code waits for task_a first, task_b can still make progress in the background.


1. Timing Comparison

Assume both A and B wait for two seconds.

Sequential Execution

await fetch_a()
await fetch_b()

This takes about four seconds.

Concurrent Execution

task_a = asyncio.create_task(fetch_a())
task_b = asyncio.create_task(fetch_b())

await task_a
await task_b

This takes about two seconds.

It is still normally single-threaded I/O concurrency. It does not mean that two Python functions are running in parallel on two CPU cores.


2. Keep a Reference After Calling create_task()

The official documentation warns that the event loop keeps only weak references to Tasks. Keep a strong reference to any background Task that must be managed reliably, and handle its exceptions.3

background_tasks: set[asyncio.Task[object]] = set()

task = asyncio.create_task(do_work())
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)

In most cases, structured concurrency is the safer and more common choice.


Part 11: TaskGroup, gather, and Structured Concurrency

Python 3.11+ provides asyncio.TaskGroup:

import asyncio

async def main() -> None:
    async with asyncio.TaskGroup() as group:
        task_a = group.create_task(fetch_a())
        task_b = group.create_task(fetch_b())

    result_a = task_a.result()
    result_b = task_b.result()

Exiting the async with block waits for every Task in the group to finish.

Its advantages include:

  • child-task lifetimes remain within the block;
  • if one child Task fails, the other related Tasks are cancelled;
  • exceptions are collected in an ExceptionGroup;
  • Tasks and exceptions are less likely to be forgotten.

The Python documentation presents TaskGroup as a more modern approach than manually managing create_task(), with stronger safety guarantees.3


2. gather: Collect Concurrent Results

results = await asyncio.gather(
    fetch_a(),
    fetch_b(),
)

gather() automatically schedules supplied coroutines as Tasks and returns their results in input order.

It is useful when:

  • you want to run a group of awaitables concurrently in a simple way;
  • results must be collected in a fixed order;
  • you have an explicit exception-propagation strategy.

By default, however, one Task failing does not automatically cancel all the others. When related tasks should share a lifetime and fail together, prefer TaskGroup.3

Part 12: Cancellation and Timeouts

Asynchronous tasks must account not only for success, but also for situations such as:

the user disconnects
an upstream request times out
the service shuts down
one concurrent child Task fails

1. Task Cancellation

task.cancel()

A cancellation request raises the following exception in the Task at the next suitable opportunity:

asyncio.CancelledError

Use try/finally in a coroutine to clean up resources:

async def worker() -> None:
    resource = await acquire_resource()

    try:
        await do_work(resource)
    finally:
        await release_resource(resource)

Normally, do not swallow CancelledError. Both TaskGroup and asyncio.timeout() depend on cancellation semantics.3


2. Timeouts

async def call_model() -> str:
    try:
        async with asyncio.timeout(10):
            return await llm_request()
    except TimeoutError:
        return "request timed out"

A timeout cancels the current Task internally, after which the context manager transforms the cancellation into TimeoutError.


Part 13: async for and Asynchronous Iteration

An ordinary for loop calls synchronous next() each time.

If obtaining the next item may itself require waiting, such as:

  • waiting for the next LLM token;
  • waiting for a WebSocket message;
  • waiting for the next page of an API response;
  • waiting for a database cursor to return the next row;

then asynchronous iteration is required.


1. AsyncIterable and AsyncIterator

The asynchronous iteration protocol includes:

__aiter__()
__anext__()

The rules are:

  • __aiter__() returns an asynchronous iterator;
  • __anext__() returns an awaitable;
  • when iteration ends, it raises StopAsyncIteration.

The Python data model formally defines this protocol.4


2. Conceptually Expanding async for

async for item in source:
    consume(item)

is conceptually close to:

iterator = source.__aiter__()

while True:
    try:
        item = await iterator.__anext__()
    except StopAsyncIteration:
        break

    consume(item)

The key difference from an ordinary for loop is:

obtaining the next item can await

3. LLM Streaming Example

async def stream_answer():
    async for token in llm_client.stream("Explain RAG"):
        yield token

The caller uses:

async for token in stream_answer():
    print(token, end="")

This is:

async def + yield
= asynchronous generator

4. Close an Asynchronous Generator When Exiting Early

If code exits with break before an asynchronous generator finishes, and that generator owns a connection, cursor, or another resource, close it explicitly:

import contextlib

async with contextlib.aclosing(stream_answer()) as stream:
    async for token in stream:
        if should_stop(token):
            break

The Python 3.14 asyncio development documentation specifically recommends explicit closure in cases such as early exit, so cleanup logic does not run later in an unpredictable context.1


Part 14: async with and Asynchronous Resource Management

An ordinary context manager:

with resource:
    ...

depends on:

__enter__()
__exit__()

An asynchronous context manager:

async with resource:
    ...

depends on:

__aenter__()
__aexit__()

The latter two methods must return awaitables.4

Conceptually, it expands to:

manager = create_manager()
resource = await manager.__aenter__()

try:
    await use(resource)
finally:
    await manager.__aexit__(...)

It is suitable for:

  • asynchronous HTTP connections;
  • database transactions;
  • WebSockets;
  • asynchronous locks;
  • timeout scopes;
  • TaskGroup;
  • trace lifetimes.

Part 15: Why Asynchronous Programs Still Need Thread Pools

1. async Cannot Change the Nature of a Synchronous Third-Party Library

Suppose a library provides only a synchronous interface:

response = requests.get(url)

Even when it appears inside async def:

async def handler():
    response = requests.get(url)

it still blocks the event-loop thread.


2. asyncio.to_thread()

For synchronous blocking I/O that is difficult to replace:

response = await asyncio.to_thread(requests.get, url)

The execution relationship is:

synchronous function
→ executes in a worker thread

event-loop thread
→ continues running other Tasks while awaiting the result

asyncio.to_thread() returns a coroutine object, so it can be used with await.


3. A Thread Pool Does Not Make a Function “Truly Asynchronous”

What it does is:

Move the blocking operation to another thread so it does not block the event-loop thread.

The blocking operation still exists. It simply no longer blocks the only event-loop chef.


4. Cancelling the Wait Does Not Stop the Thread

If the Task waiting for to_thread() is cancelled, Python normally cannot forcibly stop the underlying thread. The synchronous function in that thread may continue running even though its result is no longer awaited.

Therefore, do not confuse:

thread bridging

with:

a natively asynchronous operation that can be cancelled freely

Part 16: What About CPU-Bound Work?

asyncio.to_thread() is mainly suitable for blocking I/O.

For substantial pure-Python CPU computation, threads normally do not provide ideal multi-core parallelism.

Common options include:

ProcessPoolExecutor
InterpreterPoolExecutor
a dedicated task queue
an external compute service
a native extension that releases the GIL

The Python documentation recommends keeping blocking or CPU-intensive code off the event-loop thread. Threads, separate interpreters, or process executors can isolate that work.1 concurrent.futures provides a unified interface for thread, interpreter, and process pools.5

A typical process-pool example is:

import asyncio
from concurrent.futures import ProcessPoolExecutor

def cpu_heavy(value: int) -> int:
    return sum(i * i for i in range(value))

async def main() -> int:
    loop = asyncio.get_running_loop()

    with ProcessPoolExecutor() as executor:
        return await loop.run_in_executor(
            executor,
            cpu_heavy,
            10_000_000,
        )

Part 17: Why Starlette and FastAPI Use a Thread Pool

Starlette uses worker threads in several situations to prevent synchronous code from blocking the event loop, including:

  • synchronous endpoints defined with def;
  • synchronous BackgroundTask work;
  • file responses;
  • file uploads;
  • some internal synchronous operations.

Starlette currently runs this synchronous code through anyio.to_thread.run_sync(). Its documentation also notes that the default thread-capacity limiter has 40 tokens and is shared with frameworks such as FastAPI. Increasing the number of threads blindly can increase memory use and context-switching overhead.6


1. The Purpose of iterate_in_threadpool()

It exposes a synchronous Iterable as an AsyncIterator:

async def iterate_in_threadpool(iterator):
    sync_iterator = iter(iterator)

    while True:
        try:
            item = await anyio.to_thread.run_sync(
                next_item,
                sync_iterator,
            )
        except EndOfIterator:
            break

        yield item

The core operation is:

synchronous next()
→ execute it in the thread pool
→ await the result
→ yield it to the asynchronous caller

The function uses:

async def + yield

so it is an asynchronous generator and can be consumed with async for.


2. Why Starlette Converts StopIteration into Another Exception

A synchronous Iterator signals completion by raising:

StopIteration

But a StopIteration raised in the thread pool cannot simply cross the Future / await boundary unchanged and then be caught outside. Starlette converts it to a custom exception inside the worker thread, catches that exception in the asynchronous generator, and ends the loop.

This detail demonstrates that:

Although synchronous iteration and asynchronous waiting can be bridged, they are not the same protocol.


Part 18: Typical Patterns in AI Applications

1. Call Multiple Independent Tools Concurrently

import asyncio

async def collect_context(query: str) -> tuple[dict, dict]:
    async with asyncio.TaskGroup() as group:
        web_task = group.create_task(search_web(query))
        db_task = group.create_task(search_database(query))

    return web_task.result(), db_task.result()

This is valid only when the two operations are independent.


2. Agent Steps That Must Run Sequentially

plan = await create_plan(user_message)
tool_result = await execute_tool(plan)
answer = await generate_answer(tool_result)

Do not turn these into three Tasks merely to “pursue concurrency,” because:

execute_tool depends on plan
generate_answer depends on tool_result

Concurrency is appropriate only for work with no dependency relationship that can start at the same time.


3. Stream LLM Tokens

async def stream_response(prompt: str):
    async for event in llm.stream(prompt):
        if event.type == "token":
            yield event.text

4. Integrate a Synchronous Document Parser into an Asynchronous Service

async def parse_document(path: str) -> list[str]:
    return await asyncio.to_thread(sync_parser.parse, path)

If parsing is CPU-intensive, use a process pool or a dedicated Worker instead of occupying the thread pool for a long time.


5. Concurrency Limits

Do not create an unlimited number of Tasks for thousands of external API calls.

Use a Semaphore:

import asyncio

limit = asyncio.Semaphore(10)

async def limited_call(item: str) -> str:
    async with limit:
        return await call_external_api(item)

This can limit:

  • LLM concurrency;
  • tool-call concurrency;
  • database connection pressure;
  • API rate-limit risk.

Part 19: Debugging Asynchronous Programs

1. Enable asyncio Debug Mode

asyncio.run(main(), debug=True)

You can also set:

PYTHONASYNCIODEBUG=1

Debug Mode helps detect:

  • slow callbacks;
  • calls from the wrong thread;
  • coroutines that were never awaited;
  • resource problems.

1


2. Inspect Object Types

import inspect

inspect.iscoroutinefunction(func)
inspect.iscoroutine(obj)
inspect.isawaitable(obj)
inspect.isasyncgenfunction(func)
inspect.isasyncgen(obj)

These checks are useful for learning, framework development, and debugging return types from third-party SDKs. Ordinary application code should prefer type annotations and documentation instead of performing dynamic checks everywhere.


3. Two Common Warnings

Coroutine was never awaited

Cause:

fetch_data()

Fix:

await fetch_data()

or:

task = asyncio.create_task(fetch_data())
await task

Task exception was never retrieved

This normally means that a background Task was created but its result was never awaited and its exception was never read.

Prefer TaskGroup. If a true background Task is necessary, keep a reference and handle its exception.


Part 20: The Most Common Misconceptions

Misconception 1: async def Automatically Makes Code Asynchronous

False.

Calling an async def function only creates a coroutine object. If its body calls synchronous blocking code, it still blocks the event loop.


Misconception 2: Writing await Always Makes an Operation Non-Blocking

False.

await sync_function()

executes sync_function() synchronously first.


Misconception 3: Every await Necessarily Switches Tasks

Not quite.

If the awaitable has already completed, execution may continue immediately. await provides the ability to suspend.


Misconception 4: Directly Awaiting Two Functions Runs Them Concurrently

False.

await a()
await b()

B is not even called until A completes.


Misconception 5: The Event Loop Executes the Current Coroutine’s Next Line

False.

When the current Task is suspended at await, the remainder of that same Task is suspended too. The event loop can run only other Tasks that have already been scheduled.


Misconception 6: A Task Is a Thread

False.

A Task is a coroutine scheduling unit in the event loop and normally still executes in the same thread.


Misconception 7: A Future Is the Same as a Task

Not exactly.

A Future is a low-level placeholder for a future result. A Task runs a coroutine and represents its final result through a Future-like interface.


Misconception 8: A Thread Pool Automatically Gives CPU Work Multi-Core Parallelism

Usually false.

Pure-Python CPU-bound work is better suited to a process pool, an interpreter pool, or a dedicated compute service.


Misconception 9: More Concurrency Is Always Faster

False.

Too many Tasks or threads can cause:

  • API rate limiting;
  • exhausted database connections;
  • increased memory use;
  • excessive context switching;
  • overloaded downstream services.

Concurrency needs limits and backpressure.


Part 21: Complete Quick-Reference Table

ConceptWhat it isTypical creationHow to consume it
IterableAn object that can provide an Iteratorlist, str, custom __iter__for
IteratorAn object that maintains traversal positioniter(source)next() / for
GeneratorAn Iterator written with yieldCall a generator functionnext() / for
Coroutine functionA function defined with async defasync def f()Call it to get a Coroutine
Coroutine objectThe result of calling a coroutine functionf()await or schedule it
AwaitableAn object usable with awaitCoroutine / Task / Futureawait
TaskA coroutine scheduled by the event loopcreate_task() / TaskGroupawait
FutureA low-level object representing a future resultUsually created by a library or event loopawait
AsyncIterableAn object that can provide an AsyncIterator__aiter__()async for
AsyncIteratorAn object that produces items asynchronously__anext__()async for
AsyncGeneratorasync def + yieldCall an async-generator functionasync for
Async context managerAcquires and releases resources asynchronously__aenter__, __aexit__async with

Part 22: Fifteen Engineering Rules

  1. Calling a coroutine function only creates a coroutine object; it does not run automatically.
  2. Do not create a coroutine object without either using await or scheduling it.
  3. The expression after await must ultimately produce an awaitable.
  4. Consecutive direct await expressions are sequential control flow.
  5. Prefer TaskGroup for concurrent tasks.
  6. Steps with data dependencies must remain sequential.
  7. Do not call synchronous blocking I/O on the event-loop thread.
  8. Synchronous I/O can be bridged temporarily with to_thread().
  9. Prefer a process pool or a dedicated Worker for CPU-intensive work.
  10. A Task is not a thread, and asyncio concurrency is not CPU parallelism.
  11. Use mechanisms such as Semaphore to limit external-call concurrency.
  12. Use finally to release resources when a Task is cancelled.
  13. Do not swallow CancelledError casually.
  14. Consider explicit closure when exiting an asynchronous generator early.
  15. Enable asyncio Debug Mode during development.

Conclusion

Python’s asynchronous system appears to contain many concepts, but its central path is straightforward:

Iterable / Iterator
solve “how to obtain data one item at a time”

Coroutine
describes “asynchronous execution that can pause and resume”

Awaitable
defines “which objects can be awaited”

Task
hands a Coroutine to the event loop for scheduling

Future
represents “a result that will exist in the future”

Event Loop
schedules Tasks, callbacks, and I/O events that already exist

async for
obtains items asynchronously

async with
acquires and releases resources asynchronously

Thread / Process Pool
moves blocking work that cannot be made natively asynchronous
off the event-loop thread

Return one last time to the most confusing question:

await fetch_a()
await fetch_b()

Why does this run sequentially?

Because the first line suspends the current Task. The second line belongs to that same Task and cannot execute until the first line finishes. At that moment, fetch_b() has not been called, no independent Task exists for it, and the event loop has no B to schedule.

By contrast:

task_a = asyncio.create_task(fetch_a())
task_b = asyncio.create_task(fetch_b())

explicitly creates two independent Tasks. Only then can the event loop let A and B make alternating progress while each waits for I/O.

Once this point is clear, the relationship among coroutines, Tasks, the event loop, and concurrency falls into place.


References