async/await の裏側 — イベントループ、コルーチン、そしてランタイムの仕組み
async/await キーワードの裏側でランタイムが何をしているのかを、JavaScript のイベントループ、Python の asyncio、Rust のポーリングベースのモデルの 3 つについて説明します。
async/await は何を隠しているのか
JavaScript、Python、Rust、C# など多くの言語が、非同期処理を書くための構文として async/await を採用しています。構文は似ていても、その裏側で動く仕組みは言語やランタイムによって大きく異なります。
async function fetchData() {
const response = await fetch("/api/data");
const json = await response.json();
return json;
}このコードを実行するとき、ランタイムは次の処理をしています。
awaitで関数の実行を中断(suspend)し、制御を取り戻す- I/O の完了をスレッドを止めずに監視する
- I/O が完了したら、中断した位置から関数の実行を再開(resume)する
- その間、ほかの非同期処理をスケジューリングして実行する
JavaScript はイベントループ、Python は asyncio、Rust はポーリングベースの Future という、それぞれ異なる実行モデルでこの処理を実現しています。
文字起こしを表示
async/awaitは、同期コードのように読める非同期処理です。でも裏側では、言語ごとにまったく違う仕組みが動いています。JavaScript、Python、Rustの3つの実行モデルを比べます。
まず、同期は処理が終わるまで待ちます。非同期は開始したら待たずに進み、完了時に通知を受けます。並行は実行期間が重なること。シングルコアでも可能です。並列は物理的に同時実行すること。こちらはマルチコアが必要です。async/awaitがまず実現するのは、必ずしも並列ではなく、並行性です。
JavaScriptはシングルスレッドで動きます。そこで中心になるのがイベントループです。コールスタックが今の処理を持ち、Web APIやNode APIがタイマーやネットワーク、ファイルI/Oを担当します。Promiseの続きはマイクロタスクへ、setTimeoutなどはマクロタスクへ入ります。
1ターンでは、まずタスクを1つ実行します。マイクロタスクは、実行中のタスクに割り込みません。スタックが空になると、マイクロタスクチェックポイントで、キューが空になるまでPromise jobなどを処理します。だから、同期ログ1、2の後にPromiseの3、最後にsetTimeoutの4が出ます。
JavaScriptのasync/awaitは、Promiseのシンタックスシュガーです。awaitに到達すると右辺を評価し、関数の残りをthenのコールバックとしてPromiseに登録します。その続きはPromiseが解決された時に、マイクロタスクキューへ入ります。関数は中断し、I/O待ちの間もイベントループは別のタスクを処理できます。
V8 v7.2以降では、既にPromiseの値をawaitするときの余分なラッパーを省きます。これにより、マイクロタスクのティック数が3から1に減ります。一方、Pythonのasync/awaitはPython 3.5で導入され、コルーチンを土台にしています。
async defの関数を呼んでも、本体はまだ実行されません。返るのはコルーチンオブジェクトです。実行にはイベントループが必要で、代表的にはasyncio.runで明示的に起動します。awaitは__await__プロトコルを使い、ループがsendでコルーチンを進め、中断すると制御がループに戻ります。CPythonでは、ローカル変数や実行位置を含むフレームがヒープ上に保持されます。
asyncioのループは、I/O多重化、コルーチンスケジューラ、Task管理で動きます。Unixではepollやkqueueなどのselector backend、Windowsでは既定でIOCPを使います。asyncioはシングルスレッドで動くのでGILの影響は小さく、得意なのはCPUバウンドではなくI/Oバウンドです。asyncの中でrequestsのような同期ライブラリを呼ぶとループを塞ぐため、aiohttpなどの非同期版が必要です。
Rustでは、非同期ランタイムは言語に組み込まれていません。tokioなどを選びます。Futureはpollされ、Readyなら完了、Pendingなら後でまた呼んでほしい、と返します。JavaScriptやPythonは完了時に続きを呼ぶプッシュ型、Rustはランタイムが完了を問い合わせるプル型です。コンパイラはasync fnを状態機械に変換し、各awaitを状態遷移にします。
FutureがPendingを返す前に、ランタイムのdriverへWakerを保存します。driverはepoll、kqueue、IOCPなどにreadinessを登録します。I/OがreadyになるとWakerが呼ばれ、ランタイムがそのFutureをもう一度pollします。Futureは状態機械の値なので、awaitごとのヒープ割り当ては必須でなく、Box化、型消去、spawn時に必要です。
状態の置き場所は、JavaScriptがV8ヒープ上のクロージャ、Pythonがヒープ上のコルーチンフレームです。Rustは具体的な状態機械をpollし、Futureをdropするだけでキャンセルできます。多くの言語では、sync関数からasync関数を直接呼べない、関数の色問題も残ります。よくある落とし穴は、asyncの中にブロッキング処理を置いて、ランタイムのスレッドを塞ぐことです。
async/awaitの共通原則は、I/O待ちでスレッドを遊ばせないことです。記事では、イベントループのインタラクティブデモや、コルーチンの状態遷移図、言語比較表でさらに確認できます。
前提知識:同期・非同期・並行・並列
この 4 つの用語は混同されやすいので、先に意味を確認しておきます。
| 用語 | 意味 |
|---|---|
| 同期(Synchronous) | 処理が完了するまでブロックして待つ |
| 非同期(Asynchronous) | 処理を開始したら完了を待たずに次に進み、完了時に通知を受ける |
| 並行(Concurrent) | 複数のタスクの実行期間が重なっている(シングルコアでも可能) |
| 並列(Parallel) | 複数のタスクが物理的に同時に実行されている(マルチコアが必須) |
async/await は並行性(concurrency)を実現する仕組みで、並列に実行されるとは限りません。例えば JavaScript の async/await は、1 つのスレッドの上で複数の処理を並行に進めます。
JavaScript:イベントループモデル
イベントループの構造
JavaScript のコードはシングルスレッドで実行されます。1 つのスレッドで複数の非同期処理を扱うために使われるのがイベントループです。
イベントループの動作には、次の 4 つの要素が関わります。
- コールスタック — 現在実行中の関数のスタック
- Web API / Node API — タイマー、ネットワーク、ファイル I/O などの非同期操作を実行する環境
- マイクロタスクキュー — Promise の
.then/.catch/.finallyコールバック、queueMicrotask() - マクロタスクキュー(タスクキュー) —
setTimeout、setInterval、I/O コールバック、UI イベント
イベントループの動作順序
ブラウザーのイベントループの 1 ターンは、次の順序で進みます。
- タスクを 1 つ取り出して実行する(最初のスクリプト、タイマーのコールバック、入力イベントなど)
- そのタスクのコールスタックが空になったら、マイクロタスクチェックポイントを実行する。Promise のジョブや
queueMicrotask()のコールバックを、キューが空になるまで処理する - 必要に応じてレンダリングを更新する
- 1 に戻り、次のタスクを取り出す
この順序のため、マイクロタスクは実行中のタスクに割り込まないが、次のタスクへ進む前にキューが空になるまで処理されます。次のコードでは、setTimeout のコールバック(タスク)より Promise のコールバック(マイクロタスク)が先に実行されます。
console.log("1"); // 同期
setTimeout(() => {
console.log("4"); // マクロタスク
}, 0);
Promise.resolve().then(() => {
console.log("3"); // マイクロタスク
});
console.log("2"); // 同期
// 出力順: 1, 2, 3, 4JavaScript のイベントループ
実行例: console.log('1'), setTimeout(cb,0), Promise.resolve().then(cb2), console.log('2')
async/await の変換
async/await は Promise のシンタックスシュガーとして説明されることが多い構文です。エンジンは実際には関数の実行状態を保存して中断・再開しますが、動作は概念的に次の Promise チェーンと同じです。
// async/await で書いたコード
async function example() {
console.log("A");
const result = await fetchData();
console.log("B");
return result;
}
// エンジンが内部的に行う変換(概念的)
function example() {
console.log("A");
return fetchData().then((result) => {
console.log("B");
return result;
});
}await 式に到達すると、エンジンは次の処理をします。
awaitに続く式を評価する。値が Promise でなければ、解決済みの Promise に包む- 関数の残りの部分を、その Promise が解決されたときに実行する処理として登録する(Promise が解決されると、再開処理がマイクロタスクキューに入る)
- 関数の実行を中断し、制御を呼び出し元に返す
そのため、I/O を待っている間もイベントループはほかのタスクを処理できます。
V8 エンジンの最適化
V8(Chrome と Node.js の JavaScript エンジン)は v7.2 で await を高速化しました(V8 のブログ記事)。await に渡された値がすでにネイティブの Promise の場合、promiseResolve でその Promise をそのまま使い、追加のラッパー Promise を作らなくなりました。これにより、await 1 回に最低 3 回必要だったマイクロタスクのティックが 1 回になりました。内部でしか使わない throwaway Promise も、仕様の修正により多くの場合は作られなくなっています。
Python:asyncio とコルーチン
Python のコルーチン
Python の async/await は Python 3.5(PEP 492)で導入され、コルーチンをベースにしています。コルーチンは実行を中断・再開できる関数で、ジェネレーターの仕組みを拡張して作られています。
import asyncio
async def fetch_data():
print("fetch start")
await asyncio.sleep(1) # I/O操作をシミュレート
print("fetch done")
return 42async def で定義された関数を呼び出しても関数本体は実行されず、コルーチンオブジェクトが返ります。
coro = fetch_data() # 関数本体はまだ実行されない!
print(type(coro)) # <class 'coroutine'>コルーチンを実行するには、イベントループ(ランタイム)が必要です。
asyncio イベントループ
Python の asyncio モジュールはイベントループの実装を提供します。JavaScript ではランタイムが最初からイベントループを動かしていますが、Python では asyncio.run() などで明示的にイベントループを作成・実行する必要があります。
import asyncio
async def main():
result = await fetch_data()
print(result)
# イベントループを作成して実行
asyncio.run(main())asyncio のイベントループは、次の 3 つの仕組みで動いています。
- I/O 多重化: Unix では
SelectorEventLoopがepoll/kqueueなどの selector backend を使います。Windows の既定は selector API ではなく、IOCP を使うProactorEventLoopです - 実行待ちのコールバックのキュー: イベントループは、すぐに実行できるコールバックをキューに入れ、1 つずつ実行します
- Task:
asyncio.Taskはコルーチンを包み、コルーチンを 1 ステップ進める処理をこのキューに登録します。コルーチンが待っている Future が完了すると、次のステップが再び登録されます
Python のコルーチンの内部実装
Python のネイティブコルーチン(async def)はジェネレーターとは別の型ですが、CPython の内部実装ではジェネレーターと同じ中断の仕組み(YIELD_VALUE オペコード)を共有しています。await は __await__ プロトコルを使い、概念的には次のように動きます。
# 概念的な内部動作
async def example():
result = await some_coroutine()
print(result)
# 内部的には(簡略化):
# 1. some_coroutine() を呼んでコルーチンオブジェクトを取得
# 2. .send(None) で実行を進める
# 3. コルーチンが中断したら、イベントループに制御を返す
# 4. I/O完了後、.send(result) で結果を渡して再開CPython の実装では、コルーチンのフレーム(ローカル変数、実行位置など)はヒープ上に保持されます。そのため、中断・再開のたびにスタックを巻き戻したり復元したりする必要がありません。
asyncio の制約と GIL
Python の asyncio には次の制約があります。
- GIL(Global Interpreter Lock): CPython の標準のビルドでは、Python バイトコードを実行できるのは一度に 1 スレッドだけです。asyncio はシングルスレッドで動くので、GIL の影響はほとんど受けません
- CPU バウンドな処理には向かない: async/await が効果を発揮するのは I/O 待ちの多い処理です。CPU バウンドな処理には
multiprocessingやconcurrent.futures.ProcessPoolExecutorを使います - ライブラリーの対応が必要: 同期的なライブラリー(例:
requests)を async コードから直接呼ぶとイベントループがブロックされるので、非同期のライブラリー(例:aiohttp)を使う必要があります
Rust:ゼロコスト Future とポーリング
Rust の非同期モデル
Rust の async/await には、JavaScript や Python と違う点が 3 つあります。
- ランタイムが言語に組み込まれていない — tokio や smol などのサードパーティーのランタイムを選んで使う
- Future はポーリングベース — コールバックで完了を知らせるのではなく、ランタイムが Future に完了したかを問い合わせる
- ゼロコスト抽象 — 手で状態機械を書いた場合と比べて、async/await による追加のオーバーヘッドがほとんどない
Future trait
Rust の Future は次の trait で定義されます。
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // 完了: 結果を返す
Pending, // 未完了: 後で再度ポーリングしてほしい
}JavaScript の Promise や Python のコルーチンは、完了した側がコールバックを呼んで知らせるプッシュベースです。一方、Rust はランタイムが Future に完了したかを問い合わせるプルベース(ポーリング)です。
async/await の状態機械変換
Rust のコンパイラーは async fn を状態機械(ステートマシン)に変換します。
async fn example() -> i32 {
let a = fetch_a().await; // 中断点1
let b = fetch_b().await; // 中断点2
a + b
}
// コンパイラーが生成する状態機械(概念的):
enum ExampleFuture {
Start,
WaitingA { future_a: FetchAFuture },
WaitingB { a: i32, future_b: FetchBFuture },
Done,
}
impl Future for ExampleFuture {
type Output = i32;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<i32> {
loop {
match self.state {
Start => {
self.future_a = fetch_a();
self.state = WaitingA;
}
WaitingA => {
match self.future_a.poll(cx) {
Poll::Ready(a) => {
self.a = a;
self.future_b = fetch_b();
self.state = WaitingB;
}
Poll::Pending => return Poll::Pending,
}
}
WaitingB => {
match self.future_b.poll(cx) {
Poll::Ready(b) => {
return Poll::Ready(self.a + b);
}
Poll::Pending => return Poll::Pending,
}
}
Done => panic!("polled after completion"),
}
}
}
}この変換では、各 await が 1 つの状態遷移に対応します。生成される Future には次の特徴があります。
awaitごとのヒープ割り当てが必須ではない: Future は具体的な状態機械の値なので、直接awaitする場合はスタック上に置けます。一方、Box 化・型消去・ランタイムへの spawn ではヒープ割り当てされます- 動的ディスパッチが不要: コンパイル時に具体的な型が確定します
Waker とランタイムの協調
ポーリングベースのモデルでは、Future をいつ再びポーリングすればよいかをランタイムに伝える仕組みが必要です。これがWakerです。
// Context にはWakerが含まれている
pub struct Context<'a> {
waker: &'a Waker,
}Future は Poll::Pending を返す前に、Waker のクローンをランタイムの reactor / driver に預けます。driver は I/O の準備完了(readiness)の通知を epoll/kqueue/IOCP などで OS に登録しておき、準備ができると Waker を呼んで、この Future を再びポーリングするようランタイムに知らせます。
1. ランタイムがFuture.poll()を呼ぶ
2. Future は内部で I/O を開始する。ランタイム driver が OS readiness を登録し、Waker を保存
3. Poll::Pending を返す
4. ランタイムは他のFutureをポーリング
5. I/O が ready になる → ランタイム driver が Waker 経由で task を起こす
6. ランタイムが該当Futureを再度poll()
7. Poll::Ready(result) を返すtokio ランタイム
tokio は Rust で最も広く使われている非同期ランタイムです。
#[tokio::main]
async fn main() {
let result = fetch_data().await;
println!("{result}");
}tokio は主に次の機能を提供します。
- マルチスレッドスケジューラー: work-stealing アルゴリズムでタスクを CPU コア間に分散する
- I/O ドライバー: epoll/kqueue/IOCP をラップした非同期 I/O
- タイマー: 階層型タイマーホイール
- シングルスレッドモード:
#[tokio::main(flavor = "current_thread")]を指定すると、1 スレッドのイベントループとして動く
言語間の比較
プッシュ vs プル
| モデル | 言語 | 仕組み | メリット |
|---|---|---|---|
| プッシュベース | JS、Python | 完了時にコールバック/継続を呼ぶ | 仕組みが単純(ポーリングや Waker が不要) |
| プルベース | Rust | ランタイムが Future をポーリング | ゼロコスト、キャンセルが自然 |
プルベースモデルの利点の 1 つはキャンセルのしやすさです。Rust では Future をドロップすると、それ以降ポーリングされないので処理が止まります(ただし、ランタイムに spawn したタスクは JoinHandle をドロップしても止まりません)。プッシュベースのモデルでは、JavaScript の AbortController のように、キャンセルを明示的に伝える仕組みが必要になることが多いです。
コルーチンの状態とメモリー割り当て
| 言語 | コルーチンの状態保持 | メモリー割り当て |
|---|---|---|
| JavaScript | 中断した関数の状態(V8 ヒープ) | GC 管理 |
| Python | コルーチンフレーム(ヒープ) | GC 管理 |
| Rust | 状態機械(スタックまたは Box) | 手動制御可能 |
| Go | Goroutine スタック(連続・可変長) | GC 管理 + スタック成長 |
関数の色問題(Function Coloring)
多くの言語では sync 関数と async 関数がはっきり区別され、「関数の色問題」と呼ばれる制約が生じます。
- sync 関数の中では async 関数の結果を
awaitで待てない(待つには、スレッドをブロックするか、asyncio.run()のようにイベントループを起動する必要がある) - async 関数から sync 関数は呼べるが、長時間ブロックする処理を呼ぶとイベントループが止まる
# NG: sync から async を直接は呼べない
def sync_function():
result = await async_function() # SyntaxError!
# OK: asyncio.run() でブリッジ
def sync_function():
result = asyncio.run(async_function())Go には async/await がなく、この問題がありません。どの関数も go キーワードで新しい goroutine として起動でき、goroutine が I/O で待つ間はランタイムがほかの goroutine を実行します。
実践的な注意点
JavaScript での注意
// NG: awaitを忘れるとPromiseオブジェクトが返る
async function bad() {
const data = fetch("/api"); // await がない!
console.log(data); // Promise オブジェクトが出力される
}
// NG: 直列実行になってしまう
async function sequential() {
const a = await fetchA(); // 1秒待ち
const b = await fetchB(); // さらに1秒待ち → 合計2秒
}
// OK: Promise.all で並行実行
async function parallel() {
const [a, b] = await Promise.all([fetchA(), fetchB()]); // 合計1秒
}Python での注意
# NG: ブロッキングI/Oをasyncコード内で使う
async def bad():
import requests
response = requests.get("https://example.com") # イベントループをブロック!
# OK: 非同期ライブラリーを使う
async def good():
import aiohttp
async with aiohttp.ClientSession() as session:
response = await session.get("https://example.com")
# 独立したタスクの並行実行
async def gather_example():
results = await asyncio.gather(
fetch_a(),
fetch_b(),
fetch_c(),
)Rust での注意
// NG: async fn内でブロッキング操作
async fn bad() {
std::thread::sleep(Duration::from_secs(1)); // ランタイムのスレッドをブロック!
}
// OK: tokioの非同期版を使う
async fn good() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
// OK: CPUバウンド処理はspawn_blockingで別スレッドに逃がす
async fn cpu_work() {
let result = tokio::task::spawn_blocking(|| {
heavy_computation()
}).await.unwrap();
}まとめ
async/await の構文は言語間で似ていますが、裏側の実装は大きく異なります。
- JavaScript: シングルスレッドのイベントループの上で、Promise を使って中断・再開する。マイクロタスクとマクロタスクの優先順位が実行順を決める
- Python: asyncio のイベントループがコルーチン(ジェネレーターの拡張)をスケジューリングする。GIL の制約の下で、I/O 待ちを並行に進める
- Rust: async fn を Future の状態機械に変換する。ランタイムがポーリングで実行を進め、
awaitごとのヒープ割り当てを必須にしない
3 つに共通するのは、I/O を待つ間スレッドを遊ばせないという設計です。async/await は、こうした非同期処理を同期コードに近い形で書けるようにする構文です。