12k
All articles

async/await в JavaScript, Rust и Python

Сравните async/await в JavaScript, Rust и Python: узнайте, когда запускаются функции, что происходит без await и как выполнять задачи параллельно.

OpenReplay Team
OpenReplay Team
async/await в JavaScript, Rust и Python

Асинхронная функция в JavaScript начинает выполняться в момент вызова. async fn в Rust или async def в Python не делает вообще ничего, пока её результат не ожидают через await или пока её не поставят в очередь на выполнение.

Это различие в моменте старта асинхронной работы порождает хорошо знакомый баг. Портированный на Rust или Python работающий код на TypeScript либо вообще не вызывает функцию, либо выполняет вызовы строго один за другим там, где вы рассчитывали на их параллельное выполнение. В этой статье один и тот же небольшой фрагмент кода запускается в Node.js, в Rust с Tokio и в Python 3.11+, и для каждого случая показан порядок вывода. Предполагается, что вы уже знакомы с асинхронностью в JavaScript. Если сначала нужно освежить базу, загляните в руководства OpenReplay: best practices for async programming in JavaScript и JavaScript async tips, tricks, and gotchas.

Ключевые выводы

  • При вызове асинхронной функции в JavaScript её тело выполняется синхронно до первого await, поэтому код до этого await выполняется сразу.
  • Вызов async fn в Rust возвращает Future и не выполняет ни строчки тела, пока future не будет ожидаться через .await или не будет передан рантайму, например Tokio.
  • Вызов функции async def в Python возвращает объект корутины, который ничего не выполняет, пока его не ожидают через await или не обернут в задачу.
  • Если создать два асинхронных вызова, а затем по очереди дождаться каждого из них, в JavaScript работа будет выполняться параллельно, а в Rust и Python — последовательно.
  • В рантаймы JavaScript цикл событий встроен. В Rust нужен исполнитель (executor), например #[tokio::main], а в Python цикл запускается через asyncio.run().

Один фрагмент, три языка, три результата

Каждая версия ниже определяет одну и ту же функцию: вывести строку, дождаться 10-миллисекундной паузы и снова вывести строку. Затем функция вызывается без await. Только JavaScript выводит a: start.

Примеры рассчитаны на LTS-версию Node.js, 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?

Разница между async/await в JavaScript, Rust и Python сводится к тому, что возвращает вызов асинхронной функции. JavaScript возвращает Promise для работы, которая уже началась. Rust и Python возвращают объект, представляющий работу, которая ещё не начиналась.

  • JavaScript: async function выполняется синхронно до первого await. Полученный Promise относится к телу функции, которое уже выполняется.
  • Rust: async fn возвращает impl Future. Трейт Future описывает значение, которое продвигается вперёд только при опросе (polling). Ничего из тела функции не выполняется, пока вы не напишете .await или не передадите future, например, в tokio::spawn.
  • Python: вызов функции async def даёт объект корутины. В документации asyncio прямо сказано, что простой вызов корутины не планирует её выполнение.

В исследовании Грея, Кришнамурти и Крайтона этот выбор описан как измерение «энергичности» (Eagerness) в дизайне асинхронности с тремя вариантами: ленивый (Lazy), энергичный (Eager) и полуэнергичный (Semi-Eager). На практике JavaScript начинает работу прямо в точке вызова, а два других языка ждут.

Что происходит, если забыть await?

Все три языка позволяют забыть await, но последствия разные. JavaScript всё равно выполняет работу, поэтому риск в том, что ошибку никто не перехватит. Rust и Python работу вообще не выполняют и сообщают вам об этом.

JavaScript. Тело функции выполняется, но если промис отклоняется и отклонение никто не обрабатывает, Node считает это ошибкой. Режим --unhandled-rejections по умолчанию — throw: отклонение выбрасывается как неперехваченное исключение и завершает процесс, если у вас не зарегистрирован обработчик:

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

Rust. Компилятор выдаёт предупреждение на этапе сборки через линт unused_must_use:

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

Python. Когда сборщик мусора удаляет корутину, которую так и не дождались, CPython выводит предупреждение в stderr. В руководстве по asyncio это описано в разделе detecting never-awaited coroutines:

RuntimeWarning: coroutine 'work' was never awaited

Исправление во всех языках одно и то же: await work("a") в JavaScript и Python и work("a").await в Rust. После этого все три версии выводят a: start, a: end, main: after call.

Как выполнять асинхронную работу конкурентно?

В JavaScript для конкурентности достаточно просто вызвать функции. В Rust и Python её нужно запросить явно — с помощью комбинатора или задачи.

Паттерн «создать, затем дождаться» наглядно показывает разницу:

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

Ни future в Rust, ни корутина в Python не стартуют, пока их не ожидают, поэтому каждый вызов выполняется только после завершения предыдущего. Способы исправления:

ЯзыкДождаться всехЗапустить сейчас, дождаться позже
JavaScriptPromise.allПросто вызвать функцию
Rusttokio::join!tokio::spawn
Pythonasyncio.gather, asyncio.TaskGroupasyncio.create_task

