Python | 自動化:コルーチン

Python
スポンサーリンク

概要(コルーチンは「途中で一旦止まって、あとで再開できる関数」)

コルーチン(coroutine)は、めちゃくちゃざっくり言うと

「自分の途中経過(状態)を持ったまま、一旦止まって、あとでそこから再開できる特別な関数」

です。

普通の関数は「呼ぶ → 最後まで走る → 終わり」ですよね。
コルーチンは「呼ぶ → 途中で一旦中断 → 他の処理に譲る → 必要なタイミングで続きを再開」という動きができます。

Python の「async / await」で使っている async def の正体も、まさにコルーチンです。
なので、「コルーチンとは何か」が腑に落ちると、asyncio の理解も一気に楽になります。


まずは普通の関数との違いからイメージする

普通の関数:スタートしたらゴールまで一気に走る

普通の関数は、こういう感じです。

def greet():
    print("こんにちは")
    print("元気ですか?")
    print("さようなら")

greet()
Python

greet() を呼んだ瞬間、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" と "二回目の値"
Python

yield のポイントは、

  • 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())
Python

await がやっていること(コルーチン同士の「バトンタッチ」)

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 defawait で書くのが主流。
async def で書かれた関数は、呼ぶと「コルーチンオブジェクト」を返し、それを await したタイミングで初めて実行される。
await は「ここで一旦譲るので、その間に他のコルーチンを動かしてOK」というサイン。
I/O 待ちが多い自動化処理では、「コルーチン同士が await でうまく譲り合う」ことで、時間の無駄を劇的に減らせる。

ここが腑に落ちると、

「なぜ async / await が必要なのか」
「なぜ requests ではなく aiohttp を使うのか」
「なぜ async 関数の中で time.sleep してはいけないのか」

といったことが、バラバラな知識ではなく、1本の線でつながって見えてきます。

タイトルとURLをコピーしました