全体を貫くメンタルモデルを最初に作る
構文の細部に入る前に、一つのたとえを覚えてください。これ以降のすべての概念を、このたとえで理解できます。
レストランの厨房 を想像してみましょう。厨房には 料理人が一人だけ います。この料理人が、Pythonのメインスレッド、すなわちイベントループのスレッドです。料理人は一度に一つの作業しかできません。食材を切る、鍋をかき混ぜる、盛り付けるといった作業を、本当の意味で二つ「同時に」行うことはできません。
しかし、この料理人は賢く働きます。スープを火にかけて沸騰するのを待つ間、何もせず立ち尽くすことはありません。別の料理の食材を切りに行きます。スープが沸騰するとコンロが合図を出し、料理人は戻ってスープの続きを処理します。
これがPythonのasyncioの本質です。一つのスレッドが、待ち時間に別の仕事を行うことで効率的な並行処理を実現します。ここで重要なのは「並行」であって「並列」ではありません。料理人はあくまで一人ですが、時間を適切に配分することで、複数の料理を同じ時間帯に進められます。
awaitは、料理人が「この工程では待つ必要があるので、その間に別の仕事をしよう」と判断する動作です。コードにawaitがなければ、料理人がスープの前に最初から最後まで立ち続け、沸騰するまで見張っているのと同じです。その間、ほかの料理はすべて止まります。
この情景を覚えておけば、以下の概念は直感的に理解しやすくなります。
第1部:イテレーション体系——「値を一つずつ受け取る方法」
なぜイテレーションプロトコルが必要なのか
100万件のデータを処理するとします。最も力任せな方法は、すべてをリストに読み込むことですが、大量のメモリを消費します。より賢い方法は、必要になるたびに一件ずつ取り出すことです。すべてのデータを同時に保持する必要はなく、「次の一件をください」と要求できる仕組みさえあれば十分です。
イテレーションプロトコルは、このために存在します。本質的にはJavaのIteratorインターフェースと同じ問題を解決していますが、Pythonの実装はより軽量で、多くの構文糖衣を備えています。
三つの中核的な役割
1️⃣ Iterable(イテラブル) は最も広い概念で、「このオブジェクトは走査できる」という意味です。Pythonでは、__iter__()メソッドを実装したオブジェクトはIterableです。list、tuple、dict、str、setはすべて該当します。Javaにたとえると、Iterable<T>インターフェースを実装したオブジェクトに相当します。
2️⃣ Iterator(イテレーター) は実際に処理を担う存在で、「走査が現在どこまで進んでいるか」と「次の値は何か」を把握しています。__iter__()(自分自身を返す)と__next__()(次の値を返し、値が残っていなければStopIterationを送出する)という二つのメソッドを実装する必要があります。JavaのIterator<T>と比べると、__next__()はnext()に、StopIterationはhasNext()がfalseを返すことに対応します。
重要なのは、Iterableは「ファクトリー」、Iteratorは「カーソル」 だという点です。リストに対してiter()を呼び出すと、互いに独立したIteratorを複数取得できます。それぞれが独自の位置状態を持ち、互いに干渉しません。一方、Iterator自体にiter()を呼ぶと自分自身を返します。プロトコル上はイテラブルでもあり、実際のカーソルでもありますが、一度しか消費できません。
まず、リストが「ファクトリー」に似ている理由を見る
例を示します。
lst = [10, 20, 30]
it1 = iter(lst)
it2 = iter(lst)
ここでは、次の関係があります。
lstはIterableです。it1とit2は、別々のIteratorです。
それぞれが独自の走査位置を保持しています。
next(it1) # 10
next(it1) # 20
next(it2) # 10
ここから、次のことが分かります。
it1はすでに2番目の要素まで進んでいます。it2はまだ先頭から始まります。
これは、同じ本の異なるページに二つのしおりを挟むようなものです。
lstは本そのものです。it1とit2は、それぞれ異なるしおりの位置です。
したがって、lstはイテレーターを作り出す供給元、つまり「ファクトリー」に近い存在です。
次に、Iteratorが「カーソル」に似ている理由を見る
先ほどの例を続けると、it1自体が現在位置を保持しています。
next(it1)を呼ぶたびに、一つ先へ進みます。
it1 = iter(lst)
next(it1) # 10
next(it1) # 20
next(it1) # 30
このit1は、データベースカーソル、ファイルの読み取り位置、メディアプレーヤーの進捗位置に似ています。
- データ集合そのものではありません。
- データ集合上の現在位置を表します。
そのため、「カーソル」というたとえがよく合います。
Iteratorにiter()を呼ぶと自分自身が返る理由
これはPythonのイテレーションプロトコルの一部です。
イテレーターにもう一度iter()を呼んでも、結果は同じオブジェクトです。
it = iter([10, 20, 30])
iter(it) is it # True
理由は次のとおりです。
- すでにイテレーションを行っているオブジェクトです。
- 新しいイテレーターを作る必要がありません。
- 自分自身でそのまま先へ進めます。
これが「Iterator自体はファクトリーでもカーソルでもある」という説明の意味です。
ただし、ここでいう「ファクトリー」とは、プロトコル上iter()に渡せるという意味にすぎません。リストのように、独立した新しいイテレーターを繰り返し生成できるという意味ではありません。
Iteratorが一度しか使えない理由
Iteratorは状態を持ち、その状態は前へ進みます。
例を示します。
it = iter([10, 20, 30])
list(it) # [10, 20, 30]
list(it) # []
最初の呼び出しでIteratorを最後まで消費したため、2回目には何も残っていません。
これが「一度しか使えない」という意味です。
一方、リストは一度きりではありません。
lst = [10, 20, 30]
list(lst) # [10, 20, 30]
list(lst) # [10, 20, 30]
iter(lst)を呼ぶたびに、先頭から始まる新しいIteratorを取得できるからです。
最も重要な違い
次の二つを比較すると覚えやすくなります。
Iterableの場合:
lst = [1, 2, 3]
iter(lst) is iter(lst) # False
通常、呼び出すたびに新しいイテレーターを取得できます。
Iteratorの場合:
it = iter(lst)
iter(it) is it # True
自分自身にiter()を呼んでも、同じオブジェクトが返ります。
3️⃣ Generator(ジェネレーター) は、yieldキーワードで定義される特殊なIteratorです。特徴は、関数本体が一度に最後まで実行されないことです。yieldに到達するたびに処理を一時停止して値を呼び出し側へ渡し、次にnext()が呼ばれると停止した場所から実行を再開します。
def countdown(n):
while n > 0:
yield n # 执行到这里暂停,把 n 交出去
n -= 1 # 下次 next() 从这里继续
for num in countdown(3):
print(num) # 依次打印 3, 2, 1
三者の型の関係は、GeneratorがIteratorの一種であり、IteratorがIterableの一種である、というものです。言い換えると、すべてのジェネレーターはforループで消費できますが、すべてのイテラブルがジェネレーターであるわけではありません。
forループの正体
for x in something:と書いたとき、Pythonは内部でおおむね次の処理を行います。
_iter = iter(something) # 调用 __iter__(),拿到迭代器
while True:
try:
x = next(_iter) # 调用 __next__(),拿下一个值
except StopIteration:
break # 没有更多值了,退出
# ... 执行循环体 ...
つまり、forは魔法ではなく、イテレーションプロトコルを使いやすくする構文糖衣です。この点を理解すれば、後で扱うasync forも自然に理解できます。
第2部:非同期処理の本質——「待っている間に別の仕事をする」
なぜ非同期処理が必要なのか
料理人のたとえに戻りましょう。プログラムが100件のネットワークリクエストを同時に扱う必要があるとします。各リクエストを送信してから応答を受け取るまでには、数百ミリ秒から数秒かかります。同期方式では、料理人は「リクエストを一件送る → 応答を何もせず待つ → 処理する → 次のリクエストを送る」という順序で働くしかありません。時間の大半が待ち時間として無駄になります。
非同期処理の中核的な考え方は、リクエストを送った後に何もせず待つのではなく、先に別のリクエストを処理し、応答が届いたら戻って続きを行う ことです。ネットワークリクエスト、ファイルの読み書き、データベースクエリといったI/Oバウンドな処理には非常に効果的です。一方、大量の数値計算のようなCPUバウンドな処理には、あまり効果がありません。CPU計算には活用できる「待ち時間」がないからです。
async defの本当の意味
async defで宣言した関数は「コルーチン関数」と呼ばれます。ただし、非常に誤解されやすい点があります。async defを書いても、関数内のコードが自動的に非同期になるわけではありません。Pythonに対して「この関数は非同期プロトコルに従い、実行を一時停止して再開できる」と宣言しているだけです。
非同期プロトコルとは、Pythonが非同期実行のために定義した一連の振る舞いの規約です。インタープリターとイベントループは、この規約によって、非同期オブジェクトをどのように待機、一時停止、再開、走査すべきかを理解します。async defの本当の意味は、関数本体を自動的に非同期化することではありません。その関数がコルーチンオブジェクトを返して非同期プロトコル体系に加わり、イベントループからスケジュール可能になると宣言します。実際に実行権を手放す場所はawaitです。非同期プロトコルは主に三つの種類から成ります。__await__()はawaitを支え、そのオブジェクトが待機可能であることを示します。__aiter__()と__anext__()はasync forを支え、そのオブジェクトが非同期にイテレーションできることを示します。__aenter__()と__aexit__()はasync withを支え、そのオブジェクトが非同期コンテキスト管理に対応することを示します。したがって、「非同期プロトコルに従う」とは本質的に、これらの規則を実装し、Pythonの非同期構文やイベントループと正しく協調できるという意味です。
これは、「作業を調整できます」と書かれたエプロンを料理人が着けるようなものです。実際の作業中に一度も「この工程は待つ必要があるので、その間に別の仕事をしよう」と判断しなければ、そのエプロンは単なる服でしかなく、何の効果もありません。
これは直感に反するものの極めて重要な点なので、反例で確認しましょう。次の関数はasync defで定義されていますが、イベントループを完全に停止させます。
async def bad_example():
import time
time.sleep(5) # 这是同步阻塞!整个事件循环被冻结 5 秒
return "done"
正しい書き方は、イベントループと協調できる非同期待機を使うことです。
async def good_example():
import asyncio
await asyncio.sleep(5) # 挂起当前协程,事件循环去干别的,5 秒后回来
return "done"
一文でまとめると、async defは関数に「一時停止できる能力」を与えますが、実際に一時停止を引き起こすのはawaitだけです。
awaitの本質
awaitの動作は二つの段階に分けられます。
第1段階では、現在のコルーチンからイベントループへ制御を返します。これは、料理人が「スープを火にかけたので、その間に食材を切ろう」と言う瞬間です。
第2段階では、待機していた処理が完了するとイベントループが結果を返し、コルーチンはawaitの場所から実行を再開します。コンロが合図を出し、料理人がスープの処理に戻る場面に相当します。
awaitの後ろには任意のオブジェクトを置けるわけではなく、「待機可能オブジェクト」(awaitable)だけを指定できます。最も一般的な三種類は、コルーチンオブジェクト(async def関数を呼び出したときに返るもの)、asyncio.Task、asyncio.Futureです。低レベルのプロトコルでは、__await__()メソッドを実装したオブジェクトはすべて待機可能ですが、日常的な開発でこのメソッドを自分で実装する必要はほとんどありません。
イベントループ:たった一人の料理人
イベントループこそが、その料理人です。無限ループとして次の手順を繰り返します。
- 実行可能キューからコルーチンを一つ取り出します。
- そのコルーチンを、
awaitに到達するか処理が完了するまで実行します。 awaitに到達した場合はコルーチンを一時停止し、何を待っているか記録します。- 以前に一時停止したコルーチンのうち、待っていた処理が完了したものがないか確認します。
- あれば、そのコルーチンを再開します。
- 手順1へ戻ります。
処理全体は 一つのスレッド で実行されます。つまり、いずれかのコルーチンがawaitに到達しなければ、そのコルーチンがスレッドを独占し、ほかのすべてのコルーチンは待たされます。async def内でtime.sleep()を書くことが致命的なのもこのためです。たった一人の料理人を完全に止めてしまいます。
第3部:同期と非同期の対応関係——全体を示すマッピング
最初の二つの部分を理解したところで、完全な対応関係を整理できます。Pythonのイテレーション体系は、実際には同期体系と非同期体系という二つの並行した構造から成ります。概念は一対一で対応しており、非同期版では、値を取得する各操作に「制御を手放せる能力」が加わっています。
最も重要な区別は、関数の四つの組み合わせ です。def + returnは通常の同期関数で、呼び出すと戻り値を直接取得します。def + yieldは同期ジェネレーターで、呼び出すとforで走査できるジェネレーターオブジェクトを取得します。async def + returnはコルーチン関数で、呼び出すとコルーチンオブジェクトを取得し、その結果はawaitで受け取る必要があります。async def + yieldは非同期ジェネレーターで、呼び出すとasync forで走査できる非同期ジェネレーターオブジェクトを取得します。
この四つの組み合わせで、実際のコードに現れるすべてのケースを網羅できます。特に、yieldとasyncは独立した二つの軸 であることに注意してください。yieldは「値を複数回に分けて生成するか、一度に返すか」を表し、asyncは「非同期プロトコル上で実行されるか」を表します。二つの軸の組み合わせによって、異なる四種類の構造が生まれます。
イテレーションを消費する方法の対応 も対称的です。同期イテレーターはforで消費し、内部では__iter__()と__next__()が呼び出されます。非同期イテレーターはasync forで消費し、内部では__aiter__()と__anext__()が呼び出されます。二つの仕組みはまったく同じ構造を持ちます。違いは、非同期版の__anext__()が待機可能オブジェクトを返すため、次の値を待っている間にイベントループが別の仕事を行えることです。
第4部:同期と非同期の橋渡し——なぜスレッドプールが必要なのか
実務では、メインプログラムはASGI Webフレームワークのような非同期構成である一方、利用するライブラリは同期データベースドライバーや同期イテレーターを返すSDKのように、同期インターフェースしか提供しない場面がよくあります。ここで難しい問題が生じます。同期ブロッキングコードをイベントループのスレッド上で直接呼び出すと、イベントループ全体が停止してしまうからです。
解決策は、同期ブロッキング処理をスレッドプールへ渡して実行すること です。イベントループのスレッド自体はブロックされず、スレッドプールの結果をawaitで待つだけです。
Starletteフレームワークのiterate_in_threadpoolは、このパターンの代表的な実装です。
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
コードを一行ずつ確認します。iter(iterator)はイテラブルをイテレーターへ変換します。anyio.to_thread.run_sync(_next, as_iterator)は、同期呼び出しであるnext(as_iterator)をスレッドプールへ渡して実行し、待機可能オブジェクトを返します。awaitはスレッドプールの完了を待ちますが、その間イベントループをブロックしません。yieldは取得した値を呼び出し側へ生成します。
関数全体はasync def + yieldなので非同期ジェネレーターであり、外部にはAsyncIteratorとして振る舞います。呼び出し側はasync forで消費でき、内部では同期イテレーターがスレッドプール上で動いていることを意識する必要がありません。
ここから、重要な設計原則が分かります。asyncはスケジューリングのプロトコルを担い、スレッドプールはブロッキングコードを隔離します。両者は協力関係にあり、互いの代替ではありません。 非同期フレームワークはブロッキング処理そのものを消せません。イベントループへ影響しない場所に隔離できるだけです。
第5部:すぐに参照できる八つの中核ルール
yieldが表すのは「値を段階的に生成すること」だけであり、非同期処理とは直接関係しません。def + yieldは同期ジェネレーターであり、async def + yieldで初めて非同期ジェネレーターになります。async defが表すのは「この関数が非同期プロトコルに従うこと」だけであり、内部コードがブロックしないことを保証しません。async defの中にtime.sleep()を書けば、やはりイベントループは停止します。- コルーチンが実際に実行権を手放すきっかけは
awaitです。awaitを含まないasync defは、実行時の効果という点では通常の関数と変わりません。 awaitが待機できるのは、待機可能オブジェクトだけです。 最も一般的なのはコルーチンオブジェクト、Task、Futureであり、低レベルでは__await__()プロトコルによって実現されます。forは同期イテレーションに、async forは非同期イテレーションに対応します。 二つの構造は対称的なので、混同しないようにしてください。- イベントループはシングルスレッドです。 イベントループのスレッド上で行われるブロッキング処理は、すべてのコルーチンを停止させます。
- 同期ブロッキングコードは、スレッドプールを介して非同期システムへ橋渡しする必要があります。
asyncio.to_thread()やanyio.to_thread.run_sync()が標準的な方法です。 - GeneratorはIteratorの特殊形であり、IteratorはIterableの特殊形です。 同様に、
AsyncGeneratorはAsyncIteratorの特殊形であり、AsyncIteratorはAsyncIterableの特殊形です。