Как стать автором
Обновить
2288.94
МТС
Про жизнь и развитие в IT

Изоляция с помощью глобальных акторов в Swift Concurrency: варианты на примере @MainActor

Время на прочтение7 мин
Количество просмотров538

Привет, Хабр! Меня зовут Алексей Григорьев, я техлид iOS-разработки продукта Membrana в МТС. Это тариф с приложением для управления приватностью в сети и окружением.

Swift Concurrency принесла множество инструментов для управления многопоточностью. Среди них глобальные акторы, которые помогают обеспечивать безопасность данных и контролировать потоки выполнения.

Один из самых распространенных и полезных глобальных акторов — это @MainActor, который гарантирует выполнение операций в главном потоке приложения. В этом посте я на его примере покажу все варианты, как можно реализовать изоляцию и что в итоге выведет код: на каком потоке будут выполнены update, internal update и set в property.

Варианты изоляции с помощью @MainActor

Отсутствие глобальной изоляции

В этом варианте код может выполняться в любых потоках, что может привести к гонкам данных и другим проблемам. Пример кода с реализацией:

protocol IUpdate {
    func update(date: Date) async
}

@Observable
final class LS: IUpdate {
    var date: Date = .now

    func update(date: Date) async {
        await internalUpdate(date: date)
    }

    private func internalUpdate(date: Date) async {
        self.date = date
    }
}

Консольный вывод:

update:  <NSThread: 0x600001767e80>{number = 8, name = (null)}
internalUpdate:  <NSThread: 0x600001767e80>{number = 8, name = (null)}
didset:  <NSThread: 0x600001767e80>{number = 8, name = (null)}

Полная изоляция на уровне типа

Весь код класса гарантированно исполняется в главном потоке, обеспечивая полную изоляцию независимо от места реализации протокола:

protocol IUpdate {
    func update(date: Date) async
}

@MainActor
@Observable
final class LS: IUpdate {
    var date: Date = .now

    func update(date: Date) async {
        await internalUpdate(date: date)
    }

    private func internalUpdate(date: Date) async {
        self.date = date
    }
}

Консольный вывод:

update:  <_NSMainThread: 0x600001700040>{number = 1, name = main}
internalUpdate:  <_NSMainThread: 0x600001700040>{number = 1, name = main}
didset:  <_NSMainThread: 0x600001700040>{number = 1, name = main}

Изоляция одного метода

Изолированный метод четко выполняется на главном потоке, остальные методы будут выполняться не в главном потоке:

protocol IUpdate {
    func update(date: Date) async
}

@Observable
final class LS: IUpdate {
    var date: Date = .now

    @MainActor
    func update(date: Date) async {
        await internalUpdate(date: date)
    }

    private func internalUpdate(date: Date) async {
        self.date = date
    }
}

Консольный вывод:

update:  <_NSMainThread: 0x60000170c000>{number = 1, name = main}
internalUpdate:  <NSThread: 0x600001779380>{number = 8, name = (null)}
didset:  <NSThread: 0x600001779380>{number = 8, name = (null)}

Изоляция свойства

В этом варианте Update и internalUpdate выполнятся не на главном потоке, а поле потребует изоляции @MainActor. В итоге мы получим ошибку компиляции — чтобы ее исправить, нужно будет создать задачу с апдейтом поля на главном потоке, что не очень удобно:

@Observable
final class LS: IUpdate {
    @MainActor
    var date: Date = .now

    func update(date: Date) async {
        await internalUpdate(date: date)
    }

    private func internalUpdate(date: Date) async {
        self.date = date
    }
}

Консольный вывод:

Compile time error: Main actor-isolated property 'date' can not be mutated from a nonisolated context

Полная изоляция через протокол (conformance на декларации класса)

Это удобный подход, позволяющий гарантировать, что все методы протокола и свойства этого типа получат изоляцию @MainActor:

@MainActor
protocol IUpdate {
    func update(date: Date) async
}

@Observable
final class LS: IUpdate {
    var date: Date = .now

    func update(date: Date) async {
        await internalUpdate(date: date)
    }

    private func internalUpdate(date: Date) async {
        self.date = date
    }
}

