Нужно ли учитывать потокобезопасность при реализации кэша в Go
Что проверяют
Знаете ли вы, что встроенная map в Go не потокобезопасна, и понимаете ли цену синхронизации. Вопрос звучит риторически, но проверяют не согласие, а то, что вы предложите дальше и какие гонки заметите в собственном коде.
Как отвечать
Учитывать нужно всегда, если к кэшу обращается больше одной горутины. Рантайм Go не портит данные молча: при конкурентной записи он намеренно роняет процесс с concurrent map writes, а параллельные чтение и запись показывает go test -race. Варианты защиты:
sync.RWMutexвокруг обычнойmap: понятный и предсказуемый выбор по умолчанию;sync.Map: годится в узком случае, когда ключ пишется один раз и потом много читается, в остальных сценариях она проигрывает мьютексу;- шардирование: несколько мапов со своими мьютексами, ключи разводятся по хешу, конкуренция за одну блокировку падает.
func (c *Cache) Get(k string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.m[k]
return v, ok
}Отдельно стоит проговорить промах кэша: если на непопулярный ключ одновременно пришли сто горутин, все сто пойдут в базу. Лечится это не блокировкой, а объединением одинаковых запросов в один, по схеме singleflight.
Где спотыкаются
Считают, что читать без блокировки безопасно, раз ничего не меняется. Чтение, идущее параллельно чужой записи, это такая же гонка: детектор её показывает, а рантайм вправе уронить программу.
Возвращают из кэша указатель или слайс и правят их уже без мьютекса. Блокировка защитила саму map, но не значение внутри: наружу должна уходить копия либо неизменяемая структура.
Забывают, что RWMutex не реентрантный. Метод под Lock, который зовёт другой метод с RLock, встаёт намертво. Сюда же поход в базу или по сети, пока блокировка удерживается: весь кэш ждёт один медленный запрос.