今日は、FastAPIや非同期Pythonを触っているエンジニアなら一度は、あるいは数え切れないほど直面してきたであろう、あの忌まわしきエラーについて深掘りしていきます。そう、RuntimeError: Cannot run the event loop while another loop is running です。
このエラーは、一見すると「ただの二重起動エラー」のように見えます。しかし、その裏側にはPythonの非同期処理ライブラリ asyncio の設計思想、オペレーティングシステムの多重化I/O、さらにはシングルスレッドでいかにして並行性を担保するかというコンピュータサイエンスの根本的な課題が凝縮されています。
本記事では、単なる回避策を提示するにとどまりません。なぜこの制約が存在するのか、歴史的にどう解決されてきたのか、そして低レイヤーのメモリ管理やスタック機構において何が起きているのか。
1. エラーの発生条件:なぜ「今」これが問題になるのか
まずは、どのようなシチュエーションでこのエラーが発生するのかを整理しましょう。FastAPI開発において、このエラーは主に以下の3つのシーンで牙を剥きます。
- Jupyter Notebook / IPython環境での実行: FastAPIのアプリをNotebook上でテストしようとした際、
uvicorn.run()を呼び出すと発生します。 - テストコード (Pytest) 内での非同期呼び出し: すでにループが走っているテスト環境下で、不適切に
asyncio.run()を呼び出した際。 - 入れ子状の非同期実行:
async def関数の中で、ライブラリ内部がさらにasyncio.run()を実行しようとした際。
特にJupyter環境は、それ自体がすでに「一つの大きなイベントループ」の中で動いています。そこにさらに別のイベントループを立ち上げようとする行為は、Pythonの asyncio の標準仕様では「禁忌」とされているのです。しかし、なぜ「二つ目のループ」がそんなに問題なのでしょうか?
2. コンピュータサイエンスから見る「イベントループ」の正体
この問題を理解するためには、抽象化された async/await の構文から一度離れ、CPUとメモリ、そしてOSのシステムコールレベルまで視点を落とす必要があります。
リアクターパターン(Reactor Pattern)の原則
asyncio の心臓部は「リアクターパターン」と呼ばれるアーキテクチャです。これは、I/Oイベントの発生を待ち受け、発生したイベントを対応するハンドラ(コルーチン)に振り分ける(ディスパッチする)無限ループです。
OSレベルでは、select、poll、epoll(Linux)、kqueue(BSD/macOS)、あるいは IOCP(Windows)といったシステムコールが使われています。これらは「どのファイル記述子(ソケット)が準備完了したか」を監視する仕組みです。
イベントループは通常、スレッドごとに「一つだけ」存在することを前提に設計されています。なぜなら、ループはプロセスの実行権限(CPU時間)を占有し、どのタスクを次に実行するかを決定する「独裁的なスケジューラ」だからです。もし一つのスレッド内に二つの独裁者が現れたらどうなるでしょうか? CPUはどちらのスケジュールに従えばいいのか分からなくなり、スタックの整合性が崩壊します。
スタック機構とコンテキストスイッチ
Pythonの非同期処理は「協調的マルチタスク」です。スレッドベースの「プリエンプティブ(強制休止型)マルチタスク」とは異なり、タスク自らが yield(あるいは await)して実行権をループに返却します。
asyncio.run() が呼ばれると、新しい EventLoop インスタンスが生成され、それがカレントスレッドの「主権」を握ります。しかし、すでにループが実行中である場合、その「主権」は既存のループが保持しています。ここで強引に新しいループを始めようとすると、既存のループの実行状態(どのコルーチンがどこまで進んだかというレジスタ状態やスタックフレーム)を退避させる場所がなくなるのです。
3. なぜ Python は「入れ子」を許さないのか(歴史的背景)
Pythonの asyncio は、Guido van Rossum 氏が主導した「Tulip」プロジェクトを源流としています。設計当時、最も重視されたのは「明示性」と「決定論的な動作」でした。JavaScript(Node.js)のイベントループがランタイムに深く統合されているのに対し、Pythonの asyncio はライブラリ層で実装されています。
PEP 3156 とシングルトン的なループ管理
asyncio の基礎を築いた PEP 3156 では、スレッドごとに一つのイベントループを Policy オブジェクトで管理する仕組みが導入されました。この設計思想には、「暗黙的な並行処理による複雑化を防ぐ」という意図がありました。
もしループの入れ子を許してしまうと、内側のループがブロックされた際に外側のループも止まってしまいます。これはデッドロックの原因になりやすく、またシグナルハンドリング(Ctrl+Cでの停止など)の挙動を極めて複雑にします。Pythonコア開発チームは、この複雑さを避けるために「一つのスレッドに一つの稼働中ループ」という厳格な制約を設けたのです。
4. コードの深淵:RuntimeError が投げられる瞬間
実際の asyncio のソースコード(base_events.py)を見てみましょう。run_forever メソッドの冒頭には以下のようなチェックが存在します(簡略化しています)。
def run_forever(self):
if self._running:
raise RuntimeError('This event loop is already running')
if events._get_running_loop() is not None:
raise RuntimeError('Cannot run the event loop while another loop is running')
...ここで events._get_running_loop() は、スレッドローカルストレージ(TLS)を確認し、現在のアクティブなループを返します。このチェックは、ソフトウェアの整合性を守るための「最後の砦」です。FastAPIはこの asyncio の上に構築されているため、FastAPI自体が問題なのではなく、その土台である asyncio の憲法に抵触しているわけです。
5. 解決策の詳細解説:禁じ手から正攻法まで
このエラーに直面した際、私たちはどのように立ち向かうべきでしょうか。状況に応じていくつかの武器があります。
解決策A:nest_asyncio による「穴あけ」
Jupyter Notebook環境などで最も一般的な解決策は nest_asyncio です。これは、asyncio のイベントループにパッチを当て、再入可能(Re-entrant)にするライブラリです。
import nest_asyncio
nest_asyncio.apply()
import uvicorn
from fastapi import FastAPI
app = FastAPI()
# これが通常はエラーになるが、apply() 後は動作する
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)技術的裏側: nest_asyncio は、run_forever や _run_once といった内部メソッドを書き換えます。新しいループが開始される際、現在のスタックポインタを保存し、既存のループの「途中の状態」を維持したまま、新しいイベントの処理を開始できるようにします。これは一種の「再帰的なイベント処理」を実現するトリッキーな手法です。
解決策B:非同期コンテキスト内での適切な待ち合わせ
もしあなたが async def の中で asyncio.run(some_coroutine()) を呼ぼうとしてこのエラーが出ているなら、それは設計のミスです。正解はシンプルに await some_coroutine() とすることです。
「非同期関数の中から、さらに非同期ループを新しく作る」必要はありません。既存のループにタスクを預ければいいだけです。この「色のついた関数(Function Coloring)」問題は、Python非同期処理における最大の学習ハードルの一つです。一度非同期の世界(Async World)に入ったら、最後までその世界のルールで通すのが鉄則です。
解決策C:低レベルAPI get_running_loop() の活用
ライブラリ開発などで、「ループがなければ作る、あればそれを使う」という挙動をさせたい場合は、以下のようなパターンが使われます。
import asyncio
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# 既存のループにタスクを投げる
loop.create_task(my_coroutine())
else:
# 新しくループを回す
asyncio.run(my_coroutine())6. アナロジーで理解する:イベントループは「たった一人の料理人」
ここで理解を深めるために、レストランの厨房を想像してください。
イベントループは「一人の超人的な料理人」です。彼は一度に一つの作業しかできませんが、手が空いた瞬間(パスタが茹であがるのを待つ間など)に別の注文の仕込みをします。これが非同期処理です。
RuntimeError: Cannot run the event loop while another loop is running が起きている状態とは、料理人がパスタを茹でている最中に、誰かが「おい、今すぐ別の料理人として、この厨房で最初から料理を始めろ!」と命令したようなものです。料理人は一人しかいません。自分自身をもう一人召喚することはできませんし、今の作業を完全に放置して「別の料理人」になりきることもできません。
nest_asyncio は、いわば「料理人がパスタのタイマーを首から下げたまま、一瞬だけ別の料理のフリをする」という特殊な訓練を施すようなものです。一方、正攻法である await は、「パスタを待っている間に、その注文の一部として別の作業を追加する」という自然な流れです。
7. 高度なトピック:Uvicorn の内部構造とシグナル
FastAPIを実行する uvicorn は、内部で uvloop という Cython で書かれた超高速なイベントループの実装(libuv のラッパー)を使用することがあります。この uvloop 環境下でも、この RuntimeError は同様に発生します。
特に複雑なのが、uvicorn を Gunicorn のワーカーとして動かす場合です。この場合、プロセスの管理(シグナル、フォーク、ライフサイクル)は Gunicorn が行い、個別のリクエスト処理を uvicorn のイベントループが担当します。この多重構造の中で、不適切にイベントループに介入しようとすると、OSのプロセス管理とPythonのループ管理が衝突し、ゾンビプロセスが発生したり、ポートが解放されなかったりといった深刻な問題に発展します。
8. まとめ:非同期処理の「一貫性」を保つために
RuntimeError: Cannot run the event loop while another loop is running は、単なるエラーメッセージではなく、Pythonがあなたのプログラムの「崩壊」を防ぐために発している警告です。
- 本質は「単一の実行主体」の保護。
- 解決策は「既存のループへの委譲(await)」か「環境へのパッチ(nest_asyncio)」。
- 設計段階で「どこがループの起点か」を明確にする。
FastAPIのようなモダンなフレームワークを使いこなすには、こうした「見えない制約」を理解し、フレームワークが提供する抽象化の裏側にあるOSやランタイムの挙動に敬意を払うことが重要です。次にこのエラーに出会ったとき、あなたは「ああ、料理人を無理に増やそうとしていたな」と冷静に対処できるはずです。
さらなるステップへ
非同期処理の深い理解は、Pythonエンジニアとしての市場価値を決定づける重要な要素です。こうした内部構造を理解した上で、実務で使えるプログラミングスキルを磨くには、体系的な知識の整理が欠かせません。
特に、非同期処理を含むPythonの高度な機能を、実際のプロジェクトでどう適用すべきかという「設計のベストプラクティス」を学ぶには、以下の書籍が非常にお勧めです。プロフェッショナルとしての土台を固めるために、ぜひ手に取ってみてください。
非同期処理だけでなく、パッケージ管理、テスト、デプロイ、そしてチーム開発に耐えうるコードの書き方までを網羅しており、今回の asyncio のような深いテーマを実戦でどう扱うべきかの指針が示されています。


コメント