12k
All articles

JavaScript・Rust・Pythonにおけるasync/awaitの違い

JavaScript、Rust、Pythonのasync/awaitを比較。関数の実行開始時点やawaitを忘れた場合の挙動、タスクを並行実行する方法を解説します。

OpenReplay Team
OpenReplay Team
JavaScript・Rust・Pythonにおけるasync/awaitの違い

JavaScriptのasync関数は、呼び出した瞬間に実行を開始します。一方、Rustのasync fnやPythonのasync defは、何かがawaitするかスケジュールするまで一切実行されません。

非同期処理が開始されるタイミングのこの違いは、よくあるバグの原因になります。正常に動作していたTypeScriptをRustやPythonに移植すると、関数がまったく実行されなかったり、並行して実行されるはずの呼び出しが1つずつ順番に実行されたりします。本記事では、同じ小さなコードスニペットをNode.js、Rust(Tokio)、Python 3.11以降で実行し、それぞれの出力順序を示します。JavaScriptの非同期処理についてはすでに理解していることを前提としています。先に背景知識を確認したい場合は、OpenReplayのガイド「JavaScriptにおける非同期プログラミングのベストプラクティス」および「JavaScript非同期処理のヒント、テクニック、落とし穴」を参照してください。

重要なポイント

  • JavaScriptでasync関数を呼び出すと、最初のawaitまでの本体が同期的に実行されるため、そのawaitより前のコードは即座に実行されます。
  • Rustのasync fnを呼び出すとFutureが返されますが、そのfutureがawaitされるか、Tokioなどのランタイムに渡されるまで、本体は一切実行されません。
  • Pythonのasync def関数を呼び出すとコルーチンオブジェクトが返されますが、awaitされるかタスクでラップされるまで何も実行されません。
  • 2つの非同期呼び出しを作成してから順番にawaitした場合、JavaScriptでは処理が並行して実行されますが、RustとPythonでは1つずつ順番に実行されます。
  • JavaScriptのランタイムにはイベントループが組み込まれています。Rustでは#[tokio::main]などのエグゼキューターが必要で、Pythonではasyncio.run()でループを開始します。

1つのスニペット、3つの言語、3つの出力

以下の各バージョンでは、同じ関数を定義しています。出力を行い、10 msのスリープをawaitし、再び出力する関数です。そして、いずれのバージョンでもこの関数をawaitせずに呼び出しています。a: startを出力するのはJavaScriptだけです。

サンプルの対象は、Node.jsのLTSリリース、Tokio 1.x、Python 3.11以降です。

JavaScript(Node.js)

async function work(label) {
  console.log(`${label}: start`);
  await new Promise((r) => setTimeout(r, 10));
  console.log(`${label}: end`);
}

work("a"); // no await
console.log("main: after call");
a: start
main: after call
a: end

Rust(Tokio):tokio = { version = "1", features = ["macros", "rt", "time"] }を使用

use std::time::Duration;

async fn work(label: &'static str) {
    println!("{}: start", label);
    tokio::time::sleep(Duration::from_millis(10)).await;
    println!("{}: end", label);
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    work("a"); // future created, never awaited
    println!("main: after call");
}
main: after call

Python 3.11以降

import asyncio

async def work(label):
    print(f"{label}: start")
    await asyncio.sleep(0.01)
    print(f"{label}: end")

async def main():
    work("a")  # coroutine created, never awaited
    print("main: after call")

asyncio.run(main())
main: after call

なぜRustのasync/awaitはawaitされるまで実行されないのか?

JavaScript、Rust、Pythonのasync/awaitの違いは、async関数を呼び出したときに何が返されるかにあります。JavaScriptは、すでに開始された処理に対するPromiseを返します。RustとPythonは、まだ開始されていない処理を表すオブジェクトを返します。

  • JavaScript: async functionは最初のawaitまで同期的に実行されます。返されるPromiseは、すでに実行中の本体に紐づいています。
  • Rust: async fnはimpl Futureを返します。Futureトレイトは、ポーリングされたときにのみ処理が進む値を表します。.awaitを記述するか、tokio::spawnなどにfutureを渡すまで、本体は一切実行されません。
  • Python: async def関数を呼び出すと、コルーチンオブジェクトが得られます。asyncioのドキュメントには、コルーチンを呼び出しても実行がスケジュールされるわけではないと明記されています。