Консольный вывод:

update:  <_NSMainThread: 0x600001700000>{number = 1, name = main}
internalUpdate:  <_NSMainThread: 0x600001700000>{number = 1, name = main}
didset:  <_NSMainThread: 0x600001700000>{number = 1, name = main}

Частичная изоляция через extension

Этот вариант будет полезен, если нужно изолировать только конкретные методы из протокола. Другие внутренние методы и свойства не защищены:

@MainActor
protocol IUpdate {
    func update(date: Date) async
}

@Observable
final class LS {
    var date: Date = .now
}

// Здесь изоляцию @MainActor получит только update 

extension LS: IUpdate {
    func update(date: Date) async {
        await internalUpdate(date: date)
    }
// А здесь изоляция уже не применится

    private func internalUpdate(date: Date) async {
        self.date = date
    }
}

Консольный вывод:

update:  <_NSMainThread: 0x600001710040>{number = 1, name = main}
internalUpdate:  <NSThread: 0x60000174cd40>{number = 6, name = (null)}
didset:  <NSThread: 0x60000174cd40>{number = 6, name = (null)}

Нет изоляции при реализации метода в отдельном extension

Несмотря на аннотацию протокола, метод не изолирован — реализация вынесена в отдельное расширение, что требует особого внимания при проектировании:

@MainActor
protocol IUpdate {
    func update(date: Date) async
}

@Observable
final class LS {
    var date: Date = .now
}

extension LS: IUpdate {}

extension LS {
    func update(date: Date) async {
        await internalUpdate(date: date)
    }

    private func internalUpdate(date: Date) async {
        self.date = date
    }
}

Консольный вывод:

update:  <NSThread: 0x600001776880>{number = 8, name = (null)}
internalUpdate:  <NSThread: 0x600001776880>{number = 8, name = (null)}
didset:  <NSThread: 0x600001776880>{number = 8, name = (null)}

Этот вариант опасен ложным чувством защищенности. Да, протокол применился на тип через extension, и на нем висит @MainActor — кажется, что на протокольный update он применится. Но на самом деле это не так: изоляция имплементируется в одном extension, а протокол применен к другому.

Вложенные типы не изолированы

Изоляция на декларации типа никак не влияет на вложенные типы:

@MainActor
@Observable
final class LS {
    var label = "Embedded"
    var date: Date = .now {
        didSet {
            print("didset: ", Thread.current)
        }
    }

    enum EmbeddedType {
        static func printEmbeddedTypeThread() {
            print("embedded type sync call: ", Thread.current)
        }

        static func printEmbeddedTypeThreadAsync() async {
            print("embedded type async call: ", Thread.current)
        }
    }
}

extension LS: IUpdate {
    func update(date: Date) async {
        print("update: ", Thread.current)
        await internalUpdate(date: date)
        EmbeddedType.printEmbeddedTypeThread()
        await EmbeddedType.printEmbeddedTypeThreadAsync()
    }

    private func internalUpdate(date: Date) async {
        print("internalUpdate: ", Thread.current)
        self.date = date
    }
}

Консольный вывод:

update:  <_NSMainThread: 0x600001700040>{number = 1, name = main}
internalUpdate:  <_NSMainThread: 0x600001700040>{number = 1, name = main}
didset:  <_NSMainThread: 0x600001700040>{number = 1, name = main}
embedded type sync call:  <_NSMainThread: 0x600001700040>{number = 1, name = main}
embedded type async call:  <NSThread: 0x60000177af00>{number = 7, name = (null)}

При проектировании вашей системы важно учитывать несколько особенностей этого варианта. В нем изоляция распространяется только на основной тип. Вложенные типы не наследуют @MainActor, даже если находятся внутри изолированного класса. Синхронные вызовы исполняются в потоке вызывающего кода, асинхронные — без изоляции.

Аннотация @MainActor — это мощный инструмент Swift Concurrency

