0317 — Async let swift evolution

Введение

Структурированный параллелизм обеспечивает парадигму порождения параллельных дочерних задач в группах задач с заданной областью, устанавливает четко определенную иерархию задач, которая позволяет обрабатывать отмену, распространение ошибок, управление приоритетами и другие сложные детали управления параллелизмом.

Это предложение направлено на то, чтобы общая задача создания дочерних задач выполнялась асинхронно и передавалась их конечные результаты их родителю, используя упрощенный синтаксис, аналогичный привязкам let.

Темы обсуждения:

  • Первоначально представленный как часть структурированного параллелизма:

Шаг №1,

Шаг №2.

  • и позже разделен на собственное предложение:

Шаг №3.

Мотивация

В SE-0304: Structured Concurrency мы представили концепцию задач и групп задач, которые можно использовать для создания нескольких одновременно выполняемых дочерних задач и сбора их результатов перед выходом из группы задач.

Группа задач — очень мощный, но низкоуровневый строительный блок, полезный для создания мощных шаблонов параллельных вычислений, таких как сбор «первых нескольких» успешных результатов и других типичных шаблонов разветвления или разброса/сбора. Они лучше всего подходят для распределения вычислений однотипных операций. Например, parallelMap может быть реализован в виде TaskGroup. В этом смысле группы задач представляют собой примитив реализации низкого уровня, а не API конечного пользователя, с которым разработчики должны много взаимодействовать, скорее ожидается, что более мощные примитивы будут построены поверх групп задач.

Группы задач также автоматически распространяют значения отмены, приоритета и локальных задач для дочерних задач и предлагают гибкий API для сбора результатов этих дочерних задач в порядке завершения, чего невозможно достичь иначе, используя другие структурированные API параллелизма. Они делают все это, поддерживая структурированный параллелизм, гарантирующий, что дочерняя задача никогда не может «пережить» (т.е. продолжать работать после выхода из области действия группы задач) родительскую задачу.

Хотя группы задач действительно очень эффективны, их трудно использовать с разнородными результатами и шаблонами пошаговой инициализации.

Следующий пример, асинхронная функция makeDinner, состоит из обоих этих шаблонов. Он состоит из трех задач, которые можно выполнять параллельно, и все они дают разные типы результатов. Чтобы перейти к заключительному этапу процесса приготовления, необходимо получить все эти результаты и передать их в последнюю функцию oven.cook(…). В некотором смысле, это самая сложная ситуация, которую можно хорошо реализовать с помощью групп задач. Рассмотрим его более внимательно:

func makeDinner() async -> Meal {
  // Create a task group to scope the lifetime of our three child tasks
  return try await withThrowingTaskGroup(of: CookingTask.self) { group in
    // spawn three cooking tasks and execute them in parallel:
    group.async {
      CookingTask.veggies(try await chopVegetables())
    }
    group.async {
      CookingTask.meat(await marinateMeat())
    }
    group.async {
      CookingTask.oven(await preheatOven(temperature: 350))
    }

    // prepare variables to collect the results
    var veggies: [Vegetable]? = nil
    var meat: Meat? = nil
    var oven: Oven? = nil

    // collect the results
    for try await task in group {
      switch task {
      case .veggies(let v):
        veggies = v
      case .meat(let m):
        meat = m
      case .oven(let o):
        oven = o
      }
    }

    // ensure every variable was initialized as expected
    assert(veggies != nil)
    assert(meat != nil)
    assert(oven != nil)

    // prepare the ingredients
    var ingredients: [Ingredient] = veggies!
    ingredients.append(meat!)

    // and, finally, cook the meal, awaiting inside the group
    let dish = Dish(ingredients: ingredients)
    return try await oven!.cook(dish, duration: .hours(3))
  }
}

Область withThrowingTaskGroup явно определяет любой потенциальный параллелизм, потому что она гарантирует, что любые дочерние задачи, порожденные в ней, ожидаются при выходе из области действия группы. Любые результаты могут быть собраны путем повторения группы. Ошибки и отмена автоматически обрабатываются для нас группой.

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

Если мы вернемся немного назад, то сможем заметить, что в примере каждая дочерняя задача действительно создает одно значение и возвращает его родительской задаче, которая затем должна собрать части и приступить к вызову какой-либо другой функции. Мы достигаем этого, подготавливая и назначая необязательные переменные, предназначенные для каждой из порожденных задач. Это не идеально, поскольку, хотя код верен, как написано, изменение этого кода для добавления переменной не только тяжело шаблонно, но и потенциально довольно подвержено ошибкам, что приводит к сбоям во время выполнения из-за принудительного развертывания, к которому в хорошо написанной программе Swift обычно не приходилось бы прибегать.

Этот шаблон потока данных от дочерних задач к родительским очень распространен, и мы хотим сделать его максимально легким и безопасным.

Предложенное решение

В этом предложении представлен простой способ создания дочерних задач и ожидания их результатов: объявления async let.

Используя async let, наш пример выглядит так:

// given: 
//   func chopVegetables() async throws -> [Vegetables]
//   func marinateMeat() async -> Meat
//   func preheatOven(temperature: Int) async -> Oven

func makeDinner() async throws -> Meal {
  async let veggies = chopVegetables()
  async let meat = marinateMeat()
  async let oven = preheatOven(temperature: 350)

  let dish = Dish(ingredients: await [try veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))
}

