1. Спільний стан: чому це стало проблемою
Поки наше LibraryCLI працювало строго послідовно — прочитали команду, виконали її, вивели результат — спільний стан міг лишатися «прихованим злом»: він нібито є, але ніхто не звертається до нього одночасно, тож усе виглядає стабільно. Та щойно ми додали паралельні запити (async let, TaskGroup, кілька Task {}), один і той самий обʼєкт у памʼяті починає одночасно цікавити кілька задач.
Під «спільним станом» (shared state) будемо розуміти дані, які живуть довше за одну функцію і можуть використовуватися з різних місць. Під «спільним змінюваним станом» (shared mutable state) — те саме, але з можливістю змінювати його (var) і тим самим провокувати гонки даних. Саме його Swift намагається ізолювати за допомогою actor і Sendable.
Для наочності ось типовий портрет проблеми в CLI:
flowchart TD
CLI["CLI (розбір команд)"] --> SVC["Сервіс застосунку"]
SVC -->|паралельно| NET1["Мережевий запит № 1"]
SVC -->|паралельно| NET2["Мережевий запит № 2"]
SVC --> REPO["Репозиторій (файл + індекс)"]
SVC --> CACHE["Кеш (словник у памʼяті)"]
SVC --> LOG["Логер (буфер / файл / консоль)"]
Мережа сама по собі часто 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] }
}
Тут важливий момент для новачка: методи get/set самі по собі не зобовʼязані бути async. Але під час звернення до них ззовні вам однаково знадобиться await, бо це міжакторний доступ.
Наприклад:
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. Репозиторій: файл на диску теж спільний стан
Репозиторій у LibraryCLI зберігає дані між запусками: ми читаємо JSON, будуємо індекс, додаємо або видаляємо книги, а потім зберігаємо все назад. Навіть якщо ви «не зберігаєте нічого в памʼяті», сам факт запису у файл — це спільний ресурс. Два паралельні записи в один і той самий файл можуть пошкодити JSON, а потім ви довго дивитиметеся на помилку декодування й робитимете вигляд, що так і задумано.
Усередині репозиторію майже завжди є змінюваний стан: колекція книг, індекс, прапорець «брудності», шлях до файлу тощо. А ще репозиторій часто викликають із різних команд: add, remove, fetch, fetch-many. І щойно ви дозволяєте паралельний fetch-many, зʼявляється спокуса: «А давайте кожна задача сама додасть книгу в репозиторій». Ось тут і починається справжній цирк.
Типова помилка: оновлювати репозиторій із паралельних задач
Проблема не в тому, що «так ніколи не можна». Проблема в тому, що це легко зробити випадково, а потім ви отримуєте гонки даних і важкі для відтворення баги.
Рішення навчального рівня: репозиторій — ідеальний кандидат на actor, бо він буквально має бути single-writer за своєю природою.
Спрощений actor-репозиторій
Нижче — каркас, щоб побачити форму. Він навмисно маленький і без деталей серіалізації: важлива ідея «увесь стан за парканом».
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 під час міжакторної взаємодії: типи, що перетинають межі актора, мають бути безпечними для передавання.
Другий: метод upsert — «велика операція». Ми не віддаємо назовні books і не даємо «помацати словник руками». Чим менше зовнішній код може зібрати «транзакцію з трьох викликів», тим менший шанс логічних гонок.
Чому не варто віддавати назовні внутрішній індекс
У багатьох репозиторіях є індекс виду Dictionary<Token, Set<BookID>>. Він дуже корисний для пошуку, але це теж змінюваний стан. Якщо ви віддаєте цей індекс назовні цілком, зовнішній код може почати «розумно оптимізувати» й випадково порушити інваріанти.
Хороший стиль: репозиторій сам виконує операції пошуку й повертає список BookSnapshot або BookID, а індекс залишається внутрішньою деталлю. Так ви утримуєте інваріанти всередині ізоляції актора.
4. Логер: це теж стан, навіть якщо здається «просто print»
З логером зазвичай сперечаються так: «Та яка там конкурентність, це ж просто повідомлення в консоль». І якщо логер справді лише викликає print, то максимум, що ви отримаєте, — перемішані рядки. Але вже цього достатньо, щоб налагодження перетворилося на квест «вгадайте, який рядок до якої задачі належить».
А якщо логер пише у файл, тримає буфер, форматує повідомлення з накопиченням контексту, то він стає класичним shared mutable state. І тут уже можна отримати не лише кашу в логах, а й пошкодження файла логів.
Ідея: серіалізуємо логування через актора
У курсі ми вже маємо контракт Logger із розділу про логування. Ми не будемо вигадувати новий протокол, щоб не ламати архітектуру. Замість цього можна зробити актор-обгортку, яка гарантує: виклики логування проходять через одну чергу (mailbox актора), а отже, виконуються послідовно відносно внутрішнього стану логера.
Каркас-ідея:
import Foundation
actor LoggerActor {
private let base: any Logger
init(base: any Logger) { this.base = base }
func log(_ message: String) {
base.log(message) // всередині актора — послідовно
}
}
Тут показано найпростіший варіант «один рядок». У реальному проєкті ви передаватимете рівень, категорію й контекст. Але головний виграш у тому, що тепер із різних задач ви можете написати:
await logger.log("fetch started")
і не думати, що дві задачі одночасно полізли в один і той самий буфер чи файл.
Нюанс щодо порядку логів
Важливо не перебільшувати: актор обробляє повідомлення послідовно, але порядок їх виконання може залежати від планування — у рантаймі враховуються пріоритети задач. Тому «строгий часовий порядок» логів не гарантується як математична аксіома.
Але «не буде одночасного запису в один і той самий внутрішній стан» — це якраз те, що нам потрібно для коректності.
5. Що ізолювати, а що залишити: шпаргалка для LibraryCLI
Коли новачок уперше чує «ізолювати стан», рука тягнеться загорнути в актор усе підряд, включно з функціями String.lowercased(). Це зрозумілий порив, але він ускладнює код. Правильніше розділити компоненти на ті, що накопичують стан, і ті, що лише обчислюють.
Нижче — орієнтир для нашого проєкту. Це не закон фізики, але добрий компас.
| Компонент у LibraryCLI | Є довгоживучий var? | Може викликатися з паралельних задач? | Що робимо |
|---|---|---|---|
| Кеш у памʼяті (Dictionary) | так | так, особливо під час fetch-many | ізолюємо актором |
| Rate limiter (лічильники/час/вікно) | так | так, якщо мережа паралельна | ізолюємо актором |
| Репозиторій (у памʼяті + файл + індекс) | так | потенційно так | ізолюємо актором (single-writer) |
| Логер (буфер/файл/лічильники) | залежить | так | часто має сенс ізолювати актором |
| ApiClient/HTTPClient без стану | ні | так | залишаємо як є |
| Парсер команд, мапінг DTO→Domain | ні | так | залишаємо як є |
| Конфіг (лише let) | ні | так | залишаємо як let, це добре |
Особливо приємно, що actor-типи в моделі Swift вважаються Sendable, бо їхню мутабельність захищає ізоляція.
6. Як не зламати архітектуру: актор навколо стану
Є небезпечний підхід: «давайте зробимо один actor App і запхаємо в нього взагалі все: мережу, CLI, парсер, репозиторій, логер». У результаті ви отримаєте один гігантський послідовний комбайн, який формально безпечний, але втрачає сенс конкурентності, а налагоджувати його все одно важко, бо він монолітний.
У навчальному проєкті нам важливіший інший підхід: актор ізолює маленький шматок стану, а чиста логіка залишається чистою. Тоді «конкурентність» живе в шарі оркестрації (сервісах), а «коректність даних» — за парканом акторів.
Мінісхема «до/після»
flowchart LR
subgraph Before["До"]
S1["Сервіс"] --> C1["Кеш (клас)"]
S1 --> R1["Репозиторій (клас)"]
S1 --> L1["Логер (клас)"]
end
subgraph After["Після"]
S2["Сервіс"] --> C2["Кеш (actor)"]
S2 --> R2["Репозиторій (actor)"]
S2 --> L2["LoggerActor (актор-обгортка)"]
end
Сенс «після» не в тому, що все стало async. Сенс у тому, що навіть якщо сервіс почне сміливіше виконувати задачі паралельно, стан не почне ламатися сам по собі.
Приклад: паралельне завантаження і безпечне збереження
Нижче — навмисно маленька ілюстрація того, як це відчувається в коді. У нас є сервіс, який отримує книгу (як snapshot) і кладе її в репозиторій. Важливо, що репозиторій — actor, тож оновлення буде послідовним відносно його стану.
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)
}
Так, це іграшка, але вона показує важливу звичку: паралелимо те, що можна паралелити (отримання даних), а спільний стан оновлюємо через ізоляцію (репозиторій). Саме це правило ми закріплювали як політику single-writer, а тепер актор робить його не моральною заповіддю, а властивістю програми.
7. Типові помилки під час виділення shared state
Помилка № 1: «Кеш — це просто словник, він же маленький, отже безпечний».
Розмір словника ніяк не впливає на наявність гонки даних. Якщо один Dictionary одночасно читають і пишуть із різних задач, це shared mutable state. Правильна стратегія — або суворо забезпечити послідовний доступ і ніколи не порушувати це правило, або ізолювати кеш актором, щоб правило виконувалося автоматично.
Помилка № 2: «Репозиторій можна оновлювати з кожної паралельної задачі, а потім якось зберегти».
Такий підхід часто дає «напівправильні» стани: одна задача встигла оновити індекс, інша — лише список книг, третя — уже почала запис у файл. Навіть якщо ви не спіймаєте явної гонки даних, можна отримати логічне псування даних. Репозиторій майже завжди має бути single-writer, і актор — найзрозуміліший спосіб це виразити.
Помилка № 3: «Логер — це виведення, він не про дані, отже його не треба ізолювати».
Логер часто тримає внутрішній стан: форматер, буфер, лічильники, файл. Під час паралельного доступу можна отримати перемішані рядки або пошкоджений файл. Навіть якщо ви пишете лише в консоль, перемішування робить налагодження болісним. Актор-обгортка навколо логера зазвичай коштує дуже дешево, а економить багато нервів.
Помилка № 4: «Давайте віддавати назовні з актора посилання на внутрішній обʼєкт, так швидше».
Це найпідступніший спосіб «обійти» ізоляцію. Якщо актор повернув назовні посилальний mutable-обʼєкт, зовнішній код може змінювати його конкурентно, і актор уже не контролює безпеку. Тому назовні краще віддавати snapshots — value-типи. І це прямо узгоджується з тим, навіщо існує Sendable: не випускати shared mutable state за межі конкурентних доменів.
Помилка № 5: «У нас усе в actor, отже будь-який метод атомарний».
Актор захищає від одночасного доступу до даних, але під час await всередині актора можлива реентрантність: виконання може призупинитися, актор обслуговуватиме інше повідомлення, а потім повернеться. Якщо ви робите «перевірив → await → змінив», ви ризикуєте інваріантами. Тому всередині акторів намагайтеся не вставляти await між перевіркою і критичною мутацією, а якщо це неминуче — перевіряйте умови повторно після await.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