JavaRush /Курси /Go SELF /sync.RWMutex — коли він корисний, а коли шкідливий

sync.RWMutex — коли він корисний, а коли шкідливий

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

1. Навіщо потрібен RWMutex: історія читачів і записувачів

Уявіть невелику бібліотеку. У ній є один рідкісний довідник — це наші спільні дані. Люди приходять читати його значно частіше, ніж переписувати або вклеювати нові сторінки. Якщо бібліотекар впускатиме всередину лише одну людину за раз, як це робить Mutex, читання сповільниться: читачі стоятимуть у черзі навіть тоді, коли не заважають один одному.

sync.RWMutex створили саме для таких ситуацій: він дозволяє кільком читачам заходити одночасно, а записувача пускає лише одного й лише тоді, коли всередині немає читачів. На папері це схоже на «прискорювач читання», але на практиці за нього доводиться платити: код стає складнішим, а помилки — цікавішими. І це вже погано.

API: read lock і write lock

Найважливіша ідея RWMutex полягає в тому, що в нього є два типи блокування: для читання і для запису. І це не «для краси», а справжній контракт, якого потрібно дотримуватися в коді. Якщо його порушити, ви або спіймаєте гонку, або отримаєте зависання, або й те, й інше — такий собі комбо-набір.

Ось коротка «шпаргалка з методів», щоб не плутатися:

Що робимо Яке блокування Методи Хто може працювати одночасно
Лише читаємо дані Блокування на читання
RLock() / RUnlock()
Кілька читачів одночасно
Змінюємо дані Блокування на запис
Lock() / Unlock()
Лише один записувач, читачі чекають

І ще важливий момент: нульове значення RWMutex уже готове до роботи. Тобто можна просто оголосити поле var mu sync.RWMutex і користуватися ним без new/make.

Але копіювати структуру з RWMutex після початку використання, як і раніше, не можна: ви отримаєте дві копії замка, які «думають», що захищають одне й те саме, а насправді кожна працює окремо.

2. Мінізастосунок: TaskStore на RWMutex

Щоб не обговорювати замки у вакуумі, продовжимо історію нашого застосунку із задачами. Нехай у нас є сховище задач у пам’яті, яке часто читають — наприклад, щоб показати список або отримати запис за id — і рідше змінюють: створюють задачу або позначають її виконаною. Це типова ситуація «багато читань / мало записів» — хороший кандидат для RWMutex.

Почнемо з моделі та структури сховища:

package main

import "sync"

type Task struct {
	ID   int
	Done bool
}

type TaskStore struct {
	mu    sync.RWMutex
	tasks map[int]Task
}

Зверніть увагу: tasks — це map. А map у Go не є потокобезпечним. Навіть «просто глянути одним оком» може закінчитися панікою або гонкою, якщо паралельно хтось пише. Тому, якщо map спільний для кількох goroutine, він майже завжди живе під замком.

Зробимо конструктор, який гарантує, що map створено:

package main

func NewTaskStore() *TaskStore {
	return &TaskStore{tasks: make(map[int]Task)}
}

Далі — читання за id. Це чисте читання, отже використовуємо RLock:

package main

func (s *TaskStore) Get(id int) (Task, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	t, ok := s.tasks[id]
	return t, ok
}

Тепер запис: наприклад, створити або зберегти задачу. Спростимо й припустимо, що ми приймаємо вже готову задачу. Запис обов’язково має відбуватися під Lock():

package main

func (s *TaskStore) Put(t Task) {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.tasks[t.ID] = t
}

І ще один запис: позначимо задачу як виконану. Тут важливий нюанс: ми спершу читаємо, а потім записуємо, але для map це все одно підсумковий запис. Отже, беремо write lock:

package main

func (s *TaskStore) MarkDone(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()

	t, ok := s.tasks[id]
	if !ok {
		return false
	}
	t.Done = true
	s.tasks[id] = t
	return true
}

3. Що вважається читанням: aliasing і «живі» колекції