async let похож на let в том смысле, что он определяет локальную константу, которая инициализируется выражением в правой части знака =. Однако он отличается тем, что выражение инициализатора оценивается в отдельной параллельно выполняемой дочерней задаче.

Дочерняя задача начинает выполняться, как только встречается async let. По умолчанию дочерние задачи используют глобальный, ограниченный по ширине, параллельный исполнитель таким же образом, как и дочерние задачи группы задач. В будущем планируется разрешить настройку исполнителя, на котором они должны выполняться. При нормальном завершении дочерняя задача инициализирует переменные в async let.

Правую часть выражения async let можно рассматривать как неявное замыкание @Sendable, подобное тому, как работает API Task.detached { … }, однако результирующая задача является дочерней задачей текущей выполняемой задачи. Из-за этого, а также из-за необходимости приостановки для ожидания результатов такого выражения, объявления async let могут возникать только в асинхронном контексте, то есть в асинхронной функции или замыкании.

Для выражений с одним оператором в инициализаторе async let ключевые слова await и try могут быть опущены. Эффекты, которые они представляют, переносятся на введенную константу и должны использоваться при ожидании константы. В приведенном выше примере овощи объявлены как async let veggies = ChopVegetables(), и даже если функция ChopVegetables является асинхронной и выбрасывает ошибки, ключевые слова await и try не должны использоваться в этой строке кода. После ожидания значения этой асинхронной async let компилятор будет обеспечивать, чтобы выражение, в котором появляются veggies, было покрыто как await, так и некоторой формой try.

Поскольку основная часть функции выполняется одновременно со своими дочерними задачами, возможно, что родительская задача (тело makeDinner в этом примере) достигнет точки, в которой ей потребуется значение async let (скажем, veggies) до того, как эти значения были произведены. Чтобы учесть это, чтение переменной, определенной async let, рассматривается как потенциальная точка приостановки и поэтому должно быть помечено await.

Детальный дизайн

Объявление констант async let

Объявления async let аналогичны объявлениям let, однако они могут появляться только в определенных контекстах.

Поскольку асинхронная задача должна иметь возможность ожидания в той области, в которой она создана, возможно объявить async let только в тех контекстах, где было бы законно написать явное await, то есть асинхронные функции:

func greet() async -> String { "hi" }

func asynchronous() async {
  async let hello = greet()
  // ... 
  await hello
}

и внутри асинхронных замыканий:

func callMe(_ maybe: () async -> String) async -> String 
  return await maybe()
}

callMe { // async closure
  async let hello = greet()
  // ... 
  return await hello
}

Не допускается объявлять async let как код верхнего уровня в синхронных функциях или замыканиях:

async let top = ... // error: 'async let' in a function that does not support concurrency

func sync() { // note: add 'async' to function 'sync()' to make it asynchronous
  async let x = ... // error: 'async let' in a function that does not support concurrency
}

func syncMe(later: () -> String) { ... }
syncMe {
  async let x = ... // error: invalid conversion from 'async' function of type '() async -> String' to synchronous function type '() -> String'
}

Async let создает дочернюю задачу, которая наследует приоритет своей родительской задачи, а также локальные значения задачи. Семантически это эквивалентно созданию одноразовой TaskGroup, которая порождает одну задачу и возвращает ее результат, однако реализация Async let может сделать больше предположений и оптимизаций в отношении времени жизни и использования этих значений.

Дочерняя задача, созданная для инициализации Async let, по умолчанию запускается в глобальном параллельном исполнителе с ограниченной шириной, который поставляется со средой выполнения Swift Concurrency.

Настройка контекста выполнения Async let — это будущее направление, которое мы, вероятно, будем исследовать с введением пользовательских исполнителей.

Инициализатор async let можно рассматривать как замыкание, которое запускает содержащийся в нем код в отдельной задаче, что очень похоже на явный group.async { <work here/> } API групп задач.

Подобно функции group.async(), замыкание является @Sendable и nonisolated, что означает, что оно не может получить доступ к неотправляемому состоянию окружающего контекста. Например, это приведет к ошибке времени компиляции, предотвращая потенциальное состояние гонки, когда Async let попытается изменить замкнутую переменную:

var localText: [String] = ...
async let w = localText.removeLast() // error: mutation of captured var 'localText' in concurrently-executing code

Инициализатор async let может ссылаться на любое отправляемое состояние, как и любое неизолированное отправляемое замыкание.

Инициализатор async let позволяет опускать ключевое слово await, если он напрямую вызывает асинхронную функцию, например:

func order() async -> Order { ... }

async let o1 = await order()
// should be written instead as
async let o2 = order()

Это связано с тем, что, глядя на объявление async let, становится очевидным, что правая функция будет использоваться для инициализации левой части, ожидая ее. Это похоже на пропуск ключевого слова return в одиночном выражении и также применяется только к инициализаторам с одним выражением.

Незаконно объявлять Async var. Это связано со сложной инициализацией, которую представляет async let, поэтому нет смысла разрешать их дальнейшую внешнюю модификацию. Это чрезвычайно усложнило бы понимание такого асинхронного кода и подорвало бы потенциальную оптимизацию, затруднив предположения о потоке данных значений.

async var x = nope() // error: 'async' can only be used with 'let' declarations

