1. Атомарного запису недостатньо
Коли ви вперше навчилися зберігати JSON на диск, здається, що проблему вже розвʼязано: «Ну ми ж записали файл, усе працює». Але в реальному світі застосунок живе не у вакуумі: його можуть запустити двічі, команди можуть виконуватися паралельно (у різних процесах), а запис за схемою «прочитав → змінив → зберіг» може призвести до втрати даних навіть за ідеальної атомарної заміни файла. Сьогодні ми фіксуємо політику single-writer і вчимося ізолювати спільний змінюваний стан так, щоб система залишалася передбачуваною.
Дві різні проблеми: «напівфайл» і «втрата оновлень»
Дуже легко переплутати ці дві проблеми, тому що обидві пов’язані із записом на диск і обидві проявляються як «щось зникло». Але в них різна природа, різні симптоми й різні способи захисту.
Перша біда — це порушення цілісності файла. Файл пошкоджено: його не можна прочитати, він не декодується і виглядає так, ніби «JSON обірвався посередині». Від цього нас рятують temp → replace, backup і recovery.
Друга біда — це втрата оновлень (lost update). Тут файл валідний і навіть чудово декодується. Просто в ньому опинилася версія даних, яка «перетиснула» зміни іншого збереження. І ключовий момент: атомарна заміна головного файла взагалі не зобовʼязана рятувати від цього. Вона гарантує: «замість старого файла зʼявився новий цілком», але не гарантує: «цей новий файл містить усі зміни, які хтось устиг зробити паралельно».
Якщо говорити мовою життєвих аналогій: temp → replace захищає від ситуації «я писав курсову, і ноутбук вимкнувся посеред збереження». А lost update — це ситуація «дві людини редагують один документ, а потім хтось натискає Save пізніше й затирає чужі правки». Файл при цьому цілком справний. Просто правок немає.
Де у нас «спільний змінюваний стан» у CLI-застосунку
У нашому навчальному CLI (умовно назвімо його LibraryCLI) спільний змінюваний стан зазвичай виглядає так:
- Стан у памʼяті: наприклад, масив книг, словник за ID, лічильник версій — що завгодно.
- Стан на диску: library.json (і поруч library.json.bak).
В ідеальному світі у нас є один процес, який робить усе послідовно: завантажив → обробив команду → зберіг → завершився. Тоді здається, що конкуренції немає.
Але в реальності конкурентність у CLI зʼявляється дуже легко: користувач може відкрити два термінали й запустити дві команди майже одночасно:
- термінал A: librarycli add ...
- термінал B: librarycli remove ...
І у вас уже два процеси, два незалежні «світи» в памʼяті, які обидва намагатимуться зберегти свій знімок назад в один і той самий library.json.
І ще один важливий момент зі світу Swift: навіть у межах одного процесу «спільний змінюваний стан» небезпечний, якщо до нього звертаються конкурентно. Swift суворо ставиться до ексклюзивності доступу до памʼяті: конкурентні записи й змішане читання/запис без належної ізоляції можуть призводити до непередбачуваної поведінки та помилок середовища виконання, особливо навколо inout і сетерів. Це добре видно в обговоренні моделі памʼяті та ексклюзивності доступу.
Сьогодні ми не будемо будувати багатопотоковий застосунок і не будемо додавати складні примітиви синхронізації. Наша мета простіша: зрозуміти, як архітектурно зробити так, щоб писав лише один компонент, а спільний змінюваний стан не розповзався по коду.
Як виглядає «втрата оновлень»: часова шкала
Корисно один раз побачити lost update як історію в картинках. Уявімо два процеси (A і B), які роблять класичний цикл «read → modify → write»:
sequenceDiagram
participant A as Процес A
participant F as library.json
participant B as Процес B
A->>F: завантажує (читає v1)
B->>F: завантажує (читає v1)
A->>A: змінює (робить v1 + додає книгу)
B->>B: змінює (робить v1 - видаляє книгу)
A->>F: зберігає (записує v2A)
B->>F: зберігає (записує v2B поверх v2A)
Зверніть увагу: і v2A, і v2B можуть бути абсолютно валідними. Але зрештою залишиться тільки той, хто зберіг останнім, а зміни першого зникнуть.
І ось тут ховається підступна пастка мислення: «але ж ми записуємо атомарно!». Так, атомарно. Та атомарність відповідає на запитання «файл цілий?», а не на запитання «усі зміни враховано?».
2. Single-writer в архітектурі
Single-writer policy починається не з FileManager, а з голови. Це насамперед політика відповідальності: у нашому застосунку має існувати один компонент, який має право писати конкретний файл, і всі інші зобовʼязані звертатися до нього, а не «трошки писати тут, трошки там».
У межах LibraryCLI це зазвичай означає, що запис на диск не розмазаний по коду команд. Команда add не повинна сама відкривати URL файла й виконувати write. Команда remove теж не повинна. Замість цього обидві команди звертаються до одного «сховища», яке вміє зберігати знімок за правилами надійного запису.
На рівні коду це можна відчути так: у нас зʼявляється тип, який володіє mainURL і надає методи рівня «завантажити/зберегти». Навіть якщо всередині поки що все просто, межа вже зʼявилася, і це важливіше за будь-яку магію.
Мініфрагмент (ідея «один писар» через окремий тип):
import Foundation
struct LibraryStorage {
let mainURL: URL
func loadData() throws -> Data {
try Data(contentsOf: mainURL)
}
func saveData(_ data: Data) throws {
// тут буде політика надійного запису з попередніх лекцій
try data.write(to: mainURL) // тимчасово, спрощено
}
}
Так, saveData поки що «наївний». Але головне — тепер лише LibraryStorage знає, куди писати, а решта коду навіть не повинна мати доступу до mainURL (або принаймні не повинна ним користуватися).
Ізоляція змінюваного стану всередині процесу
Друга частина ідеї single-writer — це ізоляція не лише файлового запису, а й «спільного стану». Навіть у послідовному CLI ви швидко помітите спокусу: зберігати поточну бібліотеку в змінній, передавати її по функціях, десь змінювати, десь зберігати.
Щоб не отримати «суп із мутацій», зручно тримати в голові простий принцип: змінювати стан може один шар, а інші шари формують наміри.
Перекладаючи на людську мову: команда має сказати «додай оцю книгу», а не «ось тобі масив, я туди сама вставлю і сама збережу». Тоді у вас зʼявляється єдина точка, де зміни застосовуються і де можна централізовано вирішувати, коли зберігаємо і що робимо з помилками.
Мініфрагмент: команда формує намір, сервіс застосовує:
import Foundation
struct AddBookInput {
let title: String
}
struct LibraryService {
func addBook(_ input: AddBookInput) {
print("Додаємо:", input.title) // Додаємо: ...
}
}
Це виглядає занадто просто, але саме в таких «простих» межах і живе ізоляція. Коли логіка виросте, ви не будете розгрібати ситуацію «а хто взагалі останнім зберіг файл?».
3. Кооперативний single-writer між процесами
Чому single-writer усередині процесу недостатній
На жаль (або на щастя, інакше було б нудно), single-writer у межах одного процесу не рятує від двох процесів. У кожному процесі ви можете бути дисциплінованими й мати рівно один LibraryStorage, але їх буде два — тому що процесів два.
Отже, нам потрібна ще одна ідея: кооперативна дисципліна «пишемо по одному» на рівні файла. І тут важливе слово «кооперативна»: ми не будуємо «непробивну систему», ми вводимо зрозуміле правило, якому слідують усі екземпляри нашого CLI.
У цю лекцію ми свідомо не тягнемо низькорівневі системні блокування. Нам потрібен простий сигнал: «триває запис». Найпростіший варіант — lock-file поруч із main-файлом: наприклад, library.json.lock.
Сенс такий: процес перед збереженням намагається створити lock-file. Якщо вдалося — він «власник запису», зберігає й видаляє lock. Якщо lock уже існує — значить, хтось пише, і ми не втручаємося.
Це схоже на табличку «зайнято» на дверях переговорної. Вона фізично не блокує відчинення дверей, але нормальні люди все ж спершу стукають.
Lock-file як мінімальний захист
Важливо заздалегідь домовитися із собою: lock-file — не срібна куля. Він не «захищає світ», він допомагає зробити поведінку передбачуваною й сильно зменшує шанс втратити оновлення через одночасні збереження.
Мінімальна реалізація «взяв lock → виконав дію → зняв lock»:
import Foundation
enum StorageWriteError: Error {
case locked
}
func withFileLock(lockURL: URL, _ body: () throws -> Void) throws {
let fm = FileManager.default
let created = fm.createFile(atPath: lockURL.path, contents: nil, attributes: nil)
guard created else { throw StorageWriteError.locked }
defer { try? fm.removeItem(at: lockURL) }
try body()
}
Зверніть увагу, чому тут такий важливий defer. defer — це ваш «аварійний прибиральник»: навіть якщо всередині body() щось упаде з помилкою, ми спробуємо прибрати lock. Без defer lock-file легко перетвориться на «вічну табличку зайнято», і тоді ваше сховище буде «назавжди зайняте» аж до ручного видалення файла.
І приклад використання:
import Foundation
let mainURL = URL(fileURLWithPath: "library.json")
let lockURL = mainURL.appendingPathExtension("lock")
do {
try withFileLock(lockURL: lockURL) {
print("Зберігаємо...") // Зберігаємо...
// тут буде надійний запис: temp → replace (+ backup)
}
} catch StorageWriteError.locked {
print("Пропускаємо збереження: сховище заблоковано") // Пропускаємо збереження: сховище заблоковано
}
Так, це не гарантує «вічної коректності», але дає ключову річ: ми перестаємо вдавати, що одночасних записів не буває.
Як повʼязати lock-file і надійний запис
Тепер складаємо все в одну «політику збереження», яка звучить майже як чек-лист:
Спочатку беремо lock, щоб не було двох писарів одночасно. Потім робимо backup, якщо це частина вашої політики. Потім пишемо через temp → replace. І лише після цього відпускаємо lock.
Ми не будемо переписувати всі функції з попередніх лекцій, але покажемо, як виглядає «склейка» на рівні верхньої функції, яка визначає порядок кроків.
import Foundation
func saveSnapshot(_ data: Data, mainURL: URL) throws {
let lockURL = mainURL.appendingPathExtension("lock")
try withFileLock(lockURL: lockURL) {
// Тут припускаємо, що в нас уже є:
// makeBackupIfPossible(of:) і atomicWrite(_:)
// із попередніх лекцій цього дня.
// Порядок важливий: backup → atomicWrite.
// try makeBackupIfPossible(of: mainURL)
// try atomicWrite(data, to: mainURL)
print("Збережено безпечно") // Збережено безпечно
}
}
Так, тут два виклики закоментовані: ми спираємося на код попередніх лекцій цього дня. Але саме ця «рамка порядку» і є політика. Якщо порядок переплутати, можна зробити backup із неправильного стану або записати основний файл без резервної копії, хоча ви думали, що копія завжди є.
Що робити, коли lock не взяли
У цей момент хочеться зробити «якось» — наприклад, коли lock не взяли, то «ну гаразд, усе одно збережемо, ми ж розумні». Це прямий шлях назад до lost update.
Коли ви не взяли lock, у вас є кілька варіантів поведінки, і кожен — частина політики. У межах курсу зазвичай обирають найбільш передбачуваний: не записувати взагалі, а повернути помилку «сховище зайняте». Це може бути неприємно користувачеві, але це чесно: ви не втрачаєте дані мовчки.
У CLI це особливо доречно: користувач може просто повторити команду за секунду. А от «мовчки перезаписати чужі зміни» — це як «непомітно забрати чужу книгу, тому що вона лежала на столі».
Чому ми говоримо «ізоляція стану», навіть без багатопоточності
Можна поставити резонне запитання: «Ми ж не пишемо багатопотоковий код, навіщо тут взагалі “ізоляція стану”?»
Тому що стан буває «спільним» не лише між потоками. Він буває спільним між:
- різними командами всередині одного процесу (кодова база зростає, і легко почати змінювати дані з різних місць),
- різними процесами (два запуски CLI),
- різними рівнями коду (парсер команд, предметна логіка, шар зберігання).
Ізоляція стану — це коли ви свідомо вирішуєте: «ось тут стан змінюється, а ось тут — лише читається або формується намір». Навіть без жодного потоку це різко зменшує хаос і робить помилки передбачуваними.
І так, Swift своїм підходом до ексклюзивності доступу нагадує нам, що «конкурентний доступ до змінюваних даних» — річ небезпечна в принципі, і з нею не можна поводитися як із дрібницею.
4. Типові помилки
Помилка №1: плутати атомарність запису із захистом від втрати оновлень.
Дуже часта логічна пастка: раз ми робимо temp → replace, значить, конкуренція вже не страшна. Але temp → replace гарантує цілісність файла, а не те, що дві паралельні правки зіллються в одну. У результаті можна отримати ідеально читабельний JSON, у якому бракує половини змін.
Помилка №2: розмазати запис по коду («та я тут на хвилинку write зроблю»).
Коли add, remove, update і ще пʼять команд самі пишуть файл напряму, single-writer зникає. Потім ви починаєте виправляти «чому іноді .bak не відповідає основному файлу» або «чому recovery відновив дивну версію», і виявляється, що хтось писав не за правилами. Ліки тут нудні, але надійні: один шар відповідає за запис, інші навіть не знають, де лежить файл.
Помилка №3: взяти lock і забути зняти його при помилці.
Lock-file без defer перетворюється на пастку: будь-яка помилка під час запису залишить .lock, і далі все сховище виглядає «вічно зайнятим». Через це розробники починають видаляти lock «на всякий випадок» або ігнорувати його — і система скочується до втрати оновлень. Правильний шлях — поставити defer одразу після успішного створення lock.
Помилка №4: ігнорувати помилку locked і все одно зберігати.
Це майже завжди означає, що ви самі скасовуєте сенс lock-file. Якщо lock не взяли — отже, за політикою ви не маєте права на запис. У цей момент краще чесно завершитися з помилкою, ніж тихо влаштувати гонку «хто швидше перезапише».
Помилка №5: намагатися зробити lock-file «абсолютним захистом».
Lock-file — кооперативний механізм. Він не захищає від «поганого сусіда», який не дотримується правил, і не є системним блокуванням. Це нормально: наше завдання сьогодні — зробити поведінку передбачуваною і різко знизити ризик втрат, а не збудувати файлову фортецю.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