Найти в Дзене
Chidorin

Проблемные шаблоны асинхронного программирования на Swift

Недавно заданный вопрос Недавно кто-то спросил меня о лучших практиках при использовании асинхронного программирования на Swift. У меня смешанные чувства относительно понятия «лучшая практика». Я вернусь к этому чуть позже. Но в данном случае я сказал, что эта технология все еще очень молода, и мы все пока пытаемся разобраться в ней. Затем я добавил, что столкнулся с рядом техник, которые часто не работают должным образом. Это не значит, что они плохие! Просто я вижу их довольно часто, и они нередко приводят к проблемам. Я подумал, что было бы полезно собрать и поделиться тем, что я заметил. Это термин, который я придумал для типа данных, использующего несколько областей изоляции внутри себя. class SomeClass {
// не изолированный
var name: String
@MainActor
var value: Int
} Тип данных, подобный этому, проблематичен, потому что одна его часть не изолирована, а другая доступна только через MainActor. Это действительно странно! Потому что этот тип не является Sendable
Оглавление

Недавно заданный вопрос

Недавно кто-то спросил меня о лучших практиках при использовании асинхронного программирования на Swift. У меня смешанные чувства относительно понятия «лучшая практика». Я вернусь к этому чуть позже. Но в данном случае я сказал, что эта технология все еще очень молода, и мы все пока пытаемся разобраться в ней.

Описание проблемных подходов

Затем я добавил, что столкнулся с рядом техник, которые часто не работают должным образом. Это не значит, что они плохие! Просто я вижу их довольно часто, и они нередко приводят к проблемам. Я подумал, что было бы полезно собрать и поделиться тем, что я заметил.

Разделение изоляции

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

class SomeClass {
// не изолированный
var name: String

@MainActor
var value: Int
}

Тип данных, подобный этому, проблематичен, потому что одна его часть не изолирована, а другая доступна только через MainActor. Это действительно странно! Потому что этот тип не является Sendable, поэтому, если вы создадите экземпляр вне MainActor, то впоследствии вы никогда не сможете вернуть его обратно в MainActor, делая значение недоступным.

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

Task.detached

Я вижу повсеместное использование Task.detached. И это имеет смысл, поскольку это удобный способ перенести выполнение задачи за пределы MainActor.

@MainActor
func doSomeStuff() {
Task.detached {
expensiveWork()
}
}

Этот метод работает! Он также выглядит очень похожим на DispatchQueue.global().async, так что кажется знакомым. Однако, хотя detached предотвращает наследование изоляции, он делает и другие вещи тоже. Отсоединённые задачи не наследуют приоритет или локальные значения задач.

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

@MainActor
func doSomeStuff() {
Task {
await expensiveWork()
}
}

nonisolated func expensiveWork() async {
}

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

Явный приоритет

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

Я считаю, что всегда следует добавлять комментарий, объясняющий, почему стандартный подход не подходит.

// Объясните причину четко! Вы уверены?
Task(priority: .background) {
await someNonCriticalWork()
}

MainActor.run

Я уже подробно рассматривал эту тему ранее. Я думаю, что MainActor.run редко бывает правильным решением. Асинхронность Swift предоставляет инструменты, чтобы гарантировать, что API не будут использоваться неправильно. Вы должны ими пользоваться! Они помогут упростить ваш код и предотвратить ошибки.

// Зачем делать так...
await MainActor.run {
doMainActorStuff()
}

// ... когда обычно сработает вот такое решение?
await doMainActorStuff()

Бессостояние акторы

Целью актора является защита изменяемого состояния. Именно этим занимаются акторы. Тем не менее, я регулярно сталкиваюсь с акторами, у которых отсутствуют свойства экземпляра. Это означает, что у них нет состояния, которое нужно защищать! Обычно это происходит потому, что вы просто хотите перенести работу за пределы основного потока. Если это так, рассмотрите возможность использования неизолированных асинхронных функций.

@preconcurrency импорт с расширениями Async

У вас есть тип, которым вы не управляете, и он использует API с обработчиками завершения. Вы хотите обернуть его методами async, но это вызывает предупреждения. Поэтому вы добавляете @preconcurrency импорт, чтобы подавить эти предупреждения.

