Недавно кто-то задал мне вопрос об изоляции акторов. Конкретные детали не так важны, но я действительно задумался над этим вопросом, потому что, конечно, любой тут начнет испытывать трудности. Изоляция является центральным элементом работы параллелизма в Swift, но это совершенно новая концепция.
Несмотря на новизну, она использует в основном знакомые механизмы. Вероятно, вы уже понимаете то, как работает изоляция, просто пока не осознаете этого.
Вот описание концепций в самых простых терминах, которые я смог придумать.
Что такое изоляция?
Изоляция — это механизм, который Swift использует для того, чтобы сделать невозможными состояние гонки. С его помощью компилятор может рассуждать о том, как осуществляется доступ к данным и когда это можно делать, а когда нельзя, гарантированно безопасным способом. Стоит также отметить, что речь идет именно о небезопасном доступе к изменяемому состоянию, а не о всех видах гонок в целом.
Определения контролируют изоляцию
Вы всегда можете посмотреть на определение, чтобы понять его изолированность.
Это радикальное отличие от других типов механизмов потокобезопасности, таких как блокировки и очереди. Я думаю, что это, вероятно, вещь номер один, которую не понимают люди, использующие параллелизм.
// no isolation for the type... class MyClass { // ... and none for the function either func method() { // so this is a non-isolated context } func asyncMethod() async { // async does not affect this, so // this is non-isolated too! } }
Определение не всегда так просто, как кажется. Оно может включать наследование, если тип имеет суперкласс или соответствует протоколам. Обычно они не находятся в одном файле или даже модуле, и для получения полной картины вам может потребоваться обратиться к ним. Однако на практике, за пределами кода пользовательского интерфейса, наследование редко влияет на изоляцию.
class MyClass: SomeSupertype, SomeProtocol { // isolation here might depend on inheritance func method() { } }
Помните: изоляция задается во время компиляции. Я повторяюсь, потому что это одновременно и критически важно, и часто вызывает путаницу.
Изоляция бывает трех видов
- Ее нет
- Статическая
- Динамическая
По умолчанию все неизолировано. Чтобы изменить это, необходимо предпринять явные действия.
Типы акторов, глобальные акторы и изолированные параметры — все это формы статической изоляции. Глобальные акторы, в частности, очень распространены. Многим проектам, даже нетривиальным, для всех требований к параллелизму потребуется только изоляция @MainActor
. Авторы библиотек с высоким уровнем параллелизма могут найти применение изолированным параметрам, но я не думаю, что они будут играть важную роль в повседневной разработке приложений.
(Изолированные параметры также скоро станут менее странными и более мощными!)
Мы перейдем к динамической изоляции через минуту.
Изоляция может меняться, когда вы await
Всякий раз, когда вы видите ключевое слово await
, изоляция может измениться.
@MainActor func doStuff() async { // I'm on the MainActor here! await anotherFunction() // have to look at the definition of anotherFunction // back on the main actor }
Это еще один очень распространенный источник путаницы! Но это потому, что в других системах параллелизма важен контекст рантайма. В Swift важны только определения!
Замыкания могут наследовать изоляцию
Это совершенно не похоже на наследование типов. Оно применяется только к аргументам закрытия и обычно встречается только в API, которые напрямую управляют функциями параллелизма, например Task
. Обратите внимание, что мы все еще следуем правилам — поведение изоляции по-прежнему контролируется определением. Это делается с помощью атрибута @_inheritActorContext.
Да, это сбивает с толку. Поначалу!
Все это означает, что изоляция не изменится внезапно, если только вы не решите, что хотите ее изменить. Какая бы изоляция ни действовала на момент создания задачи, она все равно будет использоваться в теле задачи по умолчанию. Это очень удобно и часто именно то, что вам нужно. Вы также можете отказаться от этого, если захотите.
@MainActor class MyIsolatedClass { func myMethod() { Task { // still isolated to the MainActor here! } Task.detached { // explicitly non-isolated, regardless // the enclosing scope } } }
Изоляция применяется к переменным
Функции — не единственное, что может быть изолировано. Нелокальные переменные тоже могут нуждаться в изоляции.
@MainActor class MyIsolatedClass { static var value = 1 // this is also MainActor-isolated }
Компилятор начал проверять это только в Swift 5.10, и это удивляет многих людей. Но в этом есть смысл. Доступ к этим значениям может быть получен в любом месте модуля, так что это должно быть сделано безопасным для потоков способом. Явная изоляция — один из способов обеспечить такую безопасность, но точно не единственный.
Вы можете отказаться от изоляции
Если у чего-то есть изоляция, которая вам не нужна, вы можете отказаться от нее с помощью ключевого слова nonisolated
. Это также может иметь большой смысл для статических констант, которые неизменяемы и безопасны для доступа из других потоков.
@MainActor class MyIsolatedClass { nonisolated func nonIsolatedMethod() { // no MainActor isolation here } nonisolated static let someConstantSTring = "I'm thread-safe!" }
(Как мы видели выше, отказ от изоляции для Task
сегодня работает немного иначе. Но над этим вопросом сейчас активно работают).
Изоляция делает протоколы сложнее
Протоколы, будучи определениями, могут управлять изоляцией так же, как и другие виды определений.
protocol NoIsolation { func method() } @MainActor protocol GloballyIsolatedProtocol { func method() } protocol PerMemberIsolatedProtocol { @MainActor func method() }
Изоляция, используемая в протоколах, или ее полное отсутствие, может иметь серьезные последствия. Это то, на что следует обратить особенно пристальное внимание при использовании параллелизма. Если вы используете много протоколов в своем коде, изучить, как полная проверка параллелизма влияет на ваш дизайн, будет очень хорошей идеей.
(У меня есть коллекция методик для работы с протоколами и параллелизмом. Но я постоянно нахожу новые проблемы! Пожалуйста, дайте мне знать, если вы столкнетесь с проблемами, которые я там не описал).
Динамическая изоляция
Может случиться так, что система типов сама по себе не описывает или не может описать фактически используемую изоляцию. Это регулярно случается с системами, построенными до появления параллелизма. Один из инструментов, который мы имеем для решения этой проблемы — динамическая изоляция. Это API, которые позволяют нам выразить изоляцию таким образом, что она не видна при простом просмотре определений.
// this delegate will actually always make calls on the MainActor // but its definition does not express this. @MainActor class MyMainActorClass: SomeDelegate { nonisolated funcSomeDelegateCallback() { // promise the compiler we will be on the // MainActor at runtime. MainActor.assumeIsolated { // access MainActor stuff, including self } } }
SwiftUI очень запутанный
Это не имеет прямого отношения к системе изоляции в языке, но это практическая проблема, которая затрагивает большое количество людей. Модель изоляции в SwiftUI чрезвычайно склонна к ошибкам, и я бы даже сказал, что ее следует изменить. Сейчас, если вы видите представление SwiftUI, которое не изолировано от MainActor, это, скорее всего, ошибка.
И UIKit, и AppKit принудительно изолируют весь MainActor, так что в этом отношении их гораздо проще использовать.
(У меня также есть несколько идей, как с этим справиться, но я бы хотел услышать отзывы об этом от более опытных пользователей SwiftUI).
Это новое, но не невозможное
Думаю, немного попрактиковавшись, вы сможете разобраться с изоляцией. И хотя я думаю, что концепция проста, на самом деле правильно выполнить изоляцию может быть невероятно сложно. Надеюсь, я рассказал достаточно для начала. Но, пожалуйста, дайте мне знать, если я что-то упустил или сделал неправильно. Эта тема непростая!