Как избежать проблем при одновременном чтении и записи из разных потоков в iOS
Что проверяют
Понимаете ли вы, откуда берётся гонка данных и каким инструментом её закрыть в конкретной ситуации: GCD, блокировкой или actor. Следом почти всегда просят написать потокобезопасный кеш, где чтений много, а записей мало, и спрашивают, как вы будете искать гонку, если она уже в коде.
Как отвечать
Начните с правила: у изменяемого состояния, к которому ходят из нескольких потоков, должен быть один «хозяин», и любой доступ идёт через него. Дальше варианты:
- последовательная очередь:
DispatchQueue(label:)по умолчанию serial. Чтение черезsync, запись черезasync, порядок гарантирован; - concurrent-очередь с барьером: чтения идут параллельно через
sync, запись отправляется черезasync(flags: .barrier). Барьерный блок дожидается текущих чтений и выполняется в одиночку, это классическая схема reader-writer; - блокировки:
NSLock,OSAllocatedUnfairLock(iOS 16+),Mutexиз модуляSynchronization(iOS 18+). Дешевле очередей, если критическая секция короткая; - actor: состояние изолировано, снаружи к нему обращаются через
await, а компилятор не пускает небезопасный доступ. В языковом режиме Swift 6 со строгой проверкой конкурентности многие гонки ловятся ещё на сборке; @MainActor: для всего, что связано с интерфейсом.
Закончите диагностикой: Thread Sanitizer включается в схеме Xcode и показывает гонку сразу с обоими стеками вызовов.
Где спотыкаются
Вызывают sync на ту же последовательную очередь изнутри неё или DispatchQueue.main.sync с главного потока. Это гарантированный дедлок.
Ставят .barrier на DispatchQueue.global(). На глобальных очередях флаг игнорируется, барьер работает только на собственной concurrent-очереди.
Считают actor полной защитой. Actor реентерабелен: пока метод ждёт на await, другой вызов успевает изменить состояние, поэтому проверку и изменение нельзя разрывать ожиданием. Туда же относится DispatchSemaphore.wait() внутри async-кода: он блокирует поток кооперативного пула.