Проблема общего состояния между задачами
Когда несколько задач (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 — реализация должна остаться корректной при любом порядке выполнения.