0296 — async await

Введение

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

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

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

Шаги быстрой эволюции: Шаг №1, Шаг №2

Мотивация: обработчики завершения неоптимальны

Асинхронное программирование с явными обратными вызовами (также называемыми обработчиками завершения) имеет много проблем, которые мы рассмотрим ниже. Мы предлагаем решить эти проблемы, введя в язык асинхронные функции. Асинхронные функции позволяют записывать асинхронный код как линейный код. Они также позволяют реализации напрямую рассуждать о шаблоне выполнения кода, позволяя обратным вызовам выполняться гораздо эффективнее.

Проблема 1: Пирамида гибели

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

func processImageData1(completionBlock: (_ result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

processImageData1 { image in
    display(image)
}

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

Проблема 2: обработка ошибок

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

// (2a) Using a `guard` statement for each callback:
func processImageData2a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            completionBlock(nil, error)
            return
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                completionBlock(nil, error)
                return
            }
            decodeImage(dataResource, imageResource) { imageTmp, error in
                guard let imageTmp = imageTmp else {
                    completionBlock(nil, error)
                    return
                }
                dewarpAndCleanupImage(imageTmp) { imageResult, error in
                    guard let imageResult = imageResult else {
                        completionBlock(nil, error)
                        return
                    }
                    completionBlock(imageResult)
                }
            }
        }
    }
}

processImageData2a { image, error in
    guard let image = image else {
        display("No image today", error)
        return
    }
    display(image)
}

Добавление Result в стандартную библиотеку улучшило обработку ошибок для Swift API. Асинхронные API были одним из главных мотиваторов для Result:

// (2b) Using a `do-catch` statement for each callback:
func processImageData2b(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        do {
            let dataResource = try dataResourceResult.get()
            loadWebResource("imagedata.dat") { imageResourceResult in
                do {
                    let imageResource = try imageResourceResult.get()
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        do {
                            let imageTmp = try imageTmpResult.get()
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        } catch {
                            completionBlock(.failure(error))
                        }
                    }
                } catch {
                    completionBlock(.failure(error))
                }
            }
        } catch {
            completionBlock(.failure(error))
        }
    }
}

processImageData2b { result in
    do {
        let image = try result.get()
        display(image)
    } catch {
        display("No image today", error)
    }
}
// (2c) Using a `switch` statement for each callback:
func processImageData2c(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        switch dataResourceResult {
        case .success(let dataResource):
            loadWebResource("imagedata.dat") { imageResourceResult in
                switch imageResourceResult {
                case .success(let imageResource):
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        switch imageTmpResult {
                        case .success(let imageTmp):
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        case .failure(let error):
                            completionBlock(.failure(error))
                        }
                    }
                case .failure(let error):
                    completionBlock(.failure(error))
                }
            }
        case .failure(let error):
            completionBlock(.failure(error))
        }
    }
}

processImageData2c { result in
    switch result {
    case .success(let image):
        display(image)
    case .failure(let error):
        display("No image today", error)
    }
}

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

Проблема 3: Условное выполнение сложно и подвержено ошибкам

Условное выполнение асинхронной функции — огромная боль. Например, предположим, что нам нужно «прокрутить» изображение после его получения. Но иногда нам приходится делать асинхронный вызов для декодирования изображения, прежде чем мы сможем прокручивать его. Возможно, лучший подход к структурированию этой функции состоит в том, чтобы написать swizzling-код во вспомогательном замыкании «продолжения», которое условно фиксируется в обработчике завершения, например:

func processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) {
    let swizzle: (_ contents: Image) -> Void = {
      // ... continuation closure that calls completionBlock eventually
    }
    if recipient.hasProfilePicture {
        swizzle(recipient.profilePicture)
    } else {
        decodeImage { image in
            swizzle(image)
        }
    }
}

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

Проблема 4: Можно допустить много ошибок

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

func processImageData4a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource, error in
        guard let dataResource = dataResource else {
            return // <- forgot to call the block
        }
        loadWebResource("imagedata.dat") { imageResource, error in
            guard let imageResource = imageResource else {
                return // <- forgot to call the block
            }
            ...
        }
    }
}

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