tokio::join! выполняет несколько future конкурентно внутри текущей задачи. tokio::spawn, напротив, передаёт future рантайму как отдельную задачу и возвращает JoinHandle, который можно дождаться позже. Порождаемый 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"))

Даже порождение задачи не столь «энергично», как вызов в JavaScript. Тело порождённой задачи не начинает выполняться, пока вызывающий код не уступит управление:

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 ещё до следующей строки вызывающего кода. Одна оговорка: в Rust такой порядок гарантирован для current_thread. В многопоточном рантайме другой воркер может подхватить порождённую задачу сразу же, поэтому строки могут перемежаться иначе.

Откуда берётся цикл событий?

В рантаймы JavaScript цикл событий встроен, а в Rust и Python его нужно запускать самостоятельно: в Rust — через исполнитель, например #[tokio::main], в Python — через asyncio.run().

  • JavaScript: Node.js и браузеры запускают цикл событий ещё до начала выполнения вашего кода. Как он устроен, объясняется в статье OpenReplay о JavaScript event loop and call stack. В Node await верхнего уровня работает в ES-модулях (файлы .mjs или "type": "module" в package.json).
  • Rust: в стандартной библиотеке нет исполнителя. #[tokio::main] оборачивает async fn main в рантайм Tokio. По умолчанию используется многопоточный рантайм (для него нужна фича rt-multi-thread), а flavor = "current_thread" переключает на однопоточный.
  • Python: async def — часть самого языка и не требует импортов. Однако для запуска корутины нужен цикл, и asyncio.run() создаёт его, выполняет корутину верхнего уровня, а затем закрывает цикл.

Сводная таблица

JavaScriptRust (Tokio)Python (asyncio)
Вызов асинхронной функцииВыполняется до первого awaitВозвращает инертный FutureВозвращает инертную корутину
Забытый awaitРабота выполняется; отклонение может уронить NodeПредупреждение компилятора, работа не выполняетсяRuntimeWarning, работа не выполняется
КонкурентностьPromise.alljoin! / spawngather / TaskGroup / create_task
РантаймВстроенныйНужно выбрать исполнительasyncio.run()

Заключение

Синтаксис async/await в JavaScript, Rust и Python выглядит одинаково, но три языка расходятся в том, когда начинается работа. JavaScript запускает её в момент вызова. Rust и Python — только когда вы ожидаете результат или явно планируете его выполнение. При портировании асинхронного кода проверьте каждое место вызова, результат которого не ожидается сразу. В Rust замените такие вызовы на tokio::join! или tokio::spawn. В Python используйте asyncio.gather, TaskGroup или create_task.

Часто задаваемые вопросы

Могут ли задачи asyncio в Python запускаться энергично, как асинхронные функции в JavaScript?

Да, если включить это явно. В Python 3.12 появилась asyncio.eager_task_factory, которая устанавливается через loop.set_task_factory(asyncio.eager_task_factory). После этого новая задача начинает выполнять свою корутину сразу же внутри вызова create_task — до первой точки блокировки. Если корутина завершается, ни разу не заблокировавшись, она вообще не попадает в цикл событий. По умолчанию по-прежнему используется ленивое планирование, а переход на энергичные задачи может изменить порядок выполнения.

Что происходит с задачей tokio::spawn, если удалить её JoinHandle?

Задача продолжает выполняться. Удаление JoinHandle отсоединяет задачу: она продолжает работать в фоне, но её возвращаемое значение теряется, и дождаться её больше нельзя. В этом отличие от неожидаемого future, возвращённого вызовом async fn, который вообще никогда не выполняется. Чтобы остановить порождённую задачу, вызовите abort() у JoinHandle. Ожидание прерванной задачи вернёт JoinError, сообщающий об отмене.

Чем asyncio.gather отличается от asyncio.TaskGroup, когда одна из задач завершается с ошибкой?

asyncio.gather не отменяет остальные задачи, если одна из них выбрасывает исключение. При значении по умолчанию return_exceptions=False первое исключение сразу передаётся ожидающему коду, а остальные задачи продолжают выполняться. При return_exceptions=True исключения возвращаются в списке результатов. asyncio.TaskGroup, появившийся в Python 3.11, отменяет оставшиеся задачи при сбое одной из них, поэтому работа после сбоя не продолжается незаметно.

Почему задача asyncio, запущенная по принципу «запустил и забыл», может исчезнуть до завершения?

Цикл событий asyncio хранит на задачи только слабые ссылки, поэтому сборщик мусора может удалить задачу, созданную через asyncio.create_task, прямо во время выполнения, если ваш код нигде её не сохраняет. Документация Python рекомендует хранить ссылку, например добавлять каждую задачу в множество и вызывать task.add_done_callback(background_tasks.discard). asyncio.TaskGroup избавляет от этой проблемы, поскольку хранит сильную ссылку на каждую задачу и дожидается их завершения.

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.