Почему при эвакуации данных в мапе Go возникает проблема с конкурентным доступом и race condition
Что проверяют
Знаете ли вы, что мапа в Go непотокобезопасна не частично, а принципиально, и понимаете ли, почему рантайм предпочитает уронить процесс, а не отдать неверные данные.
Как отвечать
Мапа растёт не одномоментно. Когда заполненность бакетов переходит порог, рантайм выделяет новый набор бакетов и переносит содержимое старых порциями, при последующих записях и удалениях. Это и есть эвакуация. Пока она идёт, часть ключей лежит в старых бакетах, часть в новых, и любой доступ обязан учитывать, переехал конкретный бакет или нет.
Отсюда и жёсткость проблемы: параллельная запись ломает не одну ячейку, а инвариант всей структуры. Читатель может пойти в бакет, который уже эвакуирован, увидеть мусор или уйти в цикл по битой цепочке. Поэтому в мапе есть флаг «идёт запись», и при обнаружении параллельного доступа программа падает с fatal error: concurrent map writes либо concurrent map read and map write. Это не паника, recover не ловит, процесс умирает целиком, и это сделано специально.
Что предлагать:
sync.RWMutexповерх обычной мапы, дефолтный выбор;- шардирование по хэшу ключа, если мьютекс стал узким местом;
sync.Mapдля узкого сценария, когда ключи пишутся редко и читаются много;- прогонять тесты с флагом
-race.
Где спотыкаются
Говорят, что опасно любое одновременное обращение. Конкурентные чтения безопасны, опасна только параллельная запись. Обратная ошибка встречается чаще: под RLock выполняют запись в мапу и удивляются падению.
Тянут sync.Map как просто быструю потокобезопасную мапу. Она выигрывает на почти неизменяемых данных и непересекающихся ключах, а на смешанной нагрузке с записями проигрывает мьютексу, теряет типизацию и не умеет отдавать размер.
Считают, что раз детектор гонок молчит, гонок нет. Он инструментирует обращения и видит только то, что реально произошло в этом прогоне: редкий путь по коду просто не выполнился, и вывод «чисто» ничего не гарантирует.