func processImageData4b(recipient:Person, completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    if recipient.hasProfilePicture {
        if let image = recipient.profilePicture {
            completionBlock(image) // <- forgot to return after calling the block
        }
    }
    ...
}

К счастью, синтаксис guard в некоторой степени защищает от забывания return, но это не всегда актуально.

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

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

Предлагаемое решение: async/await

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

(1) повышение производительности асинхронного кода;

(2) улучшенный инструментарий для обеспечения более последовательного взаимодействия при отладке, профилировании и изучении кода;

(3) основа для будущих функций параллелизма, таких как приоритет задач и отмена.

Пример из предыдущего раздела демонстрирует, как async/await значительно упрощает асинхронный код:

func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image

func processImageData() async throws -> Image {
  let dataResource  = try await loadWebResource("dataprofile.txt")
  let imageResource = try await loadWebResource("imagedata.dat")
  let imageTmp      = try await decodeImage(dataResource, imageResource)
  let imageResult   = try await dewarpAndCleanupImage(imageTmp)
  return imageResult
}

Многие описания async/await обсуждают его через общий механизм реализации: проход компилятора, который делит функцию на несколько компонентов. Это важно на низком уровне абстракции, чтобы понять, как работает машина, но на высоком уровне мы хотели бы призвать вас игнорировать это. Вместо этого думайте об асинхронной функции как об обычной функции, которая имеет особую силу отказаться от своего потока. Асинхронные функции обычно не используют эту мощь напрямую; вместо этого они делают вызовы, и иногда эти вызовы требуют от них отказаться от своего потока и ждать, пока что-то произойдет. Когда это будет завершено, функция снова возобновит выполнение.

Аналогия с синхронными функциями очень сильна. Синхронная функция может сделать вызов; когда это происходит, функция немедленно ожидает завершения вызова. После завершения вызова управление возвращается к функции и продолжается с того места, где оно было остановлено. То же самое и с асинхронной функцией: она может выполнять вызовы как обычно; когда это происходит, он (обычно) немедленно ожидает завершения вызова. Как только вызов завершится, управление вернется к функции, и она вернется туда, где была. Единственное отличие состоит в том, что синхронные функции могут в полной мере использовать (часть) своего потока и его стека, тогда как асинхронные функции могут полностью отказаться от этого стека и использовать свое собственное отдельное хранилище. Эта дополнительная мощность, предоставляемая асинхронным функциям, связана с некоторыми затратами на реализацию, но мы можем немного снизить ее, разрабатывая ее целостно.

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

Напротив, асинхронная функция может вызывать как синхронные, так и асинхронные функции. Пока он вызывает синхронную функцию, конечно, он не может отказаться от своего потока. На самом деле асинхронные функции никогда не отказываются от своего потока самопроизвольно; они отказываются от своего выполнения только тогда, когда достигают так называемой точки приостановки. Точка приостановки может возникнуть непосредственно внутри функции или внутри другой асинхронной функции, которую вызывает функция, но в любом случае функция и все ее асинхронные вызывающие объекты одновременно покидают поток. (На практике асинхронные функции компилируются так, чтобы не зависеть от потока во время асинхронного вызова, так что только самая внутренняя функция должна выполнять какую-либо дополнительную работу.)

Когда управление возвращается к асинхронной функции, оно возвращается именно туда, где оно было. Это не обязательно означает, что он будет работать в том же потоке, что и раньше, потому что язык не гарантирует этого после приостановки. В разработке, потоки в основном являются механизмом реализации, а не частью предполагаемого интерфейса параллелизма. Однако многие асинхронные функции не просто асинхронны: они также связаны с конкретными акторами (которые являются предметом отдельного предложения) и всегда должны выполняться как часть этого актора. Swift гарантирует, что такие функции действительно вернутся к своему актору для завершения выполнения. Соответственно, библиотеки, которые используют потоки непосредственно для изоляции состояния — например, создавая свои собственные потоки и последовательно планируя задачи для них — обычно должны моделировать эти потоки как акторы в Swift, чтобы эти основные языковые гарантии функционировали должным образом.

Точки приостановки

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

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

