JavaRush /Курсы /Go SELF /sync.RWMutex — когда полезен, когда вреден

sync.RWMutex — когда полезен, когда вреден

Go SELF
68 уровень , 1 лекция
Открыта

1. Зачем нужен RWMutex: история читателей и писателей

Представьте маленькую библиотеку. В ней есть один редкий справочник — это наши общие данные. Люди приходят читать его гораздо чаще, чем переписывать или вклеивать новые страницы. Если библиотекарь будет пускать внутрь по одному человеку (как Mutex), то чтение станет медленным: читатели будут стоять в очереди даже тогда, когда они не мешают друг другу.

sync.RWMutex придуман как раз для таких ситуаций: он позволяет нескольким читателям заходить одновременно, но писателя запускает только одного и только когда читателей внутри нет. На бумаге это звучит как «ускоритель чтения», но на практике у него есть цена: код становится сложнее, а ошибки — интереснее (в плохом смысле).

API: read-lock и write-lock

Самая важная идея RWMutex в том, что у него есть два типа блокировок: для чтения и для записи. И это не «для красоты», а реальный контракт, который должен соблюдаться в коде. Если его нарушить, вы либо поймаете гонку, либо получите зависание, либо и то и другое — такой себе комбо-набор.

Вот минимальная «шпаргалка по методам», чтобы не путаться:

Что делаем Какая блокировка Методы Кто может заходить параллельно
Только читаем данные Read lock
RLock() / RUnlock()
Несколько читателей одновременно
Меняем данные Write lock
Lock() / Unlock()
Только один писатель, читатели ждут

И ещё важный момент: zero value у 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, где доступ только через методы владельца состояния.

1
Задача
Go SELF, 68 уровень, 1 лекция
Недоступна
Счётчик просмотров
Счётчик просмотров
1
Задача
Go SELF, 68 уровень, 1 лекция
Недоступна
Конфиг хранилище
Конфиг хранилище
1
Задача
Go SELF, 68 уровень, 1 лекция
Недоступна
Снимок задач
Снимок задач
1
Задача
Go SELF, 68 уровень, 1 лекция
Недоступна
Гарантия уникальности
Гарантия уникальности
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