Спочатку здається, що правило просте: «під RLock читаємо, під Lock пишемо». Але в реальному житті з’являється тонкий момент: ви можете прочитати посилання на внутрішні дані, а змінити їх уже після виходу з RLock. Формально ваш метод лише читав, але фактично він віддав назовні доступ до нутрощів без захисту. Це й є класичний баг через aliasing.

Із map це трапляється рідше, бо map — посилальний тип, і ви можете повернути сам map назовні. Тоді зовнішній код почне записувати в нього без замка. Із слайсами — ще веселіше: ви повернули []Task, а зовнішній код зробив append або змінив елемент — і все, «захист» зник.

Тому для конкурентно безпечного типу є хороше правило дизайну: не віддавати назовні «живі» внутрішні колекції, якщо ви не хочете, щоб ними керували зовні. Найчастіше повертають «знімок» — копію.

Покажемо правильний List(): усередині під RLock робимо копію задач, а потім працюємо з нею без замка.

package main

func (s *TaskStore) List() []Task {
	s.mu.RLock()
	defer s.mu.RUnlock()

	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	return out
}

З погляду конкуренції це чудово: ми тримаємо RLock лише доти, доки читаємо map і наповнюємо слайс. Щойно копія готова — можна відпустити замок.

4. Коли RWMutex корисний

Найчесніша відповідь на запитання «коли брати RWMutex?» звучить так: коли ви вже розумієте свій профіль навантаження. Якщо у вас справді багато конкурентних читань і вони короткі, то RWMutex дозволяє цим читанням не штовхатися ліктями. У цей момент ви виграєте завдяки паралельності читачів.

Дуже типовий приклад — сховище конфігурації або кеш «останнього стану», який читає кожна goroutine, а оновлюється рідко. Там RWMutex може бути доречним: читачі йдуть паралельно, а оновлення відбувається швидко й блокує всіх лише на короткий час.

Тут важливо підкреслити одну думку: RWMutex — це не «покращений Mutex». Це окремий інструмент, який починає працювати на вашу користь, коли виконано дві умови: читань багато, а саме читання триває недовго.

Якщо читання довге, наприклад під RLock ви сортуєте великий масив, форматуєте JSON або робите I/O, ви перетворюєте «прискорювач» на «сповільнювач із чергою», бо записувач чекатиме надто довго.

5. Коли RWMutex шкідливий

Дуже спокусливо сказати: «раз RWMutex дає паралельні читання, то давайте завжди ставити його». Але це як купувати спортивну машину, щоб возити картоплю ґрунтовою дорогою. Теоретично можна. Практично — клопоту буде більше, ніж користі.

Шкода від RWMutex зазвичай проявляється трьома способами.

По-перше, він ускладнює код і підвищує шанс помилки. У Mutex у вас одна пара методів, а тут дві — і їх легко переплутати. Плюс з’являється «напівзапис»: здається, що ви лише читаєте, але насправді змінюєте поле структури, модифікуєте елемент слайса або дописуєте в map — і це вже порушення контракту.

По-друге, у RWMutex є накладні витрати. Якщо конкурентності мало або читання майже не перетинаються в часі, ви можете не пришвидшитися, а сповільнитися через складніший механізм.

По-третє, є ефекти «голодування» (starvation) та очікування. У RWMutex читачі можуть тримати RLock довго й тим самим затримувати записувача. А коли записувач уже чекає, нові читачі зазвичай починають гальмувати (це залежить від реалізації та умов), і ви можете отримати неприємний профіль затримок: то швидко, то раптом усе стало. Навіть якщо формально дедлока немає, користувач сприймає це як «сервіс завис».

6. Заборонений трюк: апгрейд RLockLock

Частий життєвий сценарій: ви хочете зробити так — «перевірю під RLock, а якщо даних немає, тоді створю». І тут рука тягнеться зробити щось на кшталт «усередині RLock викликати Lock». Так робити не можна: RWMutex не є реентерабельним, і спроба взяти write lock, поки ви тримаєте read lock, навіть у тій самій goroutine, дуже легко призводить до взаємного блокування. Ви чекаєте, доки вийдуть читачі, а один із читачів — це ви.

