Лекция 13. Конкурентность и асинхронное программирование
1. Введение
Современные программы редко выполняют только одно действие. Серверное приложение обрабатывает тысячи запросов, утилита командной строки параллельно читает файлы и обрабатывает данные. Swift, начиная с версии 5.5, предоставляет встроенную модель структурированной конкурентности с async/await, задачами (Task), группами задач и акторами.
2. Проблемы конкурентного выполнения
Состояние гонки (Race Condition) — результат зависит от порядка выполнения задач:
var counter = 0// Задача 1: читает counter (0), прибавляет 1, записывает 1// Задача 2: читает counter (0), прибавляет 1, записывает 1// Ожидание: counter == 2, реальность: counter == 1Deadlock — задача A ждёт ресурс задачи B, а B ждёт ресурс A. Обе заблокированы навсегда.
Data Race — несколько потоков одновременно обращаются к одной памяти, хотя бы один пишет. Результат — неопределённое поведение.
В Python GIL частично защищает от data race в потоках, но не устраняет race condition. Swift не имеет GIL — вся ответственность лежит на разработчике и системе типов.
3. Синхронное vs асинхронное выполнение
Синхронный код блокирует поток до завершения каждой операции. Асинхронный — приостанавливается, освобождая поток для других задач:
// Синхронно — каждая строка ждёт предыдущуюfunc syncWork() { let a = computeA(); let b = computeB(); print(a + b) }
// Асинхронно — приостанавливается, поток свободенfunc asyncWork() async { let a = await computeA() let b = await computeB() print(a + b)}Сравнение с Python:
async def async_work(): a = await compute_a() # приостанавливается b = await compute_b() print(a + b)await — точка приостановки (suspension point). После неё выполнение может продолжиться в другом потоке.
4. Определение асинхронных функций
Функция помечается async перед стрелкой возврата. Порядок при ошибках: async throws:
func fetchData() async -> String { try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 секунда return "Данные загружены"}
enum FileError: Error { case notFound, permissionDenied }
func loadFile(path: String) async throws -> String { try? await Task.sleep(nanoseconds: 500_000_000) guard !path.isEmpty else { throw FileError.notFound } return "Содержимое \(path)"}Task.sleep(nanoseconds:) — аналог asyncio.sleep() в Python.
5. Вызов асинхронных функций: await
Асинхронная функция вызывается только из асинхронного контекста с await. Для throwing-функций — try await:
func processFile() async { do { let content = try await loadFile(path: "data.txt") print(content) } catch { print("Ошибка: \(error)") }}Точка входа в асинхронный код на Linux:
@mainstruct MyApp { static func main() async { let result = await fetchData() print(result) }}6. async let — параллельный запуск задач
Когда задачи независимы, async let запускает их параллельно:
func fetchUsers() async -> String { try? await Task.sleep(nanoseconds: 2_000_000_000); return "10 пользователей"}func fetchStats() async -> String { try? await Task.sleep(nanoseconds: 1_000_000_000); return "CPU: 45%"}func fetchLogs() async -> String { try? await Task.sleep(nanoseconds: 1_500_000_000); return "1500 записей"}
func loadDashboard() async { async let users = fetchUsers() async let stats = fetchStats() async let logs = fetchLogs() let (u, s, l) = await (users, stats, logs) // ~2 сек вместо ~4.5 print("Пользователи: \(u), Статистика: \(s), Логи: \(l)")}Аналог в Python:
async def load_dashboard(): users, stats, logs = await asyncio.gather( fetch_users(), fetch_stats(), fetch_logs() )7. Task — создание задач
Task позволяет запустить асинхронный код из синхронного контекста.
func startWork() { // Наследует приоритет и контекст вызывающей стороны Task { let data = await fetchData() print("Получено: \(data)") }
// Отсоединённая задача — не наследует контекст Task.detached(priority: .background) { let data = await fetchData() print("Фоновая: \(data)") }
print("Задачи запущены, не ждём завершения")}Приоритеты: .high, .medium, .low, .background.
8. Отмена задач: Task cancellation
Swift использует кооперативную отмену — задача получает сигнал, но сама решает, как реагировать:
func longRunningWork() async { for i in 1...100 { if Task.isCancelled { print("Задача отменена на итерации \(i)") return } try? await Task.sleep(nanoseconds: 100_000_000) print("Итерация \(i)") }}
let task = Task { await longRunningWork() }try? await Task.sleep(nanoseconds: 500_000_000)task.cancel() // сигнал отменыTask.checkCancellation() — альтернатива, выбрасывающая CancellationError:
func processItems(_ items: [String]) async throws { for item in items { try Task.checkCancellation() // бросает CancellationError try? await Task.sleep(nanoseconds: 200_000_000) print("Обработан: \(item)") }}9. TaskGroup — группы задач для динамического параллелизма
async let удобен при известном количестве задач. Для динамического — withTaskGroup:
func processAllFiles(_ filenames: [String]) async -> [String] { await withTaskGroup(of: String.self) { group in for filename in filenames { group.addTask { try? await Task.sleep(nanoseconds: 500_000_000) return "Обработан: \(filename)" } } var results: [String] = [] for await result in group { results.append(result) } return results }}Для throwing-задач — withThrowingTaskGroup:
func computeAll(datasets: [String]) async throws -> [Int] { try await withThrowingTaskGroup(of: Int.self) { group in for ds in datasets { group.addTask { try await processDataset(ds) } } var results: [Int] = [] for try await r in group { results.append(r) } return results }}Аналог в Python:
async def process_all(filenames): tasks = [asyncio.create_task(process(f)) for f in filenames] return await asyncio.gather(*tasks)10. Акторы (actor) — безопасность данных
Актор — ссылочный тип с изоляцией состояния. Доступ сериализуется — только одна задача работает с ним в момент времени:
actor BankAccount { let owner: String private(set) var balance: Double
init(owner: String, balance: Double) { self.owner = owner self.balance = balance }
func deposit(_ amount: Double) { balance += amount }
func withdraw(_ amount: Double) throws { guard balance >= amount else { throw BankError.insufficientFunds } balance -= amount }}
enum BankError: Error { case insufficientFunds }Обращение к актору из внешнего кода требует await:
func bankDemo() async { let account = BankAccount(owner: "Алексей", balance: 1000) await account.deposit(500) print("Баланс: \(await account.balance)")}11. actor vs class — изоляция состояния
| Аспект | class | actor |
|---|---|---|
| Наследование | Да | Нет |
| Изоляция | Нет | Автоматическая |
| Доступ извне | Прямой | Через await |
| Data race | Подвержен | Защищён |
actor Counter { private var value = 0 func increment() { value += 1 } func getValue() -> Int { return value }}
func counterDemo() async { let counter = Counter() await withTaskGroup(of: Void.self) { group in for _ in 0..<100 { group.addTask { await counter.increment() } } } print("Итог: \(await counter.getValue())") // Гарантированно 100}12. nonisolated методы
Если метод актора не обращается к изменяемому состоянию, его можно пометить nonisolated — вызов не потребует await:
actor UserProfile { let id: Int let username: String var loginCount: Int = 0
init(id: Int, username: String) { self.id = id; self.username = username }
nonisolated func displayName() -> String { return "\(username) (#\(id))" // только let-свойства — безопасно }
func recordLogin() { loginCount += 1 } // var — изолирован}
func profileDemo() async { let profile = UserProfile(id: 1, username: "swift_dev") let name = profile.displayName() // без await await profile.recordLogin() // с await}13. @MainActor
Глобальный актор, привязанный к главному потоку. На Linux нет UI, но @MainActor гарантирует выполнение на одном потоке — полезно для сериализации доступа:
@MainActorfunc updateState(message: String) { print("[Главный поток] \(message)")}
@MainActorclass AppState { var status = "Запуск" func updateStatus(_ s: String) { status = s; print("Статус: \(status)") }}14. @Sendable — безопасная передача между задачами
Протокол Sendable указывает, что значение можно безопасно передавать между задачами. Автоматически соответствуют: типы значений с Sendable-свойствами, акторы, базовые типы (Int, String и т.д.).
@Sendable-замыкания не могут захватывать изменяемое состояние:
func runInBackground(_ work: @escaping @Sendable () async -> Void) { Task.detached { await work() }}
// Пользовательские Sendable-типыstruct Config: Sendable { let maxRetries: Int let timeout: Double}
final class Endpoint: Sendable { let url: String let method: String init(url: String, method: String) { self.url = url; self.method = method }}15. Сравнение с asyncio в Python
| Аспект | Swift | Python (asyncio) |
|---|---|---|
| Объявление | func f() async -> T | async def f() -> T |
| Вызов | await f() | await f() |
| Параллельный запуск | async let a = f() | asyncio.create_task(f()) |
| Группа задач | withTaskGroup { ... } | asyncio.gather(...) |
| Отмена | task.cancel() + Task.isCancelled | task.cancel() + CancelledError |
| Безопасность | Акторы, Sendable, компилятор | Нет (GIL помогает, но не гарантирует) |
| Потоки | Реальный параллелизм | Однопоточный event loop |
// Swift — реальный параллелизмfunc downloadAll() async { async let p1 = download("page1") async let p2 = download("page2") let results = await [p1, p2] for r in results { print(r) }}
func download(_ name: String) async -> String { try? await Task.sleep(nanoseconds: 1_000_000_000) return "Загружено: \(name)"}# Python — однопоточный event loopasync def download_all(): results = await asyncio.gather(download("page1"), download("page2")) for r in results: print(r)
async def download(name): await asyncio.sleep(1); return f"Загружено: {name}"16. Практический пример: параллельная обработка данных
func sumArray(_ array: [Int]) async -> Int { try? await Task.sleep(nanoseconds: 100_000_000) return array.reduce(0, +)}
func parallelSum() async { let datasets = [Array(1...1000), Array(1001...2000), Array(2001...3000), Array(3001...4000)]
let total = await withTaskGroup(of: Int.self) { group in for ds in datasets { group.addTask { await sumArray(ds) } } var sum = 0 for await partial in group { sum += partial } return sum } print("Общая сумма: \(total)") // 8_002_000}17. Практический пример: актор-логгер
actor AsyncLogger { private var logs: [String] = []
func log(_ message: String) { logs.append("[\(logs.count + 1)] \(message)") }
func allLogs() -> [String] { return logs }}
func loggerDemo() async { let logger = AsyncLogger()
await withTaskGroup(of: Void.self) { group in for i in 1...20 { group.addTask { try? await Task.sleep( nanoseconds: UInt64.random(in: 50_000_000...300_000_000)) await logger.log("Задача \(i) завершена") } } }
let all = await logger.allLogs() print("Записей: \(all.count)") // Гарантированно 20 for entry in all { print(entry) }}Без актора параллельная запись в массив привела бы к data race.
18. Структурированная vs неструктурированная конкурентность
| Тип | Механизм | Когда использовать |
|---|---|---|
| Структурированная | async let, TaskGroup | Задачи с чётким временем жизни |
| Неструктурированная | Task { } | Запуск из синхронного контекста |
| Отсоединённая | Task.detached { } | Полностью независимая работа |
Структурированная конкурентность гарантирует: если родительская задача отменяется, дочерние отменяются автоматически. Это упрощает управление ресурсами.
19. Упражнения
Упражнение 1. Напишите func delay(_ seconds: Double) async -> String, приостанавливающуюся на указанное время и возвращающую "Ожидание \(seconds) сек завершено". Вызовите с await.
Упражнение 2. С помощью async let запустите три вызова delay (1, 2 и 3 секунды) параллельно. Убедитесь, что общее время ~3 секунды, а не ~6.
Упражнение 3. Создайте func processItems(_ items: [String]) async -> [String] с withTaskGroup, параллельно переводящую строки в верхний регистр с имитацией задержки.
Упражнение 4. Напишите актор WordCounter со словарём [String: Int]. Метод count(word:) увеличивает счётчик, topWords(n:) возвращает n самых частых слов. Тестируйте из нескольких задач.
Упражнение 5. Реализуйте кооперативную отмену: задача обрабатывает массив с Task.checkCancellation(), отмените её через 0.3 секунды.
Упражнение 6. Перепишите упражнение 3 с withThrowingTaskGroup: строки короче 3 символов должны вызывать ошибку.
20. Вопросы для самопроверки
- Чем отличается конкурентность от параллелизма?
- Что такое race condition и data race? В чём разница?
- Чем
async/awaitв Swift отличается отasync/awaitв Python? - Что происходит в точке
await— блокируется ли поток? - В чём разница между
async letи последовательнымиawait? - Чем
Task { }отличается отTask.detached { }? - Почему отмена задач в Swift называется «кооперативной»?
- Когда
TaskGroupпредпочтительнееasync let? - Чем
actorотличается отclass? Какую проблему он решает? - Что такое
nonisolatedи когда его применять? - Зачем нужен протокол
Sendable? - Чем реальный параллелизм Swift отличается от event loop в Python
asyncio?