Ответ на вопрос.
До Swift 5.5, конкурентность полагалась на Grand Central Dispatch (GCD) и ручное управление потоками, что часто приводило к гонкам данных и повреждению памяти из-за незащищенного общего изменяемого состояния. Swift ввел структурированную конкурентность с помощью Actor, чтобы обеспечить гарантии изоляции, но компилятору нужен был механизм, чтобы убедиться, что значения, передаваемые между этими изолированными доменами, по своей сути безопасны для потоков. Это привело к протоколу Sendable, который отмечает типы как безопасные для обмена между конкурентными границами, обеспечивая семантику значений или внутреннюю синхронизацию на уровне типа.
Когда Actor получает значение из внешнего источника его изоляции, это значение может быть типом ссылки, разделяемым с другими контекстами выполнения, что позволяет одновременно изменять его, нарушая безопасность памяти. Традиционные подходы полагаются на блокировки времени выполнения или мьютексы для защиты критических секций, но они вносят накладные расходы, риски взаимных блокировок и подвержены человеческим ошибкам в реализации. Задачей было спроектировать абстракцию без затрат, которая статически проверяет безопасность потоков на этапе компиляции, сохраняя при этом характеристики производительности и удобство использования Swift.
Компилятор Swift требует соответствия Sendable для всех типов, передаваемых через границы Actor, используя статический анализ для проверки безопасности без накладных расходов времени выполнения. Типы значений, такие как struct и enum, по умолчанию являются Sendable, так как они демонстрируют семантику значений и используют оптимизации копирования при записи, чтобы предотвратить общее изменяемое состояние. Для типов ссылок (class) компилятор требует явного соответствия Sendable, обязывая класс быть final и содержать только свойства Sendable, эффективно гарантируя неизменяемое состояние или внутреннюю синхронизированность, которые не могут быть повреждены при доступе из нескольких потоков.
// Явно Sendable struct struct UserData: Sendable { let id: UUID let score: Int } // Явно Sendable финальный класс с неизменяемым состоянием final class Configuration: Sendable { let apiEndpoint: String let timeout: Duration init(endpoint: String, timeout: Duration) { self.apiEndpoint = endpoint self.timeout = timeout } } actor DataProcessor { func process(_ data: UserData) async { // Безопасно: UserData является Sendable print("Обработка \(data.id)") } }
При проектировании приложения для реальной финансовой торговли наша команда реализовала PriceFeedActor, ответственный за агрегирование рыночных данных из нескольких соединений WebSocket, который нуждался в получении разобранных JSON-данных от NetworkManager, работающего в фоновом потоке. Изначально мы использовали класс типа ссылки MarketData, чтобы избежать копирования больших наборов данных во время частых обновлений, но компилятор Swift не позволял нам передавать эти объекты непосредственно в Actor, потому что они не имели соответствия Sendable и содержали изменяемые словари для кэширования расчетов. Это заставило нас переработать нашу модель данных для сохранения гарантий изоляции Actor, не жертвуя производительностью, необходимой для принятия решений в подмиллисекундных торговых операциях.
Мы переработали MarketData в struct, содержащую закрытое хранилище для больших буферов байтов, и использовали механизмы копирования при записи Swift через ManagedBuffer, чтобы разделять основное хранилище до момента изменения. Этот подход автоматически обеспечивал явное соответствие Sendable, гарантируя безопасность на этапе компиляции в то время, как минимизировалось дублирование памяти при операциях с активным чтением. Однако сложность реализации ручной логики копирования при записи вносила накладные расходы на обслуживание, и мы рисковали ухудшением производительности, если поведение автоматического копирования сработает неожиданно во время операций записи на горячем пути.
Мы сохранили тип ссылки MarketData, но реорганизовали его как final class с исключительно константами let и глубоко неизменяемыми свойствами Sendable, что позволило нам делить единственный экземпляр только для чтения между несколькими Actors без гонок данных. Это сохранило эффективность семантики ссылок для больших наборов данных и полностью устранило накладные расходы на копирование, но потребовало реорганизации нашей стратегии кэширования, чтобы использовать изменяемое состояние, изолированное Actor, вместо внутренних изменений класса. Архитектурные изменения потребовали значительной переработки нашего слоя кэширования, чтобы переместить изменяемое состояние в специальные Actors, увеличивая сложность кода, но обеспечивая строгие гарантии изоляции.
Как временная мера для устаревших классов, связанных с Objective-C, которые нельзя было немедленно переработать, мы пометили их как @unchecked Sendable, чтобы подавить предупреждения компилятора, пока вручную проверяли безопасность потоков через внутренние блокировки. Это позволило бы быстро перейти к новой модели Actor, но фактически отключило статические гарантии Swift и вновь привело к риску гонок данных времени выполнения, если наша логика ручной синхронизации содержала ошибки. Следовательно, мы ограничили этот подход только некритической инфраструктурой логирования, избегая его использования для производственных финансовых данных, где безопасность была первоочередной.
Мы приняли подход с struct для высокочастотных потоковых данных, используя оптимизированные схемы с копированием при записи, в то время как сохраняли неизменяемый подход с class для статических объектов конфигурации, к которым одновременно обращаются несколько Actors. Этот гибридный подход полностью устранил все сбои в гонках данных, обнаруженных во время стресс-тестирования, сократив количество отчетов о ошибках, связанных с конкурентностью, на 94% по сравнению с предыдущей архитектурой на базе GCD. Проверки на этапе компиляции Sendable выявили три потенциальные гонки при разработке, которые могли бы вызвать случайные сбои в производстве в предыдущей системе с ручными блокировками.
Почему тип, соответствующий Sendable, все еще не компилируется, когда захвачен замыканием, переданным в асинхронную задачу, и как атрибут @Sendable для замыканий разрешает эту неоднозначность?
Хотя тип может быть Sendable, замыкания в Swift по умолчанию захватывают переменные по ссылке, что могло бы позволить последующие изменения захваченной переменной после того, как замыкание было отправлено в другой Actor. Атрибут замыкания @Sendable ограничивает захваты только изменяемыми значениями Sendable и обеспечивает, что само замыкание не выходит из конкурентного домена небезопасно. Это гарантирует, что замыкание и все его захваченные состояния поддерживают гарантии изоляции через границы Actor, предотвращая появление гонок данных через изменяемые списки захвата в асинхронных операциях.
Как строгая проверка конкурентности в Swift 6 влияет на неявно импортированные заголовки Objective-C и какие механизмы позволяют продолжать взаимодействие с устаревшими фреймворками, не имеющими аннотаций Sendable?
Swift 6 вводит строгую проверку конкурентности, которая по умолчанию рассматривает большинство типов Objective-C как не Sendable из-за их неспособности предоставить статические гарантии безопасности. Разработчикам необходимо использовать инструкции импорта @preconcurrency, чтобы постепенно принять проверки безопасности, или вручную аннотировать заголовки Objective-C макросами SWIFT_SENDABLE. Эти аннотации позволяют компилятору различать потокобезопасные устаревшие объекты и те, которым необходимы границы изоляции, что позволяет обеспечить совместимость без ущерба для безопасности чистого кода Swift.
В чем принципиальная разница между неизолированными методами внутри Actor и типами Sendable, и когда вызов неизолированного метода на изменяемом экземпляре класса вводит неопределенное поведение?
Неизолированные методы позволяют синхронный доступ к данным Actor из-за его границ изоляции, но они выполняются на исполнительном устройстве вызывающего, а не на последовательном исполнительном устройстве Actor. Это требует, чтобы метод не получал прямой доступ к изменяемому состоянию Actor, поскольку это обошло бы гарантии изоляции Actor. Когда это применяется к изменяемому типу ссылки, который не является Sendable, неизолированные методы могут вводить гонки данных, если они обращаются к общему изменяемому состоянию без надлежащей синхронизации, что может привести к повреждению памяти или неопределенному поведению.