Помимо ожидания доступа к своему значению, async let ведет себя так же, как типичный let, поэтому невозможно передать его inout другим функциям — просто потому, что это let, и они не могут быть переданы как inout .

Шаблонное объявление async let

Можно создать async let, где левая сторона является шаблоном, например. кортеж, например:

func left() async -> String { "l" }
func right() async -> String { "r" }

async let (l, r) = (left(), right())

await l // at this point `r` is also assumed awaited-on

Чтобы понять семантику выполнения приведенного выше фрагмента, мы можем вспомнить синтаксический сахар, согласно которому правая часть async let фактически представляет собой просто одновременное выполняемое асинхронное замыкание:

async let (l, r) = {
  return await (left(), right())
  // -> 
  // return (await left(), await right())
}

это означает, что весь инициализатор async let представляет собой одну задачу, и если внутри него выполняется несколько вызовов асинхронных функций, они выполняются один за другим. Это конкретное применение общего правила async let, которым разрешено опускать одно ключевое слово await перед их выражениями. Поскольку в этом примере мы вызываем две асинхронные функции для формирования кортежа, await может быть перемещено за пределы выражения, и именно это ожидание опущено в сокращенной форме async let, которую мы видели в первом фрагменте.

Это также означает, что как только мы вводим continue после строки await l, становится известно, что значение r также выполнено успешно (и не нужно будет выдавать «неявное ожидание», которое мы подробно обсудим ниже).

Еще одним следствием этой семантики является то, что если какая-либо часть инициализатора выдает исключение, любое ожидание для такого шаблона, объявленного как async let, должно считаться выбрасыванием ошибки, поскольку они инициализируются «вместе». Чтобы визуализировать это, давайте рассмотрим следующее:

async let (yay, nay) = ("yay", throw Boom())
try await yay // because the (yay, nay) initializer is throwing

Поскольку мы знаем, что правая часть — это просто одно замыкание, выполняющее всю инициализацию, мы знаем, что если какая-либо из операций в правой части размера является выбрасывающий ошибку, весь инициализатор будет считаться выбрасывающий ошибку. Таким образом, даже ожидание yay здесь должно быть готово к тому, что этот инициализатор сработает, и, следовательно, должно включать ключевое слово try в дополнение к await.

Ожидание значений async let

Поскольку async let представляет константы, которые будут «заполнены позже» параллельно выполняемой задачей с правой стороны, ссылка на них должна быть покрыта ключевым словом await:

async let name = getName() 
async let surname = getSurname() 
await name
await surname

Также можно просто покрыть все выражение, где используется async let, одним await, подобно тому, как то же самое можно сделать с помощью try:

greet(await name, await surname)
await greet(name, surname)
// or even
await print("\(name) \(surname)")

Если инициализатор конкретного async let вызывал исключение, то ожидание константы async let должно быть покрыто с помощью варианта ключевого слова try:

async let ohNo = throwThings()
try await ohNo
try? await ohNo
try! await ohNo

В настоящее время требуется покрывать каждую ссылку на async let с помощью соответствующих ключевых слов try и await, например:

async let yes = ""
async let ohNo = throwThings()

_ = await yes
_ = await yes
_ = try await ohNo
_ = try await ohNo

Это простое правило позволяет нам уже продвигать эту функцию. Возможно, можно использовать анализ на основе потока управления, чтобы включить «только первая ссылка на конкретный async let на каждом пути потока управления должна быть await», поскольку с технической точки зрения каждое последующее ожидание будет неоперативным и не будет приостановливать, так как значение уже заполнено, а местозаполнитель заполнен.

Ожидание неявного async let

async let который был объявлен, но никогда не ожидался явно, так как область, в которой он был объявлен, выходит, будет ожидаться неявно. Эта семантика используется для поддержки гарантий структурированного параллелизма, предоставляемых async let.

Чтобы продемонстрировать эту семантику, давайте взглянем на эту функцию, которая порождает две дочерние задачи, быструю и медленную, но не ожидает ни одну из них:

func go() async { 
  async let f = fast() // 300ms
  async let s = slow() // 3seconds
  return "nevermind..."
  // implicitly: cancels f
  // implicitly: cancels s
  // implicitly: await f
  // implicitly: await s
}

Предполагая, что время выполнения fast() и slow() соответствует комментариям рядом с ними, функция go() всегда будет выполняться не менее 3 секунд. Или, если сформулировать правило в более общем виде, для возврата любого структурированного вызова потребуется столько времени, сколько потребуется для завершения самой длинной из его дочерних задач.

Поскольку мы возвращаемся из функции go() без ожидания значений f или s, оба они будут неявно отменены и ожидаются перед возвратом из функции go(). Это сама природа структурированного параллелизма, и избежать этого можно только путем создания не дочерних задач, например. с помощью Task.detached или других будущих API, которые позволят создавать не дочерние задачи.

Если бы вместо этого мы ждали одного из значений, например. быстрый (f) испускаемый код не должен был бы неявно отменять или ждать его, так как об этом уже позаботились явно:

func go2() async {
  async let f = fast()
  async let s = slow()
  _ = await f
  return "nevermind..."
  // implicitly: cancels s
  // implicitly: awaits s
}

Продолжительность вызова go2() остается неизменной, она всегда равна time(go2) == max(time(f), time(s)).