Gray、Krishnamurthi、Crichtonによる研究論文では、この設計上の選択を非同期設計における「Eagerness(積極性)」という観点として説明しており、Lazy、Eager、Semi-Eagerの3つの選択肢を挙げています。実際のところ、JavaScriptは呼び出し箇所で処理を開始し、他の2つの言語は待機します。

awaitを忘れるとどうなるのか?

3つの言語のいずれでもawaitを書き忘れることはありえますが、その失敗の仕方は異なります。JavaScriptでは処理自体は実行されるため、誰もキャッチしないエラーが発生するリスクがあります。RustとPythonでは処理が完全にスキップされ、そのことが通知されます。

JavaScript: 本体は実行されますが、rejectされた場合にそのrejectionを誰も処理しないと、Nodeはそれをエラーとして扱います。--unhandled-rejectionsのデフォルトモードはthrowであり、ハンドラーが登録されていない限り、rejectionはキャッチされない例外として送出され、プロセスが終了します。

async function save() { throw new Error("disk full"); }
save(); // no await, no .catch()

Rust: コンパイラーがunused_must_use lintを通じてビルド時に警告を出します。

warning: unused implementer of `Future` that must be used
  = note: futures do nothing unless you `.await` or poll them

Python: awaitされなかったコルーチンがガベージコレクションされる際、CPythonはstderrに警告を出力します。asyncioのガイドでは、これについて「一度もawaitされなかったコルーチンの検出」で解説しています。

RuntimeWarning: coroutine 'work' was never awaited

修正方法はどの言語でも同じです。JavaScriptとPythonではawait work("a")、Rustではwork("a").awaitとします。この変更により、3つの言語すべてでa: start、a: end、main: after callの順に出力されます。

非同期処理を並行実行するには?

JavaScriptでは、関数を呼び出すだけで並行処理が実現されます。RustとPythonでは、コンビネーターやタスクを使って明示的に並行処理を指示する必要があります。

「作成してからawaitする」パターンを見ると、その違いが明確にわかります。

const pa = work("a"), pb = work("b");
await pa; await pb;          // JS: a: start, b: start, a: end, b: end
let fa = work("a"); let fb = work("b");
fa.await; fb.await;          // Rust: a: start, a: end, b: start, b: end
ca = work("a"); cb = work("b")
await ca; await cb           # Python: a: start, a: end, b: start, b: end

Rustのfutureも、Pythonのコルーチンも、awaitされるまで開始されないため、各呼び出しは前の呼び出しが完了してから初めて実行されます。修正方法は以下のとおりです。

言語すべての完了を待つ今すぐ開始し、後でawaitする
JavaScriptPromise.all関数を呼び出すだけ
Rusttokio::join!tokio::spawn
Pythonasyncio.gather、asyncio.TaskGroupasyncio.create_task

tokio::join!は、現在のタスク内で複数のfutureを並行実行します。一方、tokio::spawnはfutureを独立したタスクとしてランタイムに渡し、後でawaitできるJoinHandleを返します。spawnされるfutureはSend + 'staticである必要があり、workが&'static strを受け取るのはそのためです。

asyncio.TaskGroupはPython 3.11で追加されました。async withブロックを抜ける際にすべてのタスクの完了を待ち、いずれかのタスクが例外を送出した場合は、他のタスクをキャンセルします。

async with asyncio.TaskGroup() as tg:
    tg.create_task(work("a"))
    tg.create_task(work("b"))

タスクをspawnした場合でも、JavaScriptの呼び出しほど即時的ではありません。spawnされた本体は、呼び出し元が制御を譲るまで実行されません。

let h = tokio::spawn(work("a"));
println!("main: after spawn");
h.await.unwrap();
t = asyncio.create_task(work("a"))
print("main: after create_task")
await t

どちらも最初にmain: after ...を出力し、その後にa: startとa: endを出力します。JavaScriptでは、呼び出し元の次の行よりも先にa: startが出力されます。ただし注意点が1つあります。Rustにおけるこの出力順序はcurrent_threadの場合に成り立つものです。マルチスレッドランタイムでは、別のワーカーがspawnされたタスクを即座に取得する可能性があるため、出力行の順序が異なる場合があります。

イベントループはどこから来るのか?

