1. Чому два процеси — окрема проблема
Коли ми кажемо «два процеси», це звучить трохи абстрактно — ніби йдеться про якісь сервери з мільйонами користувачів. Але на практиці це може бути буквально ви й ваш колега, які одночасно запускають одну CLI-команду, або два термінали на одному ноутбуці. Проблема підступна тим, що файл майже завжди лишається цілісним і «гарним», тож баг може жити дуже довго.
Уявімо наш навчальний застосунок — простий менеджер задач tasker, який зберігає їх у файлі tasks.json. Команда tasker add "buy milk" працює так: спершу читаємо файл, далі розбираємо JSON, додаємо задачу й записуємо файл.
Тепер запускаємо дві команди майже одночасно.
sequenceDiagram
participant A as Процес A (tasker add)
participant F as tasks.json
participant B as Процес B (tasker done)
A->>F: Читає стару версію (v1)
B->>F: Читає стару версію (v1)
A->>A: Обчислює v2 (додав задачу)
B->>B: Обчислює v3 (позначив done)
A->>F: Публікує v2 (temp+rename)
B->>F: Публікує v3 (temp+rename)
Обидва процеси діяли чесно й акуратно. Обидва використали надійний запис. Але фінал такий: у файлі лишиться v3, а зміни процесу A — додана задача — можуть зникнути, тому що B прочитав стару версію (v1), не знав про нову задачу і записав «свою» v3 як повну заміну. Це називають втратою оновлень (lost update).
Головна думка: temp+rename захищає від «півфайла», але не захищає від «півлогіки» , коли два незалежні обчислення змагаються за право бути останніми.
2. Ідея lock‑file: домовленість між процесами
Lock-file — це просте й людяне рішення. Замість того щоб намагатися зробити магію на рівні ОС (там починаються нюанси, різні платформи, різні типи блокувань), ми вводимо зрозуміле правило: якщо поруч є файл блокування, значить запис уже триває — не лізь.
Важливо одразу правильно розуміти lock-file: це домовленість, а не абсолютна гарантія від усього. Якщо один процес аварійно завершився, lock-file може залишитися (ми це обговоримо далі). Але навіть така проста домовленість різко знижує ймовірність втрати даних у звичайних сценаріях CLI.
Політика виглядає так:
- для tasks.json ми заводимо tasks.json.lock;
- процес, який хоче змінити tasks.json, спочатку намагається ексклюзивно створити tasks.json.lock;
- якщо lock створити не вдалося, бо він уже існує — отже, хтось інший зараз пише, і ми коректно виходимо з помилкою «сховище зайняте»;
- якщо lock створено — працюємо: читаємо, рахуємо, пишемо (через temp+rename/backup);
- наприкінці видаляємо lock‑файл.
Ключовий технічний трюк: ексклюзивне створення робиться через os.OpenFile з прапорцями os.O_CREATE|os.O_EXCL. Ідея в тому, що ОС має атомарно виконати операцію «створи файл, але лише якщо його ще немає», без вікна, де два процеси встигнуть домовитися по-різному.
3. os.O_CREATE|os.O_EXCL: що означає зв’язка прапорців
Прапорці в os.OpenFile часто виглядають як заклинання: O_CREATE, O_EXCL, O_WRONLY… Але тут логіка доволі приземлена, і її корисно проговорити словами.
os.O_CREATE каже: «якщо файлу немає — створи». Але сам по собі він не забороняє двом процесам майже одночасно пройти однаковий шлях.
os.O_EXCL додає суворості: «я хочу ексклюзивність». І ось важливий нюанс: O_EXCL має сенс саме разом із O_CREATE, бо «ексклюзивність» у цьому контексті означає ексклюзивно створити новий файл.
Тобто нас цікавить не «ексклюзивно відкрити», а «ексклюзивно створити», щоб другий процес не міг непомітно створити такий самий lock.
Додамо до цього os.O_WRONLY, щоб відкрити файл на запис. Це зручно ще й тому, що ми можемо записати у lock-file корисну налагоджувальну інформацію (наприклад, PID). Але навіть якщо нічого не писати, сам факт створення файла вже відіграє роль.
4. Реалізація: AcquireLock і Release
Коли ви пишете lock‑file, особливо легко зробити «майже правильний код», який потім лишає по собі сміття або не дає зрозуміти, чому щось зламалося. Тому ми писатимемо так, як звикли в Go: маленькі функції, ранні повернення і зрозумілий текст помилок.
Окремо: звільнення lock — це cleanup. Тут ідеально підходить defer, бо він гарантує виконання за будь-якого виходу з функції. Це саме той випадок, коли defer допомагає робити код безпечнішим і простішим для мозку.
Мініструктура Lock
Щоб нам було зручно зберігати і шлях, і відкритий файл, зробимо маленький тип.
package storage
import (
"os"
)
type Lock struct {
f *os.File
path string
}
Тут немає жодної «ООП-магії». Це просто контейнер із двох полів.
Захоплення lock‑file: AcquireLock
Тепер найважливіше: захоплення lock‑файла. Ми розрізняємо два типи помилок: «lock уже є» і «щось справді пішло не так (права, шлях, диск)».
package storage
import (
"fmt"
"os"
)
func AcquireLock(lockPath string) (*Lock, error) {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
if os.IsExist(err) {
return nil, fmt.Errorf("сховище зайняте: lock уже існує")
}
return nil, fmt.Errorf("не вдалося створити lock %s: %w", lockPath, err)
}
return &Lock{f: f, path: lockPath}, nil
}
Зверніть увагу: ми повертаємо помилку з контекстом через fmt.Errorf(... %w ...), щоб нагорі було видно, на якому кроці все впало, але при цьому причина помилки не губилася. Це базова й дуже важлива практика Go.
Звільнення lock‑file: Release
Звільнення — це «закрити й видалити». Зазвичай помилки звільнення не мають затьмарювати основну помилку бізнес-операції, тому в Release часто роблять best-effort: закрили, видалили, помилки не повертаємо (або логуємо на межі застосунку).
package storage
import "os"
func (l *Lock) Release() {
_ = l.f.Close()
_ = os.Remove(l.path)
}
Так, тут ми ігноруємо помилки. В ідеальному світі можна збирати їх і повертати, але для навчального патерна «lock-file як домовленість» цього достатньо. Головне — завжди викликати Release через defer.
5. Де ставити lock: навколо запису чи навколо всієї операції
У цьому місці багато хто інтуїтивно помиляється: «ну lock потрібен для запису, значить, обгорнемо тільки запис». Звучить логічно, але тоді ви все одно можете втратити оновлення.
Втрата оновлень стається тому, що обидва процеси читають одну й ту саму стару версію. Отже, якщо ваша мета — щоб записував лише один процес, lock має покривати всю критичну ділянку, включно з читанням.
Тобто правильна форма така:
AcquireLock
Read file
Compute new state
Write (temp+rename + backup)
ReleaseLock
А от так — недостатньо:
Read file
Compute new state
AcquireLock
Write
ReleaseLock
Бо на момент AcquireLock ви вже прочитали старі дані.
6. Вбудовуємо lock‑file у сховище tasker
Уявімо, що в нас є функція збереження SaveTasks, яка вже вміє надійно писати файл (temp+rename, backup). Ми додамо туди lock так, щоб код читався зверху вниз і виглядав як протокол.
Нехай файл задач називається tasks.json, а lock — tasks.json.lock.
package storage
import (
"fmt"
)
func SaveTasks(path string, data []byte) error {
lock, err := AcquireLock(path + ".lock")
if err != nil {
return err
}
defer lock.Release()
if err := WriteWithBackup(path, data, 0644); err != nil {
return fmt.Errorf("збереження задач: %w", err)
}
return nil
}
Тут важливі два моменти.
Перший: defer lock.Release() має стояти одразу після успішного AcquireLock. Тоді ви не забудете його поставити, і ваш майбутній баг буде десь в іншому місці (що, звісно, теж неприємно, але хоча б різноманіття).
Другий: ми не вигадуємо новий спосіб запису. Lock‑file — це не заміна temp+rename, а додатковий «турнікет» на вході: лише один процес проходить усередину, а вже всередині ми виконуємо надійний протокол запису.
7. Практичні нюанси й обмеження lock‑file
Що казати користувачу CLI, якщо lock уже існує
Коли ваш користувач бачить помилку, йому зазвичай нецікаво, які там прапорці O_EXCL і що таке атомарність. Йому важливо зрозуміти: «Це я винен? Це баг? Що робити?»
Тому в повідомленні для «lock exists» зазвичай корисно бути коротким і дружнім: «Сховище зайняте, спробуйте пізніше» або «Інший екземпляр програми виконує запис».
На рівні storage ми повернули "сховище зайняте: lock уже існує". На межі CLI (у main) ви вже вирішите, друкувати це як є чи перетворити на фразу людською мовою.
І тут є тонка грань: якщо ви завжди пишете одне й те саме, це чудово для UX, але іноді хочеться діагностики. Гарний компроміс — коротке повідомлення користувачу й обгорнута помилка для логів/відладки (ми постійно це робимо в курсі).
«Залиплий» lock: чому так буває
Lock‑file — домовленість, і в неї є слабке місце: якщо процес помер (panic, kill -9, вимкнули живлення), defer не відпрацює, і lock‑file може залишитися.
У результаті наступний запуск казатиме «сховище зайняте», хоча насправді «ніхто не пише, просто замок забули на дверях».
Що з цим робити? У межах цієї лекції ми фіксуємо важливу думку: lock‑file розв’язує часту проблему, але не є ідеальною системою блокувань. У зрілих системах додають «lease» (час життя замка), записують усередину PID і timestamp, перевіряють, чи живий процес, або використовують спеціалізовані механізми ОС. Але це вже інша глибина теми.
Для нашого навчального CLI найчастіше достатньо домовитися так: якщо ви впевнені, що жоден запис не триває, можна видалити tasks.json.lock вручну. А щоб користувачу було легше, інколи роблять команду tasker doctor або tasker unlock — але це вже продуктові рішення, не обов’язкові для розуміння механіки O_EXCL.
Таблиця: що захищає кожен шар
Щоб не переплутати, корисно тримати в голові просту мапу відповідальності:
| Механізм | Від чого захищає | Від чого не захищає |
|---|---|---|
|
від «півверсії файла» у разі збою під час запису | від втрати оновлень, коли дані записують два процеси |
|
від «логічно поганої нової версії» (можна відкотитися) | від часткового файла (це розв’язує temp+rename) |
|
від одночасного виконання read→compute→write двома процесами | від «вічного lock» після аварійного завершення |
Ця таблиця важлива саме як інженерна модель: кожен шар закриває свою дірку, але не замінює інші.
8. Типові помилки з lock‑file
Помилка № 1: робити lock лише навколо Rename, а не навколо читання.
Такий код виглядає «правильним», бо ви справді захищаєте момент публікації. Але втрата оновлень стається раніше — коли два процеси читають одну й ту саму стару версію. Якщо мета — не втрачати оновлення, lock має охоплювати весь цикл read→compute→write.
Помилка № 2: використовувати os.O_EXCL без os.O_CREATE і чекати ексклюзивності.
Інтуїтивно здається, що O_EXCL — це «нікого не впущу». Але в цьому контексті це «ексклюзивне створення». Якщо забути O_CREATE, логіка ламається, і ви можете отримати зовсім не ту поведінку, на яку очікуєте.
Помилка № 3: забути defer Release() одразу після успішного захоплення lock.
Це класика: ви захопили lock, далі пішли писати код, додали ще два return, і в одному з них забули звільнення. Результат — lock лишається навіть за нормального завершення, і програма починає «сама себе блокувати». defer якраз придуманий, щоб такі помилки траплялися рідше.
Помилка № 4: повертати користувачу «сирий» текст системної помилки без контексту.
Якщо просто прокинути помилку open tasks.json.lock: file exists, користувач може не зрозуміти, що відбувається. У Go ми зазвичай обгортаємо помилку контекстом операції (створення lock ... %w), а на межі застосунку перетворюємо це на коротке й зрозуміле повідомлення.
Помилка № 5: вважати lock‑file залізобетонною гарантією коректності на всі випадки життя.
Lock‑file — хороший практичний договір, але він не лікує аварійні завершення, не розв’язує питання «як чекати lock» і не замінює протокол надійного запису. Якщо пам’ятати межі його відповідальності, він працює чудово й робить поведінку CLI значно передбачуванішою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