🍎 Swift

Multithreading e assincronia em Swift: um guia completo

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты — повар в ресторане. У тебя есть одна плита, одна сковорода и одно время. Ты готовишь яичницу: разбиваешь яйца, жаришь, ждёшь, пока белок схватится, потом переворачиваешь, ждёшь снова, подаёшь. Всё это время плита занята, ты занят, и ничего другого не происходит. Это синхронная работа — каждое действие выполняется строго по очереди, одно за другим.

А теперь представь, что у тебя есть помощник. Ты ставишь варить суп на одной конфорке, а помощник в это время режет салат на соседнем столе. Плита работает, помощник работает, ты контролируешь оба процесса. Это многопоточность — несколько задач выполняются одновременно (или почти одновременно), используя разные ресурсы.

Но есть и третий вариант. Ты ставишь суп вариться, а сам не ждёшь, пока он закипит, а идёшь чистить картошку. Когда суп закипит — раздастся звуковой сигнал, ты вернёшься, убавишь огонь и продолжишь чистить картошку. Это асинхронность — ты не блокируешь себя ожиданием, а переключаешься на другую задачу, пока ждёшь завершения первой.

💡 Главная идея: Многопоточность — это про одновременное выполнение (параллелизм). Асинхронность — про ожидание без блокировки (non-blocking wait). В Swift они часто работают вместе.

Зачем это нужно в программировании? Представь, что твоё приложение загружает фотографию из интернета. Если делать это синхронно, экран «замрёт» на несколько секунд — пользователь подумает, что программа зависла. Асинхронная загрузка позволяет показать пользователю анимацию загрузки, дать ему возможность нажать кнопку «Назад» и вообще не блокировать интерфейс.

Теория: как это работает изнутри

Процессы и потоки

Операционная система управляет процессами — это запущенные программы (например, Safari, Telegram, твой код). Внутри одного процесса может быть несколько потоков (threads). Поток — это лёгкая единица выполнения, которая имеет свой стек вызовов и может выполняться независимо от других потоков.

Если у процессора 4 ядра, он может выполнять 4 потока действительно одновременно. Если потоков больше, чем ядер, операционная система переключается между ними так быстро, что создаётся иллюзия одновременности (это называется concurrency — конкурентность).

Grand Central Dispatch (GCD)

В Swift управление потоками происходит через Grand Central Dispatch — это библиотека Apple, которая берёт на себя всю сложность создания и уничтожения потоков. Ты просто говоришь: «Выполни этот кусок кода в фоне», а GCD сама решает, какой поток использовать.

У GCD есть очереди (queues). Очередь — это как конвейер: задачи поступают в неё и выполняются одна за другой (последовательная очередь) или все сразу (параллельная очередь).

Тип очереди Поведение Пример использования
Serial (последовательная) Задачи выполняются по очереди, одна после другой Доступ к файлу, чтобы не записать данные одновременно из двух мест
Concurrent (параллельная) Задачи могут выполняться одновременно Загрузка нескольких изображений сразу
Main queue (главная) Выполняется на главном потоке, отвечает за интерфейс Обновление UI после загрузки данных

Async/await в Swift

Начиная с Swift 5.5, появилась современная модель асинхронности — async/await. Это синтаксический сахар, который делает асинхронный код похожим на обычный синхронный. Вместо того чтобы писать замыкания (closures) и передавать их в функции, ты просто пишешь await перед вызовом асинхронной функции, и компилятор сам разворачивает этот код в сложную машину состояний.

Ключевые понятия async/await:

  • async функция — функция, которая может приостанавливать своё выполнение. Помечается ключевым словом async.
  • await — точка приостановки. Когда ты вызываешь асинхронную функцию с await, текущий поток освобождается для других задач, а когда результат готов — выполнение продолжается.
  • Task — единица асинхронной работы. Ты создаёшь Task, чтобы запустить асинхронный код из синхронного контекста.
  • Actor — защищает данные от одновременного доступа. Это как класс, но с гарантией, что только один поток может работать с его свойствами в любой момент времени.