Требование await для потенциальных точек приостановки следует прецеденту Swift, требующему, чтобы выражения try покрывали вызовы функций, которые могут вызывать ошибки. Отмечать потенциальные точки приостановки особенно важно, потому что приостановки прерывают атомарность. Например, если асинхронная функция выполняется в заданном контексте, защищенном последовательной очередью, достижение точки приостановки означает, что другой код может чередоваться в той же последовательной очереди. Классический, но несколько избитый пример, где эта атомарность имеет значение, — это моделирование банка: если депозит зачисляется на один счет, но операция приостанавливается до обработки соответствующего снятия, создается окно, в котором эти средства могут быть потрачены дважды. Более уместным примером для многих программистов Swift является поток пользовательского интерфейса: точки приостановки — это точки, в которых пользовательский интерфейс может быть показан пользователю, поэтому программы, которые создают часть своего пользовательского интерфейса, а затем приостанавливают работу, рискуют представить мерцающий, частично построенный пользовательский интерфейс. (Обратите внимание, что точки приостановки также вызываются явно в коде с использованием явных обратных вызовов: приостановка происходит между точкой, где внешняя функция возвращается, и началом выполнения обратного вызова.) Требование, чтобы все потенциальные точки приостановки были помечены, позволяет программистам безопасно предположить, что места без потенциальные точки подвески будут вести себя атомарно, а также для более легкого распознавания проблематичных неатомарных паттернов.

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

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

Детальный разработка

Асинхронные функции

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

func collect(function: () async -> Int) { ... }

Объявление функции или инициализатора также может быть явно объявлено как async:

class Teacher {
  init(hiringFrom: College) async throws {
    ...
  }
  
  private func raiseHand() async -> Bool {
    ...
  }
}

Тип ссылки на функцию или инициализатор, объявленный async, является типом async функции. Если ссылка является «каррированной» статической ссылкой на метод экземпляра, это «внутренний» тип функции, который является async, в соответствии с обычными правилами для таких ссылок.

Специальные функции, такие как методы доступа deinit и хранилища (т. е. методы получения и установки свойств и индексов), не могут быть async.

Обоснование: свойства и индексы, которые имеют только геттер, потенциально могут быть async. Однако свойства и индексы, которые также имеют async установщик, подразумевают возможность передавать ссылку как inout и углубляться в свойства самого этого свойства, что зависит от того, что установщик фактически является «мгновенной» (синхронной, не throws) операцией. Запрет async свойств — это более простое правило, чем только разрешение async свойств и индексов только для получения.

Если функция является одновременно async и throws, ключевое слово async должно предшествовать throws в объявлении типа. Это же правило применяется, если выполняется async и rethrows

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

async инициализатор класса, который имеет суперкласс, но не имеет вызова инициализатора суперкласса, получит неявный вызов super.init() только в том случае, если суперкласс имеет синхронный назначенный инициализатор без аргументов.

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

Типы асинхронных функций

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

struct FunctionTypes {
  var syncNonThrowing: () -> Void
  var syncThrowing: () throws -> Void
  var asyncNonThrowing: () async -> Void
  var asyncThrowing: () async throws -> Void
  
  mutating func demonstrateConversions() {
    // Okay to add 'async' and/or 'throws'    
    asyncNonThrowing = syncNonThrowing
    asyncThrowing = syncThrowing
    syncThrowing = syncNonThrowing
    asyncThrowing = asyncNonThrowing
    
    // Error to remove 'async' or 'throws'
    syncNonThrowing = asyncNonThrowing // error
    syncThrowing = asyncThrowing       // error
    syncNonThrowing = syncThrowing     // error
    asyncNonThrowing = syncThrowing    // error
  }
}

Ожидание выражений

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

Рассмотрим следующий пример:

// func redirectURL(for url: URL) async -> URL { ... }
// func dataTask(with: URL) async throws -> (Data, URLResponse) { ... }

let newURL = await server.redirectURL(for: url)
let (data, response) = try await session.dataTask(with: newURL)

В этом примере кода приостановка задачи может произойти во время вызовов redirectURL(for:) и dataTask(with:), поскольку они являются асинхронными функциями. Таким образом, оба выражения вызова должны содержаться в некотором выражении await, потому что каждый вызов содержит потенциальную точку приостановки. Операнд await может содержать более одной потенциальной точки приостановки. Например, мы можем использовать один await для покрытия обеих потенциальных точек приостановки из нашего примера, переписав его как:

