Асинхронное ожидание является частью новых структурных изменений параллелизма, которые появились в Swift 5.5 во время WWDC 2021. Параллелизм в Swift означает, что несколько фрагментов кода могут выполняться одновременно. Это очень упрощенное описание, но оно уже должно дать вам представление о том, насколько важен параллелизм в Swift для производительности ваших приложений. С помощью новых асинхронных методов и операторов ожидания мы можем определить методы, выполняющие работу асинхронно.
Возможно, вы уже читали о Swift Concurrency Manifesto Криса Латтнера, о котором было объявлено несколько лет назад. Многие разработчики в сообществе Swift с нетерпением ждут будущего, когда появится структурированный способ определения асинхронного кода. Теперь, когда он, наконец, здесь, мы можем упростить наш код с помощью async-await и сделать наш асинхронный код более удобным для чтения.
Что такое асинхронность?
Async означает асинхронный, и его можно рассматривать как атрибут метода, показывающий, что метод выполняет асинхронную работу. Пример такого метода выглядит следующим образом:
func fetchImages() async throws -> [UIImage] {
// .. perform data request
}
Метод fetchImages определен как асинхронный выбрасывающий ошибки, что означает, что он выполняет асинхронное задание, которое может привести к сбою. Метод вернет коллекцию изображений, если все пойдет хорошо, или выдаст ошибку, если что-то пошло не так.
Как async заменяет обратные вызовы завершения закрытия
Асинхронные методы заменяют часто встречающиеся обратные вызовы завершения закрытия. Обратные вызовы завершения были обычным явлением в Swift для возврата из асинхронной задачи, часто в сочетании с параметром Result. Выше упомянутый метод был бы написан следующим образом:
func fetchImages(completion: (Result<[UIImage], Error>) -> Void) {
// .. perform data request
}
Определение метода с помощью закрытия завершения все еще возможно в Swift сегодня, но у него есть несколько недостатков, которые решаются с помощью вместо этого использования async:
- Вы должны убедиться, что сами вызываете закрытие завершения в каждом возможном выходе метода. Если этого не сделать, приложение может бесконечно ждать результата.
- Замыкания труднее читать. Рассуждать о порядке выполнения не так просто, как это легко сделать со структурированным параллелизмом.
- Следует избегать циклов сохранения с использованием слабых ссылок.
- Разработчики должны переключать результат, чтобы получить результат. Невозможно использовать операторы try catch на уровне реализации.
Эти недостатки основаны на версии закрытия, использующей относительно новое перечисление Result. Вполне вероятно, что многие проекты все еще используют обратные вызовы завершения без этого перечисления:
func fetchImages(completion: ([UIImage]?, Error?) -> Void) {
// .. perform data request
}
Такое определение метода еще больше усложняет анализ результата на стороне вызывающей стороны. И значение, и ошибка являются необязательными, что требует от нас выполнения развертывания в любом случае. Развертывание этих опций приводит к еще большему беспорядку в коде, что не помогает улучшить читаемость.
Что такое Await?
Await — это ключевое слово, которое будет использоваться для вызова асинхронных методов. Вы можете видеть их как лучших друзей в Swift, так как они никогда не обходятся без другого. Вы могли бы в основном сказать:
«Await ожидает обратного вызова от своего приятеля async»
Хоть это и звучит по-детски, но это не ложь! Мы могли бы взглянуть на пример, вызвав наш ранее определенный асинхронный метод выборки изображений:
do {
let images = try await fetchImages()
print("Fetched \(images.count) images.")
} catch {
print("Fetching images failed with error \(error)")
}
В это трудно поверить, но приведенный выше пример кода выполняет асинхронную задачу. Используя ключевое слово await, мы указываем нашей программе ожидать результата от метода fetchImages и продолжать работу только после получения результата. Это может быть коллекция изображений или ошибка, если что-то пошло не так при получении изображений.
Что такое структурированный параллелизм?
Структурированный параллелизм с вызовами асинхронных методов ожидания упрощает определение порядка выполнения. Методы выполняются линейно, без переходов туда и обратно, как в случае с замыканиями.
Чтобы лучше объяснить это, мы можем посмотреть, как мы будем вызывать приведенный выше пример кода до того, как появится структурированный параллелизм:
// 1. Call the method
fetchImages { result in
// 3. The asynchronous method returns
switch result {
case .success(let images):
print("Fetched \(images.count) images.")
case .failure(let error):
print("Fetching images failed with error \(error)")
}
}
// 2. The calling method exits
Как видите, вызывающий метод возвращается до того, как изображения будут получены. В конце концов, результат получен, и мы возвращаемся к нашему потоку в обратном вызове завершения. Это неструктурированный порядок выполнения, и ему может быть трудно следовать. Это особенно верно, если мы выполним другой асинхронный метод в нашем обратном вызове завершения, который добавит еще один обратный вызов закрытия:
// 1. Call the method
fetchImages { result in
// 3. The asynchronous method returns
switch result {
case .success(let images):
print("Fetched \(images.count) images.")
// 4. Call the resize method
resizeImages(images) { result in
// 6. Resize method returns
switch result {
case .success(let images):
print("Decoded \(images.count) images.")
case .failure(let error):
print("Decoding images failed with error \(error)")
}
}
// 5. Fetch images method returns
case .failure(let error):
print("Fetching images failed with error \(error)")
}
}
// 2. The calling method exits
Каждое закрытие добавляет еще один уровень отступа, что затрудняет соблюдение порядка выполнения.
Переписывание приведенного выше примера кода с использованием async-await лучше всего объясняет, что делает структурированный параллелизм:
do {
// 1. Call the method
let images = try await fetchImages()
// 2. Fetch images method returns
// 3. Call the resize method
let resizedImages = try await resizeImages(images)
// 4. Resize method returns
print("Fetched \(images.count) images.")
} catch {
print("Fetching images failed with error \(error)")
}
// 5. The calling method exits
Порядок выполнения является линейным и, следовательно, легко соблюдается и обосновывается. Понимание асинхронного кода будет проще, пока мы все еще выполняем иногда сложные асинхронные задачи.
Асинхронные методы вызывают функцию, которая не поддерживает параллелизм.
При первом использовании async-await вы можете столкнуться с ошибкой, например:
Эта ошибка возникает, когда мы пытаемся вызвать асинхронный метод из среды синхронных вызовов, которая не поддерживает параллелизм. Мы можем решить эту ошибку, либо определив наш метод fetchData как асинхронный:
func fetchData() async {
do {
try await fetchImages()
} catch {
// .. handle error
}
}
Однако это приведет к перемещению ошибки в другое место. Вместо этого мы могли бы использовать метод Task.init для вызова асинхронного метода из новой задачи, которая поддерживает параллелизм, и присвоить конечный результат свойству в нашей модели представления:
final class ContentViewModel: ObservableObject {
@Published var images: [UIImage] = []
func fetchData() {
Task.init {
do {
self.images = try await fetchImages()
} catch {
// .. handle error
}
}
}
}
Используя асинхронный метод с завершающим замыканием, мы создаем среду, в которой можем вызывать асинхронные методы. Метод выборки данных возвращается, как только вызывается асинхронный метод, после чего все асинхронные обратные вызовы будут выполняться внутри замыкания.
Принятие async-await в существующем проекте
Применяя async-await в существующих проектах, вы должны быть осторожны, чтобы не сломать весь код сразу. При выполнении таких больших рефакторингов полезно подумать о сохранении старых реализаций на время, чтобы вам не пришлось обновлять весь код, прежде чем вы узнаете, достаточно ли стабильна ваша новая реализация. Это похоже на устаревшие методы в SDK, которые используются многими разными разработчиками и проектами.
Очевидно, что вы не обязаны это делать, но это может облегчить опробование async-await в вашем проекте. Кроме того, Xcode упрощает рефакторинг вашего кода, а также предоставляет возможность создать отдельный асинхронный метод:
Каждый метод рефакторинга имеет свою цель и приводит к различным преобразованиям кода. Чтобы лучше понять, как это работает, мы будем использовать следующий код в качестве входных данных для рефакторинга:
Преобразовать функцию в асинхронную
Первый вариант рефакторинга преобразует метод извлечения изображений в асинхронный вариант без сохранения неасинхронного варианта. Эта опция будет полезна, если вы не хотите поддерживать свою старую реализацию. Полученный код выглядит следующим образом:
struct ImageFetcher {
func fetchImages() async throws -> [UIImage] {
// .. perform data request
}
}
Добавить асинхронную альтернативу
Опция добавления альтернативного асинхронного рефакторинга гарантирует сохранение старой реализации, но заботится о добавлении доступного атрибута:
struct ImageFetcher {
@available(*, renamed: "fetchImages()")
func fetchImages(completion: @escaping (Result<[UIImage], Error>) -> Void) {
Task {
do {
let result = try await fetchImages()
completion(.success(result))
} catch {
completion(.failure(error))
}
}
}
func fetchImages() async throws -> [UIImage] {
// .. perform data request
}
}
Доступный атрибут полезен, чтобы знать, где вам нужно обновить свой код для нового варианта параллелизма. Тем не менее, реализация Xcode по умолчанию не содержит никаких предупреждений, поскольку она не помечена как устаревшая. Для этого вам нужно настроить доступный маркер следующим образом:
@available(*, deprecated, renamed: "fetchImages()")
Вы можете узнать больше о маркере доступности в моей статье Как использовать атрибут #available в Swift.
Преимущество использования этого варианта рефакторинга заключается в том, что он позволяет постепенно адаптироваться к новым структурным изменениям параллелизма без необходимости сразу преобразовывать весь проект. Промежуточное построение полезно, так как вы знаете, что изменения вашего кода работают должным образом. Реализации, использующие старый метод, получат следующее предупреждение:
Вы можете постепенно изменять свои реализации по всему проекту и использовать предоставленную кнопку исправления в Xcode для автоматического преобразования кода для использования новой реализации.
Добавить асинхронную оболочку
Последний метод рефакторинга приведет к самому простому преобразованию, поскольку он просто использует ваш существующий код:
struct ImageFetcher {
@available(*, renamed: "fetchImages()")
func fetchImages(completion: @escaping (Result<[UIImage], Error>) -> Void) {
// .. perform data request
}
func fetchImages() async throws -> [UIImage] {
return try await withCheckedThrowingContinuation { continuation in
fetchImages() { result in
continuation.resume(with: result)
}
}
}
}
Недавно добавленный метод использует метод withCheckedThrowingContinuation, который был введен в Swift для преобразования методов на основе замыкания без особых усилий. Методы без выдачи могут использовать withCheckedContinuation, который работает так же, но не поддерживает выдачу ошибок.
Эти два метода приостанавливают текущую задачу до тех пор, пока не будет вызвано данное закрытие, чтобы вызвать продолжение метода асинхронного ожидания. Другими словами: вы должны убедиться, что закрытие продолжения вызывается на основе обратного вызова вашего собственного метода, основанного на замыкании. В нашем примере это сводится к вызову продолжения с нашим значением результата, возвращаемым исходным обратным вызовом выборки изображений.
Выбор правильного метода рефакторинга async-await для вашего проекта
Этих трех вариантов рефакторинга должно быть достаточно, чтобы преобразовать ваш существующий код в асинхронные альтернативы. В зависимости от размера вашего проекта и количества времени, которое у вас есть на рефакторинг, вы можете выбрать другой вариант рефакторинга. Тем не менее, я бы настоятельно рекомендовал применять изменения постепенно, поскольку это позволяет изолировать измененные части, облегчая проверку того, работают ли ваши изменения должным образом.
Устранение ошибки «Ссылка на захваченный параметр «self» в параллельно выполняющемся коде»
Еще одна распространенная ошибка при работе с асинхронными методами:
«Ссылка на захваченный параметр «self» в параллельно выполняющемся коде»
В основном это означает, что мы пытаемся сослаться на неизменяемый экземпляр self. Другими словами, вы, скорее всего, ссылаетесь либо на неизменяемое свойство, либо на экземпляр, например структуру, как в следующем примере:
Эту ошибку можно исправить, сделав ваши свойства изменяемыми или изменив структуру на ссылочный тип, например класс.
Будет ли async await концом перечисления Result?
Мы видели, что асинхронные методы заменяют асинхронные методы, использующие обратные вызовы закрытия. Мы могли бы спросить себя, будет ли это концом перечисления Result в Swift. В конце концов, они нам больше не нужны, поскольку мы можем использовать операторы try-catch в сочетании с async-await.
Перечисление Result не исчезнет в ближайшее время, поскольку оно все еще используется во многих местах в проектах Swift. Тем не менее, я не удивлюсь, увидев, что он устарел, как только скорость принятия async-await становится выше. Лично я не использовал перечисление Result ни в каком другом месте, кроме обратных вызовов завершения. Как только я полностью использую async-await, я больше не буду использовать перечисление.