Особое внимание следует уделить форме объявлений async let _ = …. Эта форма интересна тем, что создает дочернюю задачу правого инициализатора, однако активно игнорирует результат. Такое объявление (и связанная с ним дочерняя задача) будет запущено и будет неявно отменено и ожидаемо, поскольку область, в которой оно было объявлено, вот-вот выйдет — так же, как неиспользуемое объявление async let.

async let и замыкания

Поскольку async let не могут пережить область, в которой они определены, передача их замыканиям требует дальнейшего обсуждения того, что является законным, а что нет.

Можно захватить async let в неэкранирующем асинхронном замыкании, например:

func greet(_ f: () async -> String) async -> String { await f() }

async let name = "Alice"
await greet { await name }

Обратите внимание, что мы должны написать await внутри замыкания, а также перед функцией greet. Это сделано специально, так как мы хотим явно указать await внутри замыкания.

То же самое относится и к автозамыканиям, чтобы было явно, что await происходит внутри замыкания, а не перед ним, требуется явное ожидание в позиции параметра, где для аргумента формируется автозамыкание:

func greet(_ f: @autoclosure () async -> String) async -> String { await f() }

async let name = "Bob"
await greet(await name) // await on name is required, because autoclosure

Недопустимо сбегающее значение async let для сбегающего замыкания. Это связано с тем, что структуры, поддерживающие реализацию Async let, могут размещаться в стеке, а не в куче. Это делает их очень эффективными и эффективно использует структурированные гарантии, которых они должны придерживаться. Эти оптимизации, однако, делают небезопасной передачу их в любые экранирующие контексты:

func greet(_ f: @escaping () async -> String) async -> String { somewhere = f; somewhere() }

async let name = "Bob"
await greet { await name } // error: cannot escape 'async let' value

асинхронное выбрасывание ошибок

Хотя можно объявить async let и никогда явно не ожидать его, это также означает, что нас не особенно волнует его результат.

Это то же самое, что порождать несколько дочерних задач в группе задач, а не собирать их результаты, например:

try await withThrowingTaskGroup(of: Int.self) { group in 
  group.async { throw Boom() }
                             
  return 0 // we didn't care about the child-task at all(!)
} // returns 0

Приведенный выше пример TaskGroup будет игнорировать Boom, созданный его дочерней задачей. Однако он будет ожидать завершения задачи (и любых других задач, которые она породила) до завершения работы withThrowingTaskGroup. Если бы мы хотели вывести на поверхность все потенциальные броски задач, порожденных в группе, мы должны были написать: for try await _ in group {}, что бы повторно генерировало Boom().

Та же концепция применяется к async let, где область действия группы заменяется синтаксической областью действия, в которой было объявлено async let. Например, следующий фрагмент семантически эквивалентен приведенному выше фрагменту TaskGroup:

// func boom() throws -> Int { throw Boom() }

func work() async -> Int {
  async let work: Int = boom()
  // never await work...
  return 0
  // implicitly: cancels work
  // implicitly: awaits work, discards errors
}

Эта функция work() никогда не будет генерировать, потому что мы не ждали генерирования async let. Если бы мы модифицировали его, чтобы явным образом ожидать его, компилятор заставил бы нас указывать не только await, но и ключевое слово try. Наличие ключевого слова try заставит нас аннотировать функцию work() как throws, как и ожидается от обычного неасинхронного кода в Swift:

// func boom() throws -> Int { throw Boom() }

func work() async throws -> Int { // throws is enforced due to 'try await'
  async let work: Int = boom()
  // ... 
  return try await work // 'try' is enforced since 'boom()' was throwing
}

В качестве альтернативы мы могли бы обработать ошибку work, обернув ее в do/catch.

Отмена и асинхронные дочерние задачи

Отмена рекурсивно распространяется по иерархии задач от родительских к дочерним задачам.

Поскольку задачи, порожденные async let, являются дочерними задачами, они, естественно, участвуют в отмене своих родителей.

Отмена родительской задачи означает, что контекст, в котором существуют объявления async let, отменяется, и все задачи, созданные этими объявлениями, также будут отменены. Поскольку отмена в Swift является кооперативной, она не предотвращает порождение задач, однако задачи, порожденные из отмененного контекста, немедленно помечаются как отмененные. Это демонстрирует ту же семантику, что и TaskGroup.async, которая при использовании из уже отмененной задачи порождает больше дочерних задач, однако они будут немедленно созданы как отмененные задачи, которые они могут проверить, вызвав Task.isCancelled.

Мы можем наблюдать это на следующем примере:

let handle = Task.detached { 
  // don't write such spin loops in real code (!!!)
  while !Task.isCancelled {
    // keep spinning
    await Task.sleep(...)
  }
  
  assert(Task.isCancelled) // parent task is cancelled
  async let childTaskCancelled = Task.isCancelled // child-task is spawned and is cancelled too
  
  assert(await childTaskCancelled)
}

handle.cancel() 

В примере используются API, определенные в предложении структурированного параллелизма: Task.detached, чтобы получить дескриптор отсоединенной задачи, которую мы можем отменить явным образом. Это позволяет нам легко проиллюстрировать, что async let, введенный в задачу, которая уже отменена, по-прежнему порождает дочернюю задачу, но порожденная задача будет немедленно отменена, о чем свидетельствует значение true, возвращаемое в переменную childTaskCancelled.

