$ sudo teach IT

Проблема общего состояния между задачами

Когда несколько задач (Task) работают параллельно и меняют одну и ту же переменную, может получиться так, что два изменения «наложатся» друг на друга и часть работы потеряется. Например, если пять задач одновременно читают число, прибавляют единицу и записывают обратно, итог может оказаться меньше пяти — какая-то из задач прочитала старое значение раньше, чем предыдущая успела его записать. Такую ситуацию называют гонкой данных: код компилируется, иногда даже работает правильно, а иногда нет — в зависимости от того, как повезёт с порядком выполнения.

Swift решает эту проблему на уровне языка с помощью специального типа — actor.

Что такое актор

Актор — это тип, похожий на класс: у него есть свойства и методы, он ссылочный, а не копируется при присваивании. Но у актора есть особое свойство — изоляция состояния. Компилятор гарантирует, что в любой момент времени с внутренними данными актора работает только один кусок кода. Если два места в программе одновременно попытаются обратиться к актору, второе обращение встанет в очередь и подождёт, пока закончится первое.

Объявляется актор почти как класс:

actor Counter {
    private var count = 0

    func increment() {
        count += 1
    }

    func value() -> Int {
        return count
    }
}

Разница не в объявлении, а в том, как к актору обращаются снаружи.

await при обращении к актору

Методы и свойства актора можно вызывать без await только изнутри самого актора. Снаружи — даже если метод не выглядит асинхронным — обращение обязательно сопровождается await:

let counter = Counter()
await counter.increment()
let current = await counter.value()
print(current)

await здесь не означает «подождать сеть или диск». Он означает «встать в очередь к актору»: если актор сейчас занят другим обращением, точка приостановки дождётся своей очереди, а тем временем поток сможет заняться другой работой. Поэтому компилятор не разрешает вызвать метод актора без await — он заставляет явно показать место, где выполнение может прерваться.

Зачем изоляция, если можно было бы использовать class

С обычным классом ничего не мешало бы вызвать метод из десяти параллельных задач одновременно — компилятор бы это разрешил, а результат оказался бы непредсказуемым. С актором тот же код становится безопасным без единой ручной блокировки:

let counter = Counter()
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<100 {
        group.addTask {
            await counter.increment()
        }
    }
}
let total = await counter.value()
print(total) // всегда 100

Каждое обращение к counter встаёт в очередь актора, поэтому ни одно увеличение не теряется, сколько бы задач ни работало параллельно.

nonisolated: когда await не нужен

Если метод или свойство актора не трогает изменяемое состояние, его можно пометить nonisolated — тогда обращение к нему не требует await, потому что и очередь ждать незачем:

actor Counter {
    let id = 42
    private var count = 0

    nonisolated func description() -> String {
        return "Счётчик номер \(id)"
    }

    func increment() {
        count += 1
    }
}

id — константа, она не меняется после создания, поэтому обращаться к ней синхронно безопасно. А вот count изменяемый, поэтому методы, которые его трогают, обязаны оставаться изолированными.

@MainActor: особый актор для главного потока

В приложениях с интерфейсом обновлять экран разрешено только с главного потока. Swift выражает это тем же механизмом изоляции: есть готовый глобальный актор MainActor, и всё, что помечено @MainActor, привязано именно к главному потоку:

@MainActor
func updateTitle(_ title: String) {
    // здесь гарантированно главный поток
}

Вызов такой функции из кода, который не находится на главном акторе, тоже требует await — по тем же правилам, что и обращение к обычному актору. Изоляция состояния и изоляция «на каком потоке выполняется» описываются одной и той же идеей.

Частые ошибки

  • Забыть await при обращении к методу или свойству актора снаружи — компилятор не даст скомпилировать код, и это подсказка, а не помеха.
  • Держать общие данные в обычном классе рядом с актором, думая, что актор их тоже защитит — актор изолирует только то, что лежит внутри него самого.
  • Считать await внутри вызова к актору признаком блокировки потоков — на самом деле это точка приостановки: пока актор занят, поток свободен для другой работы.
  • Помечать nonisolated метод, который на самом деле читает или меняет изменяемое свойство — компилятор такое не пропустит, и это тоже защита от гонки данных.

Резюме

  • actor — ссылочный тип с изоляцией состояния: одновременно с его данными работает только один кусок кода.
  • Обращение к методам и свойствам актора снаружи требует await, даже если метод не выглядит асинхронным.
  • Изнутри самого актора await для доступа к своим же данным не нужен.
  • nonisolated снимает изоляцию с методов, которые не трогают изменяемое состояние.
  • @MainActor — готовый глобальный актор для кода, которому нужно выполняться на главном потоке.

Проверьте себя

4 вопроса

Счётчик-актор

Реализуйте actor Counter, который безопасно считает количество вызовов из параллельных задач.

У актора должно быть приватное свойство count типа Int, начинающееся с нуля, и два метода:

  • increment() — увеличивает count на единицу;
  • value() -> Int — возвращает текущее значение count.

Тесты обращаются к актору снаружи через await, в том числе одновременно из нескольких задач в TaskGroup — реализация должна остаться корректной при любом порядке выполнения.