Синтаксис в Swift: все формы и варианты

1. GCD — классический способ (до Swift 5.5)

// Создаём параллельную очередь с именем "my.background.queue"
let backgroundQueue = DispatchQueue(label: "my.background.queue", attributes: .concurrent)

// Запускаем задачу в фоне
backgroundQueue.async {
    // Этот код выполняется в фоновом потоке
    let result = self.doHeavyWork()
    
    // Возвращаемся на главный поток для обновления UI
    DispatchQueue.main.async {
        self.updateUI(with: result)
    }
}

// Можно также запустить задачу с задержкой
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
    print("Прошло 2 секунды")
}

2. Async/await — современный способ

// Объявляем асинхронную функцию
func fetchUserData() async -> User {
    // Представим, что здесь сетевой запрос
    // await — точка приостановки
    let data = await URLSession.shared.data(from: url)
    return parseUser(from: data)
}

// Вызываем асинхронную функцию
Task {
    let user = await fetchUserData()
    print("Получили пользователя: (user.name)")
}

3. Параллельные вызовы с async let

// Запускаем несколько асинхронных задач параллельно
async let firstImage = loadImage(from: url1)
async let secondImage = loadImage(from: url2)
async let thirdImage = loadImage(from: url3)

// Ждём все три результата одновременно
let images = await [firstImage, secondImage, thirdImage]
// Все три загрузки выполнялись параллельно!

4. TaskGroup — для динамического количества задач

// Когда не знаешь заранее, сколько будет задач
let imageURLs = [url1, url2, url3, url4, url5]

let images = await withTaskGroup(of: UIImage.self) { group in
    for url in imageURLs {
        group.addTask {
            return await loadImage(from: url)
        }
    }
    
    var results = [UIImage]()
    for await image in group {
        results.append(image)
    }
    return results
}

5. Actor — защита данных

// Actor гарантирует, что только один поток может менять его состояние
actor BankAccount {
    private var balance: Double = 0
    
    func deposit(amount: Double) {
        balance += amount
    }
    
    func withdraw(amount: Double) -> Bool {
        if balance >= amount {
            balance -= amount
            return true
        }
        return false
    }
    
    func getBalance() -> Double {
        return balance
    }
}

// Использование
let account = BankAccount()
Task {
    await account.deposit(amount: 100)
    let balance = await account.getBalance()
    print("Баланс: (balance)")
}

6. MainActor — для UI-кода

// @MainActor гарантирует, что код выполняется на главном потоке
@MainActor
class ViewModel: ObservableObject {
    @Published var text = ""
    
    func updateUI() async {
        // Этот метод всегда выполняется на главном потоке
        let data = await fetchData() // А это может быть в фоне
        text = data
    }
}

// Можно пометить отдельное свойство
@MainActor var uiState: UIState = .loading

7. Continuations — мост между старым и новым

// Если у тебя есть старая функция с замыканием, а нужна async
func oldStyleFetch(completion: @escaping (Result<Data, Error>) -> Void) {
    // какой-то старый код
}