Правильна схема зазвичай виглядає як «подвійна перевірка»: спочатку читаємо під RLock, потім відпускаємо його, потім беремо Lock і перевіряємо ще раз, бо за цей час хтось міг уже записати.

Мініприклад «створити задачу, якщо її немає» (спрощено):

package main

func (s *TaskStore) Ensure(id int) {
	s.mu.RLock()
	_, ok := s.tasks[id]
	s.mu.RUnlock()
	if ok {
		return
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tasks[id]; !ok {
		s.tasks[id] = Task{ID: id}
	}
}

Так, це виглядає трохи довше, ніж «хочу апгрейд локів». Зате це працює передбачувано й не потребує шаманства.

7. Практика: snapshot під RLock і робота поза замком

Коли ви використовуєте RWMutex, корисно тримати в голові просту думку: замок захищає не рядок коду, а інваріант даних. Якщо всередині структури є кілька пов’язаних полів, які мають залишатися узгодженими, то запис має змінювати їх разом під одним Lock, а читання має дивитися на узгоджений знімок під RLock.

Дуже допомагає патерн «під замком — лише дані, поза замком — важка робота». Наприклад, якщо ви хочете відсортувати список задач за id, не потрібно тримати RLock під час сортування. Під RLock зробіть копію (snapshot), відпустіть замок і вже потім сортуйте копію. Так ви зменшуєте час утримання блокування і скорочуєте затримки для записувачів.

Схематично це можна подати так:

flowchart TD
    A[Потрібно віддати список задач] --> B[Беремо RLock]
    B --> C[Копіюємо дані в новий слайс]
    C --> D[Відпускаємо RLock]
    D --> E[Сортуємо/фільтруємо копію без замка]
    E --> F[Повертаємо результат]

І ще: якщо вам доводиться постійно пояснювати собі, чому тут RLock, а тут Lock, і ви відчуваєте, що все стало надто складним, дуже ймовірно, що вам поки що потрібен звичайний Mutex. Починати з Mutex — абсолютно нормальна стратегія, бо вона зменшує простір для помилок.

8. Типові помилки під час роботи з sync.RWMutex

Помилка №1: запис під RLock (навіть «маленький»).
Найчастіша пастка — коли під read lock ви робите щось, що технічно є записом: m[k]++, slice[i] = ..., append(slice, ...), зміну поля структури. RLock дозволяє паралельні читання, але не захищає від паралельних записів — і ви знову отримуєте data race. Якщо є хоч найменша ймовірність зміни, потрібен Lock.

Помилка №2: переплутали пари RLock/RUnlock і Lock/Unlock.
Це виглядає смішно, доки не трапиться. Потім це виглядає як «чому воно зависло на рівному місці». Рятує дисципліна: ставити defer одразу після успішного Lock()/RLock(), а також тримати методи короткими, щоб замок був «на виду».

Помилка №3: тримати RLock надто довго і робити під ним важку роботу.
Іноді розробник бере RLock, отримує дані й починає під цим RLock сортувати, форматувати, будувати велику відповідь або навіть робити I/O. У результаті записувачі чекають «вічність», а читачі починають гальмувати, коли записувач уже стоїть у черзі. Правильніше зробити snapshot під RLock, відпустити замок і продовжити обробку на копії.

Помилка №4: спроба апгрейду RLockLock без звільнення read lock.
Інтуїтивно здається, що «я вже всередині, зараз просто посилю блокування». У RWMutex це не працює так, як хотілося б. Дуже легко отримати дедлок: ви чекаєте, доки підуть читачі, але один із читачів — ви самі. Правильний шлях — відпустити RLock, потім взяти Lock і зазвичай ще раз перевірити умову.

Помилка №5: віддавати назовні внутрішні map/[]T і вважати, що замок і далі захищає.
Замок захищає лише те, що відбувається всередині ваших методів. Якщо ви повернули назовні внутрішній map або слайс, зовнішній код може змінювати їх коли завгодно і як завгодно, обходячи вашу синхронізацію. Лікується або «знімками» (копіями), або API на кшталт Get/Set/List, де доступ відбувається лише через методи власника стану.

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