1. Почему общий var становится проблемой
Когда вы пишете обычный последовательный код, переменная var ведёт себя как послушный блокнот: записали число, потом прочитали — всё логично. Но как только в программе появляются несколько задач, которые выполняются «почти одновременно», этот блокнот внезапно превращается в блокнот на кухне в коммуналке: вы оставили там записку «молока 1 литр», отвернулись, а кто-то уже успел дописать «…было». И вот вы оба уверены, что поступили правильно — но результат странный.
Техническое имя этой неприятности — shared mutable state: «разделяемое изменяемое состояние». Это значит, что где-то в памяти есть одно место (например, объект класса со свойством var), и к нему обращаются разные задачи. Если одна задача читает значение, а другая в этот же момент меняет, вы можете получить поведение, которое зависит от порядка выполнения. И вот это уже похоже на квест «угадай, что выведет программа сегодня».
Важно: мы сейчас говорим не про «редкий баг», а про класс ошибок, который бывает очень трудно ловить. Он может проявляться раз в тысячу запусков, а потом исчезнуть, как только вы включите логирование.
Именно ради борьбы с такими ошибками в Swift появились actor: язык предлагает не «советы по дисциплине», а реальные правила доступа к состоянию, которые проверяет компилятор. И это не философия — это «чтобы ночью спать».
2. Мини-пример гонки: счётчик, который иногда врёт
Сначала сделаем максимально бытовой пример: счётчик. Представьте, что два человека одновременно кликают «лайк» (или две задачи одновременно обновляют метрику). Если счётчик реализован как обычный класс с var, вы легко можете получить ситуацию «оба увеличили, а выросло на 1».
Схема ошибки обычно такая:
sequenceDiagram
participant T1 as Task 1
participant T2 as Task 2
participant C as Counter.value
T1->>C: read (видит 0)
T2->>C: read (видит 0)
T1->>C: write (ставит 1)
T2->>C: write (ставит 1)
Обе задачи действовали «логично»: прочитали 0, увеличили, записали 1. Но вместе они потеряли одно увеличение — и это классический симптом гонки.
Чтобы не углубляться в сложные примеры, покажем «подозрительный» код класса:
import Foundation
final class CounterBox {
var value: Int = 0
func increment() {
value += 1
}
}
Проблема тут не в том, что += «сломанный». Проблема в том, что value += 1 — это на уровне реального выполнения примерно «прочитал → прибавил → записал», и эти шаги могут перемежаться с такой же операацией из другой задачи.
И вот важный момент для понимания: если вы видите в проекте долгоживущий объект (class) с изменяемыми свойствами (var), и этот объект доступен из разных Task, то вы почти наверняка стоите рядом с миной. Она может не взорваться на ваших тестах — но это не значит, что мины нет.
3. actor: ссылочный тип с изоляцией состояния
Если смотреть только на синтаксис, actor выглядит подозрительно знакомо: у него есть свойства, методы, init, можно хранить состояние и писать логику. Но ключевое отличие в том, что актёр — это специальный ссылочный тип, который защищает своё изменяемое состояние от гонок данных.
В Swift это не «договорённость команды», а модель, которую поддерживает компилятор: доступ к состоянию ограничен правилами actor isolation.
Давайте начнём с «самого честного» минимального актёра:
import Foundation
actor Counter {
private var value: Int = 0
func increment() {
value += 1
}
func get() -> Int { value }
}
Здесь почти всё знакомо, но уже есть важные отличия по смыслу.
Во-первых, состояние (value) мы держим private, потому что актёр лучше работает как «капсула»: снаружи вы не лазите в его внутренности руками, а просите его сделать операцию.
Во-вторых, внутри актёра мы обращаемся к value как к обычной переменной: без await, без «танцев». Это важная часть модели: внутри актёра код выглядит последовательным относительно его состояния.
В-третьих, снаружи актёр ведёт себя так, будто у него есть «почтовый ящик»: задачи отправляют ему запросы на выполнение методов, и он выполняет их по одному, не позволяя двум задачам одновременно менять его var. Это как «островок однопоточности» вокруг конкретного состояния.
Чтобы не путаться, полезно держать маленькую таблицу в голове:
| Тип | Семантика | Можно иметь var? | Что происходит при доступе из разных задач |
|---|---|---|---|
|
value (копируется) | да | обычно безопаснее, потому что копии не разделяют память |
|
reference (разделяется) | да | легко получить shared mutable state и гонки |
|
reference + изоляция | да | mutable state защищён правилами actor isolation |
И ещё маленькая «самоирония программиста»: actor не делает вас бессмертным. Он просто убирает самый неприятный тип ошибок — гонки на доступ к состоянию — и заставляет явно обозначать границы, где вы «переходите в чужой огород».
Мини-примеры: от счётчика к хранилищу
Чтобы мозг не воспринимал актёры как «что-то для банков и NASA», посмотрим на ещё один бытовой паттерн: «ключ → значение». В реальном проекте это кэш, настройки, индекс, лимитер — да что угодно.
Мини-хранилище строк:
import Foundation
actor KeyValueStore {
private var storage: [String: String] = [:]
func set(_ value: String, for key: String) {
storage[key] = value
}
func get(_ key: String) -> String? {
storage[key]
}
}
Здесь актёр делает одну простую вещь: держит внутри Dictionary, который потенциально может дёргаться из разных задач. Если бы это был class, и вы параллельно делали set/get, вам пришлось бы вручную синхронизировать доступ. С actor модель синхронизации встроена в правила: доступ к storage изолирован актёром.
Сделаем актёр-кэш «ответ по ключу»:
import Foundation
actor ResponseCache {
private var storage: [String: Data] = [:]
func get(_ key: String) -> Data? {
storage[key]
}
func set(_ data: Data, for key: String) {
storage[key] = data
}
}
Внутри — обычный словарь. Без «магических» контейнеров. Безопасность здесь не в типе Dictionary, а в том, что к нему нельзя обратиться «как попало»: только актёр решает, когда и как меняется его состояние.
4. Actor isolation и граница await
Сердце актёров — это actor isolation: набор правил, которые определяют, что можно делать с состоянием актёра и где. Главное правило звучит почти по-детски: «stored properties актёра можно трогать напрямую только на self». Компилятор использует это правило, чтобы предотвращать гонки данных статически.
Супер-типичная ситуация: у вас есть два объекта, и вы хотите внутри одного обновить состояние другого «по привычке». В class это можно (и это опасно), а в actor компилятор скажет «нет».
Представим банковский счёт:
import Foundation
actor BankAccount {
private var balance: Int
init(balance: Int) {
self.balance = balance
}
func deposit(_ amount: Int) {
balance += amount
}
}
Если вы захотите сделать перевод «в лоб»: прочитать и изменить balance у другого счёта напрямую — для актёров так делать нельзя. Обращение к чужому состоянию — это уже граница изоляции (cross-actor), и она регулируется отдельными правилами: синхронно подлезть к чужому actor-isolated var нельзя.
Почему это хорошо для нас как для разработчиков? Потому что компилятор не даёт вам написать код, который «иногда работает». Он заставляет выразить намерение правильно: если вы хотите взаимодействовать с другим актёром — это уже граница изоляции, и вы должны пройти её безопасным способом.
Отсюда же вытекает следующий «шок новичка»: «почему компилятор вдруг требует await, хотя метод не async?!» Это нормальная реакция. Она даже полезная: вы почувствовали границу изоляции.
В модели Swift любой доступ к изолированным членам актёра извне — это потенциально асинхронная операция, потому что актёр может быть занят выполнением другой операции. Поэтому такие обращения требуют await: вы как бы говорите «окей, подожду своей очереди».
Мини-пример использования Counter:
import Foundation
let counter = Counter()
Task {
await counter.increment()
let v = await counter.get()
print("value =", v) // value = 1
}
Даже если increment() не помечен как async, снаружи он вызывается через await. Это не «потому что метод медленный». Это потому, что доступ к актёру — это «встать в очередь к актёру», а очередь по определению может ждать.
5. LibraryCLI: актёр как single-writer для кэша и индекса
Когда курс говорит «не параллелим мутирующие компоненты, соблюдаем single-writer», это звучит как правило хорошего тона. Актёр же превращает этот стиль в конструкцию языка: у состояния появляется хозяин, и только он имеет право менять внутренние var.
Представьте наш LibraryCLI на поздних стадиях: мы можем параллельно получать данные из сети, делать несколько запросов, агрегировать результаты. Но когда дело доходит до сохранения, обновления кэша или обновления индекса, мы хотим, чтобы запись была последовательной и предсказуемой. Если кэш или индекс — это actor, то «один писатель» становится не пожеланием, а устройством кода.
Очень важная привычка проектирования: старайтесь, чтобы актёр не раздавал наружу свои внутренние структуры данных «живьём». Пусть он принимает команду «сделай» и возвращает результат, а не отдаёт ссылку на «внутренний словарь». Цель актёра — не просто хранить данные, а гарантировать, что данные не превратятся в shared mutable mess.
6. Типичные ошибки при первом знакомстве с actor
Ошибка №1: воспринимать actor как “class для ускорения”.
Актёр не обязан ускорять код. Он прежде всего делает код корректным, устраняя гонки данных на доступ к состоянию. Если вы попытаетесь использовать актёры как «параллелизм ради параллелизма», вы можете даже замедлить программу, просто потому что добавили лишние точки ожидания.
Ошибка №2: хранить состояние публичным var и ожидать, что актёр “всё равно защитит”.
Формально актёр защитит доступ, но API станет неудобным и хрупким: внешний код начнёт строить логику из серии «прочитал → подумал → записал». Это почти всегда ведёт к логическим гонкам. Правильнее держать stored state private и давать методы, которые выражают намерение.
Ошибка №3: удивляться await на “не async” методах и пытаться “сбежать” от него.
await в данном случае — не про длительность, а про границу изоляции. Он показывает: «сейчас я обращаюсь к чужому изолированному состоянию и могу подождать». Если вы начинаете бороться с await, обычно вы боретесь не с синтаксисом, а с самой моделью безопасности.
Ошибка №4: делать один гигантский актёр “на всё приложение”.
Если вы запихнёте в один актёр кэш, логгер, репозиторий, лимитер, индекс и половину бизнес-логики, вы получите огромный «монолит», который сложно понимать и который превращает весь проект в одну очередь. Актёр хорош, когда изолирует конкретный кусок состояния, а не всю вселенную.
Ошибка №5: думать, что актёры отменяют необходимость думать про архитектуру.
Актёр решает важную проблему — гонки данных, но не проектирует ваш API за вас. Вы всё ещё должны решать, какие операции делать атомарными, где хранить инварианты, что возвращать наружу. Просто теперь у вас есть надёжный инструмент, который помогает не наступать на самые острые грабли.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