JavaRush /Курси /Go SELF /Два процеси пишуть одночасно

Два процеси пишуть одночасно

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

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
від «півверсії файла» у разі збою під час запису від втрати оновлень, коли дані записують два процеси
.bak
від «логічно поганої нової версії» (можна відкотитися) від часткового файла (це розв’язує temp+rename)
lock-file
від одночасного виконання readcomputewrite двома процесами від «вічного lock» після аварійного завершення

Ця таблиця важлива саме як інженерна модель: кожен шар закриває свою дірку, але не замінює інші.

8. Типові помилки з lock‑file

Помилка № 1: робити lock лише навколо Rename, а не навколо читання.
Такий код виглядає «правильним», бо ви справді захищаєте момент публікації. Але втрата оновлень стається раніше — коли два процеси читають одну й ту саму стару версію. Якщо мета — не втрачати оновлення, lock має охоплювати весь цикл readcomputewrite.

Помилка № 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 значно передбачуванішою.

1
Задача
Go SELF, 42 рівень, 3 лекція
Недоступна
Ексклюзивний замок
Ексклюзивний замок
1
Задача
Go SELF, 42 рівень, 3 лекція
Недоступна
Замок із PID
Замок із PID
1
Задача
Go SELF, 42 рівень, 3 лекція
Недоступна
Лічильник без гонок
Лічильник без гонок
1
Задача
Go SELF, 42 рівень, 3 лекція
Недоступна
Список із бекапом
Список із бекапом
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