Я постоянно нахожу, что атрибут @preconcurrency сбивает с толку. Но я устал от этого. Давайте просто раз и навсегда разберемся, как пользоваться этой штукой.
Один атрибут, много целей
Помните, что принцип параллелизма в Swift основан на системе типов. Это означает, что определения чрезвычайно важны для функционирования системы. Атрибут @preconcurrency изменяет способ интерпретации определений компилятором. В целом, это ослабляет некоторые правила, которые в противном случае могли бы затруднить или даже сделать невозможным использование определения.
У него есть три различных варианта использования. И хотя все они относятся к определениям, детали совершенно разные. Мы рассмотрим их все в порядке возрастания сложности.
Соответствие доконкурентному курсу
На самом деле это новая функция Swift 6, представленная в SE-0423. Основная идея этой функции заключается в том, чтобы обеспечить простой и безопасный способ устранения несоответствий в изоляции протоколов. Это говорит компилятору, что да, на самом деле, мой изолированный тип может соответствовать этому неизолированному протоколу. Это было введено, чтобы помочь с протоколами, которые еще не помечены глобальным субъектом, но должны быть помечены.
Несоответствие изоляции
Вот традиционный подход:
// Определено в каком-либо другом модуле
public protocol ViewDelegateProtocol {
func respondToEvent()
}
@MainActor
class MyViewController: ViewDelegateProtocol {
// Шаг 1: Удаляем изоляцию, которая неправильно соответствует
nonisolated func respondToEvent() {
// Шаг 2: Возвращаем её обратно
MainActor.assumeIsolated {
// ...
}
}
}
Этот метод все еще работает. И иногда он остается полезным. Но в нем слишком много шаблонов. И неизолированные методы в конечном итоге предоставляют API для вашего типа, который очень легко использовать неправильно. Сравните всё это с одним-единственным атрибутом, который даёт вам очень лаконичный и безопасный вариант.
@MainActor
class MyViewController: @preconcurrency ViewDelegateProtocol {
func respondToEvent() {
// Во всём этом нет необходимости
// nonisolated/assumedIsolated
}
}
Гораздо приятнее!
Намеренно неизолированные протоколы
Интересно, что предконкурентное соответствие имеет и другое применение! Оно также удобно для соответствия протоколу, который не нужно изолировать. То есть они намеренно не изолированы. Примером может служить NSTextStorageDelegate. Этот протокол можно использовать в любом потоке / изоляции / очереди / чём угодно, при условии, что вы сохраняете его там. Это более или менее та же ситуация, что и при несоответствии, и такая же сложная при использовании с изолированными типами.
Но вы можете использовать предконкурентное соответствие, чтобы заставить его работать.
@MainActor
class MyViewController: @preconcurrency NSTextStorageDelegate {
func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorage.EditActions,
range changedRange: NSRange,
changeInLength delta: Int
) {
// ...
}
}
Я первый признаю, что это странно. В этом нет ничего «предконкурентного», так что это своего рода обходной путь. Но здесь это работает хорошо, потому что, с точки зрения компилятора, эти две ситуации функционально эквивалентны.
Двигаемся дальше!
Работа со Swift 5
Следующий способ использования @preconcurrency — это когда вы создаёте API. Вот ситуация. У вас есть общедоступный API, изначально реализованный до Swift 6. Допустим, он выглядит примерно так:
/// Выполняет свою работу.
///
/// Вызывает блокировку в фоновой очереди по завершении.
public func work(block: @escaping () -> Void) {
// Здесь происходит работа...
}
Обратите внимание на документацию! Блокировка закрытия будет вызвана в фоновой очереди. Но закрытие формируется в месте вызова, которое, вероятно, не будет находиться в фоновом режиме. Чтобы этот API работал, блок должен перемещаться из того места, где он был создан, в эту фоновую очередь. И единственные типы, которые поддерживают такого рода операции, должны соответствовать стандарту Sendable.
Этот API очень проблемный. Он не может быть реализован без ошибок в Swift 6. Но, что ещё хуже, он может вызвать серьёзные проблемы во время выполнения для клиента Swift 6. Это связано с тем, что он выполняет что-то внутреннее, чего на самом деле не позволяет его определение.
Однако вам не обязательно переносить модуль на Swift 6, чтобы исправить это. Вы можете просто изменить подпись. Давайте исправим это, сделав это закрытие доступным для отправки.
public func work(block: @escaping @Sendable () -> Void) {
// Здесь происходит работа...
}
С этим изменением мы можем устранить внешние проблемы и подготовиться к внутреннему переходу на Swift 6. Но мы также ввели неожиданный побочный эффект! Посмотрим, что произойдёт с клиентами в режиме Swift 5:
doWork {
// Предупреждение: Захват 'nonSendableType' с неотправляемым типом 'NonSendable' в закрытии '@Sendable'
print(nonSendableType)
}
Это происходит даже в том случае, если у клиента Swift 5 отключены предупреждения о параллелизме! Это многих удивляет, и это понятно. Что происходит?
Ну, компилятор не позволяет вам отключить предупреждения для функций параллелизма, которые вы активно используете. Этот клиент активно использует API, и для этого требуется закрытие @Sendable. Поэтому компилятор обеспечивает это, даже если они не хотят видеть эти диагностические сообщения.
Хотите угадать, в чём заключается решение?
@preconcurrency
public func work(block: @escaping @Sendable () -> Void) {
// Выполняет свою работу...
}
Применение @preconcurrency таким образом эффективно обусловливает функции параллелизма, которые вы использовали в своём определении. Если клиент находится в режиме «предконкуренции», предупреждения будут подавляться. Если нет, они будут отображаться.
Это работает не только для @Sendable. Это также можно использовать для создания условий для глобальной изоляции участников. Посмотрите, как это используется в SwiftUI.
@MainActor
@preconcurrency
public protocol View {
// ...
}
API с атрибутом @preconcurrency также совместимы с ABI. Добавление аннотаций может изменить двоичный интерфейс для API, но встроенные клиенты по-прежнему будут работать корректно без необходимости пересобирать библиотеку для обновления. Это очень важно для Apple, но я сомневаюсь, что это будет слишком полезно для большинства сторонних разработчиков.
Работа со Swift 6
Последнее использование этого атрибута — это то, что все пытаются использовать, когда сталкиваются с ошибкой параллелизма, которую они не понимают: @preconcurrency import.
Я считаю, что эта конструкция сбивает с толку по трём причинам. Во-первых, трудно сформировать хорошую мысленную модель того, какие проблемы может решить импорт в безналичной форме. Во-вторых, не всегда ясно, почему возникает диагностика. Так что даже при глубоком понимании иногда бывает сложно рассуждать о том, как использование preconcurrency может изменить ситуацию. И, в-третьих, я с сожалением сообщаю, что в реализации импорта @preconcurrency были обнаружены многочисленные ошибки, которые значительно усложнили изучение всего этого.
Мы собираемся преодолеть все эти трудности! В основном!
Хороший способ думать об импорте @preconcurrency — это сказать компилятору: «Я правильно использую этот API, просто отсутствуют аннотации». Помните, что мы имеем дело с импортом, поэтому всегда участвуют как минимум два модуля. Это делает примеры немного сложнее.
Типы, не подлежащие отправке
// Модуль A
class NonSendable {}
func returnNonSendable() async -> NonSendable {
NonSendable()
}
// Клиент
import module
// Ошибка: Несоответствующий типу 'NonSendable', возвращаемый неявно асинхронным
// вызов неизолированной функции не может пересекать границы актёра
let value = await returnNonSendable()
Неизолированная асинхронная функция подразумевает выполнение в фоновом режиме. Но эта функция пытается создать в фоновом режиме тип, который нельзя отправить, и передать его вызывающему. Это почти никогда не сработает. Однако это крайне распространённый шаблон, особенно при переходе с Objective-C на async.
Тем не менее, возможно, этот ненужный тип просто ещё не помечен как отправляемый. Или, возможно, этот конкретный образец использования безопасен.
Импорт @preconcurrency решает такую проблему. И, чтобы облегчить жизнь, компилятор поймёт это и предложит вам добавить атрибут @preconcurrency.
(См. ключевое слово @Sendable и SE-0430, чтобы лучше понять этот конкретный пример.)
Отправляемые затворы
Вот ещё один интересный пример. Предположим, вы используете протокол из внешнего модуля, например, такой:
// Модуль A
public protocol Worker {
func doWork(block: @escaping () -> Void)
}
// Клиент
import module
class MyClass: Worker {
func doWork(block: @escaping () -> Void) {
}
}
Проблема в том, что из документации вы знаете, что этот обратный вызов может быть вызван в любом потоке. Это значит, что он должен быть @Sendable, как мы уже видели раньше. За исключением того, что протокол определён иначе. И если вы добавите аннотацию @Sendable, вы больше не будете соответствовать протоколу. Это может существенно осложнить реализацию соответствия, как вам хотелось бы.
Вы можете исправить это с помощью @preconcurrency!
@module(preconcurrency) import Module
class MyClass: Worker {
// Компилятор примет это небольшое несоответствие
func doWork(block: @escaping @Sendable () -> Void) {
}
}
Это тоже случай, когда компилятор понимает проблему и предлагает использовать импорт @preconcurrency.
Опасности
Есть кое-что, о чём, я думаю, вам следует помнить при использовании @preconcurrency: неверное понимание поведения кода и ошибки компилятора могут привести к непредвиденным последствиям.