// Превращаем в async функцию
func newStyleFetch() async throws -> Data {
    return try await withCheckedThrowingContinuation { continuation in
        oldStyleFetch { result in
            switch result {
            case .success(let data):
                continuation.resume(returning: data)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Примеры кода — от простого к сложному

Пример 1: Загрузка изображения с индикатором загрузки

import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        loadImage()
    }
    
    func loadImage() {
        // Показываем индикатор загрузки на главном потоке
        activityIndicator.startAnimating()
        
        // Создаём фоновую очередь
        DispatchQueue.global(qos: .userInitiated).async {
            // Симулируем загрузку (в реальности — URLSession)
            sleep(2) // Имитация долгой операции
            
            let image = UIImage(systemName: "star.fill")!
            
            // Возвращаемся на главный поток для обновления UI
            DispatchQueue.main.async {
                self.imageView.image = image
                self.activityIndicator.stopAnimating()
            }
        }
    }
}

Пример 2: Параллельная загрузка нескольких изображений

func loadMultipleImages() async -> [UIImage] {
    let urls = [
        URL(string: "https://example.com/image1.jpg")!,
        URL(string: "https://example.com/image2.jpg")!,
        URL(string: "https://example.com/image3.jpg")!
    ]
    
    // Используем TaskGroup для параллельной загрузки
    return await withTaskGroup(of: UIImage?.self) { group in
        for url in urls {
            group.addTask {
                // Каждая загрузка выполняется параллельно
                if let data = try? await URLSession.shared.data(from: url).0 {
                    return UIImage(data: data)
                }
                return nil
            }
        }
        
        var images = [UIImage]()
        for await image in group {
            if let image = image {
                images.append(image)
            }
        }
        return images
    }
}

Пример 3: Таймер с асинхронным ожиданием

func countdown(from seconds: Int) async {
    for remaining in stride(from: seconds, through: 0, by: -1) {
        print("Осталось (remaining) секунд")
        // Приостанавливаемся на 1 секунду, не блокируя поток
        try? await Task.sleep(nanoseconds: 1_000_000_000)
    }
    print("Время вышло!")
}

// Использование
Task {
    await countdown(from: 5)
}

Пример 4: Обработка ошибок в асинхронном коде

enum NetworkError: Error {
    case invalidURL
    case noData
    case decodingError
}

func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/(id)")
    guard let url = url else {
        throw NetworkError.invalidURL
    }
    
    let (data, response) = try await URLSession.shared.data(from: url)
    
    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw NetworkError.noData
    }
    
    let decoder = JSONDecoder()
    guard let user = try? decoder.decode(User.self, from: data) else {
        throw NetworkError.decodingError
    }
    
    return user
}

// Использование с обработкой ошибок
Task {
    do {
        let user = try await fetchUser(id: 42)
        print("Пользователь: (user.name)")
    } catch NetworkError.invalidURL {
        print("Некорректный URL")
    } catch NetworkError.noData {
        print("Сервер вернул пустой ответ")
    } catch {
        print("Неизвестная ошибка: (error)")
    }
}

Пример 5: Actor для безопасного счётчика

actor Counter {
    private var value = 0
    
    func increment() {
        value += 1
    }
    
    func getValue() -> Int {
        return value
    }
}

// Демонстрация безопасного доступа
func testCounter() async {
    let counter = Counter()
    
    // Запускаем 1000 задач, которые увеличивают счётчик
    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<1000 {
            group.addTask {
                await counter.increment()
            }
        }
    }
    
    let finalValue = await counter.getValue()
    print("Финальное значение: (finalValue)") // Всегда 1000!
}

Пример 6: Асинхронная последовательная обработка

// Обработка элементов массива по очереди, но асинхронно
func processItems(_ items: [String]) async -> [String] {
    var results = [String]()
    
    for item in items {
        // Каждый элемент обрабатывается асинхронно,
        // но дожидаемся результата перед следующей итерацией
        let processed = await processSingleItem(item)
        results.append(processed)
    }
    
    return results
}

func processSingleItem(_ item: String) async -> String {
    // Симулируем обработку (например, запрос к API)
    try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 секунды
    return item.uppercased()
}

// Использование
Task {
    let items = ["apple", "banana", "cherry"]
    let processed = await processItems(items)
    print(processed) // ["APPLE", "BANANA", "CHERRY"]
}

Пример 7: Комбинирование async/await с Combine

import Combine

class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var results: [String] = []
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // Слушаем изменения текста с задержкой (debounce)
        $searchText
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .sink { [weak self] text in
                guard let self = self, !text.isEmpty else { return }
                
                // Запускаем асинхронный поиск
                Task {
                    let searchResults = await self.performSearch(query: text)
                    
                    // Возвращаемся на главный поток для обновления UI
                    await MainActor.run {
                        self.results = searchResults
                    }
                }
            }
            .store(in: &cancellables)
    }
    
    func performSearch(query: String) async -> [String] {
        // Симулируем сетевой запрос
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        return ["Результат для (query) 1", "Результат для (query) 2"]
    }
}

Сравнение с Python: чем похоже и чем отличается

