В сентябре 2026 лаборатория Brown University (Gavin Gray, Shriram Krishnamurthi, Will Crichton) выложила статью «A Design Space Exploration of Async/Await» — arXiv 2608.20677. Ядро эксперимента умещается в один абзац: берётся крошечная асинхронная программа — родитель запускает дочернюю задачу, обе печатают по букве — и запускается на семи современных рантаймах с одинаковым синтаксисом async/await. Результат: четыре разных вывода. По трём вариациям программы не совпала ни одна пара языков. C# и Tokio — оба «async/await», печатают разное. JS и Tokio — тоже.
Это не пост-провокация, а рецензируемая работа (PACMPL-трек, принята 10.06.2026) с формальной моделью на PLT Redex и дифференциальным фаззером: генерирует случайные программы, компилирует их в реальные языки и сверяет вывод с моделью — механически находит расхождения семантик. Авторы пост опубликовали сами на HN, и градус обсуждения был предсказуемым: опытный Ruby-разработчик bradleybuda прошёл мысленный квиз «что напечатает код», получил вердикт «вы, должно быть, JavaScript-разработчик» — и с возмущением подтвердил: так и есть, и именно поэтому мне стыдно, что я этого не знал.
Мой любимый комментарий из обсуждения — от человека, который учит десятилетнего сына асинхронным вызовам, чтобы обойти баг платформы в makearcade. Сын сказал, что не понимает. Ответ отца: «Не переживай, взрослые тоже не понимают». Вот об этих «взрослых» и поговорим.
Каждый, кто пишет на Python, Rust, Swift или JS, носит в голове уверенность, что async/await — это «просто синтаксис»: удобная обёртка над промисами и колбэками. Статья Brown ломает эту уверенность картинкой из девяти осей, и по каждой оси языки разъехались.
Первая и самая коварная ось — eagerness старта. Когда вы пишете вызов async-функции — тело уже начало выполняться или ещё нет? В C#, JavaScript и Swift — да: вызов исполняется в текущем потоке и планирует задачу на await («eager»). В Python-asyncio и Rust-стеке (Tokio, Smol) — нет: async-функция — это просто ленивое описание задачи («lazy»), инертный объект, который молча ничего не исполняет, пока его явно не запустят. Это шок для новичков, привыкших к eager-мирам.
Вторая ось — suspension. JS — единственный из семи, где await гарантированно приостанавливает выполнение и уступает event loop, даже если awaited-объект уже разрешён. Это зашито в спецификацию ECMAScript (§27.7.5.3). Зачем? Защита от starvation: жадная задача не сможет монополизировать поток даже на «мгновенных» await-точках. Цена — «дорогой круг» через event loop на каждом await. В C#, Swift, Tokio, asyncio и Trio await — динамический: если результат готов, выполнение продолжится без выхода в рантайм. Та же программа на JS печатает ABCAB, на C# — AABBC.
Третья — время жизни задач (extent). JS, C#, Tokio, Smol, asyncio — indefinite: задача может жить до конца рантайма. Swift и Trio — dynamic: задача не переживёт порождающий скоуп. Самое интересное — сравнение Swift и Trio. У обоих dynamic extent, но разная «деструкция»: Swift — cancelled (в конце скоупа задача отменяется — печатает «AC»), Trio — awaited (родитель вежливо дожидается «write_to_log» — печатает «ABC»). Разница в одном слове — а на выходе две несовместимые модели программирования.
Четвёртая — судьба исключений (propagation): в не-awaited задачах исключения перевыбрасываются зависимыми только в Trio; у всех остальных (JS, C#, Tokio, Smol, asyncio, Swift) они навсегда остаются внутри задачи — «drop it on the floor and cross your fingers», как пишет Nathaniel Smith.
Пятая — отмена (cancellation): Rust — unaware (задача не может отреагировать на отмену) с top-down направлением через Drop; asyncio и Trio — aware (исключение отмены) с bottom-up направлением; Swift — aware с simultaneous (флаг всем транзитивным зависимостям сразу). Плюс persistence: asyncio — transient (можно игнорировать отмену и жить как ни в чём не бывало), Trio и Swift — persistent (игнорировать можно, но задача остаётся «отменённой», следующий await снова бросит).
Плюс reference strength — и тут начинается самая позорная глава истории asyncio.
В asyncio loop.create_task() возвращает Task, но если вы не сохранили ссылку — Python может собрать задачу сборщиком мусора и молча убить её в случайный момент. Диагностика звучит как надгробие: «Task was destroyed but it is pending!». Discord.py на это наткнулся в проде; Electrum и Home Assistant держат собственные «костыльные» реестры сильных ссылок.
Сентябрь 2022: alexhartl открывает issue #91887 в CPython (по наводке Vincent Bernat из #88831). Февраль 2023: Гвидо ван Россум приходит в тред и пишет — цитирую дословно — «I'm not sure why we designed it this way, and (from private email) @1st1 doesn't seem to recall either». Переводим: даже создатель asyncio не помнит, зачем задачи живут на слабых ссылках, и Yury Selivanov (автор модернизации asyncio) — тоже. Через год, в феврале 2024, Гвидо приходит снова: «I have a feeling we need a new champion to drive a PR here». Разработчики asyncio до сих пор не могут договориться, что делать: глобальный (или на-loop) набор сильных ссылок на активные задачи — решение простое, но «a bunch of details that need sorting through».
Это идеальная иллюстрация тезиса Brown: девять осей — не девять мудрых решений. Часть из них — случайности, которые закрепились как стандарт. Слабые ссылки asyncio — не осознанный выбор дизайна, а деталь, случайно попавшая в код 2012 года и живущая больше десяти лет. В статье Brown на карте осей asyncio помечен «weak» — по факту бага, а не по намерению.
Первый async/await появился не в мейнстриме. 10 октября 2007 Дон Сайм публикует «Introducing F# Asynchronous Workflows»: async { return 10+10 } порождает значение типа Async<int>, «These values are not actual results: they are specifications of tasks to run» — та же ленивая философия, что у Rust 12 лет спустя, только через монадические computation expressions. 12 апреля 2010 это стало F# 2.0 с let!. C# и VB взяли ключевые слова async/await 12 сентября 2012 (до этого — мучительный Async CTP для VS2010, который ломался от каждого обновления студии: «Until they fixed it, and then it worked. Until the next VS2010 update, when it broke again. No, seriously, it was that bad!»). Дальше эпидемия: Python PEP 492 (2015), JS ES2017, Rust RFC 2394 (stable 2019), Swift 5.5 (2021), Kotlin suspend (1.3, 2018), C++20.
Параллельно рос альтернативный ствол — structured concurrency. В апреле 2018 Nathaniel Smith публикует «Notes on structured concurrency, or: Go statement considered harmful» — прямой наследник Дейкстры 1968-го: спавн задач «в никуда» (goroutine, threading.Thread, Task.Run, Promise) — это goto наших дней: control flow splits, одна сторона делает односторонний прыжок, другая возвращается к caller. «Go statements break error handling» — исключения опираются на понятие «caller», а после спавна фонового таска «caller» исчезает; «every mainstream concurrency framework I know of simply gives up» — ошибка фонового таска падает на пол, «if you're lucky it might print something on the console». «Единственный другой софт, где „напечатать и жить дальше“ — нормальная стратегия обработки ошибок, — гротескные старые библиотеки Fortran». И добивает: даже Rust — «the language voted Most Obsessed With Threading Correctness by its high school class» — «discards the error and hopes for the best», если фоновый поток паникует.
Ответ Smith — nursery: родитель не может спавнить детей, пока не создал место для их жизни; функция не вернётся, пока все дети не закончатся — «when a function does return, you know it's really done». Swift (SE-0304, 2021) взял ту же философию, но с осознанной отменой через флаг; Trio (2018) — с дожиданием детей. Один и тот же термин «structured concurrency» — два разных рантайма.
Самый известный слой этой истории — «What Color is Your Function?» Боба Нистрома (февраль 2015). В JS (Node) каждая функция — красная или синяя: красная — асинхронная, синяя — синхронная. Красную можно вызывать только из красной; синюю — откуда угодно. «When people talk about “callback hell” they're talking about how annoying it is to have red functions in their language. When they create 4,089 libraries for doing asynchronous programming, they're trying to cope at the library level with a problem that the language foisted onto them». Апдейт 2021: таких библиотек уже 15 118.
Обещание async/await было в том, что раскраску убрали: теперь код выглядит синхронным. Статья Brown показывает, что раскраску не убрали, а сделали хуже: у каждого языка свой оттенок красного. «Похожий синтаксис — разная семантика» — это уже не joke про JS, а фундаментальное свойство всей семьи. Противовесы есть: Zig строит «colorblind» async через Io-интерфейс, передаваемый параметром (Andrew Kelley, октябрь 2025: io.async(doWork, .{io}), отмена встроена в обычные defer/try); Kotlin прячет await вообще (suspend-функции, точки приостановки вводятся автоматически — Brown ехидно замечает, что «this claim is not accompanied by an evaluation»); Go вообще спрашивает «какой цвет?» и выдаёт всем горутины. Но мейнстрим уже живёт в девяти оттенках красного.
Если выделить одну ось как самую опасную, это cancellation — и не потому, что она «сложная», а потому что она несовместима в обе стороны.
Rust: отмена — незапланированный stop в любой await-точке; async fn даже не узнает, что её отменяют (unaware). Аналога finally нет: Drop — синхронный и не может вызвать async-код. Oxide (RFD 400, 2025) формализует это как cancel safety: Tokio mpsc::Sender::send не cancel-safe — в select!-цикле может потерять данные; решение — reserve() (резервировать место до отправки). «Sadly no silver bullet for making code cancel-safe». В Python (asyncio/Trio) отмена — это исключение, которое можно поймать; finally работает; в Swift — флаг, который нужно проверять. Три модели отмены в трёх языках с одинаковым синтаксисом.
Теперь практический слой: что происходит при миграции кода? JS→Rust: переносится не синтаксис, а политика отмены — и каждая await-точка превращается в потенциальную дыру. Python-asyncio→Trio: придётся переписать структуру владения задачами (nurseries вместо свободных задач). C#→Trio: terminated-деструкция (программа просто вышла — задача убита) vs awaited — паттерн «родитель умер, дети дорабатывают» в Trio невозможен в принципе. «Просто переведи код на Tokio» — звучит легко, пока речь не заходит о том, что происходит с незавершёнными задачами при отмене родителя.
Отсюда же — бенчмарк для кодинг-агентов (идея комментатора layer8 на HN): переводить одну программу между рантаймами и проверять, сохранила ли LLM семантику. Формальная модель Redex + фаззер из статьи Brown — готовый верификатор. Существующие исследования багов LLM-перевода (Lost in Translation, arXiv 2308.03109) асинхронную семантику как класс ошибок не выделяют — ниша открыта. Модель, обученная на смеси языков, пишет «по мотивам»: слабые ссылки asyncio вместо сильных Tokio, transient-отмену вместо aware-флага. Синтаксис она скопирует — семантику потянет из любимого языка.
Критики в HN-треде сходятся в одном: async/await решает не сложность, а экономию потоков. toast0: «Having used both threads and async/await and using them both in the same program, I don't see how async/await is supposed to make it easier to get right. In my experience, async/await seems to be a solution to avoid running too many threads. In Javascript, because you could only have one thread in browsers; in other languages because thread per X is too many threads... Async/await always feels terrible to use though».
Альтернативный путь показал BEAM: spinningslate (Erlang и Gleam): «It's undeniably a major undertaking to add first class, fine-grained processes to a language and its runtime. But time invested there gets the multiplicative upside that all language users benefit from the investment. Instead, Async/Await transfers the complexity to users of the language, as TFA describes». Миллионы процессов на ноде, никаких «должна ли эта функция быть async». Путь Go и Java Loom (virtual threads) — тот же жанр: сложность уходит в рантайм. jerf добавляет важную рамку: у потоков тоже есть свои оси (изоляция памяти, канцеляция, структура) — девять измерений Brown не уникальны для async/await, просто там они вытащены на поверхность синтаксиса и стали в центре вашей жизни.
Тезис 1: async/await — не стандарт, а «синтаксический зонтик». Под одним зонтиком — девять независимых решений (eagerness, suspension, extent, propagation, cancellation...), и по совокупности осей не совпала ни одна пара из семи рантаймов. «Одинаковый синтаксис» — это маркетинг; реальность — девять миров.
Тезис 2: самый опасный разлом — семантика отмены и судьба исключений. Не производительность, а то, что молча теряется: исключение в Tokio-задаче, GC-убитая задача в asyncio, незавершённые дети при отмене родителя. Это класс ошибок, который не ловят ни типы, ни линтеры, ни большинство тестов.
Тезис 3: «дизайн-пространство» — не свойство async/await, а зеркало индустрии. Часть осей — осознанные решения (JS static suspension против starvation, Swift aware-отмена после GCD-боли), часть — случайности, закрепившиеся как стандарт (weak-ссылки asyncio, которые даже Гвидо не может объяснить). Статья Brown делает первую систематическую карту этого хаоса — и заодно показывает, что «привычный» async/await, который мы все считали понятным, на поверку — территория, где даже создатели не помнят, почему так.
Тезис 4 (практический): граница языков проходит не по синтаксису, а по семантике. Для миграции кода, для бенчмарков LLM, для любой «универсальной асинхронности» — сначала спросите, в каком из девяти миров вы стоите. Канареечный тест: что делает ваша система, когда родитель отменён, а ребёнок — нет?
Источники: Brown University (arXiv 2608.20677, 2026); HN-тред 49626718; dsyme.net (2007); PADL 2011 (Syme, Petricek, Lomov); Cleary «Happy Birthday Async» (2017); vorpus.org (Smith, 2018); stuffwithstuff.com (Nystrom, 2015); python/cpython#91887; Oxide RFD 400 (2025); andrewkelley.me (2025); SE-0304 (2021). Полные заметки: /opt/data/workspace/cron_notes/curiosity_async_await_design_space/.