let (data, response) = try await session.dataTask(with: server.redirectURL(for: url))

У await нет дополнительной семантики; как и try, он просто отмечает, что выполняется асинхронный вызов. Тип выражения await — это тип его операнда, а его результат — результат его операнда. Операнд await также может не иметь потенциальных точек приостановки, что приведет к предупреждению от компилятора Swift, следуя прецеденту выражений try:

let x = await synchronous() // warning: no calls to 'async' functions occur within 'await' expression

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

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

Потенциальная точка приостановки не должна находиться внутри блока отложенного выполнения.

Если и await, и вариант try (включая try! и try?) применяются к одному и тому же подвыражению, await должно следовать за try/try!/try?:

let (data, response) = await try session.dataTask(with:server.redirectURL(for: url)) // error: must be `try await`
let (data, response) = await (try session.dataTask(with:server.redirectURL(for: url))) // okay due to parentheses

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

Замыкания

Замыкание может иметь тип async функции. Такие замыкания могут быть явно помечены как async следующим образом:

{ () async -> Int in
  print("here")
  return await getInt()
}

Предполагается, что анонимное замыкание имеет тип async функции, если оно содержит выражение await.

let closure = { await getInt() } // implicitly async

let closure2 = { () -> Int in     // implicitly async
  print("here")
  return await getInt()
}

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

// func getInt() async -> Int { ... }

let closure5 = { () -> Int in       // not 'async'
  let closure6 = { () -> Int in     // implicitly async
    if randomBool() {
      print("there")
      return await getInt()
    } else {
      let closure7 = { () -> Int in 7 }  // not 'async'
      return 0
    }
  }
  
  print("here")
  return 5
}

Перегрузка и разрешение перегрузки

Существующие API-интерфейсы Swift обычно поддерживают асинхронные функции через интерфейс обратного вызова, например,

func doSomething(completionHandler: ((String) -> Void)? = nil) { ... }

Многие такие API, вероятно, будут обновлены путем добавления асинхронной формы:

func doSomething() async -> String { ... }

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

doSomething() // problem: can call either, unmodified Swift rules prefer the `async` version

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

// Existing synchronous API
func doSomethingElse() { ... }

// New and enhanced asynchronous API
func doSomethingElse() async { ... }

В первом случае правила перегрузки Swift предпочитают вызывать функцию с меньшим количеством аргументов по умолчанию, поэтому добавление асинхронной функции нарушит существующий код, вызывающий оригинальный doSomething(completionHandler:) без обработчика завершения. Это приведет к ошибке в строках:

error: `async` function cannot be called from non-asynchronous context

Это создает проблемы для развития кода, потому что разработчикам существующих асинхронных библиотек пришлось бы либо иметь жесткий разрыв совместимости (например, для новой основной версии), либо им потребовались бы разные имена для всех новых асинхронных версий. Последнее, вероятно, приведет к схеме, подобной широко распространенному суффиксу Async в C#.

Второй случай, когда обе функции имеют одинаковую сигнатуру и отличаются только асинхронностью, обычно отвергается существующими правилами перегрузки Swift. Они не позволяют двум функциям различаться только своими эффектами, и нельзя определить две функции, которые отличаются, например, только throws.

// error: redeclaration of function `doSomethingElse()`.

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

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

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

func f() async {
  // In an asynchronous context, the async overload is preferred:
  await doSomething()
  // Compiler error: Expression is 'async' but is not marked with 'await'
  doSomething()
}

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

func f() async {
  let f2 = {
    // In a synchronous context, the non-async overload is preferred:
    doSomething()
  }
  f2()
}

Автоматические замыкания

Функция не может принимать параметр автозакрытия типа асинхронной функции, если только сама функция не является асинхронной. Например, следующее объявление некорректно:

// error: async autoclosure in a function that is not itself 'async'
func computeArgumentLater<T>(_ fn: @escaping @autoclosure () async -> T) { } 

Это ограничение существует по нескольким причинам. Рассмотрим следующий пример:

// func getIntSlowly() async -> Int { ... }

let closure = {
  computeArgumentLater(await getIntSlowly())
  print("hello")
}