Характеристика Swift Python
Основная модель Async/await + GCD + Actors Async/await + asyncio + threading
Параллелизм Настоящий параллелизм (потоки ОС) GIL ограничивает параллелизм потоков (один поток за раз для CPU-задач)
Асинхронность Основана на GCD и операционной системе Основана на event loop (цикл событий) в asyncio
Защита данных Actor — встроенная языковая конструкция Lock, RLock, Semaphore — библиотечные средства
Синтаксис async/await func foo() async -> T async def foo() -> T
Запуск асинхронной задачи Task { await foo() } asyncio.run(foo())
Параллельные задачи async let или TaskGroup asyncio.gather()
UI-поток MainActor — встроенная поддержка Нет встроенной концепции, нужно использовать библиотеки (PyQt, Tkinter)
⚠️ Важное отличие: В Python из-за GIL (Global Interpreter Lock) потоки не могут выполняться одновременно на разных ядрах для CPU-интенсивных задач. Для этого используют multiprocessing. В Swift такого ограничения нет — потоки могут работать на всех ядрах.

Типичные ошибки и подводные камни

1. Забыл вернуться на главный поток

// ❌ Ошибка: обновление UI из фонового потока
DispatchQueue.global().async {
    self.label.text = "Готово!" // КРАШ!
}

// ✅ Правильно: возвращаемся на главный поток
DispatchQueue.global().async {
    DispatchQueue.main.async {
        self.label.text = "Готово!"
    }
}

2. Состояние гонки (race condition)

// ❌ Ошибка: два потока одновременно меняют массив
var sharedArray = [Int]()
DispatchQueue.concurrentPerform(iterations: 100) { i in
    sharedArray.append(i) // Непредсказуемый результат!
}

// ✅ Правильно: используем Actor или сериализованный доступ
actor SafeArray {
    private var array = [Int]()
    func append(_ value: Int) {
        array.append(value)
    }
}

3. Deadlock (взаимная блокировка)

// ❌ Ошибка: синхронное ожидание на главном потоке
DispatchQueue.main.sync { // Deadlock! Главный поток ждёт сам себя
    print("Это никогда не выполнится")
}

// ✅ Правильно: используем async или asyncAfter
DispatchQueue.main.async {
    print("Выполнится без блокировки")
}

4. Забыл про отмену задач

// ❌ Ошибка: задача продолжает выполняться после ухода с экрана
Task {
    let data = await fetchLargeData()
    updateUI(with: data) // Пользователь уже ушёл, UI не существует
}

// ✅ Правильно: проверяем отмену
Task {
    guard !Task.isCancelled else { return }
    let data = await fetchLargeData()
    guard !Task.isCancelled else { return }
    await MainActor.run {
        updateUI(with: data)
    }
}

5. Слишком много потоков

// ❌ Ошибка: создаём 1000 потоков вручную
for _ in 0..<1000 {
    Thread.detachNewThread {
        // Слишком много потоков — тратим ресурсы
    }
}

// ✅ Правильно: используем GCD с параллельной очередью
let queue = DispatchQueue(label: "my.queue", attributes: .concurrent)
for _ in 0..<1000 {
    queue.async {
        // GCD сама управляет количеством потоков
    }
}

Лучшие практики и советы профессионалов

  1. Всегда используй async/await вместо GCD для нового кода. Это безопаснее, читаемее и легче поддерживается.
  2. Для защиты данных используй Actor, а не ручные блокировки. Actor гарантирует безопасность на уровне компилятора.
  3. Помечай UI-код @MainActor. Это автоматически гарантирует выполнение на главном потоке.
  4. Не вызывай async функции из синхронного контекста без Task. Используй Task.detached, если нужно запустить задачу, не наследуя контекст.
  5. Используй TaskGroup для динамического количества параллельных задач. Это эффективнее, чем создавать Task для каждой задачи.
  6. Проверяй отмену задач (Task.isCancelled). Особенно в длительных операциях, чтобы не тратить ресурсы впустую.
  7. Избегай синхронных вызовов async функций. Не используй semaphore или dispatchGroup для синхронного ожидания — это может привести к deadlock.
  8. Тестируй многопоточный код. Используй Thread Sanitizer (в Xcode) для поиска гонок данных.
  9. Документируй, какие функции асинхронные. Добавляй суффикс Async к названиям функций, чтобы было понятно.
  10. Не злоупотребляй параллелизмом. Если задача выполняется 1 миллисекунду, оверхед на создание потока может быть больше, чем сама задача.
