概要(コルーチンは「途中で一旦止まって、あとで再開できる関数」)
コルーチン(coroutine)は、めちゃくちゃざっくり言うと
「自分の途中経過(状態)を持ったまま、一旦止まって、あとでそこから再開できる特別な関数」
です。
普通の関数は「呼ぶ → 最後まで走る → 終わり」ですよね。
コルーチンは「呼ぶ → 途中で一旦中断 → 他の処理に譲る → 必要なタイミングで続きを再開」という動きができます。
Python の「async / await」で使っている async def の正体も、まさにコルーチンです。
なので、「コルーチンとは何か」が腑に落ちると、asyncio の理解も一気に楽になります。
まずは普通の関数との違いからイメージする
普通の関数:スタートしたらゴールまで一気に走る
普通の関数は、こういう感じです。
def greet():
print("こんにちは")
print("元気ですか?")
print("さようなら")
greet()
Pythongreet() を呼んだ瞬間、3行目まで一気に実行して、もう二度と途中から再開はできません。
「途中で止めて、あとで続きから」という概念はありません。
コルーチン:途中で「ここまで一旦返すね」と中断できる
一方、コルーチンは「途中で一旦外に制御を返し、あとで再開できる」ようになっています。
昔の Python では yield を使ったジェネレータが、コルーチンの元祖的な存在でした。
def simple_coroutine():
print("スタート")
x = yield "一回目の値"
print("再開時に渡された x:", x)
yield "二回目の値"
print("終了")
c = simple_coroutine()
print(next(c)) # → "スタート" と "一回目の値"
print(c.send(10)) # → "再開時に渡された x: 10" と "二回目の値"
Pythonyield のポイントは、
yieldに来た瞬間、その位置で関数が「一旦停止」する- 次に
next()やsend()で呼ばれたとき、止まっていた場所から再開する
という動きです。
この「一旦止まって、また動き出す」という特性こそが「コルーチンらしさ」です。
async / await のコルーチン(現代 Python のメインストリーム)
async def で作るコルーチン
今の Python(3.5 以降)では、非同期処理用のコルーチンは async def で書きます。
import asyncio
async def my_coroutine():
print("開始")
await asyncio.sleep(1)
print("1秒後に再開")
Pythonこの my_coroutine は、呼んだ瞬間に実行されるわけではありません。
coro = my_coroutine()
print(coro) # <coroutine object my_coroutine at 0x...>
Python返ってくるのは「コルーチンオブジェクト」です。
これは「まだ動いていない、“これから実行される予定の処理”」です。
このコルーチンを実際に動かすには、
await my_coroutine()- または
asyncio.create_task(my_coroutine())してイベントループに渡す
のどちらかが必要です。
async def main():
await my_coroutine()
asyncio.run(main())
Pythonawait がやっていること(コルーチン同士の「バトンタッチ」)
await は、コルーチンにとっての「yield に近い動き」をします。
async def my_coroutine():
print("開始")
await asyncio.sleep(1)
print("1秒後に再開")
Pythonここで await asyncio.sleep(1) は、こういう意味を持っています。
- 「ここから先の処理は、sleep が終わるまで一旦止めていいよ」
- 「その間、イベントループは他のコルーチンを動かして構わない」
つまり、
「イベントループに対して、今は I/O 待ちなので、別のコルーチンに CPU を譲ります」
というメッセージを送っているのと同じです。
これが、コルーチンを使った「同時進行」の正体です。
複数のコルーチンが、それぞれの await で上手に譲り合うことで、一つのスレッドの中で効率よく動きます。
超シンプルなコルーチン同時実行例(asyncioベース)
2つのコルーチンを同時に走らせてみる
細かい理屈より、まずは挙動を見てください。
import asyncio
async def worker(name, delay):
print(f"{name} 開始")
await asyncio.sleep(delay)
print(f"{name} 終了({delay}秒待ち)")
async def main():
task1 = asyncio.create_task(worker("A", 2))
task2 = asyncio.create_task(worker("B", 3))
await task1
await task2
asyncio.run(main())
Python流れをかみ砕くと、こうです。
worker はコルーチン関数(async def)worker("A", 2) を呼ぶと、「コルーチンオブジェクト」が返るだけで、まだ動かないasyncio.create_task(...) で、それを「実行中のタスク」に変換し、イベントループに登録する
イベントループが task1 を動かして「A 開始」表示 → await asyncio.sleep(2) のところで「一旦他に譲る」
空いたタイミングで task2 が動き、「B 開始」表示 → await asyncio.sleep(3) でまた譲る
2秒経ったら task1 が再開 → 「A 終了」
さらに1秒後に task2 再開 → 「B 終了」
大事なのは、「A が待っている間に B が動き、B が待っている間に A が動く」という“交互進行”が、
一つのスレッドの中で、コルーチンの譲り合いによって実現していることです。
ここで「A の処理」「B の処理」が、それぞれ独立したコルーチンとして存在し、await を使って「ここは待つから好きにして」と言っているイメージを掴んでください。
なぜコルーチンが自動化に効くのか(I/O待ちの山を捌く視点)
コルーチンは「待ちの多い仕事」を細切れにして回す
自動化の世界では、
- Web API を何十回・何百回も叩く
- サイトをスクレイピングする
- 外部サービスとの通信をする
といった「待ち時間(ネットワーク)がとにかく長い処理」が多いです。
同期的に書くと、
1番目の API を叩く → 待つ
2番目を叩く → 待つ
3番目を叩く → 待つ
…
となり、待ち時間が全部足し算されます。
コルーチン(async/await)で書くと、
1番目のコルーチンが API を叩いて await で待つ
その間に 2番目・3番目の API コルーチンも動き出す
どれかが結果を受け取ったら、そのコルーチンがまた再開
…
という風に、「待ち時間同士を重ね合わせる」ことができます。
結果として、「一番遅い API の待ち時間プラス少し」くらいの時間で、全部取り終わることも珍しくありません。
マルチスレッドとの違い(コルーチンは軽くて、明示的)
マルチスレッドも「同時進行」ですが、
OS がスレッドの切り替えタイミングを決めるので、
コードから見ると「いつどこで切り替わるか」が見えにくくなります。
コルーチン(async/await)は、
- 切り替えポイントを
awaitで明示する - 頭の中で「ここで止まる → ここから再開」が追いやすい
- スレッドではなく 1つのスレッド内での「論理的な並行処理」なので、軽い
という特徴があります。
「スレッド増やす前に、まず I/O 待ちをコルーチンで上手く回収する」という発想は、
規模が大きくなったときほど効いてきます。
ジェネレータ型コルーチンと async コルーチンの関係(軽く触れる)
yield を使う古典的コルーチン
実は Python には、async が登場する以前から「コルーチン的なもの」がありました。
それが「ジェネレータを使ったコルーチン」です。
def my_gen():
print("start")
x = yield 1
print("x:", x)
y = yield 2
print("y:", y)
yield 3
print("end")
g = my_gen()
print(next(g)) # start → 1
print(g.send(10)) # x: 10 → 2
print(g.send(20)) # y: 20 → 3
try:
next(g) # end → StopIteration
except StopIteration:
pass
Pythonここでは、
yieldが「一時停止ポイント」send()で再開しつつ値を送れる
という仕組みを使って、「外部と双方向にやり取りできる関数」を実現していました。
今は「非同期処理は async/await で書く」のが基本
ジェネレータコルーチンは今でも使い道はありますが、
非同期 I/O を扱うときは、ほぼすべて async def / await の世界で完結します。
なので、初心者のうちは、
「コルーチン= async def で書かれた、途中で await できる関数」
と覚えてしまって構いません。yield ベースの古いスタイルは、「Pythonの歴史の一部なんだな」くらいの認識でOKです。
コルーチン設計のコツ(async 関数をどう切り分けるか)
一つのコルーチンは「ひとまとまりの仕事」にする
良いコルーチン(async 関数)は、
- 役割がはっきりしている(例:1つのAPIを叩く、1ファイルを処理する)
- 内部の I/O ポイントで
awaitしている - 入力(引数)と出力(戻り値)が明確
という形をしています。
例えば、
async def fetch_user(session, user_id):
url = f"https://api.example.com/users/{user_id}"
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.json()
Python「1人のユーザー情報を取る」という意味がすごく明確ですよね。
その上で、「複数ユーザーをまとめて処理する」コルーチンは、
この小さなコルーチンを並列に投げる役割だけを持たせるときれいです。
async def fetch_users(session, user_ids):
tasks = [asyncio.create_task(fetch_user(session, uid)) for uid in user_ids]
return await asyncio.gather(*tasks)
Pythonこうすると、「個々の仕事」と「並列制御」の責任が分かれるので、
バグりづらく、読みやすく、テストもしやすくなります。
コルーチンの中に「同期の重たい処理」を埋め込まない
これは何度でも言いたいポイントです。
async の中で、
time.sleep()requests.get()- 重い計算 for ループを何秒も
みたいな処理をそのまま書くと、イベントループが止まります。
コルーチンの強みを殺してしまう行為です。
やるなら、
- 待ち →
await asyncio.sleep() - HTTP → aiohttp(非同期ライブラリ)
- CPU重い処理 →
await asyncio.to_thread(重い関数, 引数)でスレッドに逃す
といった形で、「イベントループをブロックしないように設計する」ことが大事です。
まとめ(コルーチンを「途中で止まりながら進む関数」として捉える)
まとめとして、頭に残してほしい軸はこうです。
コルーチンは「途中で一旦止まって、あとでその続きから再開できる関数」。
昔は yield を使ったジェネレータで、今は async def + await で書くのが主流。async def で書かれた関数は、呼ぶと「コルーチンオブジェクト」を返し、それを await したタイミングで初めて実行される。await は「ここで一旦譲るので、その間に他のコルーチンを動かしてOK」というサイン。
I/O 待ちが多い自動化処理では、「コルーチン同士が await でうまく譲り合う」ことで、時間の無駄を劇的に減らせる。
ここが腑に落ちると、
「なぜ async / await が必要なのか」
「なぜ requests ではなく aiohttp を使うのか」
「なぜ async 関数の中で time.sleep してはいけないのか」
といったことが、バラバラな知識ではなく、1本の線でつながって見えてきます。