Это хорошо работает с кооперативным характером отмены задач в истории параллелизма Swift. Задачи, которые могут и хотят участвовать в обработке отмены, должны проверить свой статус с помощью Task.isCancelled или попробовать Task.checkCancellation(), где это уместно.

Анализ ограничений и преимуществ асинхронного режима

Сравнение с TaskGroup

С точки зрения семантики асинхронный let можно рассматривать как сахар для ручного использования группы задач, создания в ней одной задачи и сбора результата из group.next() везде, где объявлено значение async let, на котором ожидается await. Как мы видели в разделе «Мотивация» предложения, такое явное использование групп на практике оказывается очень многословным и подверженным ошибкам, поэтому возникает необходимость в «сахаре» для конкретного шаблона.

Объявление async let на самом деле является не просто сахарным синтаксисом для групп задач и может использовать дополнительную известную во время компиляции структуру объявленных задач. Например, можно избежать выделения кучи для достаточно небольших async let дочерних задач, избежать очередей и других механизмов, которые группа задач должна использовать для реализации «по порядку завершения» получения значений из next().

Однако за это приходится платить: объявления async let менее гибки, чем группы, и это то, что мы рассмотрим в этом разделе.

В частности, объявления async let не могут выражать динамическое количество задач, выполняемых параллельно, как показано в этой группе:

func toyParallelMap<A, B>(_ items: [A], f: (A) async -> B) async -> [B] { 
  return await withTaskGroup(of: (Int, B).self) { group in
    var bs = [B?](repeating: nil, count: items.count)
    
    // spawn off processing all `f` mapping functions in parallel
    // in reality, one might want to limit the "width" of these
    for i in items.indices { 
      group.async { (i, await f(items[i])) }
    }
    
    // collect all results
    for await (i, mapped) in group {
      bs[i] = mapped
    }
    
    return bs.map { $0! }
  }
}

В приведенном выше toyParallelMap количество дочерних задач является динамическим, поскольку оно зависит от количества элементов в массиве элементов во время выполнения. Такие шаблоны невозможно выразить с помощью async let, потому что нам нужно знать, сколько объявлений async let нужно создать во время компиляции. Можно попытаться смоделировать их следующим образом:

// very silly example to show limitations of `async let` when facing dynamic numbers of tasks
func toyParallelMapExactly2<A, B>(_ items: [A], f: (A) async -> B) async -> [B] { 
  assert(items.count == 2)
  async let f0 = f(items[0])
  async let f1 = f(items[1])
  
  return await [f0, f1]
}

И хотя второй пример читается очень красиво, на практике реализация такой функции параллельной map не работает, потому что размер входных items неизвестен (и нам пришлось бы реализовать 1…n версий такой функции).

Другой API, который невозможно реализовать с помощью async let и потребует использования группы задач, — это все, что требует некоторого понятия порядка выполнения. Поскольку объявления async let должны ожидаться, невозможно выразить «в зависимости от того, что завершится первым», и для реализации такого API необходимо использовать группу задач.

Например, функция race(left:right:), показанная ниже, запускает две дочерние задачи параллельно и возвращает ту из них, которая завершилась первой. Такой API невозможно реализовать с помощью асинхронного разрешения, и его необходимо реализовать с помощью группы:

func race(left: () async -> Int, right: () async -> Int) async -> Int {
  await withTaskGroup(of: Int.self) { group in 
    group.async { left() }
    group.async { right() }

    return await group.next()! // !-safe, there is at-least one result to collect
  }
}

Сравнение с Задачей и (не предлагаемым) будущим

Стоит сравнить объявления async let с еще одним предложенным API, способным запускать асинхронные задачи: Task {} и Task.detached {}, предложенными в SE-0304: предложение структурированного параллелизма

Во-первых, Task.detached большую часть времени вообще не следует использовать, потому что он не распространяет приоритет задачи, локальные значения задачи или контекст выполнения вызывающей стороны. Мало того, отдельная задача по своей сути не структурирована и, таким образом, может пережить определяющую ее область действия.

Это сразу показывает, насколько async let и общая концепция дочерних задач превосходят отдельные задачи. Они автоматически распространяют всю необходимую информацию о расписании и метаданные, необходимые для отслеживания выполнения. И их можно распределять более эффективно, чем отдельные задачи.

Таким образом, в то время как теоретически можно думать об async let как о введении (скрытой) Task или будущего, который создается в точке объявления async let и значение которого извлекается в await, на практике это сравнение не замечает основной сила асинхронности позволяет: структурированные параллельные дочерние задачи.

Дочерние задачи в предлагаемой модели структурированного параллелизма (намеренно) более ограничены, чем главные цели общего назначения. В отличие от типичной реализации futures, дочерняя задача не сохраняется за пределами области, в которой она была создана. К моменту выхода из области дочерняя задача должна быть либо завершена, либо она будет неявно ожидаться. Когда область выходит из-за выброшенной ошибки, дочерняя задача будет неявно отменена до того, как она будет ожидаться. Эти ограничения намеренно сохраняют те же свойства структурированного параллелизма, которые обеспечивают явные группы задач.

Это также намеренно, и в отличие от задач невозможно передать «все еще вычисляемое» значение другой функции. С дескрипторами или фьючерсами можно довольно часто «передавать дескриптор» другой функции, например:

func take(h: Task<String, Error>) async -> String {
  return await h.get()
}

