1. Зачем задаваться вопросом «что защищаем»
Когда вы впервые узнали про Mutex, очень хочется сделать универсальное заклинание: «Если страшно — оберни в mu.Lock()». Так иногда даже работает… пока проект маленький. Но как только появляется app-слой, storage-слой и «ой, давайте ещё кеш для скорости», универсальный замок превращается в универсальную причину зависаний, лагов и загадочных дедлоков.
Смысл этой лекции не в том, чтобы выучить ещё один API (его сегодня почти не будет), а в том, чтобы научиться отвечать на три вопроса: какое состояние у нас вообще есть, кто его владелец, и каким примитивом синхронизации его защищать. Потому что Mutex — это не «скрепка для всего», а скорее «ремень безопасности»: пристёгиваться надо, но не к чужой машине и не за шею.
Где живёт state: app, storage и cache
Если упростить, состояние (state) — это всё, что может меняться и на что одновременно могут смотреть/влиять разные goroutine. Но в реальных приложениях состояние обычно расползается по слоям: часть живёт в app (бизнес-логика), часть — в storage (хранение/БД/файлы), часть — в кеше (ускоритель и источник «слегка устаревшей правды»).
Удобно держать в голове такую таблицу — она не «истина в последней инстанции», но хороший компас:
| Где живёт state | Примеры | Что важно защищать | Типичный инструмент |
|---|---|---|---|
| App (usecases/service) | счётчики запросов, «в полёте» операции, внутренняя карта дедупликации, in-memory очереди | бизнес-инварианты и согласованность нескольких полей | чаще Mutex, иногда atomic |
| Storage (repo/dao) | map в памяти, файл, соединение к БД, индекс | целостность хранилища и последовательность записи | обычно Mutex, иногда RWMutex |
| Cache (ускоритель) | кеш задач по id, кеш списка задач, кеш «последний результат» | консистентность структуры кеша + политика инвалидции | часто RWMutex + местами atomic для метрик |
И тут важная мысль: не всё, что называется state, обязано быть общим. Иногда лучший способ «защитить state» — сделать так, чтобы его вообще не разделяли. В духе известной идеи Go “share memory by communicating” (делиться памятью через коммуникацию), когда владение данными у одной goroutine, а остальные общаются через каналы.
Но сегодня мы остаёмся в мире «у нас есть общее состояние, и мы хотим его укротить».
2. State в app-слое: что может ломаться
App-слой обычно воспринимается новичком как «просто функции, которые вызывают storage». И из-за этого его часто оставляют без синхронизации: «ну там же нет map…». А потом внезапно появляется фича «покажи статистику», «не запускай два одинаковых импорта параллельно», «дедуплицируй одинаковые запросы», «считай метрики» — и вот у app-слоя уже есть собственное изменяемое состояние.
Чтобы было конкретнее, будем мысленно развивать наше учебное приложение — менеджер задач (условно назовём его tasker). У нас есть сущность Task и операции «создать», «получить список», «пометить выполненной». HTTP/CLI могут вызывать эти операции параллельно (в HTTP это вообще норма).
Пример app-state: «не запускать две одинаковые операции параллельно»
Представьте, что у нас есть импорт задач из файла. Мы хотим, чтобы два параллельных запроса import одного и того же файла не запускались одновременно (иначе дубли, гонки, странные эффекты). Это чистый app-state: это не storage и не кеш, это бизнес-ограничение «один импорт на файл».
package app
import "sync"
type ImportGuard struct {
mu sync.Mutex
inFlight map[string]bool
}
func (g *ImportGuard) TryStart(name string) bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.inFlight == nil {
g.inFlight = make(map[string]bool)
}
if g.inFlight[name] {
return false
}
g.inFlight[name] = true
return true
}
Здесь мы защищаем инвариант: «для каждого name либо импорт идёт, либо нет». И этот инвариант касается сразу map и логики проверки/установки — поэтому Mutex, а не atomic.
И обратите внимание: это состояние не должно утекать наружу. Никто не должен получать ссылку на inFlight, иначе мы потеряем смысл защиты.
Пример app-state: быстрые счётчики через atomic
Иногда app-слой хранит очень простые числа: «сколько было запросов», «сколько было ошибок». Это удобно для диагностики. Тут действительно часто уместен atomic, потому что нам не нужен большой инвариант — мы просто увеличиваем счётчик.
package app
import "sync/atomic"
type Metrics struct {
Requests atomic.Int64
Errors atomic.Int64
}
func (m *Metrics) IncRequest() { m.Requests.Add(1) }
func (m *Metrics) IncError() { m.Errors.Add(1) }
Важно помнить правило: если вы выбрали atomic для переменной — все обращения к ней должны быть через Load/Store/Add. «Один разочек m.Requests++» — это как «один разочек в проде без миграций»: может сработать, но потом будет стыдно.
4. State в storage-слое: где появляются гонки
Storage — это место, где хранится «истина» (или то, что приложение считает истиной). Даже если у вас вместо БД пока что map в памяти или файл на диске, требования примерно одинаковые: запись должна быть последовательной и предсказуемой, чтение не должно видеть полусломанное состояние, а данные не должны «случайно» меняться из-за того, что кто-то удержал ссылку на внутренности.
In-memory storage: map + Mutex и копии наружу
Начнём с простого: хранилище задач в памяти. map[int]Task нельзя читать/писать конкурентно без согласования. Даже «только чтение» опасно, если где-то рядом есть запись.
package storage
import "sync"
type Task struct {
ID int
Title string
Done bool
}
type MemStore struct {
mu sync.Mutex
tasks map[int]Task
next int
}
Метод добавления задачи должен менять сразу два поля (next и tasks), поэтому это одна критическая секция.
package storage
func (s *MemStore) Create(title string) Task {
s.mu.Lock()
defer s.mu.Unlock()
if s.tasks == nil {
s.tasks = make(map[int]Task)
s.next = 1
}
t := Task{ID: s.next, Title: title}
s.tasks[t.ID] = t
s.next++
return t
}
Смотрите на это как на кассу в магазине: пока кассир пробивает товар, второй кассир не должен одновременно менять те же цифры на табло.
А теперь ключевой момент: если мы делаем List(), нельзя возвращать наружу что-то, что позволит внешнему коду обойти синхронизацию. С map это очевидно (не отдаём сам map). Со slice часто «неочевидно» — но принцип тот же: отдаём копию.
package storage
func (s *MemStore) List() []Task {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
Да, это чуть дороже. Но это цена за то, что storage остаётся хозяином своих данных.
Файловый storage: состояние — это ещё и протокол записи
Когда storage пишет в файл, состояние состоит не только из переменных в памяти. Состояние — это ещё и процесс: открыть файл, записать, закрыть, возможно заменить старую версию новой. Если два параллельных запроса начнут писать в один и тот же файл, можно получить перемешанные байты и файл, который «вроде есть, но его нельзя прочитать».
Поэтому для файлового storage часто делают простое правило: все операции записи сериализованы одним Mutex. И очень желательно не держать этот lock дольше нужного: под lock мы делаем I/O, да, но стараемся не делать под ним лишнюю работу вроде JSON-валидации входных данных (её лучше сделать раньше в app-слое).
5. Cache: ускоряем чтение, но не подменяем истину
Кеш — это как шпаргалка на экзамене. Он ускоряет, но если шпаргалка устарела, можно уверенно написать чушь. Поэтому кеш важно проектировать с пониманием: что является источником истины, а что является производным ускорителем.
В нашем tasker кеш может хранить, например, «последний список задач» и сбрасываться (инвалидироваться) при любой записи (create/done/delete). Тогда чтения будут быстрыми, а записи будут сбрасывать кеш.
Кеш-обёртка над Store: RWMutex + инвалидция
Сделаем интерфейс хранилища (он у нас уже логически существует в проекте), а сверху — кеширующую обёртку:
package app
import "tasker/storage"
type Store interface {
Create(title string) (storage.Task, error)
List() ([]storage.Task, error)
MarkDone(id int) error
}
Кеш:
package app
import "sync"
type CachedStore struct {
mu sync.RWMutex
underlying Store
cachedList []storage.Task
hasList bool
}
Чтение списка: под RLock проверяем кеш, при наличии возвращаем копию, чтобы никто не мог «подправить кеш руками».
package app
import "tasker/storage"
func (c *CachedStore) List() ([]storage.Task, error) {
c.mu.RLock()
if c.hasList {
cp := make([]storage.Task, len(c.cachedList))
copy(cp, c.cachedList)
c.mu.RUnlock()
return cp, nil
}
c.mu.RUnlock()
list, err := c.underlying.List()
if err != nil {
return nil, err
}
c.mu.Lock()
c.cachedList = list
c.hasList = true
c.mu.Unlock()
return list, nil
}
Да, здесь есть тонкость: мы не держим lock, пока ходим в underlying storage. Это важно: storage может делать I/O, может быть медленным, и держать под это кеш-замок — плохая идея. Мы берём данные «снаружи», а потом аккуратно кладём в кеш.
Запись: после успешной записи инвалидируем кеш. Инвалидировать под Lock, иначе можно оставить полусостояние.
package app
func (c *CachedStore) MarkDone(id int) error {
if err := c.underlying.MarkDone(id); err != nil {
return err
}
c.mu.Lock()
c.hasList = false
c.cachedList = nil
c.mu.Unlock()
return nil
}
И вот тут главный концепт: кеш защищает свою структуру (hasList, cachedList), но не должен «влезать» в защиту underlying storage. У storage — свой замок, у кеша — свой.
6. Как выбирать и располагать замки
Формулируем «объекты защиты»
Когда вы думаете «нужно поставить Mutex», попробуйте сформулировать фразу в стиле:
«Я защищаю _____ от _____, чтобы гарантировать _____».
Например:
«Я защищаю MemStore.tasks и MemStore.next от конкурентных записей, чтобы гарантировать, что ID уникальны и map не ломается».
Или:
«Я защищаю CachedStore.cachedList от конкурентного чтения/записи, чтобы гарантировать, что кеш либо полностью валиден, либо полностью инвалидирован».
Эта формулировка помогает понять важную вещь: мы защищаем не код, а смысл (инвариант). И один и тот же Mutex должен охранять один набор связанных смыслов. Если замок «охраняет всё подряд» — вы рано или поздно начнёте держать его во время чего-то долгого и получите очередь на вход.
Где должен жить замок: app vs storage vs cache
Очень хочется сделать так:
var mu sync.Mutex // один на всё!
А потом вызывать mu.Lock() в начале каждого handler’а. Это действительно убирает data race… ценой того, что ваше приложение становится строго однопоточным. Вы как будто купили Go за конкурентность, а потом заклеили все горутины скотчем «не бегать».
Гораздо здоровее мыслить через владельцев состояния:
Storage владеет данными хранения и защищает их своим замком. Cache владеет своим кешем и защищает его своим замком. App владеет своими бизнес-инвариантами и защищает их своим замком/атомиками.
И крайне желательно, чтобы замки не торчали наружу как часть публичного API. Если кто-то снаружи может сделать store.mu.Lock(), вы теряете контроль: внешний код может забыть Unlock(), может держать lock слишком долго, может устроить дедлок, и вы даже не сможете это нормально отлаживать (потому что «это не я, это он» — аргумент слабый).
Схема запроса и порядок lock’ов
Когда приходит HTTP-запрос POST /tasks/{id}/done, примерно происходит вот что:
sequenceDiagram
participant H as HTTP handler
participant A as App service
participant C as Cache wrapper
participant S as Storage
H->>A: MarkDone(id)
A->>A: atomic Requests.Add(1)
A->>C: MarkDone(id)
C->>S: MarkDone(id) (storage lock внутри)
S-->>C: ok
C->>C: cache lock -> invalidate list
C-->>A: ok
A-->>H: 204 No Content
В этой схеме важно, что замки не «пересекаются слоями»: storage делает своё, кеш — своё, app — своё. Так проще соблюдать порядок и не попасть в ситуацию «держу app-lock и жду storage, а storage внутри ждёт что-то, что ждёт app-lock».
Нюанс: defer Unlock и циклы
defer mu.Unlock() — прекрасная привычка, но у неё есть характер: defer выполнится в конце функции, а не «в конце текущей итерации цикла». Из-за этого можно случайно удерживать lock гораздо дольше, чем вы думаете.
Эта ошибка настолько реальная, что её любят ловить в продакшене уже после того, как всё «вроде работало». В одном разборе проблем производительности прямо показана ситуация, где defer внутри цикла приводит к удержанию lock до конца функции и создаёт огромную очередь ожидания.
Мораль тут простая: defer используем, но осознанно. Если lock должен отпускаться на каждой итерации — делаем Unlock() явно или выносим тело итерации в отдельную маленькую функцию, чтобы defer сработал «пораньше».
7. Типичные ошибки
Ошибка №1: путать «источник истины» и кеш.
Часто кеш начинают использовать как хранилище: «а давайте просто менять cachedList, и в storage не ходить». Это быстро приводит к рассинхронизации: разные операции начинают видеть разные версии реальности, а при перезапуске приложения всё внезапно «теряется». Правильная дисциплина такая: storage — это правда, кеш — это ускоритель, который можно выкинуть в любой момент без потери смысла.
Ошибка №2: один Mutex на весь мир.
Глобальный замок кажется простым: гонок нет, можно спать спокойно. Но вы очень быстро замечаете, что приложение «почему-то тормозит», хотя у вас аж goroutine. Причина обычно в том, что вы превратили конкурентную систему в очередь к одному замку. Гораздо лучше разделить состояние по владельцам и охранять каждый смысл своим lock’ом.
Ошибка №3: держать lock, пока вызываете «чужой» код или делаете долгий I/O.
Самая токсичная комбинация — держать Mutex и одновременно ждать чего-то: чтения файла, ответа сети, сообщения из канала. Это прямой путь к дедлокам и «подвисаниям без паники». Гораздо надёжнее брать под lock только то, что нужно для snapshot/обновления, затем отпускать lock и уже потом делать долгие операции.
Ошибка №4: отдавать наружу внутренние структуры и тем самым обходить синхронизацию.
Если storage возвращает ссылку на свой slice или map, внешний код может поменять данные без lock — и вы снова в мире гонок, только уже без явных Lock() в коде, то есть баг становится «магическим». В таких местах лучше возвращать копию (snapshot). Да, это аллокации. Но они дешевле, чем неделя отладки «почему иногда Done=true сам по себе».
Ошибка №5: смешивать atomic и Mutex для одного и того же значения без чёткой причины.
Иногда делают так: «под lock читаю, а инкремент через atomic — быстрее же». Это создаёт очень мутную модель памяти в голове команды: никто не понимает, где правда. Если переменная атомарная — она атомарная всегда. Если переменная часть инварианта — она под Mutex. Микс возможен, но только когда вы можете чётко объяснить, что именно вы гарантируете (и обычно новичкам лучше туда не ходить).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