JavaScriptのランタイムにはイベントループが組み込まれていますが、RustとPythonでは自分でイベントループを開始する必要があります。Rustでは#[tokio::main]などのエグゼキューターを使い、Pythonではasyncio.run()を使います。

  • JavaScript: Node.jsやブラウザでは、コードの実行開始前からすでにイベントループが動作しています。その仕組みについては、OpenReplayの記事「JavaScriptのイベントループとコールスタック」で解説しています。Nodeでは、トップレベルawaitはESモジュール(.mjsファイル、またはpackage.jsonで"type": "module"を指定した場合)で使用できます。
  • Rust: 標準ライブラリにはエグゼキューターが含まれていません。#[tokio::main]はasync fn mainをTokioランタイムでラップします。デフォルトではマルチスレッドランタイム(rt-multi-threadフィーチャーが必要)を使用し、flavor = "current_thread"を指定するとシングルスレッドランタイムに切り替わります。
  • Python: async defは言語の一部であり、インポートは不要です。ただし、コルーチンを実行するにはループが必要です。asyncio.run()はループを作成し、トップレベルのコルーチンを実行した後、ループを閉じます。

比較まとめ

JavaScriptRust(Tokio)Python(asyncio)
async関数の呼び出し最初のawaitまで実行何もしないFutureを返す何もしないコルーチンを返す
awaitの書き忘れ処理は実行される。rejectionによりNodeがクラッシュする可能性ありコンパイラー警告、処理は実行されないRuntimeWarning、処理は実行されない
並行処理Promise.alljoin! / spawngather / TaskGroup / create_task
ランタイム組み込みエグゼキューターを選択asyncio.run()

まとめ

async/awaitの構文はJavaScript、Rust、Pythonで同じように見えますが、処理がいつ開始されるかについては3つの言語で考え方が異なります。JavaScriptは呼び出し時に処理を開始します。RustとPythonは、結果をawaitしたとき、または明示的にスケジュールしたときにのみ処理を開始します。非同期コードを移植する際は、結果が即座にawaitされていない呼び出し箇所をすべて確認してください。Rustでは、それらの呼び出しをtokio::join!またはtokio::spawnに置き換えます。Pythonでは、asyncio.gather、TaskGroup、またはcreate_taskを使用します。

よくある質問

Pythonのasyncioタスクを、JavaScriptのasync関数のように即時開始させることはできますか?

はい、オプトインで可能です。Python 3.12でasyncio.eager_task_factoryが追加されました。これはloop.set_task_factory(asyncio.eager_task_factory)で設定します。設定後は、新しいタスクがcreate_taskの呼び出し内で即座にコルーチンの実行を開始し、最初にブロックする地点まで実行されます。コルーチンがブロックせずに完了した場合、そのタスクはイベントループに載ることはありません。デフォルトは引き続き遅延スケジューリングであり、eagerタスクに切り替えると実行順序が変わる可能性があります。

JoinHandleをdropした場合、tokio::spawnのタスクはどうなりますか?

タスクは実行を続けます。JoinHandleをdropするとタスクがデタッチされ、バックグラウンドで実行が継続されますが、戻り値は失われ、どこからもjoinできなくなります。これは、async fnの呼び出しで返されたawaitされていないfutureとは異なります。後者は一切実行されません。spawnしたタスクを停止するには、JoinHandleに対してabort()を呼び出します。abortされたタスクをawaitすると、キャンセルを示すJoinErrorが返されます。

1つのタスクが失敗した場合、asyncio.gatherとasyncio.TaskGroupにはどのような違いがありますか?

asyncio.gatherは、1つのタスクが例外を送出しても他のタスクをキャンセルしません。デフォルトのreturn_exceptions=Falseでは、最初の例外が即座にawait側のコードに伝播し、残りのタスクは実行を続けます。return_exceptions=Trueの場合は、例外が結果リストの中に返されます。Python 3.11で追加されたasyncio.TaskGroupは、1つのタスクが失敗すると残りのタスクをキャンセルするため、失敗した処理が気づかれないまま実行され続けることはありません。

fire-and-forgetで起動したasyncioタスクが、完了前に消えてしまうことがあるのはなぜですか?

asyncioのイベントループはタスクへの弱参照しか保持しないため、コード側でタスクを保存していない場合、asyncio.create_taskで作成したタスクが実行途中でガベージコレクションによって削除される可能性があります。Pythonのドキュメントでは、参照を保持することを推奨しています。たとえば、各タスクをsetに追加し、task.add_done_callback(background_tasks.discard)を呼び出す方法があります。asyncio.TaskGroupは各タスクへの強参照を保持してそれらをawaitするため、この問題を回避できます。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.