Совместимость источника

Это изменение является чисто дополнением к исходному языку.

Влияние на стабильность ABI

Это изменение является чисто дополнительным к ABI.

Влияние на устойчивость API

Все изменения, описанные в этом документе, являются дополнительными к языку и локальны, например, внутри тел функций. Таким образом, это не влияет на отказоустойчивость API.

Будущие направления

Await в списках захвата

Поскольку async let не может быть закрыто сбегающим замыканием, так как это небезопасно продлит время жизни за пределы времени жизни функции, в которой оно было объявлено, разработчики, которым необходимо дождаться значения асинхронного разрешения, прежде чем передать его в сбегающее замыкание придется написать:

func run() async { 
  async let alcatraz = "alcatraz"
  // ... 
  escapeFrom { // : @escaping () async -> Void
    alcatraz // error: cannot refer to 'async let' from @escaping closure
  }
  // implicitly: await alcatraz
}

Единственный законный способ добиться этого в настоящем предложении — ввести другое значение и сохранить в нем ожидаемое значение:

func run() async { 
  async let alcatraz = "alcatraz"
  // ... 
  let awaitedAlcatraz = await alcatraz
  escapeFrom { // : @escaping () async -> Void
    awaitedAlcatraz // ok
  }
}

Это правильно, но немного раздражает, поскольку нам пришлось придумывать новое имя для ожидаемого значения. Вместо этого мы могли бы использовать списки захвата, дополненные возможностью ожидания такого значения в точке создания замыкания:

func escapeFrom(_ f: @escaping () -> ()) -> () {}

func run() async { 
  async let alcatraz = "alcatraz"
  // ... 
  escapeFrom { [await alcatraz] in // value awaited on at closure creation
    alcatraz // ok
  }
}

Этот фрагмент семантически эквивалентен предыдущему в том смысле, что await alcatraz происходит до того, как функция escapeFrom сможет запуститься.

Хотя это лишь небольшое синтаксическое улучшение по сравнению со вторым фрагментом в этом разделе, оно приветствуется и согласуется с предыдущими шаблонами в swift, где можно захватить [weak variable] в замыканиях.

Список захвата необходим только для @escaping-замыканий, поскольку сбегающее замыкание гарантированно не «переживут» область, из которой они вызываются, и, таким образом, не могут нарушить гарантии структурированного параллелизма, на которые опирается async let.

Пользовательские исполнители и async let

Разумно потребовать, чтобы определенные async let инициализаторы запускались на определенных исполнителях.

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

actor Worker { func work() {} }
let worker: Worker = ...

async let x = worker.work() // implicitly hops to the worker to perform the work

Причины, по которым может быть полезно указать исполнителя дочерних задач, могут быть многочисленными, и список ни в коем случае не является исчерпывающим, но чтобы дать представление, указание исполнителя дочерних задач может:

  • заблаговременно настраивать исполнителей, чтобы полностью избежать переключения потоков и исполнителей в таких задачах,
  • выполнять дочерние задачи одновременно, но не параллельно с задачей создания (например, запускать дочерние задачи на том же последовательном исполнителе, что и вызывающий актор),
  • если известно, что работа над дочерней задачей является тяжелой и блокирующей, может быть полезно делегировать ее конкретному «исполнителю блокировки», у которого будет выделенное небольшое количество потоков, в которых он будет выполнять блокирующую работу; Благодаря такому разделению на основной глобальный пул потоков не будут влиять проблемы голодания, которые в противном случае вызвали бы такие блокирующие задачи.
  • различные другие примеры, где требуется жесткий контроль над контекстом выполнения…

Мы должны иметь возможность разрешать такую конфигурацию в зависимости от области действия, например:

await withTask(executor: .globalConcurrentExecutor) { 
  async let x = ...
  async let y = ...
  // x and y execute in parallel; this is equal to the default semantics
}

actor Worker {
  func work(first: Work, second: Work) async {
    await withTask(executor: self.serialExecutor) {
      // using any serial executor, will cause the tasks to be completed one-by-one,
      // concurrently, however without any real parallelism.
      async let x = process(first)
      async let y = process(second)
      // x and y do NOT execute in parallel
    }
  }
}

Детали API еще предстоит выяснить, но общая возможность указывать исполнителя для дочерних задач полезна и будет рассмотрена в будущем.

Рассмотрены альтернативы

Явные фичи

Как обсуждалось в предложении по структурированному параллелизму, мы предпочитаем не раскрывать фичи или Task для дочерних задач в группах задач, потому что это либо может подорвать иерархию задач, выйдя из родительской группы задач и ожидая их на неопределенное время позже, либо В результате существует два вида будущего, одно из которых динамически утверждает, что оно используется только в рамках задачи. async let позволяет передавать поток данных от дочерних задач к родительским, как в будущем, без необходимости раскрывать будущее общего назначения.

«Не создавать задачи при отмене родителя»

Было бы очень запутанно, если бы задачи async let автоматически «не запускались», если родительская задача была отменена. Такая семантика предлагается группами задач через API group.asyncUnlessCancelled, однако было бы довольно сложно выразить с помощью простых объявлений let, так как фактически все такие объявления должны были бы стать неявно бросающими вызовы, что пожертвовало бы их общим удобством использования. Мы убеждены, что реализация стратегии совместной отмены хорошо работает для async let, потому что она хорошо сочетается с тем, как все асинхронные функции должны обрабатывать отмену с самого начала: только когда они этого хотят, в соответствующих местах своего выполнения, и сами решают, предпочитают ли они генерировать Task.CancellationError или возвращать частичный результат при отмене.

