Привет! Меня зовут Андрей Максимкин, я iOS-разработчик в hh. Мы в команде активно используем async/await подход при написании нового кода, а также активно применяем при переписывании старого. В процессе работы сталкивались с некоторыми интересными и не самыми очевидными моментами — их и рассмотрим в статье. А пока начнём с небольшого введения.
Работа с различными потоками — очень важная часть разработки мобильных приложений под iOS. Грамотное распределение нагрузки положительно влияет на скорость работы приложения, а значит, и на пользовательский опыт. До Swift 5.5 для работы с многопоточностью в основном использовали фреймворки GCD и NSOperation. Начиная с версии Swift 5.5 стал доступен функционал async/await. В статье мы кратко расскажем о базовых принципах данного подхода и сделаем акцент на проблемах и особенностях, которые необходимо знать при написании кода. Информация будет полезна тем, кто уже знаком с функционалом async/await, а некоторые примеры могут быть интересны и более продвинутым разработчикам.
Введение
Итак, async/await — это подход, который позволяет работать с асинхронным кодом. При этом он делает код более удобным для чтения и написания, избавляя от необходимости использовать сложные замыкания и коллбеки. Перед написанием кода рассмотрим основные моменты async/await.
Асинхронные функции объявляются с помощью ключевого слова async. Такие функции могут приостанавливать своё выполнение, ожидая результата других асинхронных операций.
Для вызова асинхронной функции используется ключевое слово await, что позволяет приостановить выполнение до получения результата.
Task — это структура, которая предоставляет замыкание для работы, которая должна быть выполнена. Вы можете дождаться завершения задачи или отменить её.
Асинхронные функции могут выбрасывать ошибки, поэтому их вызов часто оборачивается в блок do-catch.
Давайте проверим на примере, действительно ли код, написанный с использование async/await, более простой. Предположим, у нас есть две асинхронные операции: загрузка данных из сети и обработка этих данных. В случае использования GCD, код может выглядеть следующим образом:
func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
DispatchQueue.global().async {
// Имитация сетевой задержки
sleep(2)
// Допустим, произошла ошибка
let errorOccurred = true
if errorOccurred {
completion(.failure(NSError(domain: "NetworkError", code: -1, userInfo: nil)))
} else {
completion(.success("Данные загружены"))
}
}
}
func processData(data: String, completion: @escaping (Result<String, Error>) -> Void) {
DispatchQueue.global().async {
// Имитация обработки данных
sleep(1)
completion(.success("Обработанные данные: \(data)"))
}
}
// Использование GCD
fetchData { result in
switch result {
case .success(let data):
processData(data: data) { result in
switch result {
case .success(let processedData):
print(processedData)
case .failure(let error):
print("Ошибка при обработке данных: \(error)")
}
}
case .failure(let error):
print("Ошибка при загрузке данных: \(error)")
}
}
Теперь давайте перепишем тот же код, используя async/await:
enum NetworkError: Error {
case networkError
}
func fetchData() async throws -> String {
// Имитация сетевой задержки
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 секунды
// Допустим, произошла ошибка
let errorOccurred = true
if errorOccurred {
throw NetworkError.networkError
}
return "Данные загружены"
}
func processData(data: String) async throws -> String {
// Имитация обработки данных
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 секунда
return "Обработанные данные: \(data)"
}
// Использование async/await
Task {
do {
let data = try await fetchData()
let processedData = try await processData(data: data)
print(processedData)
} catch {
print("Ошибка: \(error)")
}
}
Даже при беглом взгляде кажется, что код, написанный с использованием async/await, обладает рядом преимуществ.
1. Читаемость. Код выглядит более линейным и понятным. Он легче воспринимается, поскольку выглядит как синхронный код.
2. Обработка ошибок. В async/await ошибки обрабатываются с помощью try/catch, что делает их более очевидными. В GCD нужно использовать замыкания и проверять ошибки вручную, обрабатывая отдельный case .failure.
3. Избежание вложенности. В примере с GCD мы видим вложенные замыкания, что затрудняет чтение и восприятие кода. В async/await такой проблемы нет.
4. Управление потоком выполнения. async/await позволяет проще управлять потоком выполнения и возвращаться к основному потоку без дополнительных усилий.
Особенности использования
В примере выше использовалась такая сущность как Task. Task — очень важная часть Swift Concurrency. Все асинхронные функции выполняются только в рамках Task, в рамках обычной последовательной функции использовать асинхронную не получится

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