На первый взгляд выражение await подразумевает для программиста, что существует потенциальная точка приостановки перед вызовом calculateArgumentLater(_:), что на самом деле не так: потенциальная точка приостановки находится внутри переданного (авто)замыкания. и используется внутри тела calculateArgumentLater(_:). Это вызывает несколько проблем.

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

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

await computeArgumentLater(getIntSlowly())

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

Соответствие протоколу

Требование протокола может быть объявлено как асинхронное. Такое требование может быть удовлетворено асинхронной или синхронной функцией. Однако требование синхронной функции не может быть удовлетворено асинхронной функцией. Например:

protocol Asynchronous {
  func f() async
}

protocol Synchronous {
  func g()
}

struct S1: Asynchronous {
  func f() async { } // okay, exactly matches
}

struct S2: Asynchronous {
  func f() { } // okay, synchronous function satisfying async requirement
}

struct S3: Synchronous {
  func g() { } // okay, exactly matches
}

struct S4: Synchronous {
  func g() async { } // error: cannot satisfy synchronous requirement with an async function
}

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

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

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

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

Контекстное ключевое слово await более проблематично, потому что оно встречается в выражении. Например, сегодня можно определить функцию await в Swift:

func await(_ x: Int, _ y: Int) -> Int { x + y }

let result = await(1, 2)

Сегодня это правильно сформированный код, который является вызовом функции ожидания. С этим предложением этот код становится выражением ожидания с подвыражением (1, 2). Это будет проявляться как ошибка времени компиляции для существующих программ Swift, потому что await можно использовать только в асинхронном контексте, а никакие существующие программы Swift не имеют такого контекста. Такие функции кажутся не очень распространенными, поэтому мы считаем, что это приемлемый разрыв исходного кода в рамках введения async/await.

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

Асинхронные функции и типы функций добавляются к ABI, поэтому они не влияют на стабильность ABI, поскольку существующие (синхронные) функции и типы функций не изменяются.

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

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

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

повторная синхронизация

Rethrows Swift — это механизм, указывающий, что конкретная функция вызывает исключение только тогда, когда один из переданных ей аргументов является функцией, которая сама вызывает исключение. Например, Sequence.map использует повторные броски, потому что единственный способ, которым операция может throws, — это если бросит само преобразование:

extension Sequence {
  func map<Transformed>(transform: (Element) throws -> Transformed) rethrows -> [Transformed] {
    var result = [Transformed]()
    var iterator = self.makeIterator()
    while let element = iterator.next() {
      result.append(try transform(element))   // note: this is the only `try`!
    }
    return result
  }
}

Вот примеры использования map на практике:

_ = [1, 2, 3].map { String($0) }  // okay: map does not throw because the closure does not throw
_ = try ["1", "2", "3"].map { (string: String) -> Int in
  guard let result = Int(string) else { throw IntParseError(string) }
  return result
} // okay: map can throw because the closure can throw

То же самое можно применить и к асинхронным функциям. Например, мы могли бы представить асинхронную map, когда ее аргумент асинхронен с reasync:

extension Sequence {
  func map<Transformed>(transform: (Element) async throws -> Transformed) reasync rethrows -> [Transformed] {
    var result = [Transformed]()
    var iterator = self.makeIterator()
    while let element = iterator.next() {
      result.append(try await transform(element))   // note: this is the only `try` and only `await`!
    }
    return result
  }
}

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

На практике здесь возникает несколько проблем:

  • Вероятно, это не очень хорошая реализация асинхронной map последовательности. Скорее всего, нам нужна параллельная реализация, которая, скажем, одновременно обрабатывает до нескольких ядер.
  • ABI генерирующих функций намеренно разработан для того, чтобы функция rethrows могла действовать как не генерирующая функция, поэтому одной точки входа ABI достаточно как для генерирующих, так и для негенерирующих вызовов. Этого нельзя сказать об асинхронных функциях, которые имеют радикально отличающийся ABI, который обязательно менее эффективен, чем ABI для синхронных функций.

Для чего-то вроде Sequence.map, который может стать параллельным, reasync, вероятно, является неправильным инструментом: перегрузка асинхронных замыканий для обеспечения отдельной (параллельной) реализации, вероятно, является лучшим ответом. Таким образом, reasync вероятно, будет гораздо менее применима, чем повторные броски.

