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 сама управляет количеством потоков
}
}
Лучшие практики и советы профессионалов
- Всегда используй async/await вместо GCD для нового кода. Это безопаснее, читаемее и легче поддерживается.
- Для защиты данных используй Actor, а не ручные блокировки. Actor гарантирует безопасность на уровне компилятора.
- Помечай UI-код @MainActor. Это автоматически гарантирует выполнение на главном потоке.
- Не вызывай async функции из синхронного контекста без Task. Используй Task.detached, если нужно запустить задачу, не наследуя контекст.
- Используй TaskGroup для динамического количества параллельных задач. Это эффективнее, чем создавать Task для каждой задачи.
- Проверяй отмену задач (Task.isCancelled). Особенно в длительных операциях, чтобы не тратить ресурсы впустую.
- Избегай синхронных вызовов async функций. Не используй semaphore или dispatchGroup для синхронного ожидания — это может привести к deadlock.
- Тестируй многопоточный код. Используй Thread Sanitizer (в Xcode) для поиска гонок данных.
- Документируй, какие функции асинхронные. Добавляй суффикс Async к названиям функций, чтобы было понятно.
- Не злоупотребляй параллелизмом. Если задача выполняется 1 миллисекунду, оверхед на создание потока может быть больше, чем сама задача.
🔥 Золотое правило: «Сначала сделай правильно, потом быстро». Не оптимизируй многопоточность заранее — сначала напиши рабочий синхронный код, а потом, если нужно, добавь асинхронность.
Вопросы для самопроверки
- В чём разница между многопоточностью и асинхронностью?
Ответ: Многопоточность — это одновременное выполнение нескольких задач на разных потоках. Асинхронность — это способ не блокировать поток во время ожидания (например, ответа от сервера), переключаясь на другие задачи. - Что такое GCD и зачем он нужен?
Ответ: Grand Central Dispatch — это библиотека Apple для управления потоками. Она берёт на себя создание, уничтожение и планирование потоков, позволяя разработчику сосредоточиться на логике. - Какие типы очередей существуют в GCD?
Ответ: Последовательные (serial) — задачи выполняются одна за другой; параллельные (concurrent) — задачи могут выполняться одновременно; главная очередь (main) — для UI-кода. - Что такое async/await в Swift?
Ответ: Это синтаксическая конструкция для написания асинхронного кода, который выглядит как синхронный. async помечает функцию как асинхронную, await — точку приостановки. - Для чего нужен Actor?
Ответ: Actor защищает данные от одновременного доступа из разных потоков. Он гарантирует, что только один поток может изменять его состояние в любой момент времени. - Что такое @MainActor?
Ответ: Это атрибут, который гарантирует, что код выполняется на главном потоке. Используется для UI-кода, чтобы избежать случайного обновления интерфейса из фонового потока. - Как запустить несколько асинхронных задач параллельно?
Ответ: Используя async let (для известного количества задач) или TaskGroup (для динамического количества). - Что такое состояние гонки (race condition)?
Ответ: Это ситуация, когда два или более потока одновременно обращаются к общим данным, и результат зависит от порядка выполнения потоков. Приводит к непредсказуемому поведению. - Как избежать deadlock?
Ответ: Не вызывать синхронные методы на главном потоке, не использовать sync на последовательных очередях, избегать вложенных блокировок. - Чем отличается async/await в Swift от Python?
Ответ: В Swift нет GIL, поэтому потоки могут выполняться на разных ядрах. В Python из-за GIL потоки не дают прироста для CPU-задач. Также в Swift есть встроенная поддержка Actor и MainActor, чего нет в Python.
Итог и что изучить дальше
Мы разобрали фундаментальные концепции многопоточности и асинхронности в Swift. Ты узнал:
- Как работают потоки и очереди GCD
- Как использовать современный async/await
- Как защищать данные с помощью Actor
- Как избегать типичных ошибок
- Как отличается подход в Swift от Python
Теперь ты готов двигаться дальше. Вот что стоит изучить в следующую очередь:
- Structured Concurrency — как правильно организовывать асинхронные задачи, чтобы они не «утекали».
- Task Local Values — передача контекстных данных между асинхронными задачами.
- AsyncSequence — работа с асинхронными последовательностями (например, потоковые данные).
- Combine framework — реактивное программирование в связке с async/await.
- Swift Concurrency в UIKit и SwiftUI — как правильно интегрировать асинхронность в приложения.
- Тестирование асинхронного кода — XCTestExpectation, async тесты.
- Производительность — измерение и оптимизация многопоточных приложений.
📚 Совет: Открой Xcode и попробуй каждый пример из статьи. Лучший способ понять многопоточность — написать код, который сначала работает неправильно, а потом исправить его. Удачи в изучении!