Она повышает безопасность данных в многопоточной среде. Однако из примеров выше мы видим, что изоляция работает по-разному в зависимости от способа применения:

  • Полная изоляция на уровне класса или при conform протокола в теле класса — надежный способ полностью защитить объект от выполнения в фоновом потоке. Если у вас есть какой-либо протокол с глобальной изоляцией и вы хотите, чтобы ваш класс и все его свойства и функции тоже находились под этой протокольной изоляцией, то смело применяйте ее на декларацию вашего класса. Например, SwiftUI-View-протокол находится под изоляцией @MainActor. Конформите этот протокол прямо на декларацию вашей view, а не через extension, как это могло бы быть по канонам iOS-разработки.

  • Частичная изоляция через методы или расширения позволяет гибко управлять многопоточностью, но требует внимательности, так как не защищает все поведение класса. Это ваш путь, если вы точно хотите, чтобы изоляция была не на всем типе, а точечно применена к каким-либо свойствам или функциям вашего типа. Например, у вашего хелпера есть какая-то функция, которая точно должна быть вызвана в главном потоке, чтобы провзаимодействовать с UIDevice или еще чем-либо синхронным, требующим исполнения на главном потоке.

  • Ошибки проектирования (например, разделение соответствия протоколу и реализации) могут привести к полной потере ожидаемой изоляции, несмотря на наличие аннотаций.

  • Наверное, только в одном случае может быть полезно. Если вы хотите отнаследоваться от протокола, но при этом не наследовать его изоляцию (потерять ее). (Даже особо и примеров не могу привести, нафига так надо, но наверняка могут возникнуть такие потребности). Просто надо держать в голове, что и такой трюк есть.

В итоге выбор подхода зависит от контекста: если объект активно взаимодействует с UI, изоляция всего класса через @MainActor будет самым надежным решением. Если же важна производительность и есть операции, которые можно выносить в фоновый поток, стоит рассмотреть частичную изоляцию.

Почему же Swift ведет себя так строго и порой контринтуитивно?

Когда аннотируем сам тип @MainActor, мы фактически заявляем: «Этот код полностью под нашим контролем». Мы становимся владельцами этого типа и отвечаем за его внутреннюю реализацию. Поэтому вполне закономерно, что весь тип автоматически изолируется.

Аналогично, если мы аннотируем с помощью @MainActor сам протокол и реализуем соответствие в теле типа, то ситуация схожа — мы по-прежнему владеем типом и отвечаем за все его поведение. Здесь можно смело использовать глобальную изоляцию через протокол.

Но все меняется, когда мы реализуем соответствие протоколу в extension. Swift не может быть уверенным, что мы полностью контролируем исходный код этого типа. Возможно, он определен в библиотеке или чужом фреймворке. В такой ситуации автоматическое распространение изоляции на весь тип может нарушить уже существующую логику: сломать жизненный цикл, привести к блокировкам, ухудшить производительность. Из-за этого изоляция применяется строго только к тем протокольным методам, которые находятся в том же extension, где реализовано соответствие.

Этот подход не костыль, а продуманное ограничение. Оно защищает нас от случайных последствий и дает четкие правила: хочешь полную изоляцию — контролируй все. Хочешь расширять чужие типы — делай это безопасно и выборочно.

И это, как ни странно, делает Swift еще надежнее.

На этом у меня все, готов ответить на вопросы из комментариев.

Теги:
Хабы:
+12
Комментарии0

Полезные ссылки

FreeIPA: как обнаружить атаку злоумышленника на любом этапе Кill Сhain. Часть 2

Время на прочтение14 мин
Количество просмотров1.1K
Всего голосов 3: ↑3 и ↓0+6
Комментарии2

Обходим подводные камни работы с UDA в коде на Lua для ScyllaDB: дружим Java-драйвер и пустые значения

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров658
Всего голосов 6: ↑6 и ↓0+11
Комментарии0

Интеграция виджета обратного звонка МТС Exolve в документацию на MkDocs

Время на прочтение8 мин
Количество просмотров544
Всего голосов 6: ↑6 и ↓0+10
Комментарии0

Путь в AI: от студента до инженера, исследователя или разработчика

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров2.1K
Всего голосов 27: ↑26 и ↓1+29
Комментарии2

Информация

Сайт
www.mts.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
OSZAR »