Автоматическое чередование высокоуровневых конкурентных операций
— 2025-05-05 Yoshua Wuyts Источник
- введение
- проблема
- решение
- дальнейшее чтение
Введение
При работе с низкоуровневым параллелизмом (атомарными операциями) языки программирования обычно очень стремятся позволить компиляторам переупорядочивать операции, если это приводит к лучшей производительности. Информация о том, допустимо ли переупорядочивать операции, кодируется с помощью Моделей памяти (Atomic Orderings), Барьеров памяти (Fences) и Атомарных операций (Operations). Странно, что большинство языков программирования, поддерживающих семантически осознанное переупорядочивание низкоуровневых конкурентных операций, не включают аналогичную поддержку для переупорядочивания выполнения высокоуровневых конкурентных операций.
Насколько мне известно, это верно для большинства языков, за заметным исключением Swift и его конструкции async let. Эта функция сохраняет линейно выглядящую природу асинхронного кода, но позволяет компилятору анализировать граф потока управления и планировать операции конкурентно, где это возможно. Это означает, что, как и с атомарными операциями, операция, определённая позже в куске кода, может завершить выполнение раньше операции, которая появляется раньше. Вот пример программы на Swift, где всё, что может быть конкурентным, на самом деле является конкурентным:
func makeDinner() async throws -> Meal {
async let veggies = chopVegetables() // 1. конкурентно с: 2, 3
async let tofu = marinateTofu() // 2. конкурентно с: 1, 3
async let oven = preheatOven(temperature: 350) // 3. конкурентно с: 1, 2, 4
let dish = Dish(ingredients: await [try veggies, tofu]) // 4. зависит от: 1, 2, конкурентно с: 3
return await oven.cook(dish, duration: .hours(3)) // 5. зависит от: 3, 4, не конкурентно
}
Проблема
Для меня это представляет вершину поддержки асинхронного/конкурентного программирования на уровне языка. Это делает тривиальным изменение любого кода, который может быть выполнен конкурентно, чтобы он действительно выполнялся конкурентно. Это позволяет компилятору позаботиться о том, что в противном случае было бы утомительным и/или нечитаемым. Возьмём, к примеру, этот код, написанный последовательным образом с использованием async/.await:
#![allow(unused)] fn main() { async fn make_dinner() -> SomeResult<Meal> { let veggies = chop_vegetables().await?; let tofu = marinate_tofu().await?; let oven = preheat_oven(350).await; let dish = Dish(&[veggies, tofu]).await; oven.cook(dish, Duration::from_mins(3 * 60)).await } }
Используя операции Future::join, мы можем переписать его для конкурентного выполнения независимых операций. Но это имеет тот недостаток, что код теперь значительно менее читаем. Вот тот же код, написанный с использованием Future::try_join:
#![allow(unused)] fn main() { use futures_concurrency::prelude::*; async fn make_dinner() -> SomeResult<Meal> { let dish_fut = { let veggies_fut = chop_vegetables(); let tofu_fut = marinate_tofu(); let (veggies, tofu) = (veggies_fut, tofu_fut).try_join().await?; Dish::new(&[veggies, tofu]).await }; let oven_fut = preheat_oven(350); let (dish, oven) = (dish_fut, oven_fut).try_join().await?; oven.cook(dish, Duration::from_mins(3 * 60)).await } }
Чтобы воспользоваться одним из ключевых преимуществ async/.await (ad-hoc конкурентное планирование), нам пришлось пожертвовать одним из его главных достоинств (читаемостью). Это плохо, когда две основные части одной и той же функции противоречат друг другу таким образом. И мы не можем просто взмахнуть волшебной палочкой и приказать компилятору автоматически выполнять эти фьючерсы конкурентно. Фьючерсы, как правило, выражают операции, которые так или иначе изменяют состояние программы. Другими словами: большинство фьючерсов кодируют побочные эффекты. И компилятор не может автоматически вывести, какие побочные эффекты можно выполнять последовательно, а какие — конкурентно. Это потому, что он не знает семантику программы.
Решение
Решение состоит в том, чтобы позволить программистам явно соглашаться на переупорядочивания в своём коде, как это делает Swift с помощью async let. Мы могли бы использовать краткую нотацию, например, .co.await (это пример, выберите свою любимую нотацию). Мы хотим, чтобы нотация была в постфиксной позиции, потому что, в отличие от Swift, мы не хотим начинать выполнение при определении операций, а только влиять на способ планирования операций при использовании .await. И таким образом нам никогда не придётся представлять это в системе типов1. Это выглядело бы примерно так:
#![allow(unused)] fn main() { async fn make_dinner() -> SomeResult<Meal> { let veggies = chop_vegetables().co.await?; let tofu = marinate_tofu().co.await?; let oven = preheat_oven(350).co.await; let dish = Dish(&[veggies, tofu]).co.await; oven.cook(dish, Duration::from_mins(3 * 60)).await } }
Этот код напрямую преобразовывался бы в эквивалент схемы на основе Future::join. Но с преимуществом гораздо меньшей церемонии для кодирования той же семантики. Другое преимущество этой схемы в том, что у нас всегда остаётся возможность планировать эти операции последовательно, если мы выберем это. Это делает схему совместимой с асинхронно-полиморфными функциями, в отличие от ручных вызовов Future::join.
Функциональность в этом духе важна, потому что для полного использования возможностей конкурентного планирования в асинхронном Rust любые операции, которые могут быть выполнены конкурентно, должны выполняться конкурентно. Но без поддержки на уровне языка это происходит за счёт серьёзного ухудшения читаемости и, как следствие, сопровождаемости. Единственный выход из этой дилеммы — сделать то, что сделал Swift, и напрямую включить поддержку этого на уровне языка.
Дальнейшее чтение
- Задачи — это неправильная абстракция — представляет трейт
ParallelFuture, который можно комбинировать со схемой, описанной в этом посте, для автоматического планирования фьючерсов на нескольких ядрах. - Древовидная структурированная конкурентность — обсуждает, что такое структурированная конкурентность, как её осмыслять и чего не хватает Rust для её полной поддержки.
- Расширение системы эффектов Rust — обсуждает, среди прочего, асинхронно-полиморфные функции.
Примечания
-
Написание
.coбез последующего.awaitдолжно быть ошибкой компиляции..coслужило бы модификатором для.await. Хотя, возможно, что-то вроде нотации C++co_awaitпроще. Какой бы ни был синтаксис, я не думаю, что он должен когда-либо появляться в системе типов. ← ↩