Конкурентность и многопоточность в Swift: продвинутый уровень
Современные приложения для iOS и macOS требуют высокой производительности и отзывчивости интерфейса. Продвинутая работа с конкурентностью в Swift выходит далеко за рамки простого использования Grand Central Dispatch (GCD). В этой статье мы рассмотрим ключевые инструменты Swift 5.5+ для управления асинхронными задачами: async/await, Actors, TaskGroup и протокол Sendable. Вы узнаете, как избежать гонок данных, правильно структурировать многопоточный код и оптимизировать производительность.
1. Основы async/await и Structured Concurrency
Ключевые слова async и await — это основа современной конкурентности в Swift. Они позволяют писать асинхронный код, который выглядит как синхронный, что значительно упрощает чтение и отладку.
Объявление асинхронной функции:
func fetchUserData() async throws -> User {
let url = URL(string: "https://api.example.com/user")!
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
return try decoder.decode(User.self, from: data)
}
Вызов асинхронной функции с обработкой ошибок:
Task {
do {
let user = try await fetchUserData()
print("User name: \(user.name)")
} catch {
print("Failed to fetch user: \(error)")
}
}
Важные моменты:
- Structured Concurrency: Каждая асинхронная задача имеет четкое время жизни, ограниченное областью видимости родительского
Task. - Отмена задач: Автоматическая отмена дочерних задач при отмене родительского Task.
- Приоритеты: Используйте
Task(priority: .userInitiated)для задач, важных для пользователя.
2. Actors: защита от гонок данных
До Swift 5.5 разработчики часто использовали сериализованные очереди GCD для защиты общего состояния. Actors предоставляют встроенную изоляцию: доступ к состоянию actor'а возможен только через await, что гарантирует потокобезопасность на уровне компилятора.
Пример Actor для банковского счета:
actor BankAccount {
private var balance: Double = 0.0
func deposit(amount: Double) {
balance += amount
}
func withdraw(amount: Double) throws {
guard balance >= amount else {
throw BankError.insufficientFunds
}
balance -= amount
}
func getBalance() -> Double {
return balance
}
}
// Использование
let account = BankAccount()
Task {
await account.deposit(amount: 100.0)
let currentBalance = await account.getBalance()
print("Balance: \(currentBalance)")
}
Преимущества Actors:
- Компиляторная проверка: Попытка доступа к свойству actor'а без
awaitвызовет ошибку компиляции. - Повторный вход (Reentrancy): Actor может приостановить выполнение текущего метода (например, при
awaitвнутри actor'а) и обработать другой вызов, что предотвращает deadlocks. - Производительность: Actors используют оптимизированные механизмы синхронизации, часто быстрее ручных блокировок.
3. TaskGroup: параллельная обработка данных
Когда нужно выполнить несколько независимых асинхронных операций и дождаться их завершения, TaskGroup — идеальный инструмент. Он автоматически управляет количеством параллельных задач и собирает результаты.
Пример загрузки нескольких изображений параллельно:
func downloadImages(urls: [URL]) async throws -> [UIImage] {
return try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
return image
}
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
Ключевые особенности TaskGroup:
- Ограничение параллелизма: По умолчанию Swift использует оптимальное количество потоков, но вы можете добавить логику для ограничения (например, через семафоры).
- Обработка ошибок: Используйте
withThrowingTaskGroupдля проброса ошибок; если одна задача выбросит ошибку, все остальные задачи будут автоматически отменены. - Сбор результатов в порядке завершения:
for try awaitобрабатывает результаты по мере их готовности, а не в порядке добавления.
4. Sendable и MainActor: безопасное взаимодействие с UI
Протокол Sendable гарантирует, что данные можно безопасно передавать между изолированными доменами (например, между Actor и Task). @MainActor — это специальный глобальный actor, который гарантирует выполнение кода на главном потоке.
Пример использования @MainActor и Sendable:
// Структура, которую можно безопасно передавать между потоками
struct UserData: Sendable {
let id: Int
let name: String
}
class ViewModel: ObservableObject {
@Published var users: [UserData] = []
@MainActor
func updateUI(with newUsers: [UserData]) {
// Этот метод гарантированно выполняется на главном потоке
self.users = newUsers
}
func loadUsers() async {
let fetchedUsers = await fetchUsersFromAPI()
// Вызов @MainActor функции из фонового контекста
await updateUI(with: fetchedUsers)
}
}
// Функция, возвращающая Sendable данные
func fetchUsersFromAPI() async -> [UserData] {
// ... асинхронный запрос
return [UserData(id: 1, name: "Alice")]
}
Рекомендации:
- Помечайте пользовательские структуры и классы как
Sendable, если они содержат только value-типы или thread-safe reference-типы. - Используйте
@MainActorдля обновления UI, чтобы избежать race conditions. - Избегайте передачи замыканий, которые захватывают не-Sendable объекты, между доменами.