Конкурентность в Swift 6: async/await, акторы и структурированная конкурентность на практике
Если вы когда-нибудь ловили баг, связанный с гонкой данных, в три часа ночи — вы знаете, почему конкурентное программирование заслужило репутацию одной из самых сложных областей разработки. Гонки данных, взаимные блокировки, непредсказуемое поведение — всё это десятилетиями преследовало разработчиков на любой платформе.
Apple долгое время предлагала Grand Central Dispatch (GCD) как основной инструмент для работы с многопоточностью. Но с выходом Swift 5.5 и последующим развитием языка до версии 6.x мир конкурентности в Swift изменился навсегда. В этом руководстве мы подробно разберём все ключевые механизмы — от базового async/await до акторов, структурированной конкурентности и новейших изменений в Swift 6.2.
От GCD к современной конкурентности
Чтобы по-настоящему оценить современный подход Swift к конкурентности, стоит вспомнить, откуда мы пришли. Grand Central Dispatch, представленный Apple в 2009 году, был революционным для своего времени. Он абстрагировал управление потоками и предлагал удобную модель очередей:
// Классический подход с GCD
DispatchQueue.global(qos: .userInitiated).async {
let data = try? Data(contentsOf: url)
let image = UIImage(data: data!)
DispatchQueue.main.async {
self.imageView.image = image
}
}
Знакомо, правда? Проблемы этого подхода были многочисленны: вложенные замыкания (callback hell), отсутствие компиляторных гарантий безопасности потоков, невозможность простой отмены операций и сложность обработки ошибок. Разработчик мог легко забыть вернуться на главный поток, случайно захватить self сильной ссылкой или допустить гонку данных, о которой компилятор никак не предупреждал.
В 2021 году с выходом Swift 5.5 Apple представила совершенно новую модель конкурентности, основанную на async/await, акторах и структурированной конкурентности. И это не просто «более удобный синтаксис» — впервые в истории Swift компилятор получил возможность проверять корректность конкурентного кода ещё на этапе компиляции. Swift 6.0 сделал эти проверки обязательными, а Swift 6.2 значительно улучшил их доступность и эргономику.
Основы async/await
Фундаментом современной конкурентности в Swift является механизм async/await. Идея проста: функция, помеченная ключевым словом async, может приостанавливать своё выполнение, не блокируя текущий поток. Это позволяет системе эффективно использовать ограниченное число потоков для обработки множества асинхронных задач.
Объявление и вызов асинхронных функций
Асинхронная функция объявляется с помощью ключевого слова async после списка параметров и перед возвращаемым типом. Если функция может выбрасывать ошибки, порядок ключевых слов — async throws:
struct User: Codable, Sendable {
let id: Int
let name: String
let email: String
}
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
// Вызов асинхронной функции
func loadUserProfile() async {
do {
let user = try await fetchUser(id: 42)
print("Загружен пользователь: \(user.name)")
} catch {
print("Ошибка загрузки: \(error)")
}
}
Каждый вызов await — это точка приостановки (suspension point). В этой точке выполнение функции может быть приостановлено, а поток освобождён для другой работы. Когда асинхронная операция завершается, выполнение возобновляется — возможно, уже на другом потоке. Честно говоря, поначалу это может сбивать с толку, но к этому быстро привыкаешь.
Преобразование callback-функций в async/await
Существующий код на замыканиях (callbacks) можно преобразовать к async/await с помощью механизма continuations. Swift предоставляет несколько типов продолжений: withCheckedContinuation, withCheckedThrowingContinuation, а также их unchecked-варианты для участков, критичных к производительности:
// Старый API на основе callback
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
} else if let data = data {
completion(.success(data))
}
}.resume()
}
// Обёртка для async/await
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetchData { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
Важный момент: продолжение (continuation) должно быть вызвано ровно один раз. Вызовете дважды — краш. Не вызовете вообще — задача зависнет навечно. Вариант withCheckedContinuation добавляет проверки в режиме отладки и помогает обнаружить такие ошибки на этапе разработки.
Структурированная конкурентность
Одна из самых мощных (и, на мой взгляд, самых элегантных) концепций в Swift — структурированная конкурентность (structured concurrency). Суть в том, что дочерние задачи всегда привязаны к родительской, образуя иерархическое дерево. Родительская задача отменяется — автоматически отменяются и все потомки. Родительская задача завершается — она гарантированно дожидается завершения всех дочерних.
Красиво, не правда ли?
async let — параллельное выполнение
Самый простой способ запустить несколько задач параллельно — использовать async let. Это объявление создаёт дочернюю задачу, которая начинает выполняться немедленно:
func loadDashboard() async throws -> Dashboard {
// Все три запроса выполняются параллельно
async let user = fetchUser(id: currentUserId)
async let notifications = fetchNotifications()
async let recommendations = fetchRecommendations()
// Ожидаем результаты всех трёх запросов
return try await Dashboard(
user: user,
notifications: notifications,
recommendations: recommendations
)
}
В этом примере три сетевых запроса выполняются одновременно, а не последовательно. Если бы каждый запрос занимал по 500 мс, последовательное выполнение заняло бы 1500 мс, а параллельное — примерно 500 мс. И если любой из запросов выбрасывает ошибку, остальные автоматически отменяются.
TaskGroup — динамическое количество задач
Когда количество параллельных задач неизвестно заранее, на помощь приходит TaskGroup. Это особенно полезно, когда нужно обработать коллекцию элементов параллельно:
func fetchAllUsers(ids: [Int]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await self.fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
Обратите внимание: результаты приходят в порядке завершения, а не в порядке добавления задач. Если нужно сохранить исходный порядок, можно использовать словарь или кортежи с индексами:
func fetchAllUsersOrdered(ids: [Int]) async throws -> [User] {
try await withThrowingTaskGroup(of: (Int, User).self) { group in
for (index, id) in ids.enumerated() {
group.addTask {
let user = try await self.fetchUser(id: id)
return (index, user)
}
}
var indexedUsers: [(Int, User)] = []
for try await pair in group {
indexedUsers.append(pair)
}
return indexedUsers
.sorted { $0.0 < $1.0 }
.map { $0.1 }
}
}
Ограничение конкурентности в TaskGroup
По умолчанию TaskGroup запускает все добавленные задачи одновременно. Если у вас тысячи элементов, это запросто может привести к перегрузке сервера или исчерпанию ресурсов. К счастью, ограничить количество одновременных задач можно паттерном «скользящего окна»:
func downloadImages(urls: [URL], maxConcurrency: Int = 5) async throws -> [Data] {
try await withThrowingTaskGroup(of: (Int, Data).self) { group in
var results: [(Int, Data)] = []
var nextIndex = 0
// Запускаем начальную порцию задач
for _ in 0..<min(maxConcurrency, urls.count) {
let index = nextIndex
group.addTask {
let (data, _) = try await URLSession.shared.data(from: urls[index])
return (index, data)
}
nextIndex += 1
}
// По мере завершения задач добавляем новые
for try await result in group {
results.append(result)
if nextIndex < urls.count {
let index = nextIndex
group.addTask {
let (data, _) = try await URLSession.shared.data(from: urls[index])
return (index, data)
}
nextIndex += 1
}
}
return results.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
Этот паттерн гарантирует, что одновременно выполняется не более maxConcurrency запросов, при этом новые задачи запускаются сразу после завершения предыдущих — максимальная пропускная способность без перегрузки ресурсов.
Акторы: защита изменяемого состояния
Акторы — это ссылочные типы (подобно классам), которые обеспечивают безопасный доступ к своему изменяемому состоянию в конкурентной среде. Актор гарантирует, что в любой момент времени только одна задача обращается к его внутреннему состоянию, автоматически сериализуя все обращения.
По сути, это тот самый «потокобезопасный класс», о котором мы всегда мечтали, но который раньше приходилось собирать вручную из блокировок и очередей.
actor BankAccount {
let accountNumber: String
private(set) var balance: Decimal
init(accountNumber: String, initialBalance: Decimal) {
self.accountNumber = accountNumber
self.balance = initialBalance
}
func deposit(amount: Decimal) {
balance += amount
}
func withdraw(amount: Decimal) throws -> Decimal {
guard balance >= amount else {
throw BankError.insufficientFunds
}
balance -= amount
return amount
}
func transfer(amount: Decimal, to other: BankAccount) async throws {
guard balance >= amount else {
throw BankError.insufficientFunds
}
balance -= amount
await other.deposit(amount: amount)
}
}
При обращении к актору извне все вызовы его методов и свойств требуют ключевого слова await, поскольку вызывающая сторона может быть приостановлена в ожидании доступа:
let account = BankAccount(accountNumber: "12345", initialBalance: 1000)
let currentBalance = await account.balance
try await account.withdraw(amount: 200)
@MainActor
@MainActor — это глобальный актор, привязанный к главному потоку. Все операции, связанные с обновлением UI, должны выполняться на главном потоке, и @MainActor обеспечивает это на уровне типов:
@MainActor
class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
func loadUser() async {
isLoading = true
defer { isLoading = false }
do {
// fetchUser выполняется асинхронно, но результат
// обрабатывается гарантированно на главном потоке
user = try await APIService.shared.fetchUser(id: 42)
} catch {
errorMessage = error.localizedDescription
}
}
}
Когда весь класс помечен @MainActor, все его свойства и методы автоматически изолированы на главном потоке. Прощайте, ошибки обновления UI из фонового потока!
Пользовательские глобальные акторы
Помимо @MainActor, вы можете определять собственные глобальные акторы для организации работы конкретных подсистем:
@globalActor
actor DatabaseActor {
static let shared = DatabaseActor()
}
@DatabaseActor
class DatabaseManager {
private var connection: DatabaseConnection?
func executeQuery(_ query: String) async throws -> [Row] {
guard let connection = connection else {
throw DatabaseError.notConnected
}
return try await connection.execute(query)
}
func connect(to url: URL) async throws {
connection = try await DatabaseConnection(url: url)
}
}
Реентерабельность акторов: скрытая ловушка
Итак, перейдём к одной из самых коварных проблем при работе с акторами — реентерабельности (reentrancy). Актор гарантирует, что только одна задача выполняет код внутри него в любой момент. Но (и это большое «но») когда метод актора содержит точку приостановки (await), актор освобождается на время ожидания, и другая задача может войти в него и изменить состояние.
Пример проблемы
Рассмотрим классический пример — кэширование с использованием актора:
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func loadImage(from url: URL) async throws -> UIImage {
// Проверяем кэш
if let cached = cache[url] {
return cached
}
// ⚠️ Точка приостановки! Актор освобождается!
let (data, _) = try await URLSession.shared.data(from: url)
let image = UIImage(data: data)!
// К этому моменту другая задача уже могла загрузить
// то же самое изображение и поместить его в кэш.
cache[url] = image
return image
}
}
Если две задачи одновременно запрашивают одно и то же изображение, обе проверят кэш, обе обнаружат его пустым и обе начнут загрузку. Гонки данных тут не будет (актор от этого защищает), но работа продублируется. А на медленном соединении это особенно неприятно.
Решение проблемы реентерабельности
Эффективное решение — хранить не только результаты, но и текущие задачи загрузки:
actor ImageCache {
private var cache: [URL: UIImage] = [:]
private var inProgress: [URL: Task<UIImage, Error>] = [:]
func loadImage(from url: URL) async throws -> UIImage {
// Проверяем готовый кэш
if let cached = cache[url] {
return cached
}
// Проверяем, не загружается ли уже это изображение
if let existingTask = inProgress[url] {
return try await existingTask.value
}
// Создаём новую задачу загрузки
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
return image
}
inProgress[url] = task
do {
let image = try await task.value
cache[url] = image
inProgress[url] = nil
return image
} catch {
inProgress[url] = nil
throw error
}
}
}
Теперь, если две задачи запрашивают одно изображение, вторая просто присоединится к уже выполняющейся загрузке через existingTask.value. Никакого дублирования.
Золотое правило: после каждой точки await внутри актора предполагайте, что любое его состояние могло измениться. Всегда перепроверяйте условия после приостановки.
Протокол Sendable
Протокол Sendable — краеугольный камень безопасности конкурентности в Swift. Он маркирует типы, экземпляры которых можно безопасно передавать между конкурентными контекстами (между акторами, задачами и т.д.).
Какие типы являются Sendable
- Типы-значения (структуры, перечисления), все хранимые свойства которых тоже
Sendable, автоматически соответствуют протоколу. - Акторы всегда являются
Sendable, поскольку доступ к их состоянию сериализован. - Классы, помеченные
final, с неизменяемыми (let) иSendable-свойствами, тоже могут соответствовать. - Базовые типы (
Int,String,Bool,Double) —Sendableпо умолчанию.
@Sendable замыкания
Замыкания, передаваемые между конкурентными контекстами, должны быть помечены @Sendable. Такие замыкания не могут захватывать изменяемые переменные из окружающего контекста:
func processItems(_ items: [Item]) async {
await withTaskGroup(of: Void.self) { group in
for item in items {
// Замыкание, передаваемое в addTask, неявно является @Sendable
group.addTask {
await self.process(item) // item должен быть Sendable
}
}
}
}
// Явное указание @Sendable
let handler: @Sendable () -> Void = {
print("Это замыкание безопасно для передачи между потоками")
}
@unchecked Sendable
Иногда тип не может автоматически соответствовать Sendable, но вы точно знаете, что он безопасен — скажем, внутренняя синхронизация реализована через блокировки. В таких случаях пригодится @unchecked Sendable:
final class ThreadSafeCache<Key: Hashable, Value>: @unchecked Sendable {
private var storage: [Key: Value] = [:]
private let lock = NSLock()
subscript(key: Key) -> Value? {
get {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
set {
lock.lock()
defer { lock.unlock() }
storage[key] = newValue
}
}
}
Будьте осторожны: @unchecked Sendable — это, по сути, обещание компилятору, что вы самостоятельно обеспечиваете потокобезопасность. Нарушите обещание — компилятор не спасёт от гонки данных. Используйте только когда действительно уверены.
Swift 6.2 и «Доступная конкурентность» (Approachable Concurrency)
Swift 6.0 сделал проверку безопасности данных обязательной. И, скажем прямо, это вызвало лавину предупреждений и ошибок компилятора во многих проектах. Разработчики, чей код был фактически однопоточным, вдруг были вынуждены разбираться с Sendable, изоляцией акторов и прочими сложностями, не имевшими отношения к их задачам.
Swift 6.2 кардинально меняет этот подход, вводя концепцию «доступной конкурентности» (Approachable Concurrency).
Изоляция MainActor по умолчанию (SE-0466)
Ключевое нововведение — возможность сделать @MainActor изоляцией по умолчанию для всего модуля. Весь код в модуле автоматически выполняется на главном потоке, если явно не указано иное. Для типичного iOS-приложения это идеально — большая часть кода и так должна работать на главном потоке.
Включается в Package.swift:
// Package.swift
.target(
name: "MyApp",
swiftSettings: [
.defaultIsolation(MainActor.self)
]
)
С этой настройкой следующий код работает без единой аннотации @MainActor:
// Всё автоматически изолировано на MainActor
class ViewModel: ObservableObject {
@Published var items: [String] = []
@Published var isLoading = false
func refresh() async {
isLoading = true
defer { isLoading = false }
items = try await APIService.shared.fetchItems()
}
}
nonisolated(nonsending) — новое поведение по умолчанию
В прежних версиях Swift асинхронная функция с nonisolated могла неожиданно переключиться на фоновый поток. Это было источником множества ошибок (особенно при работе с UI). Swift 6.2 вводит модификатор nonisolated(nonsending), который становится поведением по умолчанию.
Суть: асинхронная функция без явной изоляции теперь наследует контекст вызывающей стороны. Вызвали с @MainActor — выполнится на главном потоке. Вызвали из фонового контекста — выполнится там. Функция больше не «убегает» на произвольный поток:
// Swift 6.2: функция наследует контекст вызывающей стороны
func processData(_ data: Data) async -> ProcessedResult {
// Если вызвана из @MainActor — выполнится на главном потоке
// Если вызвана из фоновой задачи — выполнится в том же контексте
let result = transform(data)
return result
}
Атрибут @concurrent
А когда вам действительно нужно, чтобы функция выполнялась в фоне параллельно с вызывающим кодом? Swift 6.2 вводит явный атрибут @concurrent:
// Явно указываем: эта функция должна работать в фоне
@concurrent
func performHeavyComputation(data: [Double]) async -> Double {
// Гарантированно выполняется вне MainActor
// Параметры должны быть Sendable
var sum = 0.0
for value in data {
sum += value * value
}
return sum.squareRoot()
}
Вот это и есть прогрессивное раскрытие (progressive disclosure) в действии. На первом уровне разработчик пишет обычный синхронный код на главном потоке. На втором — подключает async/await, оставаясь в контексте вызывающей стороны. И только на третьем, когда действительно нужна параллельность, он помечает функцию @concurrent и берёт на себя ответственность за Sendable-совместимость.
Практические паттерны
Теория — это хорошо, но давайте посмотрим на реальные паттерны, с которыми вы столкнётесь в боевых приложениях.
Паттерн: обновление токена авторизации с помощью актора
Классическая задача — когда токен авторизации протух, его нужно обновить ровно один раз, даже если несколько запросов одновременно обнаружили проблему:
actor AuthManager {
private var accessToken: String?
private var refreshToken: String?
private var refreshTask: Task<String, Error>?
func validToken() async throws -> String {
// Если есть действующий токен — возвращаем
if let token = accessToken, !isExpired(token) {
return token
}
// Обновление уже идёт — присоединяемся
if let existingTask = refreshTask {
return try await existingTask.value
}
// Начинаем обновление
let task = Task { () -> String in
guard let refresh = refreshToken else {
throw AuthError.notAuthenticated
}
let newTokens = try await APIService.shared.refreshToken(refresh)
return newTokens.accessToken
}
refreshTask = task
do {
let newToken = try await task.value
self.accessToken = newToken
self.refreshTask = nil
return newToken
} catch {
self.refreshTask = nil
self.accessToken = nil
self.refreshToken = nil
throw error
}
}
private func isExpired(_ token: String) -> Bool {
guard let payload = decodeJWT(token),
let exp = payload["exp"] as? TimeInterval else {
return true
}
return Date().timeIntervalSince1970 >= exp
}
}
Этот паттерн элегантно решает сразу несколько проблем: предотвращает множественные запросы на обновление, обеспечивает потокобезопасный доступ и корректно обрабатывает ошибки. Я использовал его в нескольких проектах, и он ни разу не подвёл.
Паттерн: параллельные API-запросы с обработкой ошибок
Часто нужно загрузить данные из нескольких источников, причём сбой одного запроса не должен блокировать остальные:
struct DashboardData {
var profile: UserProfile?
var orders: [Order]
var recommendations: [Product]
var notifications: [Notification]
}
func loadDashboard() async -> DashboardData {
async let profileResult = Result { try await fetchProfile() }
async let ordersResult = Result { try await fetchOrders() }
async let recommendationsResult = Result { try await fetchRecommendations() }
async let notificationsResult = Result { try await fetchNotifications() }
let profile = try? await profileResult.get()
let orders = (try? await ordersResult.get()) ?? []
let recommendations = (try? await recommendationsResult.get()) ?? []
let notifications = (try? await notificationsResult.get()) ?? []
return DashboardData(
profile: profile,
orders: orders,
recommendations: recommendations,
notifications: notifications
)
}
Все четыре запроса идут параллельно. Один упал? Не страшно, остальные продолжат работу. Для пользователя это означает, что экран загрузится быстро, даже если один из сервисов тормозит.
Паттерн: ограничение конкурентности при массовой загрузке
Вот более продвинутый вариант «скользящего окна» с отслеживанием прогресса и поддержкой отмены:
actor DownloadManager {
private var activeTasks = 0
private let maxConcurrent: Int
init(maxConcurrent: Int = 4) {
self.maxConcurrent = maxConcurrent
}
func downloadAll(
urls: [URL],
onProgress: @Sendable (Int, Int) -> Void
) async throws -> [URL: Data] {
try await withThrowingTaskGroup(of: (URL, Data).self) { group in
var results: [URL: Data] = [:]
var completed = 0
var iterator = urls.makeIterator()
// Заполняем начальный пул задач
for _ in 0..<min(maxConcurrent, urls.count) {
guard let url = iterator.next() else { break }
group.addTask { [url] in
try Task.checkCancellation()
let (data, _) = try await URLSession.shared.data(from: url)
return (url, data)
}
}
// Обрабатываем результаты и добавляем новые задачи
for try await (url, data) in group {
results[url] = data
completed += 1
onProgress(completed, urls.count)
if let nextURL = iterator.next() {
group.addTask { [nextURL] in
try Task.checkCancellation()
let (data, _) = try await URLSession.shared.data(from: nextURL)
return (nextURL, data)
}
}
}
return results
}
}
}
Миграция с GCD на Swift Concurrency
Переход с GCD на современную модель конкурентности — это не тот процесс, который стоит делать за один вечер. Нужно планирование и понимание ключевых различий.
Пошаговый подход к миграции
- Начните снизу. Оборачивайте существующие callback-API в async/await через
withCheckedThrowingContinuation. Новый код сможет использовать async/await, а внутренняя реализация остаётся нетронутой. - Замените DispatchQueue.main.async на @MainActor. Вместо ручного переключения на главную очередь используйте аннотацию для классов и функций, работающих с UI.
- Замените общие мутабельные состояния на акторы. Класс с блокировками или последовательной очередью? Самое время превратить его в актор.
- Замените DispatchGroup на TaskGroup. Паттерн «запустить несколько задач и дождаться всех» естественно выражается через
withTaskGroupилиasync let. - Замените DispatchWorkItem на Task. Задачи в Swift Concurrency поддерживают кооперативную отмену из коробки.
Типичные ошибки при миграции
Ошибка 1: Блокировка потока внутри async-функции. Нельзя использовать семафоры, блокировки или DispatchQueue.sync внутри асинхронных функций — это может привести к deadlock из-за ограниченного пула потоков кооперативного исполнителя:
// ❌ НЕПРАВИЛЬНО: блокировка потока в async-контексте
func fetchData() async -> Data {
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data
semaphore.signal()
}.resume()
semaphore.wait() // ⚠️ Блокирует поток кооперативного пула!
return result!
}
// ✅ ПРАВИЛЬНО: использование continuation
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
Ошибка 2: Неструктурированные задачи повсюду. Многие разработчики, привыкшие к GCD, лепят Task { } на каждом шагу, теряя все преимущества структурированной конкурентности — автоматическую отмену, распространение ошибок и гарантии времени жизни:
// ❌ НЕПРАВИЛЬНО: неструктурированная задача без необходимости
func loadImages(urls: [URL]) async -> [UIImage] {
var images: [UIImage] = []
for url in urls {
let task = Task {
try await downloadImage(from: url)
}
if let image = try? await task.value {
images.append(image)
}
}
return images // Последовательная загрузка, не параллельная!
}
// ✅ ПРАВИЛЬНО: структурированная конкурентность
func loadImages(urls: [URL]) async -> [UIImage] {
await withTaskGroup(of: UIImage?.self) { group in
for url in urls {
group.addTask {
try? await downloadImage(from: url)
}
}
var images: [UIImage] = []
for await image in group {
if let image { images.append(image) }
}
return images
}
}
Ошибка 3: Игнорирование кооперативной отмены. В отличие от DispatchWorkItem.cancel(), отмена задачи в Swift Concurrency — кооперативная. Задача не прекращает выполнение автоматически, нужно периодически проверять статус:
func processLargeDataset(_ items: [Item]) async throws -> [Result] {
var results: [Result] = []
for item in items {
// Проверяем отмену перед каждой итерацией
try Task.checkCancellation()
let result = await process(item)
results.append(result)
}
return results
}
Ошибка 4: Избыточное использование Task.detached. Task.detached создаёт полностью отвязанную задачу — без наследования приоритета или контекста актора. В подавляющем большинстве случаев обычный Task { } или структурированная конкурентность подходят лучше.
Ошибка 5: Неправильная работа с AsyncSequence. При итерации по AsyncStream текущая задача блокируется до получения следующего элемента. Две последовательные итерации — и вторая просто никогда не начнётся:
// ❌ НЕПРАВИЛЬНО: две последовательности обрабатываются последовательно
func monitor() async {
for await event in eventStream { // Блокирует здесь
handleEvent(event)
}
for await metric in metricStream { // Никогда не дойдём сюда!
handleMetric(metric)
}
}
// ✅ ПРАВИЛЬНО: параллельная обработка через TaskGroup
func monitor() async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
for await event in self.eventStream {
self.handleEvent(event)
}
}
group.addTask {
for await metric in self.metricStream {
self.handleMetric(metric)
}
}
}
}
Советы для плавной миграции
- Мигрируйте постепенно. Не пытайтесь переписать весь проект разом. Начните с одного модуля или фичи.
- Используйте Swift 6.2 с MainActor по умолчанию. Это существенно уменьшит количество ошибок компилятора при миграции.
- Включите strict concurrency checking в режиме warning. Так вы увидите проблемы, не блокируя сборку.
- Пишите тесты. Конкурентный код особенно важно тестировать — ошибки могут проявляться недетерминированно.
- Документируйте изоляцию. Для каждого публичного API чётко указывайте, на каком акторе он выполняется.
- Используйте инструменты Xcode. Thread Sanitizer (TSan) и Instruments помогут найти гонки данных, которые компилятор не в состоянии поймать (особенно в
@unchecked Sendableтипах).
Заключение
Модель конкурентности Swift прошла впечатляющий путь — от GCD и замыканий до системы, где безопасность гарантируется компилятором. Вот что стоит запомнить:
- async/await — фундамент. Асинхронный код в линейном стиле, без вложенных замыканий и ручного управления потоками.
- Структурированная конкурентность (
async let,TaskGroup) даёт иерархическое управление задачами с автоматической отменой и распространением ошибок. - Акторы защищают состояние от гонок данных, но помните о реентерабельности — после каждого
awaitсостояние может измениться. - Sendable гарантирует безопасность передачи данных между контекстами.
@unchecked Sendable— только когда вы точно знаете, что делаете. - Swift 6.2 с «доступной конкурентностью» делает всё это проще:
@MainActorпо умолчанию,nonisolated(nonsending)и явный@concurrent— это прогрессивное раскрытие сложности в лучшем виде.
Миграция с GCD на Swift Concurrency — это инвестиция, которая окупается надёжностью и читаемостью кода. Начните с оборачивания callback-API, постепенно внедряйте акторы и используйте структурированную конкурентность. С каждой версией Swift конкурентная модель становится всё зрелее — и сейчас самое время полностью освоить её возможности.