Требование await для любого пути выполнения, который ожидает async let

В первоначальных версиях этого предложения мы рассмотрели правило принудительного ожидания объявления async let на каждом пути потока управления, по которому может пройти выполнение функции. Это правило оказалось слишком упрощенным, потому что, как правило, невозможно аннотировать все ребра потока управления, что привело бы к ожиданию завершения дочерней задачи. Наиболее проблематичный случай связан с краем потока управления из-за сгенерированного исключения, например,

func runException() async {
  do {
    async let a = f()
    try mayFail() // no way to "await a" only along the thrown-error edge; it is an implicit suspension point
    ... await a ...
  } catch {
    ...
  }
}

Когда mayFail() завершится нормально, позже мы будем ожидать a, чтобы async let был связан с явной точкой приостановки. Однако, когда функция mayFail() выдает ошибку, поток управления переходит к блоку catch и должен ждать завершения дочерней задачи, создавшей a. Эта последняя точка приостановки является неявной, и нет прямого способа сделать ее явной, которая не включала бы перемещение определения a за пределы блока do…catch.

Есть и другие места, где есть ребра потока управления, которые будут неявно ожидать дочерних задач для async let в области видимости, например, функция с async let в цикле:

func runLoop() async {
  for e in list {
    async let a = f(e)
    guard <condition> else {
      break // cancels and implicitly awaits the task that produces "a"
    }
    ... await a ...
  }
  foo()
}

Наиболее многообещающий подход к маркировке всех точек приостановки async let явно включает маркировку ребер потока управления, которые могут привести к потенциальной точке приостановки с помощью await. Для самого последнего примера это означает использование await break:

func runLoop() async {
  for e in list {
    async let a = f(e)
    guard <condition> else {
      await break   // awaits the child task that produces the value "a"
    }
    ... await a ...
  }
  foo()
}

Точно так же потребуется await continue. Для первого примера это означает пометить вызов mayFail() c await, потому что потенциально вызывающий вызов вызов создает край потока управления за пределами области действия:

func runException() async {
  do {
    async let a = f()
    try await mayFail() // awaits the child task that produces a; mayFail() itself may not even be "async"
    ... await a ...
  } catch {
    ...
  }
}

Несколько двусмысленно, что в данном случае означает try await, потому что mayFail() может быть, а может и не быть async вообще. Если это так, то await выполняет двойную функцию, охватывая как потенциальные точки приостановки для вызова mayFail(), так и потенциальную точку приостановки при ожидании дочерней задачи вдоль границы управления потоком выброшенной ошибки.

Точно так же потребуется await throw для случаев, когда выражение с прямым генерированием подразумевает точку приостановки для ожидания завершения дочерней задачи async let:

func runThrow() async {
  do {
    async let a = f()
    if <condition> {
      await throw SomeError() // awaits the child task that produces a
    }
    ... await a ...
  } catch {
    ...
  }
}

Однако не все ребра потока управления, включающие неявные async let приостановки, имеют определенное ключевое слово, к которому мы можем присоединить await, потому что некоторые из них возникают в результате перехода в последующий код. Для таких случаев можно было бы иметь автономный оператор await, помечающий провал:

func runIfFallthrough() async {
  if <condition> {
    async let a = f()
    ... code ...
    // falling out of this block must await the child task that produces a, so require a freestanding "await"
    await
  }
  ... more code ...
}

То же самое потребуется, например, в случаях оператора switch, который вводит async let:

func runSwitchCase() async {
  switch <expression> {
  case .a:
    async let a = f()
    // falling out of this block must await the child task that produces a, so require a freestanding "await"
    await

  default:
    ... code ...
  }
  ... code ...
}

Вышеприведенное является значительным расширением грамматики: введение ключевого слова await перед break, continue, throw и fallthrough; требование await для определенных выражений броска, которые в противном случае не включают async операции; и добавление автономного оператора await. Это также должно быть связано с правилами, которые требуют нового ожидания только тогда, когда оно имеет семантическое значение. Например, дополнительное await не требуется, если все асинхронные дочерние async let уже явно ожидались другим способом, например,

func runIfFallthroughOkay() async {
  if <condition> {
    async let a = f()
    ... code ...
    if <other condition> {
      ... await a ...
    } else {
      ... await a ...
    }
    // no need for "await" here because we've already waited for "a" along all paths
  }
  ... more code ...
}

Кроме того, каждая async функция уже вызывается с await, которое охватывает любые точки приостановки, возникающие при выходе из функции. Следовательно, ребро потока управления, которое выходит из функции, не должно требовать дополнительного await для любых ожидаемых async let дочерних задач. По этой причине нет await return. Это также означает, что другие ребра потока управления, выходящие из функции, не нужно аннотировать. Например:

func runThrowsOkay() async {
  async let a = f()
  if <condition> {
    throw SomeError() // no need for "await" because this edge exits the function
  } 

  // no need for "await" at the end because we are exiting the function
}