Несомненно, для reasync есть несколько применений, например, ?? оператор для необязательных параметров, где асинхронная реализация прекрасно уступает синхронной реализации:

func ??<T>(
    _ optValue: T?, _ defaultValue: @autoclosure () async throws -> T
) reasync rethrows -> T {
  if let value = optValue {
    return value
  }

  return try await defaultValue()
}

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

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

Сделать ожидание подразумевающим try

Многие асинхронные API включают файловый ввод-вывод, работу в сети или другие операции, которые могут привести к сбою, поэтому они будут одновременно асинхронными и throws. В месте вызова это означает, что try await будет повторяться много раз. Чтобы сократить шаблон, await может подразумевать try, поэтому следующие две строки будут эквивалентны:

let dataResource  = await loadWebResource("dataprofile.txt")
let dataResource  = try await loadWebResource("dataprofile.txt")

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

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

Запуск асинхронных задач

Поскольку только асинхронный код может вызывать другой асинхронный код, это предложение не дает возможности инициировать асинхронный код. Это сделано намеренно: весь асинхронный код выполняется в контексте «задачи» — понятия, которое определено в предложении по структурированному параллелизму. Это предложение предоставляет возможность определять асинхронные точки входа в программу через @main, например,

@main
struct MyProgram {
  static func main() async { ... }
}

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

func f() async -> String { "hello, asynchronously" }

print(await f()) // error: cannot call asynchronous function in top-level code

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

Ни одна из проблем, связанных с кодом верхнего уровня, не влияет на фундаментальные механизмы async/await, определенные в этом предложении.

Ожидайте, как синтаксический сахар

Это предложение делает асинхронные функции основной частью системы типов Swift, отличной от синхронных функций. Альтернативный дизайн оставил бы систему типов неизменной и вместо этого сделал бы синтаксический сахар async и await над некоторым типом Future<T, Error>, например,

async func processImageData() throws -> Future<Image, Error> {
  let dataResource  = try loadWebResource("dataprofile.txt").await()
  let imageResource = try loadWebResource("imagedata.dat").await()
  let imageTmp      = try decodeImage(dataResource, imageResource).await()
  let imageResult   = try dewarpAndCleanupImage(imageTmp).await()
  return imageResult
}

Этот подход имеет ряд недостатков по сравнению с предложенным здесь подходом:

  • В экосистеме Swift не существует универсального типа Future, на основе которого его можно было бы построить. Если бы экосистема Swift уже в основном остановилась на одном будущем типе (например, если бы он уже был в стандартной библиотеке), подход синтаксического сахара, подобный описанному выше, кодифицировал бы существующую практику. Не имея такого типа, нужно было бы попытаться абстрагироваться от всех различных типов будущих типов с помощью какого-то протокола Futurable. Это может быть возможно для некоторого набора будущих типов, но лишит вас каких-либо гарантий поведения или производительности асинхронного кода.
  • Это несовместимо с дизайном throws. Тип результата асинхронных функций в этой модели — это тип будущего (или «любой Futurable тип»), а не фактическое возвращаемое значение. Их всегда нужно ожидать немедленно (отсюда синтаксис постфикса), иначе вы в конечном итоге будете работать с Futurable, когда вам действительно важен результат асинхронной операции. Это становится моделью программирования с будущим, а не моделью асинхронного программирования, когда многие другие аспекты асинхронной разработки намеренно отталкивают от размышлений о будущем.
  • Исключение асинхронности из системы типов устранило бы возможность перегрузки на основе асинхронности. См. предыдущий раздел о причинах перегрузки в асинхронном режиме.
  • Фьючерсы являются относительно тяжеловесными типами, и их формирование для каждой асинхронной операции имеет нетривиальные затраты как на размер кода, так и на производительность. Напротив, глубокая интеграция с системой типов позволяет создавать и оптимизировать асинхронные функции для эффективной приостановки. Все уровни компилятора и среды выполнения Swift могут оптимизировать асинхронные функции таким образом, который был бы невозможен с функциями, возвращающими будущее.
  • Лист регистраций изменений