Start with a Mental Model That Runs Through the Entire Article
Before diving into any syntax details, remember one metaphor, because every concept that follows can be understood through it.
Imagine a restaurant kitchen. There is only one chef in the kitchen—this is Python’s main thread, or event-loop thread. The chef can do only one thing at any given moment: chop ingredients, stir a pan, or plate a dish. They cannot truly do two things “at the same time.”
But the chef is smart. After putting a pot of soup on the stove and waiting for it to boil, they do not stand there idly. Instead, they turn around and prepare the ingredients for another dish. When the soup boils, the stove sounds an alert, and the chef returns to handle it.
This is the essence of Python’s asyncio: one thread achieves efficient concurrency by doing other work while it waits. The key word here is “concurrency,” not “parallelism.” There is still only one chef, but sensible scheduling allows several dishes to make progress during the same period.
await is the action of the chef saying, “I need to wait at this step, so I will work on something else first.” If a block of code contains no await, it is equivalent to the chef watching a pot from start to finish until it boils—all other dishes come to a halt.
Keep this picture in mind, and every concept below will become more intuitive.
Part 1: The Iteration System—“How to Obtain Values One at a Time”
Why We Need an Iteration Protocol
Suppose you need to process one million records. The most brute-force approach is to load all of them into a list, but doing so consumes a great deal of memory. A smarter approach is to fetch one record only when you need it. You do not need to hold all the data at once; you only need a mechanism that lets you say, “Give me the next one.”
That is why the iteration protocol exists. It solves essentially the same problem as Java’s Iterator interface, but Python’s implementation is lighter and provides more syntactic sugar.
The Three Core Roles
1️⃣ An Iterable is the broadest concept. It simply means “this object can be traversed.” In Python, any object that implements __iter__() is an Iterable. list, tuple, dict, str, and set are all Iterables. In Java terms, it corresponds to an object that implements the Iterable<T> interface.
2️⃣ An Iterator is the component that actually does the work. It knows “where the traversal currently is” and “what the next value is.” It must implement two methods: __iter__() (which returns itself) and __next__() (which returns the next value, or raises StopIteration when no value remains). Compared with Java’s Iterator<T>, __next__() corresponds to next(), while StopIteration corresponds to hasNext() returning false.
One crucial point is that an Iterable is a “factory,” whereas an Iterator is a “cursor.” Calling iter() on a list can produce multiple independent Iterators. Each has its own position and does not interfere with the others. Calling iter() on an Iterator itself, however, returns that same Iterator—it satisfies both sides of the protocol, acts as the cursor, and is single-use.
First, See Why a List Resembles a “Factory”
For example:
lst = [10, 20, 30]
it1 = iter(lst)
it2 = iter(lst)
Here:
lstis anIterable.it1andit2are two differentIteratorobjects.
They each maintain their own traversal position:
next(it1) # 10
next(it1) # 20
next(it2) # 10
You can see that:
it1has already reached the second element.it2still starts from the beginning.
It is like placing two bookmarks on different pages of the same book:
lstis the book itself.it1andit2represent two different bookmark positions.
Therefore, lst is more like a source that creates iterators—in other words, a “factory.”
Next, See Why an Iterator Resembles a “Cursor”
Continuing the previous example, it1 itself carries the current position.
Every call to next(it1) moves it forward:
it1 = iter(lst)
next(it1) # 10
next(it1) # 20
next(it1) # 30
This it1 resembles a database cursor, a file read pointer, or a media player’s progress indicator:
- It is not the data collection itself.
- It represents the current position within that data collection.
That is why “cursor” is such an apt metaphor.
Why Calling iter() on an Iterator Returns the Iterator Itself
This behavior is part of Python’s iteration protocol.
Calling iter() on an iterator again returns the same object:
it = iter([10, 20, 30])
iter(it) is it # True
The reasons are:
- It is already the object performing the iteration.
- There is no need to create another iterator.
- It can continue advancing by itself.
This is what the statement “an Iterator itself is both a factory and a cursor” means.
Here, however, “factory” only means that it is accepted by iter() at the protocol level. It does not mean that an Iterator can repeatedly produce fresh, independent iterators the way a list can.
Why an Iterator Is Single-Use
An Iterator has state, and that state advances.
For example:
it = iter([10, 20, 30])
list(it) # [10, 20, 30]
list(it) # []
The first call consumes the Iterator completely, so nothing remains for the second call.
That is what “single-use” means.
A list, by contrast, is not single-use:
lst = [10, 20, 30]
list(lst) # [10, 20, 30]
list(lst) # [10, 20, 30]
Each call to iter(lst) can obtain a new Iterator from the beginning.
The Most Important Difference
Remember the distinction by comparing these two snippets.
An Iterable:
lst = [1, 2, 3]
iter(lst) is iter(lst) # False
It can usually provide a new Iterator on every call.
An Iterator:
it = iter(lst)
iter(it) is it # True
Calling iter() on it returns the same object.
3️⃣ A Generator is a special kind of Iterator defined with the yield keyword. What makes it special is that its function body does not run to completion all at once. Whenever execution reaches yield, the function pauses and hands a value to the caller. The next call to next() resumes execution from the point where it paused.
def countdown(n):
while n > 0:
yield n # 执行到这里暂停,把 n 交出去
n -= 1 # 下次 next() 从这里继续
for num in countdown(3):
print(num) # 依次打印 3, 2, 1
Their type relationship is as follows: a Generator is a kind of Iterator, and an Iterator is a kind of Iterable. In other words, every generator can be consumed by a for loop, but not every iterable is a generator.
What a for Loop Really Does
When you write for x in something:, Python does roughly the following behind the scenes:
_iter = iter(something) # 调用 __iter__(),拿到迭代器
while True:
try:
x = next(_iter) # 调用 __next__(),拿下一个值
except StopIteration:
break # 没有更多值了,退出
# ... 执行循环体 ...
So for is not magic; it is syntactic sugar for the iteration protocol. Once you understand this, async for follows naturally.
Part 2: The Essence of Asynchrony—“Do Something Else While Waiting”
Why We Need Asynchrony
Return to the chef metaphor. Suppose your program needs to handle 100 network requests concurrently. After each request is sent, receiving a response can take anywhere from a few hundred milliseconds to several seconds. With synchronous execution, the chef must “send one request → wait idly for its response → process it → send the next request.” Most of the time is wasted waiting.
The central idea of asynchrony is: after sending a request, do not wait idly; process other requests first, and return when the response arrives. This is highly effective for I/O-bound work such as network requests, file operations, and database queries. It provides little benefit for CPU-bound work such as intensive mathematical computation, because CPU work has no waiting interval to exploit.
What async def Really Means
A function declared with async def is called a coroutine function. There is, however, a common misconception: async def does not automatically make the code inside a function asynchronous. It merely tells Python, “This function follows the asynchronous protocol, and its execution can be suspended and resumed.”
The asynchronous protocol is a set of behavioral contracts that Python defines for asynchronous execution. It lets the interpreter and event loop know how to wait for, suspend, resume, and iterate over asynchronous objects. The real meaning of async def is not that it automatically makes the function body asynchronous. Instead, it declares that the function returns a coroutine object and participates in the asynchronous protocol so the event loop can schedule it. Execution actually yields control at await. The asynchronous protocol mainly covers three categories: __await__() supports await and marks an object as awaitable; __aiter__() and __anext__() support async for and mark an object as asynchronously iterable; and __aenter__() and __aexit__() support async with and mark an object as an asynchronous context manager. Therefore, “following the asynchronous protocol” essentially means implementing these rules so that an object can cooperate correctly with Python’s asynchronous syntax and event loop.
This is like a chef putting on an apron labeled “I coordinate my work.” If the chef never actually says, “I need to wait at this step, so I will work on something else first,” the apron is just clothing and has no practical effect.
This counterintuitive point is extremely important, so it is worth reinforcing with a negative example. Although the following function uses async def, it blocks the event loop completely:
async def bad_example():
import time
time.sleep(5) # 这是同步阻塞!整个事件循环被冻结 5 秒
return "done"
The correct approach is to use an asynchronous wait that cooperates with the event loop:
async def good_example():
import asyncio
await asyncio.sleep(5) # 挂起当前协程,事件循环去干别的,5 秒后回来
return "done"
In one sentence: async def gives a function the ability to be suspended, but only await actually triggers that suspension.
The Essence of await
What await does can be divided into two steps:
First, it returns control from the current coroutine to the event loop. This is the moment when the chef says, “The soup is on the stove; I will go chop ingredients.”
Second, after the awaited operation completes, the event loop delivers the result, and the coroutine resumes from the await expression. This is the stove sounding an alert and the chef returning to the soup.
You cannot place an arbitrary object after await; it must be an “awaitable.” The three most common awaitables are coroutine objects (returned when you call an async def function), asyncio.Task, and asyncio.Future. At the protocol level, any object that implements __await__() is awaitable, but in everyday development you rarely need to implement this method yourself.
The Event Loop: The One and Only Chef
The event loop is that chef. It operates as an infinite loop:
- Take a coroutine from the ready queue.
- Run that coroutine until it reaches
awaitor finishes. - If it reaches
await, suspend the coroutine and record what it is waiting for. - Check whether an operation awaited by any previously suspended coroutine has completed.
- If so, resume that coroutine.
- Return to step 1.
The entire process runs in a single thread. This means that if any coroutine does not reach an await, it monopolizes that thread, and every other coroutine must wait. That is also why writing time.sleep() inside async def is disastrous: it freezes the only chef.
Part 3: The Correspondence Between Synchronous and Asynchronous Code—A Complete Mapping
With the first two parts understood, we can now build a complete mapping. Python’s iteration system actually consists of two parallel structures: a synchronous system and an asynchronous system. Their concepts correspond one to one; the asynchronous version simply adds the ability to yield control at every operation that obtains a value.
The four function combinations are the most important distinction. def + return is an ordinary synchronous function, and calling it gives you the return value directly. def + yield is a synchronous generator, and calling it gives you a generator object that can be traversed with for. async def + return is a coroutine function, and calling it gives you a coroutine object whose result must be obtained with await. async def + yield is an asynchronous generator, and calling it gives you an asynchronous generator object that can be traversed with async for.
These four combinations cover every case you will encounter in real code. Pay particular attention to the fact that yield and async are two independent dimensions. yield determines whether values are produced incrementally or returned all at once. async determines whether execution participates in the asynchronous protocol. Combining the two dimensions produces four different constructs.
The corresponding ways of consuming iteration are equally symmetrical. A synchronous iterator is consumed with for, which calls __iter__() and __next__() behind the scenes. An asynchronous iterator is consumed with async for, which calls __aiter__() and __anext__(). The two mechanisms have exactly the same structure, except that the asynchronous version’s __anext__() returns an awaitable, allowing the event loop to do other work while it waits for the next value.
Part 4: Bridging Synchronous and Asynchronous Code—Why a Thread Pool Is Necessary
In real projects, you will often encounter this situation: your main program is asynchronous, such as an ASGI web framework, but a library you call exposes only a synchronous interface, such as a synchronous database driver or an SDK that returns a synchronous iterator. This creates a difficult problem: you cannot call synchronous blocking code directly on the event-loop thread, because doing so blocks the entire event loop.
The solution is to send the synchronous blocking operation to a thread pool. The event-loop thread itself remains unblocked; it merely uses await to wait for the thread pool’s result.
Starlette’s iterate_in_threadpool is a classic implementation of this pattern:
async def iterate_in_threadpool(iterator: Iterable[T]) -> AsyncIterator[T]:
as_iterator = iter(iterator) # 拿到同步迭代器
while True:
try:
yield await anyio.to_thread.run_sync(_next, as_iterator)
# 关键:next() 这个可能阻塞的操作在线程池里执行
# await 等待线程池的结果,期间事件循环可以去做别的
except _StopIteration:
break
Read the code line by line. iter(iterator) turns the iterable into an iterator. anyio.to_thread.run_sync(_next, as_iterator) sends the synchronous next(as_iterator) call to the thread pool and returns an awaitable. await waits for the thread pool to finish without blocking the event loop. yield emits the value to the caller.
The function as a whole is async def + yield, so it is an asynchronous generator and presents itself externally as an AsyncIterator. The caller can consume it with async for without needing to know that a synchronous iterator is working in a thread pool underneath.
This reveals an important engineering principle: async provides the scheduling protocol, while a thread pool isolates blocking code. They cooperate; one does not replace the other. An asynchronous framework cannot eliminate blocking. It can only isolate blocking work where it will not interfere with the event loop.
Part 5: Eight Core Rules for Quick Reference
yieldmeans only “produce values incrementally” and has nothing inherently to do with asynchrony.def + yieldis a synchronous generator; onlyasync def + yieldis an asynchronous generator.async defmeans only “this function follows the asynchronous protocol”; it does not guarantee that the code inside is non-blocking. Writingtime.sleep()insideasync defstill freezes the event loop.awaitis what actually makes a coroutine yield control. Anasync deffunction withoutawaithas the same execution behavior as an ordinary function.awaitcan wait only for awaitable objects. The most common are coroutine objects,Task, andFuture; the underlying mechanism is the__await__()protocol.foris for synchronous iteration, andasync foris for asynchronous iteration. Their structures are symmetrical; do not mix them up.- The event loop is single-threaded. Any blocking operation on the event-loop thread blocks every coroutine.
- Synchronous blocking code must be bridged into an asynchronous system through a thread pool.
asyncio.to_thread()andanyio.to_thread.run_sync()are the standard approaches. - A Generator is a special case of Iterator, and an Iterator is a special case of Iterable. Likewise, an
AsyncGeneratoris a special case ofAsyncIterator, and anAsyncIteratoris a special case ofAsyncIterable.