Она на этапе компиляции доказывает, что изменяемое состояние не достижимо из двух доменов изоляции одновременно: значение, пересекающее границу, либо Sendable, либо доказуемо передано. Версия компилятора определяет, какие проверки есть; языковой режим таргета определяет, будут ли они ошибками.
Что проверяется
Sendable на границах: аргументы и результаты вызовов между акторами, захваты в Task и @Sendable-замыканиях.
Изоляция акторов: к изолированному состоянию обращаются только изнутри его актора или через вызов с await.
Глобальное изменяемое состояние: глобальная переменная или static var должна быть изолирована, неизменяема или защищена (SE-0412).
Изоляция регионов: не-Sendable значение может пересечь границу, только когда больше ничто его не достаёт (SE-0414).
Не проверяется: логические гонки вроде «проверил, потом действуешь» через await, deadlock и порядок выполнения. Swift 6 убирает гонки данных (data race), а не race condition вообще.
Компилятор и языковой режим
Xcode 16+ поставляет компилятор Swift 6, но каждый таргет выбирает свой режим: SWIFT_VERSION в Xcode, .swiftLanguageMode(_:) в SwiftPM. Пакет с swift-tools-version: 6.0 по умолчанию в режиме 6.
Режим Swift 5 с -strict-concurrency=complete выполняет тот же анализ, но выдаёт предупреждения. Режим Swift 6 делает их ошибками и добавляет рантайм-проверки изоляции (SE-0423) там, куда входит непроверенный код.
Режимы свободно смешиваются между модулями, и именно это позволяет мигрировать таргет за таргетом.
iOSSenior
Строгая конкурентность Swift 6
Домены изоляции, Sendable, глобальные акторы, region isolation, sending, миграция на Swift 6
Закрыто вопросов
0 / 19
- 0Повторить
- 1Часто спрашивают
Вопросы
Доменом изоляции называют область, изменяемое состояние которой в каждый момент трогает одна задача: экземпляр актора, глобальный актор вроде @MainActor; nonisolated-код не принадлежит ни одному. Значение переходит между доменами, только если оно Sendable или передано без остатка.
Домены
Экземпляр актора: его хранимое состояние и изолированные методы. Два экземпляра одного актора образуют два домена.
Глобальный актор: @MainActor или ваш собственный @globalActor. Все объявления с этим атрибутом делят один домен.
Неизолированный код: свободные функции, nonisolated-члены, обычные типы. Не-Sendable значение здесь принадлежит той задаче, которая сейчас выполняет код.
Функция может и позаимствовать домен вызывающего через isolated-параметр (isolation: isolated (any Actor)? = #isolation), и тогда хелпер выполняется внутри актора вызывающего, без перехода.
Как значение пересекает границу
Sendable-типы: значимые типы из Sendable-частей, акторы, неизменяемые final-классы, типы под защитой Mutex. Делиться ими безопасно, дополнительных доказательств не нужно.
Несвязанные (disconnected) не-Sendable значения: изоляция регионов позволяет только что созданному объекту перейти границу, если отправитель больше его не трогает; sending записывает это обещание в сигнатуру.
Переходит не состояние, а вызов: код из другого домена добирается до изолированного состояния только через вызов с await, который переключается на executor владельца.
Для класса Sendable не выводится: конформанс пишете вы, и класс должен быть final, с неизменяемыми Sendable-свойствами и без суперкласса, кроме NSObject. @unchecked Sendable снимает все проверки и честен, только если каждый доступ защищает ваш лок.
Явный конформанс: структурам и enum он выводится внутри модуля; классам никогда, как бы неизменяемо они ни выглядели.
**final: подкласс мог бы добавить изменяемое хранилище, поэтому не-final класс ничего гарантировать не может.
Sendable-типы свойств**: let text: NSMutableString не пройдёт: неизменяемая ссылка на изменяемый объект всё равно остаётся разделяемым изменяемым состоянием.
Во что обходится @unchecked Sendable
Он выключает сразу все проверки выше. Вы утверждаете, что любой доступ сериализует нечто невидимое компилятору и что это останется правдой после того, как файл отредактирует следующий человек.
Честные случаи: legacy-класс, чьё private-состояние всегда трогают под одним NSLock, или обёртка над задокументированно потокобезопасным объектом на C или Objective-C.
Лучше: держать состояние в Mutex (iOS 18+), который сам Sendable, так что класс получает проверяемый конформанс; или сделать класс actor, если вызывающие могут делать await.
Строка (1) объявляет глобальную переменную, в которую может писать любой поток без сериализации. Строка (2) неизменяема, но указывает на не-Sendable класс с var-хранилищем, так что состояние за ней всё равно разделяемое. Любое исправление делает состояние неизменяемым, изолированным или защищённым.
(1) «static property 'currentUserID' is not concurrency-safe because it is nonisolated global shared mutable state».
(2) «…because non-'Sendable' type 'Analytics' may have shared mutable state». let сам по себе не спасает: безопасным должен быть и тип за ним.
Допустимые формы
**static let Sendable-типа**: сделайте Analytics актором или final-классом, который хранит состояние в Mutex. Ленивая инициализация статиков и так потокобезопасна.
Изолировать: @MainActor static var currentUserID: String?. Вызывающим вне главного актора придётся писать await, и в этом переходе весь смысл.
Защитить: static let launches = Atomic<Int>(0) или Mutex<[String: Int]> из модуля Synchronization (iOS 18+).
Крайний случай: nonisolated(unsafe) static var плюс лок, которым вы управляете сами. Компилируется и не проверяет ничего.
Компилятор задаёт один вопрос: кто сериализует доступ? let Sendable-типа, актор и лок дают ответ; молчание его не даёт.
Он выключает проверку конкурентности для одного объявления и обещает, что каждое обращение вы сериализуете сами; никакого лока и никакой защиты в рантайме он не добавляет. Честен он только тогда, когда рядом можно написать «это безопасно, потому что…» и показать механизм.
Применяется к глобальной переменной или static var, к хранимому свойству или к локальной переменной (SE-0412). Покрывает объявление, но никогда не объект, на который указывает значение.
Честные случаи
Хранилище, которое на каждом пути защищает лок, объявленный прямо рядом.
Значение, записанное один раз при запуске, до появления любого конкурентного кода, и дальше только читаемое.
C-хэндл, потокобезопасность которого задокументирована на стороне C, куда компилятор всё равно не заглядывает.
Глушилка
Всё, что нельзя обосновать одним предложением. nonisolated(unsafe) static var cache: [URL: Data] компилируется и устраивает гонку при первой же конкурентной записи.
let Sendable-типа он не нужен никогда: ленивая инициализация глобалов и так потокобезопасна.
Предпочитайте альтернативы, которые компилятор продолжает проверять: @MainActor, Mutex или Atomic, актор.
Mutex<Value> (iOS 18+) представляет собой лок, который владеет защищаемым значением: значение достижимо только внутри withLock, а сам мьютекс Sendable, поэтому класс, который его хранит, конформит Sendable без @unchecked.
Владеет состоянием: у NSLock лок и var живут раздельно, ничто не мешает пути, который забыл взять лок, и класс в итоге становится @unchecked Sendable. С Mutex доступ без лока просто не написать.
Некопируемый: Mutex объявлен как ~Copyable, поэтому он живёт в let класса или актора либо в глобальном или static let.
Только синхронный: withLock принимает не-async замыкание, так что удерживать его через await нельзя; с NSLock такая ошибка спокойно компилируется.
Не рекурсивный: никогда не вызывайте withLock того же мьютекса изнутри его собственного замыкания.
**OSAllocatedUnfairLock** (iOS 16+): та же идея «лок владеет состоянием» для более старых deployment target. Его withLock требует @Sendable-замыкание и Sendable-результат; withLockUnchecked это правило снимает.
counts[host, default: 0] += 1 проходит через геттер, а затем через сеттер, то есть берёт лок дважды: копирует словарь под одним локом, увеличивает копию без лока и записывает её обратно под другим. Параллельные вызовы затирают друг друга, и инкременты теряются.
Компилятор доволен, логика сломана: каждый доступ к словарю идёт внутри withLock, поэтому класс Sendable и Swift 6 его принимает. Но get отдаёт копию, разделяющую буфер, += изменяет эту копию вне лока, а set записывает её обратно. Это потерянное обновление (lost update), и copy-on-write без лока ещё и гоняется за буфер словаря, поэтому Thread Sanitizer сообщает о Swift access race в record.
Ловушка кроется в сеттере: пара get/set под локом создаёт иллюзию атомарности: +=, append и запись по сабскрипту выглядят атомарными в месте вызова, хотя на деле это два отдельных захвата лока. У самописных property wrapper вроде @Atomic поверх NSLock тот же изъян.
Исправление: делать всё «прочитать, изменить, записать» внутри одного withLock, наружу отдавать методы-намерения (record, reset) и только снимок для чтения. Для одного счётчика Atomic<Int> с wrappingAdd(1, ordering: .relaxed) делает то же без лока.
Как поймать в следующий раз: запускать тесты под Thread Sanitizer и добавить тест, который вызывает record 100 000 раз из DispatchQueue.concurrentPerform и проверяет точную сумму; версия с багом теряет большую часть инкрементов. На ревью публичный сеттер у состояния под локом даёт повод присмотреться.
Глобальный актор представляет собой один общий экземпляр актора, названный атрибутом, так что объявления из множества типов делят один домен изоляции; @MainActor служит встроенным примером. Свой стоит объявлять только для общепроцессного состояния, которое трогают несколько типов, например базы данных.
Как объявить и применить
@globalActor actor StorageActor { static let shared = StorageActor() }: протокол требует только статический актор shared.
@StorageActor на типе изолирует все его члены; на протоколе его наследуют конформеры; на функции или свойстве он изолирует только это объявление.
В отличие от @MainActor, собственный глобальный актор не привязан к потоку: он выполняется на кооперативном пуле, как любой актор.
У объявления одна изоляция, потому что его тело выполняется на одном executor, поэтому @MainActor @StorageActor func даёт ошибку («declaration can not have multiple global actor attributes»). Чтобы работать с обоими, будьте изолированы к одному и делайте await к другому; каждый переход становится точкой приостановки, и к возвращению состояние другого актора могло измениться.
Когда лучше обычный актор
Если состоянием владеет один объект, обычный actor даёт то же взаимное исключение без атрибута, размазанного по всему модулю.
nonisolated выводит объявление из домена изоляции его актора: вызывающие обращаются к нему без await, а оно само не может трогать изолированное состояние. Это утверждение о проверках, а не о планировании; ни одна из трёх форм сама по себе не переносит работу в фоновый поток.
Хранимое свойство
Разрешён на let Sendable-типа. Внутри модуля такой let и так читается без await; nonisolated делает это верным и для других модулей.
Начиная с SE-0434 разрешён и на Sendable-var в структуре, изолированной к глобальному актору. Изменяемому свойству класса нужен nonisolated(unsafe), а это обещание, а не доказательство.
Синхронный метод
Выполняется в потоке вызывающего. Используйте для реализаций требований протоколов (==, hash(into:), description) и членов, которые читают только неизменяемое состояние.
Вызванный с главного актора, тяжёлый синхронный nonisolated-метод всё равно блокирует главный поток.
async-метод
С NonisolatedNonsendingByDefault (Swift 6.2, часть Approachable Concurrency) выполняется на акторе вызывающего. Без этой фичи выполняется всегда на глобальном пуле.
@concurrent явно просит пул; nonisolated(nonsending) явно просит актор вызывающего.
В таком виде код не скомпилируется из-за ошибки «sending 'report' risks causing data races»: report читается после того, как его передали актору. Удалите print, и код скомпилируется: изоляция на основе регионов (SE-0414) доказывает, что после вызова до report больше ничто не дотянется.
Как работают регионы
Компилятор группирует значения в регионы. Два значения попадают в один регион, как только одно может достать другое: присваивание, захват, передача в один и тот же вызов.
Регион, до которого больше ничто не дотягивается, называется несвязанным (disconnected), и его можно отправить через границу изоляции. Отправка его поглощает: все значения в нём после этого недоступны, и чтение тоже считается использованием.
Анализ выполняется в пределах функции и учитывает порядок, поэтому один и тот же вызов может быть законным в одной строке и отклонённым несколькими строками ниже.
Что ещё ломает компиляцию
Слияние с долгоживущим состоянием: self.last = report или добавление в массив, который вы храните, до вызова. Регион перестаёт быть несвязанным.
Параметр: в func archive(_ report: Report, to: Archive) значение изолировано к задаче (task-isolated): у вызывающего может остаться ссылка. Пометьте параметр sending, чтобы перенести доказательство на вызывающего.
Report по-прежнему не Sendable. Это передача владения, а не потокобезопасность: вы доказываете, что ссылка одна и вы её отдаёте.
@Sendable ограничивает захваты замыкания Sendable-значениями, потому что оно может выполняться где угодно и сколько угодно раз. sending ничего не говорит о типе: он передаёт одно значение, причём вызывающий от него отказывается, а вызываемый получает его несвязанным.
**@Sendable**: часть типа функции. Захват не-Sendable Draft падает с «capture of 'draft' with non-Sendable type 'Draft' in a '@Sendable' closure», как бы аккуратно ни вёл себя вызывающий.
**sending на параметре: аргумент в точке вызова должен быть несвязанным и поглощается вызовом. На результате вызываемый обещает свежее несвязанное значение, и вызывающий может отправить его дальше.
Ошибка, которую исправляет только sending**: хелпер, пробрасывающий не-Sendable параметр актору. Внутри него параметр изолирован к задаче, поэтому вызов падает с «sending 'draft' risks causing data races». sending переносит обязательство на вызывающего, у которого значение действительно свежая локальная переменная. Объявить тип @unchecked Sendable было бы неправдой.
SE-0430: Task.init и TaskGroup.addTask принимают sending-замыкания. Замыкание задачи выполняется один раз, поэтому его захваты достаточно передать, а не делать разделяемыми навсегда. Вот почему Task { print(draft.body) } компилируется, а написанный вручную @Sendable-колбэк тот же захват отклоняет, если вызывающий потом не трогает draft.
Коротко: @Sendable значит «можно вызывать откуда угодно и сколько угодно раз»; sending значит «теперь твоё, больше не моё».
С NonisolatedNonsendingByDefault (SE-0461, Swift 6.2) функция nonisolated async выполняется на акторе вызывающего, поэтому decode теперь работает на главном акторе и декодирует каждую картинку в главном потоке. Пометьте её @concurrent, чтобы вернуть на глобальный пул.
До переключения: nonisolated async-функция всегда уходила с актора вызывающего (правило SE-0338), поэтому этот код случайно декодировал вне главного потока. Тот же исходник поменял смысл, когда переключили настройку сборки.
Зачем Swift это поменял: вызов неизолированного async-метода раньше был пересечением границы, и не-Sendable аргументы давали ошибки. Оставаясь в домене вызывающего, функция этого пересечения избегает, а большинство async-хелперов просто ждут I/O, где остаться на месте ничего не стоит.
Исправление: @concurrent static func decode(_:). Она всегда выполняется на пуле, поэтому её аргументы должны быть Sendable (Data такой); только что созданный результат, например UIImage, может вернуться.
Аудит после переключения флага: найдите nonisolated async-функции, которые нагружают CPU (декодирование, парсинг, обработка изображений), и пометьте их @concurrent. nonisolated(nonsending) явно записывает новое поведение по умолчанию.
await отмечает возможную приостановку, а не смену потока; он никогда не означал «в фоне».
Таргет за таргетом, начиная с листьев графа зависимостей: в режиме Swift 5 включите полную проверку, чтобы каждое нарушение было предупреждением, доведите предупреждения до нуля, затем переключите этот таргет на Swift 6. Каждый шаг заканчивается зелёной сборкой, которую можно выпускать.
Порядок
Сначала листовые модули: модуль, который импортирует неаннотированный код, наследует его проблемы, поэтому идите вверх по графу.
**StrictConcurrency в режиме Swift 5** (то же, что -strict-concurrency=complete): основная часть работы. targeted даёт более дешёвый первый проход по большому legacy-таргету.
Узкие upcoming features, по одному pull request на каждую: GlobalConcurrency, InferSendableFromCaptures, DisableOutwardActorInference, IsolatedDefaultValues.
**.swiftLanguageMode(.v6) или SWIFT_VERSION = 6**: оставшиеся предупреждения становятся ошибками, добавляются рантайм-проверки изоляции.
Что решить заранее
Использовать ли в таргетах приложения изоляцию MainActor по умолчанию и Approachable Concurrency. Они меняют семантику, поэтому включайте их отдельным коммитом до основной работы, а не посередине.
Как обращаться со сторонним кодом: @preconcurrency import работает как заём с датой возврата, который убирают, когда зависимость выпустит аннотации.
У тестовых таргетов и расширений приложения свои настройки, им нужен такой же проход.
Типичные ошибки
Переключить весь воркспейс на Swift 6 одним коммитом: сотни ошибок и ни одного состояния, которое можно выпустить.
Массово расставить @unchecked Sendable и nonisolated(unsafe), чтобы стало зелёным. Сборка проходит, гонки остаются.
Если вешать @MainActor на всё подряд, пока ошибки не исчезнут, CPU-работа переезжает в главный поток.
Изоляция MainActor по умолчанию (SE-0466) делает каждое неаннотированное объявление @MainActor; Approachable Concurrency включает набор upcoming features, подходящих этой модели. В приложении это убирает большинство Sendable-ошибок; во фреймворке меняет изоляцию публичного API.
Две настройки
Default Actor Isolation: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor или .defaultIsolation(MainActor.self) в SwiftPM (tools 6.2). Явным действием становится уход с главного актора: nonisolated, @concurrent или actor.
Approachable Concurrency: SWIFT_APPROACHABLE_CONCURRENCY, зонтик для upcoming features. Из них здесь важнее всего NonisolatedNonsendingByDefault (неизолированные async-функции выполняются на акторе вызывающего) и InferIsolatedConformances (конформансы типа на главном акторе становятся изолированными к нему).
Это независимые переключатели. Новые проекты приложений в Xcode 26 включают оба.
Почему таргету приложения это нужно
Вью, вью-модели и большая часть кода приложения и так были бы на главном акторе. Когда это поведение по умолчанию, ничто не пересекает границ, ничему не нужно быть Sendable, а оставшиеся диагностики указывают на настоящую конкурентность.
Цена: CPU-работа оказывается в главном потоке, если её никто не пометит @concurrent.
Почему библиотеке не стоит
Ваши публичные типы и функции становятся @MainActor, и клиенту, который парсит JSON на фоновом акторе, придётся делать await к вашему парсеру и выполнять его в главном потоке.
Если поменять настройку потом, это будет ломающее исходники изменение для каждого клиента.
Вместо этого аннотируйте явно: @MainActor на типах, обращённых к UI, Sendable-значимые типы и акторы для остального.
Equatable.== объявлен как неизолированное требование: любой код может вызвать его синхронно из любого потока, а реализация на главном акторе этого обеспечить не может. Выходы: nonisolated-реализация, изолированный конформанс (Swift 6.2) или протокол, подходящий под изоляцию.
Почему
@MainActor на типе изолирует все его члены, включая ==. Swift 6.0 сообщает «conformance of 'Model' to protocol 'Equatable' crosses into main actor-isolated code and can cause data races».
То же столкновение бывает с Hashable, Comparable, CustomStringConvertible и делегатными протоколами старых SDK.
Варианты
**nonisolated-реализация**: правильна, когда читает только неизменяемое Sendable-состояние, например let id. Обычно это и есть ответ.
Изолированный конформанс (SE-0470): extension Model: @MainActor Equatable. Реализация может читать состояние главного актора, а пользоваться конформансом может только код на главном акторе. InferIsolatedConformances выводит это для типов на главном акторе автоматически.
**async-требование** в вашем собственном протоколе: await вызывающего и есть переход.
Изолированный протокол: @MainActor protocol Presentable, когда все конформеры относятся к UI-коду.
Подвох
Изолированный конформанс нельзя использовать там, где обобщённый код может выполниться вне главного актора: компилятор отклоняет его для обобщённого параметра с Sendable или SendableMetatype.
Конформанс с @preconcurrency тоже компилируется, но превращает ошибку в падение в рантайме; оставьте его для legacy-делегатов.
На импорте он отключает диагностики об отсутствующем Sendable у типов этого модуля; на конформансе позволяет изолированному типу выполнить неизолированное требование, меняя ошибку компиляции на падение в рантайме. Ни то, ни другое гонку не убирает: одно её прячет, другое превращает в краш.
@preconcurrency import Legacy
Sendable-диагностики о типах из этого модуля подавляются. Ваши собственные ошибки изоляции и другие модули это не затрагивает.
Когда модуль выпустит аннотации конкурентности, компилятор предупредит, что атрибут ни на что не влияет, и это сигнал его удалить.
Риск: тип, который на самом деле не потокобезопасен, теперь пересекает домены вообще без диагностики.
extension Box: @preconcurrency LegacyDelegate
Тип с @MainActor может реализовать требование неизолированного делегатного протокола. Компилятор вставляет в каждую реализацию динамическую проверку изоляции (SE-0423).
Риск: если SDK хоть раз вызовет колбэк вне главного потока, приложение упадёт в продакшене. Это правильный размен, только если SDK документирует доставку в главном потоке.
Как к ним относиться
Как к займам с датой возврата: ведите список, пересматривайте при каждом обновлении зависимостей и прогоняйте Thread Sanitizer по путям за @preconcurrency import, потому что компилятор их больше не проверяет.
assumeIsolated в рантайме проверяет, что вы уже на главном акторе, а затем синхронно выполняет замыкание с изоляцией главного актора; поток он никогда не переключает. Он уместен, когда доставка в главном потоке гарантирована, и падает (в релизных сборках тоже), когда это не так.
MainActor.assumeIsolated объявлен как статический метод; у остальных акторов есть метод экземпляра assumeIsolated. Внутри замыкания можно напрямую трогать изолированное состояние, а колбэк остаётся синхронным и упорядоченным.
Против Task { @MainActor in … }
Task возвращается сразу и выполняет тело позже: порядок меняется, делегатный метод не может вернуть значение, вычисленное там, а захваченные аргументы должны быть Sendable или переданы.
Используйте Task, когда поток не гарантирован; assumeIsolated берите, когда гарантирован и работа должна остаться синхронной.
Ограничения
Он доказывает, где вы находитесь, а не что состояние свободно от гонок.
Он не может перевести вас на актор: вызванный вне главного потока, он даёт краш, а не переход.
MainActor.preconditionIsolated() и assertIsolated() проверяют то же самое, не выполняя изолированный код.
Для целого делегатного протокола изолированный конформанс (Swift 6.2) или конформанс с @preconcurrency часто чище, чем оборачивать каждый метод.
Поставьте между SDK и своим кодом один маленький неизолированный объект: только его трогают потоки вендора, он не владеет изменяемым состоянием и пересылает каждое событие в AsyncStream, который потребляет изолированная модель. Граница пересекается в одном месте и Sendable-значениями.
Устройство
final class Bridge: NSObject, VendorDelegate, Sendable только с let-свойствами честно проходит проверку, поэтому SDK может вызывать его из любого потока.
Он хранит AsyncStream.Continuation (makeStream требует iOS 17+), который Sendable и безопасен для yield из любого потока. События описываются Sendable-enum или структурами.
Модель перебирает for await event in bridge.events внутри своей изоляции, так что её состояние никуда не уходит.
Одноразовые completion handler превращаются в withCheckedContinuation. Возобновляйте ровно один раз и оборачивайте в withTaskCancellationHandler, чтобы отмена задачи останавливала SDK.
Научите импортёр
Если заголовки ваши, аннотируйте их один раз: NS_SWIFT_SENDABLE на потокобезопасных классах, NS_SWIFT_UI_ACTOR на классах только для главного потока, NS_SWIFT_NONISOLATED на отдельных членах. Выигрывает каждое место вызова в каждом модуле.
Чего не делать
@preconcurrency на конформансе утверждает доставку в главном потоке; здесь это превращает гонку в краш.
@unchecked Sendable на модели глушит границу вместо того, чтобы её перенести.
Правы оба: Swift 6 доказывает, что незащищённое Swift-значение не пересекает границу изоляции в коде, который ему разрешили проверить, а TSan следит за реальными обращениями к памяти, включая код, который компилятор пропустил или не видел. Гонка живёт в этой слепой зоне.
Слепые зоны
Отключённые проверки: @unchecked Sendable, nonisolated(unsafe), @preconcurrency import. Лок, взятый на трёх путях и забытый на четвёртом, отлично компилируется.
Указатели: UnsafeMutablePointer и OpaquePointer не Sendable, но обычный обходной путь (обёртка с @unchecked Sendable или nonisolated(unsafe)) компилируется чисто, и две задачи могут писать через один указатель. Указатель из withUnsafeMutableBytes, сбежавший из своего замыкания, даёт ещё и use-after-free.
Непроверенный код: таргеты, всё ещё в режиме Swift 5, бинарные фреймворки, тела функций на C и Objective-C, глобалы C.
**deinit**: по умолчанию он неизолирован, так что deinit класса с @MainActor выполняется в том потоке, который отпустил последнюю ссылку. Если инвалидировать там Timer, это произойдёт вне главного потока; isolated deinit (SE-0371, Swift 6.2) это исправляет.
Вообще не гонки данных: ошибки реентерабельности, о которых не сообщает ни компилятор, ни TSan, и deadlock (TSan сообщает только об инверсии порядка захвата реальных локов).
Как искать
Включите Thread Sanitizer в разделе Diagnostics схемы и прогоните путь с гонкой под нагрузкой, например тестом, который запускает сотни задач. TSan видит только реально случившиеся обращения; готовьтесь к замедлению в 5–15 раз, и вместе с Address Sanitizer он не работает.
Читайте оба стека. «Previous write … by thread N» показывает вторую половину гонки, и обычно она указывает на модуль, который обошёл изоляцию.
TSAN_OPTIONS=halt_on_error=1 останавливает на первом отчёте, и состояние остаётся в отладчике.
Исправляйте владельцем, которого проверяет компилятор (актором или Mutex), а не очередной аннотацией.