При работе с async/await мы периодически сталкивались с проблемами, которые нам казались непривычными, а иногда и непонятными. Рассмотрим некоторые случаи.
Deadlock(Взаимная блокировка)/Livelock(Активная блокировка)
Одна из самых известных проблем при работе с асинхронным кодом.
Мне не удалось воспроизвести deadlock, но livelock воспроизводится достаточно просто. Рассмотрим следующий пример:
// Функция A, которая вызывает функцию B
func functionA() async {
print("Function A: Waiting for Function B")
await functionB()
print("Function A: Finished")
}
// Функция B, которая вызывает функцию A
func functionB() async {
print("Function B: Waiting for Function A")
await functionA()
print("Function B: Finished")
}
// Основная функция для запуска
func main() async {
await functionA()
}
// Запуск программы
Task {
await main()
}
1. Функция functionA ожидает завершения functionB.
2. Функция functionB, в свою очередь, ожидает завершения functionA.
Когда вы запускаете этот код, он застрянет в бесконечном взаимном вызове функций, поскольку functionA ожидает functionB, а functionB ожидает functionA — это создаёт livelock.
В сравнении с deadlock проблема кажется не такой страшной. В данном случае функции продолжат бесконечно вызывать друг друга. Краша приложения не произойдёт, но старайтесь избегать взаимных вызовов между асинхронными функциями. На практике такие простые примеры редко бывают, обычно в цепочке участвуют ещё несколько посредников.
Priority inversion — ещё одна проблема многопоточного программирования.
Priority inversion (инверсия приоритетов) — ситуация в многопоточной среде, когда низкоприоритетный поток (или задача) удерживает ресурсы, необходимые для выполнения высокоприоритетного потока. Это может привести к тому, что высокоприоритетный поток не сможет быть выполнен вовремя, даже если он имеет более высокий приоритет.
Перед тем, как шагнуть дальше, изучим функцию Task.yield().
Согласно документации Apple, Task.yield() может приостановить выполнение Task, чтобы позволить другим задачам работать некоторое время, прежде чем выполнение вернется к этой Task. Если Task имеет наивысший приоритет, то исполнение продолжится, а приостановки не будет.
Теперь рассмотрим пример, где class SharedResource — это некий общий ресурс, к которому пытаются обратиться несколько тасок, которые имеют разные приоритеты выполнения.
// Ресурс, который будет заблокирован
class SharedResource {
var isLocked = false
func accessResource(by taskName: String) async throws {
while isLocked {
await Task.yield() // Ожидание освобождения ресурса
}
isLocked = true
print("\(taskName) is accessing the shared resource.")
try await Task.sleep(nanoseconds: 2 * 1_000_000_000) // Имитация работы с ресурсом
isLocked = false
print("\(taskName) has released the shared resource.")
}
}
// Основная функция, где происходит инверсия приоритетов
@MainActor
func runTasks() async {
let resource = SharedResource()
// Задача высокого приоритета
let userInitiatedPriorityTask = Task(priority: .userInitiated) {
try await resource.accessResource(by: "UserInitiated Priority Task")
}
// Задача среднего приоритета
let mediumPriorityTask = Task(priority: .medium) {
try await resource.accessResource(by: "Medium Priority Task")
}
// Задача низкого приоритета, которая блокирует ресурс
let lowPriorityTask = Task(priority: .low) {
try await resource.accessResource(by: "Low Priority Task")
}
// Ожидание завершения всех задач
try? await userInitiatedPriorityTask.value
try? await mediumPriorityTask.value
try? await lowPriorityTask.value
}
// Запуск основной функции
Task {
await runTasks()
}
Здесь у нас есть три таски с приоритетами выполнения .userInitiated, .medium, .low. Скомпилируем код и посмотрим, что выводится в консоль. Получается, при каждом запуске результат может отличаться. Например, быть таким:
UserInitiated Priority Task is accessing the shared resource.
Low Priority Task is accessing the shared resource.
UserInitiated Priority Task has released the shared resource.
Medium Priority Task is accessing the shared resource.
Low Priority Task has released the shared resource.
Medium Priority Task has released the shared resource.
Возникает такая ситуация, что задача с более низким приоритетом захватывает общий ресурс (SharedResource), что блокирует доступ к нему для задач с более высоким приоритетом. В то время как задача с более низким приоритетом удерживает ресурс, задачи с более высоким приоритетом ждут. Это может привести к ситуации, когда высокоприоритетная задача не может выполниться вовремя. Из-за этого происходит инверсия приоритетов — низкоприоритетная задача удерживает ресурс, необходимый для выполнения высокоприоритетной задачи. Такого рода проблемы решают акторы (actors).
Actors — это ссылочный тип, концептуально похожий на класс. Предназначен для безопасного доступа к общему изменяемому состоянию в многопоточной среде. Согласно документации Apple, акторы по умолчанию выполняют задачи в глобальном пуле потоков (global concurrency thread pool). Глобальный пул потоков — это набор потоков, которые могут быть повторно использованы для выполнения задач. Вместо создания нового потока для каждой задачи система может взять существующий поток из пула, что экономит ресурсы и время на создание и уничтожение потоков.
А чтобы наш код заработал корректно, достаточно заменить class SharedResource на actor SharedResource и при выполнении система сама выстроит очередь в зависимости от приоритетов задач.
Заменяем, получаем:
UserInitiated Priority Task is accessing the shared resource.
UserInitiated Priority Task has released the shared resource.
Medium Priority Task is accessing the shared resource.
Medium Priority Task has released the shared resource.
Low Priority Task is accessing the shared resource.
Low Priority Task has released the shared resource.
Таски и Акторы
Ещё хотелось бы рассмотреть несложный пример, когда мы внутри Task вызываем асинхронную функцию.
class AsyncClassA {
func asyncFunctionA() async {
Task { @MainActor in
let asyncClassB = AsyncClassB()
// 1
await asyncClassB.asyncFunctionB()
}
}
}
class AsyncClassB {
func asyncFunctionB() async {
// 2
print(#function)
}
}
class ViewController: UIViewController {
let asyncClassA = AsyncClassA()
override func viewDidLoad() {
super.viewDidLoad()
Task {
await asyncClassA.asyncFunctionA()
}
}
}
Итак, вопрос: на каком потоке будет выполняться задача в точках 1 и 2? Ответ посмотрим на скриншотах ниже.


В первом случае ожидаемо функция выполняется на главном потоке, а вот во втором — нет. На первый взгляд, это может показаться нелогичным, особенно тем, кто привык работать с GCD. На самом деле, всё ожидаемо и вот почему.
Асинхронные методы не наследуют контекст вызывающего актора. Контекст, используемый функцией asyncFunctionB, определяется класcом AsyncClassB, в котором отсутствует квалификатор @MainActor, и самой функцией, в которой также отсутствует этот квалификатор. Нет никаких причин, по которым AsyncClassB будет запускаться на главном потоке.
Вложенные таски
А теперь рассмотрим пример со вложенными тасками, там тоже есть интересный момент.
func runTasks() async {
Task(priority: .high) {
print("external", Task.currentPriority.rawValue)
await Task(priority: .low) {
print("internal", Task.currentPriority.rawValue)
}.value
}
}
Task {
await runTasks()
}
Какой приоритет будет у internal таски? Проверим консоль
external 25
internal 25
Компилятор повысил приоритет internal таски до high. А теперь поменяем местами приоритеты тасок и посмотрим, что получится:
func runTasks() async {
Task(priority: .low) {
print("external", Task.currentPriority.rawValue)
await Task(priority: .high) {
print("internal", Task.currentPriority.rawValue)
}.value
}
}
Task {
await runTasks()
}
external 17
internal 25
Здесь уже без неожиданностей, у каждой таски именно тот приоритет, который мы задали изначально. А теперь немного поправим наш код, добавив использование taskGroup:
func runTasks() async {
Task(priority: .low) {
print("external", Task.currentPriority.rawValue)
await withTaskGroup(of: Void.self) { group in
group.addTask(priority: .high) {
print("internal", Task.currentPriority.rawValue)
}
}
}
}
Task {
await runTasks()
}
external 17
internal 17

Что тут происходит вообще? Теперь приоритет таски internal поменялся на low. Получается так, что Swift использует систему управления задачами, которая учитывает различные факторы при планировании выполнения задач. Наша маркировка — лишь один из факторов, который может не давать 100% гарантии. Таким образом, когда вы запускаете внутреннюю задачу с высоким приоритетом внутри задачи с низким приоритетом, она может по-прежнему исполняться с низким приоритетом из-за контекста родительской задачи.
MainActor
Рассмотрим пример c MainActor:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
DispatchQueue.global().async {
self.functionA()
}
}
func functionA() {
functionB()
}
@MainActor
func functionB() {
print(#function)
}
}
Вопрос: в каком потоке будет выполняться functionB? Запустим код и проверим:

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

Заключение
Таким образом, использование async/await в Swift значительно упрощает работу с асинхронным кодом и делает его более удобным для чтения и поддержки. Мы в hh уже повсеместно используем async/await в коде, но с пониманием относимся к тем, кто всё ещё размышляет, стоит ли переходить на него. Конечно, по-прежнему не существует серебряной пули, которая решит все проблемы с многопоточностью (livelock, race condition, data race и др.), а также с консистентностью и читаемостью кода. Однако, async/await — мощный механизм, который значительно расширяет наши возможности. Знание тонкостей его работы позволяет допускать меньше ошибок при работе с потоками, а значит, делать наши приложения быстрее и удобнее для пользователей.