Перейти к содержимому

Лекция 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 == 1

Deadlock — задача 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:

@main
struct 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 — изоляция состояния

Аспектclassactor
НаследованиеДаНет
ИзоляцияНетАвтоматическая
Доступ извнеПрямойЧерез 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 гарантирует выполнение на одном потоке — полезно для сериализации доступа:

@MainActor
func updateState(message: String) {
print("[Главный поток] \(message)")
}
@MainActor
class 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

АспектSwiftPython (asyncio)
Объявлениеfunc f() async -> Tasync def f() -> T
Вызовawait f()await f()
Параллельный запускasync let a = f()asyncio.create_task(f())
Группа задачwithTaskGroup { ... }asyncio.gather(...)
Отменаtask.cancel() + Task.isCancelledtask.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 loop
async 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. Вопросы для самопроверки

  1. Чем отличается конкурентность от параллелизма?
  2. Что такое race condition и data race? В чём разница?
  3. Чем async/await в Swift отличается от async/await в Python?
  4. Что происходит в точке await — блокируется ли поток?
  5. В чём разница между async let и последовательными await?
  6. Чем Task { } отличается от Task.detached { }?
  7. Почему отмена задач в Swift называется «кооперативной»?
  8. Когда TaskGroup предпочтительнее async let?
  9. Чем actor отличается от class? Какую проблему он решает?
  10. Что такое nonisolated и когда его применять?
  11. Зачем нужен протокол Sendable?
  12. Чем реальный параллелизм Swift отличается от event loop в Python asyncio?