JavaRush /Курси /Swift SELF /Конкурентний запис

Конкурентний запис

Swift SELF
Рівень 61 , Лекція 3
Відкрита

1. Атомарного запису недостатньо

Коли ви вперше навчилися зберігати JSON на диск, здається, що проблему вже розвʼязано: «Ну ми ж записали файл, усе працює». Але в реальному світі застосунок живе не у вакуумі: його можуть запустити двічі, команди можуть виконуватися паралельно (у різних процесах), а запис за схемою «прочитав → змінив → зберіг» може призвести до втрати даних навіть за ідеальної атомарної заміни файла. Сьогодні ми фіксуємо політику single-writer і вчимося ізолювати спільний змінюваний стан так, щоб система залишалася передбачуваною.

Дві різні проблеми: «напівфайл» і «втрата оновлень»

Дуже легко переплутати ці дві проблеми, тому що обидві пов’язані із записом на диск і обидві проявляються як «щось зникло». Але в них різна природа, різні симптоми й різні способи захисту.

Перша біда — це порушення цілісності файла. Файл пошкоджено: його не можна прочитати, він не декодується і виглядає так, ніби «JSON обірвався посередині». Від цього нас рятують temp → replace, backup і recovery.

Друга біда — це втрата оновлень (lost update). Тут файл валідний і навіть чудово декодується. Просто в ньому опинилася версія даних, яка «перетиснула» зміни іншого збереження. І ключовий момент: атомарна заміна головного файла взагалі не зобовʼязана рятувати від цього. Вона гарантує: «замість старого файла зʼявився новий цілком», але не гарантує: «цей новий файл містить усі зміни, які хтось устиг зробити паралельно».

Якщо говорити мовою життєвих аналогій: temp → replace захищає від ситуації «я писав курсову, і ноутбук вимкнувся посеред збереження». А lost update — це ситуація «дві людини редагують один документ, а потім хтось натискає Save пізніше й затирає чужі правки». Файл при цьому цілком справний. Просто правок немає.

Де у нас «спільний змінюваний стан» у CLI-застосунку

У нашому навчальному CLI (умовно назвімо його LibraryCLI) спільний змінюваний стан зазвичай виглядає так:

  1. Стан у памʼяті: наприклад, масив книг, словник за ID, лічильник версій — що завгодно.
  2. Стан на диску: 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 — кооперативний механізм. Він не захищає від «поганого сусіда», який не дотримується правил, і не є системним блокуванням. Це нормально: наше завдання сьогодні — зробити поведінку передбачуваною і різко знизити ризик втрат, а не збудувати файлову фортецю.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