Данная статья является переводом оригинальной статьи Jayesh Kawli Async / Await in Swift

Apple представила концепцию async/await в Swift 5.5 и анонсировала ее на сессии WWDC21. Сегодня мы увидим async/await в действии и то, как мы можем использовать async/await для написания удобочитаемого асинхронного кода в своем приложении.

Пожалуйста, обратите внимание, что async/await доступен только в Swift 5.5 и Xcode 13. Поэтому, пожалуйста, не забудьте загрузить последнюю версию Xcode, прежде чем приступить к этому руководству

Введение

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

Структурированный параллелизм облегчает чтение, отслеживание и понимание кода. Таким образом, Apple стремится сделать код более читабельным, внедрив концепцию async /await начиная с Swift 5.5. Давайте начнем обучение с рассмотрения того, как выглядит асинхронный код до async /await и как мы можем его реорганизовать, чтобы использовать эту новую функциональность.

Приступим

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

enum DownloadError: Error {
    case badImage
    case unknown
}

typealias Completion = (Result<Response, DownloadError>) -> Void

func saveChanges(completion: Completion) {
    Thread.sleep(forTimeInterval: 2)
    
    let randomNumber = Int.random(in: 0..<2)
    
    if randomNumber == 0 {
		completion(.failure(.unknown))
        return
    }
    completion(.success(Response(id: 100)))
}

// Calling the function
saveChanges { result in
    switch result {
    case .success(let response):
        print(response)
    case .failure(let error):
        print(error)
    }
}
// Following code

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

Этот код неструктурирован. Мы вызываем SaveChanges, а затем продолжаем выполнение следующего кода в том же потоке. Когда изменения сохраняются в асинхронном стиле, вызывается completion closure, мы получаем результат в обратном вызове и приступаем к его обработке. Однако этот код не структурирован, и поэтому ему трудно следовать.

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

Перепишем код с использованием async/await

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

  • Пометим функцию ключевым словом async. Это делается путем добавления ключевого слова async в конце имени функции в определении функции

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

  • Пусть функция возвращает значение success. Ошибки будут обрабатываться в блоке-catch на стороне вызывающего абонента в случае, если вызываемый объект выдает ошибку.

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

// Refactored function
func saveChanges() async throws -> Response {
    Thread.sleep(forTimeInterval: 2)
    
    let randomNumber = Int.random(in: 0..<2)
    
    if randomNumber == 0 {
        throw DownloadError.unknown
    }
    
    return Response(id: 100)
}

// Calling function

func someSyncFunction() {
    // Beginning of async context
    Task(priority: .medium) {
    do {
        let result = try await saveChanges()
           print(result.id)
        } catch {
            if let downloadError = error as? DownloadError {
                // Handle Download Error
            } else {
                // Handle some other type of error
            }
        }
    }
    
    // Back to sync context
}

Что такое Tasks?

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

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

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

Чем код с completion-closure отличается от кода с async/await при обработке сбоев?

Классический completion closure код использует либо тип Result либо передает пару (Result, Error) в completion closure

typealias Completion_Result_Type = (Result<Response, Error>) -> Void
typealias Completion_Pair_Type = (Response, Error) -> Void

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

 Async/await код скорее полагается на исключения. Если что-то идет не так, он реагирует, выдавая исключение. Даже если какой-то код, которым мы напрямую не управляем, завершается сбоем, вызывающий может обнаружить сбой, когда вызываемый объект генерирует исключение. Таким образом, функции, написанные с использованием конструкции async/await, должны возвращать только допустимое значение. Если он столкнется с ошибкой, то скорее всего в конечном итоге выдаст исключение.

Объединяем async/await код

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

func someSynchronousFunction() {
    Task(priority: .medium) {
        let response = await saveChanges()
        // Following code
    }
    // Synchronous code continues
}

func saveChanges() async -> Response {
    // Some async code
    return Response(id: 100)
}

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

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

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

Возможные варианты приоритета задач следующие

  • high

  • medium

  • low

  • userInitiated

  • utility

  • background

Альтернативный способ вызова асинхронного кода из синхронного метода

Создание задачи и выполнение кода изнутри - это не единственный способ выполнения кода с асинхронным ожиданием. Мы также можем обернуть код внутри async closure и вызвать асинхронную функцию точно так же, как мы бы вызвали ее внутри Task.