Вышеуказанные правила пытаются ограничить места, в которых требуются новые синтаксисы await, только теми, где они семантически значимы, то есть теми местами, где дочерние задачи async let еще не ожидают своего завершения явно. Правила настолько сложны, что мы не ожидаем, что программисты смогут правильно написать await во всех местах, где это требуется. Скорее, компилятор Swift должен будет предоставить сообщения об ошибках с Fix-Its, чтобы указать места, где требуются дополнительные аннотации await, и эти await останутся артефактом для читателя.

Мы считаем, что сложность решения для маркировки всех точек приостановки, которое включает в себя как расширение грамматики для маркировки ребер потока управления, так и чувствительный к потоку анализ, требующий дополнительной маркировки await только при необходимости, превышает преимущества ее добавления. Вместо этого мы считаем, что наличия async let в блоке со сложным потоком управления достаточно, чтобы подразумевать наличие дополнительных точек приостановки.

Обертки свойств вместо Async let

Комбинация оберток свойств и эффективных свойств подразумевает, что можно аппроксимировать поведение async let с помощью оболочки свойств, например,

@AsyncLet var veggies = try await chopVegetables()

Одна из проблем с этим подходом заключается в том, что оболочки свойств не могут обеспечить семантику структурированного параллелизма. Это становится более очевидным при попытке реализовать такую оболочку свойства:

@propertyWrapper
class AsyncLet<Wrapped: Sendable> {
  var task: Task<Wrapped, Error>
  
  init(wrappedValue fn: @Sendable @escaping @autoclosure () async throws -> Wrapped) {
    self.task = Task.detached {  // have to produce a detached task; cannot create a child task
      try await fn()
    }
  }
  
  var wrappedValue: Wrapped {
    get async throws {
      try await task.value
    }
  }
  
  deinit {
    // we can cancel the task...
    task.cancel()
    
    // ... but we cannot wait for it to complete, because deinits cannot be async
  }
}

Подход с оберткой свойств вынужден создавать неструктурированный параллелизм для захвата задачи, которая затем подлежит экранированию (например, свойство синтезированного резервного хранилища _veggies). Если у нас есть неструктурированный параллелизм, вернуть структуру обратно невозможно: деинициализатор не может ждать завершения задачи, поэтому задача будет продолжать работать после того, как свойство @AsyncLet будет уничтожено. Отсутствие структуры также влияет на способность компилятора обдумывать (и, следовательно, оптимизировать) использование этой функции: как структурированный примитив параллелизма, async let может быть оптимизирован компилятором, чтобы (например) совместно использовать хранилище своих кадров асинхронного стека с его родительская асинхронная задача, устраняющая ложные выделения и предоставляющая более оптимальные шаблоны доступа для результирующего значения. Чтобы решить проблемы семантики и производительности при использовании оболочек свойств, оболочка свойства @AsyncLet фактически представляет собой жестко запрограммированный синтаксис в компиляторе, который похож на оболочку свойства, но на самом деле не является оболочкой свойства.

Одна вещь, которая теряется при подходе с оберткой свойств, заключается в том, что определение свойства, такого как

@AsyncLet var veggies = try await chopVegetables()

теряет ключевое слово async. С async let введенные имена явно async и, следовательно, должны await при их использовании, как и в случае с другими asyns сущностями в языке:

async let veggies = chopVegetables()
...
await veggies

Фигурные скобки вокруг инициализатора async let

Выражение в правой части объявления async let выполняется в отдельной дочерней задаче, которая выполняется одновременно с функцией, инициирующей async let. Было предложено, чтобы задача вызывалась более явно, добавляя отдельный набор фигурных скобок вокруг выражения, например,

async let veggies = { try await chopVegetables() }

Проблема с требованием фигурных скобок заключается в том, что это нарушает эквивалентность между типом объявляемой сущности (veggies имеет тип [Vegetable]) и значением, которым она инициализируется (которое теперь выглядит как @Sendable() async throws -> [ Vegetable]). Эта эквивалентность сохраняется почти во всем языке; единственным реальным исключением является синтаксис if let, который лишает уровень необязательности и часто считается ошибкой дизайна в Swift. Для async let требование фигурных скобок стало бы особенно неудобным, если бы определялось значение типа замыкания:

async let closure = { { try await getClosure() } }

Требование фигурных скобок в правой части async let было бы отходом от существующего прецедента Swift с объявлениями let. В тех случаях, когда вы определяете синтаксически большую дочернюю задачу, разумно создать и немедленно вызвать замыкание, что является обычной практикой с lazy переменными:

async let image: Image = {
  let data = try await download(url: url)
  return try await Image(from: data)
}()

Лист изменений

После первого обзора:

  • Расширено обсуждение неявных точек подвеса в разделе «Рассматриваемые альтернативы» с более полным эскизом проекта, позволяющим сделать все точки подвеса явными.
  • Добавлено обсуждение использования оберток свойств вместо async let в рассмотренные альтернативы.
  • Добавлено обсуждение необходимости использования фигурных скобок вокруг выражения инициализатора async let в разделе «Рассмотренные альтернативы».

После начальной презентации (как часть структурированного параллелизма):

  • переименован обратно в async let, чтобы соответствовать обновленному именованию в API структурированного параллелизма,
  • переименован async let в spawn let, чтобы соответствовать использованию spawn в остальных API структурированного параллелизма,
  • добавлены детали обработки отмены
  • добавлены детали обработки ожидания