1. Общий state: почему это стало проблемой
Пока наше LibraryCLI было строго последовательным (прочитали команду → сделали → вывели результат), общий state мог быть «скрытым злом»: вроде бы он есть, но никто не дергает его одновременно, и поэтому всё выглядит стабильным. Но как только мы добавили параллельные запросы (async let, TaskGroup, несколько Task {}), одна и та же штука в памяти начинает интересовать несколько задач «почти одновременно».
Под «общим состоянием» (shared state) будем понимать данные, которые живут дольше одной функции и могут быть использованы из разных мест. Под «общим изменяемым состоянием» (shared mutable state) — то же самое, но с возможностью менять (var) и тем самым устраивать гонки данных. Именно его Swift пытается загнать в клетку из actor и Sendable.
Чтобы было наглядно, вот типичный портрет проблемы в CLI:
flowchart TD
CLI["CLI (парсинг команд)"] --> SVC["Application Service"]
SVC -->|параллельно| NET1["Network fetch #1"]
SVC -->|параллельно| NET2["Network fetch #2"]
SVC --> REPO["Repository (файл + индекс)"]
SVC --> CACHE["Cache (словарь в памяти)"]
SVC --> LOG["Logger (буфер/файл/консоль)"]
Сеть сама по себе часто stateless: запрос ушёл — ответ пришёл. А вот кэш, репозиторий и логгер — долгоживущие штуки. Если их дергать конкурентно без дисциплины, начинается классика: «иногда работает, иногда нет, а иногда ещё и в пятницу вечером».
Как найти shared mutable state в проекте
В больших системах «всеобщий аудит конкурентности» звучит как обещание с понедельника начать новую жизнь. Но в учебном проекте это можно сделать честно и довольно быстро, потому что нас интересуют всего три-четыре типовых «накопителя состояния». И почти всегда они узнаваемы по запаху: var, коллекции (Dictionary, Array), и что-то, что живёт в сервисе как поле, а не как локальная переменная.
Практическая мысль такая: локальные переменные внутри функции обычно безопасны, потому что они принадлежат одному потоку выполнения. А вот всё, что хранится в свойствах долгоживущего объекта/сервиса и переживает несколько команд CLI — кандидат на изоляцию.
Очень простое правило «подсвети маркером»
Представьте, что вы берёте маркер и обводите в коде:
- class с var-свойствами,
- static var, глобальные var, синглтоны,
- поля сервисов вида private var storage: [Key: Value],
- всё, что пишет в файл, держит индекс, или копит статистику.
Swift 6 ещё и сам будет подсказывать, что глобальные var — это боль: строгая конкурентность специально усиливает требования к глобальным переменным, потому что к ним «можно обратиться откуда угодно», а значит — это идеальный рассадник гонок данных.
2. Кэш: маленький Dictionary, большие последствия
Кэш в учебном проекте выглядит очень невинно. «Мы просто запомним ответ, чтобы не ходить в сеть ещё раз». В одиночном потоке это так и работает. Но при параллельных запросах у кэша появляется два типовых сценария поломки: либо две задачи одновременно пишут в один и тот же словарь, либо одна читает в момент, когда другая меняет структуру словаря (а это уже прямой путь к гонке данных).
Наивная версия
import Foundation
final class ResponseCache {
private var storage: [String: Data] = [:]
func get(_ key: String) -> Data? { storage[key] }
func set(_ data: Data, for key: String) { storage[key] = data }
}
Это не «плохой код» — это код, который честно предполагает последовательный доступ. Проблема начинается, когда вы делаете несколько сетевых задач параллельно и все они смотрят в один ResponseCache.
Версия, которая дружит с конкурентностью: actor
Актёр как раз и создан для того, чтобы «мешок состояния» обслуживался последовательно, даже если клиентов много.
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 }
}
Здесь важный момент для новичка: методы get/set сами по себе не обязаны быть async. Но при обращении к ним снаружи у вас всё равно будет await, потому что это cross-actor доступ.
Например:
import Foundation
let cache = ResponseCache()
func demo() async {
await cache.set(Data("OK".utf8), for: "health")
let data = await cache.get("health")
print(data?.count ?? 0) // 2
}
Что именно хранить в кэше: «снимки», а не «живые ссылки»
Кэш особенно опасен, если вы кладёте туда ссылочные изменяемые объекты (class) и потом отдаёте их наружу. Тогда актёр вроде бы и изолирует, но вы утащили «живую» ссылку за границу — и снова shared mutable state, только теперь он замаскирован.
Правильнее кэшировать либо Data, либо value-типы (структуры) без ссылочной мутабельности. Это напрямую связано с идеей Sendable: через границы конкурентных контекстов безопаснее передавать значения, а не «общие изменяемые ссылки».
3. Репозиторий: файл на диске тоже общий state
Репозиторий в LibraryCLI хранит данные между запусками: мы читаем JSON, строим индекс, добавляем/удаляем книги, сохраняем обратно. Даже если вы «не храните ничего в памяти», сам факт записи в файл — это общий ресурс. Две параллельные записи в один и тот же файл могут дать вам повреждённый JSON, а потом вы будете долго смотреть на ошибку декодирования и делать вид, что так и задумано.
Внутри репозитория почти всегда есть изменяемое состояние: коллекция книг, индекс, флаги «грязности», путь к файлу и т.п. А ещё репозиторий любят вызывать из разных команд: add, remove, fetch, fetch-many. И как только вы разрешили параллельный fetch-many, у вас появляется соблазн: «а давайте каждая задача сама добавит книгу в репозиторий». Вот тут и начинается настоящий цирк.
Типовая ошибка: обновлять репозиторий из параллельных задач
Проблема не в том, что «так нельзя никогда». Проблема в том, что это легко сделать случайно, и потом вы получаете гонки данных и трудно воспроизводимые баги.
Решение учебного уровня: репозиторий — идеальный кандидат на actor, потому что он буквально должен быть single-writer по своей природе.
Упрощённый actor-репозиторий
Ниже — каркас, чтобы увидеть форму. Он намеренно маленький и без деталей сериализации: важна идея «весь state за забором».
import Foundation
struct BookID: Hashable, Sendable {
let rawValue: UUID
}
struct BookSnapshot: Sendable {
let id: BookID
let title: String
}
actor LibraryRepository {
private var books: [BookID: BookSnapshot] = [:]
func upsert(_ book: BookSnapshot) { books[book.id] = book }
func all() -> [BookSnapshot] { Array(books.values) }
}
Обратите внимание на два момента.
Первый: наружу мы отдаём snapshot (BookSnapshot) — value-структуру, которую безопаснее переносить между задачами, чем «живую» ссылку на объект. Это хорошо ложится на требования Sendable при cross-actor взаимодействии: типы, пересекающие границы актёра, должны быть безопасны для передачи.
Второй: метод upsert — «крупная операция». Мы не выдаём наружу books и не даём «потрогать словарь руками». Чем меньше наружный код может собрать «транзакцию из трёх вызовов», тем меньше шанс логических гонок.
Почему не стоит отдавать наружу внутренний индекс
Во многих репозиториях есть индекс вида Dictionary<Token, Set<BookID>>. Он очень полезен для поиска, но это тоже изменяемый state. Если вы отдаёте этот индекс наружу целиком, то наружный код может начать «умно оптимизировать» и случайно нарушить инварианты.
Хороший стиль: репозиторий сам выполняет операции поиска и возвращает список BookSnapshot или BookID, а индекс остаётся внутренней деталью. Так вы удерживаете инварианты внутри изоляции актёра.
4. Логгер: состояние, даже если кажется «просто print»
С логгером обычно спорят так: «Да какая там конкурентность, это же просто сообщения в консоль». И если логгер действительно просто вызывает print, то максимум, что вы получите — перемешанные строки. Но уже этого достаточно, чтобы отладка превращалась в квест «угадай, какая строка к какой задаче относится».
А если логгер пишет в файл, держит буфер, форматирует сообщения с накоплением контекста, то он становится классическим shared mutable state. И тут уже можно получить не только кашу в логах, но и порчу файла логов.
Идея: сериализуем логирование через актёра
В курсе у нас есть контракт Logger (из дня про логирование). Мы не будем изобретать новый протокол, чтобы не ломать архитектуру. Вместо этого можно сделать актёр-обёртку, который гарантирует: вызовы логирования проходят через одну очередь (mailbox актёра), а значит, выполняются последовательно относительно внутреннего состояния логгера.
Каркас-идея:
import Foundation
actor LoggerActor {
private let base: any Logger
init(base: any Logger) { self.base = base }
func log(_ message: String) {
base.log(message) // внутри актёра — последовательно
}
}
Здесь показан простейший «одна строка» вариант. В реальном проекте вы будете прокидывать level/category/контекст. Но главный выигрыш в том, что теперь вы можете из разных задач сделать:
await logger.log("fetch started")
и не думать, что две задачи одновременно полезли в один и тот же буфер/файл.
Нюанс про порядок логов
Важно не переобещать: актёр выполняет сообщения последовательно, но порядок обслуживания может зависеть от планирования (в рантайме учитываются приоритеты задач). Поэтому «строгий временной порядок» логов не гарантируется как математическая аксиома.
Но «не будет одновременной записи в один и тот же внутренний state» — это как раз то, что нам нужно для корректности.
5. Что изолировать, а что оставить: шпаргалка для LibraryCLI
Когда новичок впервые слышит «изолировать state», рука тянется завернуть в актёр всё подряд, включая функции String.lowercased(). Это понятный порыв, но он делает код тяжелее. Правильнее разделить компоненты на «накапливают состояние» и «чисто вычисляют».
Ниже — ориентир для нашего проекта. Это не закон физики, но хороший компас.
| Компонент в LibraryCLI | Есть долгоживущий var? | Может вызываться из параллельных задач? | Что делаем |
|---|---|---|---|
| In-memory cache (Dictionary) | да | да (особенно при fetch-many) | изолируем актёром |
| Rate limiter (счётчики/время/окно) | да | да, если сеть параллельная | изолируем актёром |
| Repository (в памяти + файл + индекс) | да | потенциально да | изолируем актёром (single-writer) |
| Logger (буфер/файл/счётчики) | зависит | да | часто имеет смысл изолировать актёром |
| ApiClient/HTTPClient без состояния | нет | да | оставляем как есть |
| Парсер команд, маппинг DTO→Domain | нет | да | оставляем как есть |
| Конфиг (только let) | нет | да | оставляем как let, это хорошо |
Отдельно приятно, что актёры сами по себе безопасно шарятся между задачами: actor-типы в модели Swift рассматриваются как Sendable, потому что их мутабельность защищена изоляцией.
6. Как не сломать архитектуру: актор вокруг state
Есть опасный стиль: «давайте сделаем один actor App и запихнём в него вообще всё: сеть, CLI, парсер, репозиторий, логгер». В итоге вы получите один гигантский последовательный комбайн, который формально безопасен, но теряет смысл конкурентности, а отлаживать его всё равно трудно (потому что он монолит).
В учебном проекте нам важнее другой стиль: актёр изолирует маленький кусок состояния, а чистая логика остаётся чистой. Тогда «конкурентность» живёт в orchestration-слое (сервисах), а «корректность данных» — за забором актёров.
Мини-схема «до/после»
flowchart LR
subgraph Before["До"]
S1["Service"] --> C1["Cache (class)"]
S1 --> R1["Repo (class)"]
S1 --> L1["Logger (class)"]
end
subgraph After["После"]
S2["Service"] --> C2["Cache (actor)"]
S2 --> R2["Repo (actor)"]
S2 --> L2["LoggerActor (actor wrapper)"]
end
Смысл «после» не в том, что всё стало async. Смысл в том, что даже если сервис начнёт параллелить задачи смелее, состояние не начнёт ломаться само по себе.
Пример: параллельная загрузка и безопасное сохранение
Ниже — намеренно маленькая иллюстрация того, как это ощущается в коде. У нас есть сервис, который получает книгу (как snapshot) и кладёт её в репозиторий. Важно, что репозиторий — актёр, поэтому обновление будет последовательным относительно его состояния.
import Foundation
struct BookSnapshot: Sendable {
let id: UUID
let title: String
}
actor LibraryRepository {
private var books: [UUID: BookSnapshot] = [:]
func upsert(_ book: BookSnapshot) { books[book.id] = book }
}
А теперь «две загрузки параллельно, запись последовательно»:
import Foundation
func demo(repo: LibraryRepository) async {
async let a = BookSnapshot(id: UUID(), title: "Swift")
async let b = BookSnapshot(id: UUID(), title: "Concurrency")
await repo.upsert(await a)
await repo.upsert(await b)
}
Да, это игрушка, но она показывает важную привычку: параллелим то, что можно параллелить (получение данных), а общий state обновляем через изоляцию (репозиторий). Именно это правило мы закрепляли как single-writer policy, а теперь актёр делает его не моральной заповедью, а свойством программы.
7. Типичные ошибки при выделении shared state
Ошибка №1: «Кэш — это просто словарь, он же маленький, значит безопасный».
Размер словаря никак не влияет на наличие гонки данных. Если один Dictionary одновременно читают и пишут из разных задач, это shared mutable state. Правильная стратегия — либо строго обеспечить последовательный доступ (и не нарушать это никогда), либо изолировать кэш актёром, чтобы правило соблюдалось автоматически.
Ошибка №2: «Репозиторий можно обновлять из каждой параллельной задачи, а потом как-нибудь сохранить».
Такой подход часто даёт «полукорректные» состояния: одна задача успела обновить индекс, другая — только список книг, третья — уже начала запись файла. Даже если вы не поймаете явную гонку данных, можно получить логическую порчу данных. Репозиторий почти всегда должен быть single-writer, и актёр — самый понятный способ это выразить.
Ошибка №3: «Логгер — это вывод, он не про данные, значит его не надо изолировать».
Логгер часто держит внутренний state: форматтер, буфер, счётчики, файл. При параллельном доступе можно получить перемешанные строки или повреждённый файл. Даже если вы пишете только в консоль, перемешивание делает отладку мучительной. Актёр-обёртка вокруг логгера обычно стоит очень дёшево, а экономит много нервов.
Ошибка №4: «Давайте отдавать наружу из актёра ссылку на внутренний объект, так быстрее».
Это самый коварный способ «обойти» изоляцию. Если актёр вернул наружу ссылочный mutable-объект, внешний код может менять его конкурентно, и актёр уже не контролирует безопасность. Поэтому наружу лучше отдавать snapshots — value-типы. И это напрямую согласуется с тем, зачем существует Sendable: не выпускать shared mutable state за границы конкурентных доменов.
Ошибка №5: «У нас всё в actor, значит любой метод атомарен».
Актёр защищает от одновременного доступа к данным, но при await внутри актёра возможна реэнтрантность: выполнение может приостановиться, актёр обслужит другое сообщение, и затем вернётся. Если вы делаете «проверил → await → изменил», вы рискуете инвариантами. Поэтому внутри актёров старайтесь не вставлять await между проверкой и критической мутацией, а если неизбежно — перепроверяйте условия после await.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