func someSynchronousFunction() {
    async {
        let response = await saveChanges()
        // Following code
    }
    // Synchronous code continues
}

func saveChanges() async -> Response {
    // Some async code
    return Response(id: 100)
}

Использование deffer внутри асинхронного контекста

Асинхронная задача, которая выполняется в Task или асинхронном клоужере, активна в контексте закрытия. Как только асинхронная задача завершается, она также завершает клоужер. Если нам нужно выполнить очистку или освободить ресурсы перед выходом из асинхронного контекста, нам нужно заключить этот код в блок defer.

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

async {
	defer {
    	// Cleanup Code
    }
	// async code
}

// OR

Task(priority: .medium) {
	defer {
    	// Cleanup Code
    }
	// async code
}

Параллельное выполнение нескольких асинхронных функций

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

Task(priority: .medium) {
	let result1 = await asyncFunction1()
}

Task(priority: .medium) {
	let result2 = await asyncFunction2()
}

Task(priority: .medium) {
	let result3 = await asyncFunction3()
}

// Following synchronous code

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

Передача результата асинхронной функции следующей функции

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

func getImageIds(personId: Int) async -> [Int] {
    // Network call
    Thread.sleep(forTimeInterval: 2)
    return [100, 200, 300]
}

func getImages(imageIds: [Int]) async -> [UIImage] {
    // Network call to get images
    Thread.sleep(forTimeInterval: 2)
    let downloadedImages: [UIImage] = []
    return downloadedImages
}

// Execution
Task(priority: .medium) {
    let personId = 3000
    // Wait until imageIds are returned
    let imageIds = await getImageIds(personId: personId)
    
    // Continue execution after imageIds are received
    let images = await getImages(imageIds: imageIds)

    //Display images
}

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

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

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

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

// An async function to download image by Id
func downloadImageWithImageId(imageId: Int) async throws -> UIImage {
    let imageUrl = URL(string: "https://imageserver.com/\(imageId)")!
    let imageRequest = URLRequest(url: imageUrl)
    let (data, response) = try await URLSession.shared.data(for: imageRequest)
    
        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
    	throw DownloadError.invalidStatusCode
    }
    
    guard let image = UIImage(data: data) else {
        throw DownloadError.badImage
    }
    return image
}

// Async task creation
Task(priority: .medium) {
    do {
        // Call function and proceed to next step
        async let image_1 = try downloadImageWithImageId(imageId: 1)
        
        // Call function and proceed to next step
        async let image_2 = try downloadImageWithImageId(imageId: 2)
        
        // Call function and proceed to next step
        async let image_3 = try downloadImageWithImageId(imageId: 3)
        
        let images = try await [image_1, image_2, image_3]
        // Display images
        
    } catch {
        // Handle Error
    }
}

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

async /await URLSession - Как Apple изменила некоторые из своих API, чтобы привести их в соответствие с новой функцией await /async

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

- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler;

Однако, начиная с Swift 5.5 и iOS 15.0, Apple изменила сигнатуру, чтобы использовать функцию async /await

public func data(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

Давайте посмотрим демонстрацию нового API в действии. Мы будем использовать аналогичный пример для загрузки изображения с заданным идентификатором. Мы определим код в асинхронной функции и передадим идентификатор изображения для загрузки. Мы будем использовать новый асинхронный API для получения данных изображения и возврата объекта UIImage. (Или генерировать исключение в случае, если что-то пойдет не так)

// Function definition
func downloadImageWithImageId(imageId: Int) async throws -> UIImage {
    let imageUrl = URL(string: "https://imageserver.com/\(imageId)")!
    let imageRequest = URLRequest(url: imageUrl)
    let (data, response) = try await URLSession.shared.data(for: imageRequest)
    
    if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
    	throw DownloadError.invalidStatusCode
    }
    
    guard let image = UIImage(data: data) else {
        throw DownloadError.badImage
    }
    return image
}

// Download image by Id
let image = try await downloadImageWithImageId(imageId: 1)
// Display image

Асинхронные свойства

Асинхронными могут быть не только методы, но и свойства. Если мы хотим пометить какое-либо свойство как асинхронное, вот как мы можем это сделать.

extension UIImage {
    var processedImage: UIImage {
        get async {
            let processId = 100
            return await self.getProcessedImage(id: 100)
        }
    }
    