Переход от обработчиков завершения к методам async может изменить семантику и привести к выполнению кода в фоновом потоке. Будьте осторожны!

Избыточная совместимость с Sendable

Часто встречаются типы, выглядящие следующим образом:

@MainActor
class SomeClass: Sendable {
}

Это странно, потому что типы, изолированные глобальным актером, уже являются Sendable. Конечно, избыточная совместимость с Sendable не является ошибкой. Но я думаю, что она может свидетельствовать о недопонимании.

@MainActor @Sendable замыкания

Вот тип, с которым вы можете столкнуться довольно часто. Это функция, которая должна выполняться на MainActor, но сама по себе также должна быть Sendable.

@MainActor @Sendable () -> Void

Однако начиная со Swift 6, замыкания @MainActor стали автоматически Sendable, как и любые другие типы, изолированные глобальными актерами. Комбинация @MainActor и @Sendable всё ещё требуется для совместимости со Swift 5 / Xcode 15. Но простое использование @MainActor () -> Void может оказаться всем необходимым и будет гораздо менее ограничивающим.

API RunLoop

По-прежнему существует множество систем, которые необходимо использовать с помощью NS/CFRunLoops, таких как NSTimer и JavaScriptCore. Эти системы не будут работать корректно в контексте асинхронности вне MainActor. Как правило, вы можете найти способ заставить их работать, используя основной поток.

Акторы и протоколы с синхронными методами

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

Использование переводов Obj-C в Async

Компилятор автоматически генерирует асинхронные версии методов Objective-C с обработчиками завершения. Плохая новость заключается в том, что, если сам тип не изолирован MainActor или не является Sendable, эти переводы будут проблематичными. Их невозможно будет использовать без диагностики ошибок без импорта @preconcurrency, и они будут иметь другую семантику, которая может сделать их небезопасными.

Лучше избегать этого, если вы не абсолютно уверены, что перевод имеет смысл. А если вы не видите реализацию, определить это может быть невозможно.

Блокировка для асинхронной работы

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

Слишком много кода в замыканиях

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

Кроме того, я нахожу это полезным для читаемости. Двойная выгода.

Неструктурная организация там, где могла бы подойти структурированная

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

Избегание типов, несовместимых с Sendable

Вы можете создавать полезные вещи с использованием типов, несовместимых с Sendable. Если им нужно участвовать в конкурентном выполнении, возможно, придется воспользоваться изолированными параметрами, но немного попрактиковавшись, вы сможете освоить их.

Хочу подчеркнуть это снова, но другим способом. Если вы добавляете асинхронные методы к типам, несовместимым с Sendable, и не используете изолированные параметры, скорее всего, всё идет не так, как задумано. К тому же, вероятно, вы отключили предупреждения, потому что такая конструкция почти никогда не работает, и компилятор это заметит.

Другие области

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

Тестирование

Тестирование кода, использующего конкуренцию, может быть весьма сложным в некоторых ситуациях. Особенно если вам нужно начать операции, имеющие побочные эффекты, которые нельзя прямо ожидать.

func hasAsyncSideEffects() {
Task {
// эти эффекты трудно тестировать
}
}

Уверен, что это не единственный сложный случай. У меня нет отличных предложений здесь. Но есть пост на PointFree с некоторыми интересными вариантами.

Мне было бы интересно услышать ваши мысли и опыт в этой области!

Принятие AsyncSequence

Безусловно, наиболее распространённым вопросом, который мне задают, является принятие AsyncSequence. Эта тема заслуживает отдельной статьи (или даже трёх), но я новичок в реактивном программировании, поэтому мне это сложно.

Ни в коем случае использование AsyncSequence не является проблемой само по себе. Я успешно использовал его, и видел, как многие другие делают это тоже. Но одно я точно знаю: если у вас возникают трудности, вы не одиноки.

Пожалуйста, поделитесь ресурсами, которые помогли вам! Или конкретными вещами, которые не работали!

Заключение: «Лучшая» практика

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

Я видел, как перечисленные выше техники приводили к неудачам. Но это не значит, что вы не должны использовать своё собственное суждение. Продолжайте экспериментировать! И расскажите мне, как это получилось.

Источник