JavaRush /Курсы /Swift SELF /actor как инструмент изоляции mutable state

actor как инструмент изоляции mutable state

Swift SELF
70 уровень , 1 лекция
Открыта

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? Что происходит при доступе из разных задач
struct
value (копируется) да обычно безопаснее, потому что копии не разделяют память
class
reference (разделяется) да легко получить shared mutable state и гонки
actor
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 за вас. Вы всё ещё должны решать, какие операции делать атомарными, где хранить инварианты, что возвращать наружу. Просто теперь у вас есть надёжный инструмент, который помогает не наступать на самые острые грабли.

1
Задача
Swift SELF, 70 уровень, 1 лекция
Недоступна
Счётчик кликов
Счётчик кликов
1
Задача
Swift SELF, 70 уровень, 1 лекция
Недоступна
Команды настроек
Команды настроек
1
Задача
Swift SELF, 70 уровень, 1 лекция
Недоступна
Пополнения кошелька
Пополнения кошелька
1
Задача
Swift SELF, 70 уровень, 1 лекция
Недоступна
Статистика событий
Статистика событий
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