    func getProcessedImage(id: Int) async -> UIImage {
        // Heavy Operation
        return self
    }
}

let originalImage = UIImage(named: "Flower")
let processed = await original?.processedImage

В приведенном выше примере

  • У нас есть асинхронное свойство processedImage

  • getter для этого свойства вызывает другую асинхронную функцию getProcessedImage, которая принимает ProcessId в качестве входных данных и возвращает обработанное изображение обратно

  • Мы предполагаем, что getProcessedImage выполняет сложную операцию и, таким образом, она заключена в асинхронный контекст

  •  Передавая исходное изображение, мы можем получить обработанное изображение, запросив у него асинхронное свойство processedImage и ожидая результата

  • Асинхронные свойства также поддерживают ключевое слово throws

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

Асинхронные свойства, которые генерируют исключения

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

extension UIImage {
    var processedImage: UIImage {
        get async throws {
            let processId = 100
            return try await self.getProcessedImage(id: processId)
        }
    }
    
    func getProcessedImage(id: Int) async throws -> UIImage {
        // Heavy Operation
        
        // Throw an error is operation encounters exception
        
        return self
    }
}


let originalImage = UIImage(named: "Flower")
let processedImage = try await original?.processedImage

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

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

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

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

let task = Task(priority: .background) {
    let networkService = NetworkOperation()
    let image = try await networkService.downloadImage(with: 1)
}

let asyncTask = async {
    let networkService = NetworkOperation()
    let image = try await networkService.downloadImage(with: 1)
}


// Probably cancel task in the deinit method 
// when current instance is deallocated

deinit {
	task.cancel()
    asyncTask.cancel()
}

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

Модульное тестирование async/ await кода

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

Если мы написали асинхронный код до async/await, тестирование выглядело бы примерно так.

func saveChanges(completion: (Response, Error?) -> Void) {
    // Internal code
}

func testMyModel() throws {
	let expectation = XCTestExpectation(description: "Some expectation description")

    let mockViewModel = ....
    mockViewModel.saveChanges { response, error in
        XCTAssertNotNil(error)
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 2.0)
}

Это очень много кода для проверки одной вещи. Давайте попробуем переписать SaveChanges, используя аasync/await, и посмотрим, как это повлияет на наше тестирование.

func saveChanges() async throws -> Response {
    // Either return Response of throw an error
}

func testMyModel() async throws {
    let mockViewModel = .....
    XCTAssertNoThrow(Task(priority: .medium) {
		try await mockViewModel.saveChanges()
    })
}

Код был сокращен всего до нескольких строк, и в нем больше нет шаблонного кода.

Как все оптимизировано для производительности приложения?

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

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

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

Использование неструктурированных задач

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

Detached Tasks

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

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

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

Task(priority: .userInitiated) {
    
    let networkOperation = NetworkOperation()
    print("About to download image")
    let image = try await networkOperation.downloadImage(with: 1)
    print("Image downloaded")
    
    print("Starting detached task in the background")
    Task.detached(priority: .background) {
        // Locally store the downloaded image in the cache
    }
    
    print("Image size is")
    print(image.size)
}

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

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

Это все, что у меня есть на сегодняшний день по теме Swift concurrency. Надеюсь, вам понравился этот пост.

Комментарии (2)


  1. YuriyPashkov
    09.07.2023 20:17

    Вот этот код

    let (data, response) = try await URLSession.shared.data(for: imageRequest)

    надо бы заключать в блок do {} catch {}, иначе при ошибках на уровне URLSession (например, интернета нет) у нас метод ошибку не выбросит и мы получим то самое:

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


    1. FreeNickname
      09.07.2023 20:17

      Как это не выбросит? URLSession бросит ошибку, метод downloadImageWithImageId её пробросит в вызывающий код. А там уже её будут обрабатывать. В Swift нельзя (неявно) не обработать ошибку, она никуда не пропадёт. Если бы ошибка не пробрасывалась наверх, компилятор просто не дал бы написать там try.

      UPD: другое дело, что тут есть кастомный тип ошибок DownloadError, и хорошо бы сделать do/catch и "обернуть" ошибку URLSession в DownloadError, но это не обязательно (ну и в целом дело вкуса), даже без этого ошибка никуда не денется.