🔥 Золотое правило: «Сначала сделай правильно, потом быстро». Не оптимизируй многопоточность заранее — сначала напиши рабочий синхронный код, а потом, если нужно, добавь асинхронность.

Вопросы для самопроверки

  1. В чём разница между многопоточностью и асинхронностью?
    Ответ: Многопоточность — это одновременное выполнение нескольких задач на разных потоках. Асинхронность — это способ не блокировать поток во время ожидания (например, ответа от сервера), переключаясь на другие задачи.
  2. Что такое GCD и зачем он нужен?
    Ответ: Grand Central Dispatch — это библиотека Apple для управления потоками. Она берёт на себя создание, уничтожение и планирование потоков, позволяя разработчику сосредоточиться на логике.
  3. Какие типы очередей существуют в GCD?
    Ответ: Последовательные (serial) — задачи выполняются одна за другой; параллельные (concurrent) — задачи могут выполняться одновременно; главная очередь (main) — для UI-кода.
  4. Что такое async/await в Swift?
    Ответ: Это синтаксическая конструкция для написания асинхронного кода, который выглядит как синхронный. async помечает функцию как асинхронную, await — точку приостановки.
  5. Для чего нужен Actor?
    Ответ: Actor защищает данные от одновременного доступа из разных потоков. Он гарантирует, что только один поток может изменять его состояние в любой момент времени.
  6. Что такое @MainActor?
    Ответ: Это атрибут, который гарантирует, что код выполняется на главном потоке. Используется для UI-кода, чтобы избежать случайного обновления интерфейса из фонового потока.
  7. Как запустить несколько асинхронных задач параллельно?
    Ответ: Используя async let (для известного количества задач) или TaskGroup (для динамического количества).
  8. Что такое состояние гонки (race condition)?
    Ответ: Это ситуация, когда два или более потока одновременно обращаются к общим данным, и результат зависит от порядка выполнения потоков. Приводит к непредсказуемому поведению.
  9. Как избежать deadlock?
    Ответ: Не вызывать синхронные методы на главном потоке, не использовать sync на последовательных очередях, избегать вложенных блокировок.
  10. Чем отличается async/await в Swift от Python?
    Ответ: В Swift нет GIL, поэтому потоки могут выполняться на разных ядрах. В Python из-за GIL потоки не дают прироста для CPU-задач. Также в Swift есть встроенная поддержка Actor и MainActor, чего нет в Python.

Итог и что изучить дальше

Мы разобрали фундаментальные концепции многопоточности и асинхронности в Swift. Ты узнал:

  • Как работают потоки и очереди GCD
  • Как использовать современный async/await
  • Как защищать данные с помощью Actor
  • Как избегать типичных ошибок
  • Как отличается подход в Swift от Python

Теперь ты готов двигаться дальше. Вот что стоит изучить в следующую очередь:

  1. Structured Concurrency — как правильно организовывать асинхронные задачи, чтобы они не «утекали».
  2. Task Local Values — передача контекстных данных между асинхронными задачами.
  3. AsyncSequence — работа с асинхронными последовательностями (например, потоковые данные).
  4. Combine framework — реактивное программирование в связке с async/await.
  5. Swift Concurrency в UIKit и SwiftUI — как правильно интегрировать асинхронность в приложения.
  6. Тестирование асинхронного кода — XCTestExpectation, async тесты.
  7. Производительность — измерение и оптимизация многопоточных приложений.
📚 Совет: Открой Xcode и попробуй каждый пример из статьи. Лучший способ понять многопоточность — написать код, который сначала работает неправильно, а потом исправить его. Удачи в изучении!