Привет, Хабр! Меня зовут Никита, я занимаюсь iOS-разработкой в Яндекс Диске и ещё веду телеграм-канал. Как вы знаете, прошлой осенью зарелизился Swift 6, а вместе c ним появились и строгие проверки для защиты от датарейсов, связанные со Swift Concurrency. В Swift 5 такие проверки включались при помощи флага -strict-concurrency=complete
, но, по заявлениям разработчиков Swift, были слишком консервативными:
Complete concurrency checking in Swift 5.10 was overly restrictive, and Swift 6 removes many false-positive data-race warnings through better Sendable inference, new analysis that proves mutually exclusive access when passing values with non-Sendable type over isolation boundaries, and more.
Я, как и многие из вас, пробовал их включать и получал несколько тысяч ошибок компиляции. Поэтому мне стало интересно, насколько в Swift 6 всё стало лучше. Проще ли теперь перейти на новую concurrency-модель и чем она отличается от версии из Swift 5? Ну и ответить на главный вопрос: как «правильно» мигрировать кодовую базу на Swift 6?
В этой статье я постараюсь разобраться с основными изменениями в каждом пропозале и поделюсь своими заметками, тем, что мне показалось самым важным или интересным. В конце статьи бонус — Playground с тестами для каждого пропозала, чтобы можно было поиграть с кодом, детальнее разобраться с изменениями и понять, как они влияют на код, написанный на Swift 5.
P. S. Сами пропозалы я расположил не в хронологическом порядке, а в том, который мне показался более удобным для чтения.
Обзор пропозалов
Region based Isolation
Proposal: SE-0414
В Swift 5 все переменные, которые вы передаёте между акторами, должны соответствовать Sendable, поэтому следующий код не будет компилироваться, несмотря на то что никаких датарейсов в нём быть не может:
// Not Sendable.
class Client {
init(name: String) { ... }
}
@MainActor
func storeClient(_ c: Client) { ... }
func openNewAccount(name: String) async {
let client = Client(name: name)
// ERROR: `Client` is non-`Sendable`!
await storeClient(client)
}
Теперь Swift для каждой новой переменной будет строить регионы изоляции и с их помощью определять, когда безопасно передавать non-Sendable
-переменные между акторами, а когда нет.
И выдавать ошибки, только если в коде действительно возможен датарейс:
func openNewAccount(name: String) async {
let client = Client(name: name)
await storeClient(client)
// ERROR: Already transferred into clientStore’s isolation domain… this could race!
client.logToAuditStream()
}
И самое интересное: если async-функция не изолируется ни на каком акторе, а значит, всегда наследует текущего актора, то Swift разрешит использовать переменную дальше, потому что это безопасно:
func nonIsolatedCallee(_ x: NonSendable) async { ... }
func useValue(_ x: NonSendable) { … }
@MainActor func transferToMainActor<T>(_ t: T) { ... }
actor MyActor {
var state: NonSendable
func example() async {
let x = NonSendable()
await nonIsolatedCallee(x)
useValue(x) // OK!
await transferToMainActor(x) // OK!
// ERROR: After transferring to main actor, permanently in main actor, so we can’t use it.
useValue(x)
}
}
Ещё из интересного
В будущих версиях Swift обещают сделать transferring
-параметры функций, чтобы можно было явно указать, что переменная перейдёт к другому актору:
func someSynchronousFunction(_ x: transferring NonSendable) {
Task {
doSomething(x)
}
}
А ещё это наконец-то разрешит писать нормальные инициализаторы у акторов:
actor Actor {
var field: NonSendable
init(_ x: transferring NonSendable) {
self.field = x
}
}
И такой же аналог для возвращаемых значений функций:
func example(_ x: NonSendable) async -> @returnsIsolated NonSendable? {
if x.boolean {
return NonSendable()
}
return nil
}
sending parameter and result values
Proposal: SE-0430
В описании прошлого пропозала я упоминал, что в будущем нам обещают transferring
-параметры для функций. Так вот, будущее наступило! В Swift 6 они уже есть, правда, называются sending
.
sending
-параметры расширяют возможности регионов изоляции, потому что позволяют явно указать, какие значения будут переданы в новый регион изоляции. А значит, не смогут больше использоваться в старом. Например, такой код в Swift 5 мог привести к датарейсу:
@MainActor var mainActorState: NonSendable?
nonisolated func test() async {
let ns = await withCheckedContinuation { continuation in
Task { @MainActor in
let ns = NonSendable()
// Oh no! `NonSendable` is passed from the main actor to a nonisolated context here!
continuation.resume(returning: ns)
// Save `ns` to main actor state for concurrent access later on
mainActorState = ns
}
}
// `ns` and `mainActorState` are now the same non-Sendable value; concurrent access is possible!
ns.mutate()
}
А теперь выдаёт корректные ошибки:
@MainActor
func acceptSend(_: sending NonSendable) {}
func sendToMain() async {
let ns = NonSendable()
// ERROR: sending `ns` may cause a race.
// NOTE: `ns` is passed as a `sending` parameter to `acceptSend`. Local uses could race with later uses in `acceptSend`.
await acceptSend(ns)
// NOTE: access here could race.
print(ns)
}
Это здорово, потому что помечать все Sendable
протоколом или оборачивать в @unchecked Sendable
— едва ли выход из ситуации. Такой подход, скорее, просто скроет проблему, чем её решит.
Поэтому чтобы безопасно передавать non-Sendable
-типы между акторами, в Swift 6 нужно использовать sending
-параметры. Это позволит компилятору гарантировать, что такие переменные не могут использоваться одновременно несколькими акторами, а значит, вам больше не нужно переживать за возможные датарейсы.
Какие системные API уже поддерживают sending
:
Strict concurrency for global variables
Proposal: SE-0412
В этом пропозале добавляются новые правила для работы с глобальными переменными.
Суть в том, что в Swift 6 все глобальные переменные должны быть изолированы на каком-то акторе либо являться Sendable
-константами:
@MainActor
var nonSendable: NonSendable = .init() // OK!
let sendableConstant: Int = .zero // OK!
var sendableVariable: Int = .zero // ERROR: Is not concurrency-safe because it is nonisolated global shared mutable state.
С одной стороны, это потокобезопасно, ведь теперь компилятор может гарантировать правильную работу регионов изоляции, включая глобальные переменные. С другой стороны, любая переменная теперь — ошибка компиляции. Из-за этого переход на Swift 6 в хоть сколько-то крупном проекте станет серьёзным челленджем.
Поэтому этот пропозал собрал немало критики: например от Алекса Гребенюка, автора Nuke и Pulse.
Inheritance of actor isolation
Proposal: SE-0420
В этом пропозале добавляется немного динамики в наследование функциями акторов, на которых они должны выполняться.
Раньше все асинхронные функции были трёх видов:
неизолированные, выполняющиеся без привязки к какому-либо актору;
методы актора, изолированные на этом же акторе;
изолированные на глобальном акторе, например
@MainActor
.
Теперь актор для изоляции можно явно передать в функцию при помощи isolated (any Actor)?
или чуть менее явно при помощи дефолтного параметра #isolation
.
Динамическое наследование изоляции позволяет не делать прыжков между акторами, а значит, не пересекать их регионы изоляции, что разрешит использовать асинхронные функции для non-Sendable
-типов:
/// This class type is not Sendable.
class Counter {
var count = 0
}
extension Counter {
/// Since this is an async function, if it were just declared
/// non-isolated, calling it from an isolated context would be
/// forbidden because it requires sharing a non-Sendable value
/// between concurrency domains. Inheriting isolation makes it
/// okay. This is a contrived example chosen for its simplicity.
func incrementAndSleep(isolation: isolated (any Actor)? = #isolation) async {
count += 1
await withCheckedContinuation { continuation in
continuation.resume()
}
}
}
actor MyActor {
var counter = Counter()
}
extension MyActor {
func testActor(other: MyActor) async {
// Allowed.
await counter.incrementAndSleep()
// Not allowed.
await counter.incrementAndSleep(isolation: other)
// Not allowed.
await counter.incrementAndSleep(isolation: MainActor.shared)
// Not allowed.
await counter.incrementAndSleep(isolation: nil)
}
}
@MainActor
func testMainActor(counter: Counter) async {
// Allowed.
await counter.incrementAndSleep()
// Not allowed.
await counter.incrementAndSleep(isolation: nil)
}
func testNonIsolated(counter: Counter) async {
// Allowed.
await counter.incrementAndSleep()
// Not allowed.
await counter.incrementAndSleep(isolation: MainActor.shared)
}
И самое главное, это частично решает проблему reentrancy
у акторов, потому что теперь вызовы асинхронных функций, которые наследуют изоляцию текущего актора, будут гарантированно вызваны синхронно.
Поэтому следующий тест будет стабильно проходить:
extension MyActor {
func incrementAndAssertCountEquals(_ expectedCount: Int) async {
await counter.incrementAndSleep() // Potential reentrancy point.
#expect(counter.count == expectedCount)
}
}
func testActorReentrancy() async {
let actor = MyActor()
var tasks = [Task<Void, Never>]()
for iteration in 1 … 100 {
tasks.append(Task { @MainActor in
await actor.incrementAndAssertCountEquals(iteration)
})
}
// Wait for all enqueued tasks to finish.
for task in tasks {
_ = await task.value
}
}
@isolated(any) Function Types
Proposal: SE-0431
Этот пропозал продолжает идею динамического наследования акторов функциями и добавляет в кложуры дополнительный сторадж для хранения информации об акторе, на котором такие функции должны выполняться.
Наличие стораджа решает сразу две проблемы в текущей Structured Concurrency.
Первая проблема — отсутствие прямого шедулинга тасок на нужного актора. У Swift просто нет информации о том, где таска должна выполняться. И несмотря на то что таска может быть помечена, например, как @MainActor
, она всё равно всегда будет стартовать на глобальном экзекьюторе и только после этого переключаться на @MainActor
. Такое поведение не только крайне неэффективно, но и ломает транзитивный порядок эвентов — там, где он должен быть гарантирован.
Например, такой код до Swift 6 выводил буквы в случайном порядке, но теперь он строго гарантирован и соответствует порядку создания тасок:
@MainActor
func testMainActor() {
Task { print(“a”) }
Task { print(“b”) }
Task { print(“c”) }
}
Вторая проблема — невозможность замкнуть текущего актора для создаваемых кложур. Единственный выход из такой ситуации сейчас — изоляция на глобальных акторах.
На самом деле этот пропозал только готовит почву для полноценного внедрения такой функциональности, которая появится уже скоро в Closure isolation control. Это позволит замыкать актора через capture list
так же, как сейчас переменные:
func delay(operation: @isolated(any) () -> ()) {
let isolation = operation.isolation
Task { [isolated isolation] in // Tentative syntax from the isolated captures pitch.
print(“waking”)
operation() // Does not cross an isolation barrier and so is synchronous.
print(“finished”)
}
}
Task Executor Preference
Proposal: SE-0417
В этом пропозале у тасок появляется возможность выполняться на кастомном экзекьюторе. Но чтобы понять, зачем это нужно, стоит сначала разобраться, кто такие экзекьюторы, чем они отличаются от акторов и какую роль каждый из них играет в Structured Concurrency.
Actor
в мире Structured Concurrency — это единственная мера изоляции. Любая асинхронная функция может быть изолирована на каком-то акторе (глобальном или локальном) и беспрепятственно использовать его стейт. Потому что выполнение таких функций последовательно: в любой момент времени может выполняться только одна из таких функций. Иначе функция будет являться неизолированной и выполняться параллельно с любыми другими частями программы.
Экзекьюторы в Structured Concurrency ответственны лишь за то, чтобы выполнять джобы, которые на них шедулятся. И в Structured Concurrency стандартный экзекьютор всего один — GlobalConcurrentExecutor
. Он как раз держит фиксированный пул тредов, распределяя джобы между ними. Над ним есть только одна обёртка в виде стандартной реализации SerialExecutor
для акторов, которая просто шедулит джобы на GlobalConcurrentExecutor
, но назначает их по одной, чтобы они не выполнялись параллельно.
И какая проблема возникает у такого подхода? Конечно, долгие таски! Ведь запустив несколько таких тасок, можно парализовать работу всей Structured Concurrency в приложении. Как мы помним, стандартный экзекьютор у нас один, все задачи выполняются только им, а пул его тредов ограничен. Например, у iPhone 6s есть всего два треда в этом пуле, а значит, достаточно запустить две долгие таски, и, кроме них, ничего выполняться больше не будет.
Конечно, такое поведение всё ещё очень зависит от приоритетов тасок, и иногда потоков может быть больше двух.
Эта проблема решилась уже в Swift 5.9 с появлением Custom Actor Executors и возможностью написать свой кастомный экзекьютор для актора, чтобы добавить новые треды в пул Structured Concurrency и выполнять какие-то долгие таски на них.
final class SpecificThreadExecutor: SerialExecutor {
// Simplified handle to some specific thread.
let someThread: SomeThread
func enqueue(_ job: consuming ExecutorJob) {
// In order to escape it to the run{} closure.
let unownedJob = UnownedExecutorJob(job)
someThread.run {
unownedJob.runSynchronously(on: self)
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
actor Worker {
let executor = SpecificThreadExecutor()
nonisolated var unownedExecutor: UnownedSerialExecutor {
// Use the shared specific thread executor mentioned above.
executor.asUnownedSerialExecutor()
}
}
Но что насчёт тасок, которые не изолированы ни на каком акторе? Ведь они всё ещё будут выполняться только дефолтным GlobalConcurrentExecutor
, а значит, проблема долгих тасок не решена до конца.
Поэтому в Swift 6 и появились Task Executor Preference
. Такие преференсы позволяют указать для таски и всех её чайлд-тасок предпочтительный экзекьютор, который будет использован вместо дефолтного GlobalConcurrentExecutor
:
Task(executorPreference: executor) {
// Starts and runs on the `executor`.
await nonisolatedAsyncFunc()
}
Task.detached(executorPreference: executor) {
// Starts and runs on the `executor`.
await nonisolatedAsyncFunc()
}
await withDiscardingTaskGroup { group in
group.addTask(executorPreference: executor) {
// Starts and runs on the `executor`.
await nonisolatedAsyncFunc()
}
}
func nonisolatedAsyncFunc() async -> Int {
// If the Task has a specific executor preference,
// runs on that `executor` rather than on the default global concurrent executor.
return 42
}
Преференс здесь означает нестрогую привязку к этому экзекьютору. Поэтому если у чайлд-таски будет изоляция какого-то актора, у которого, в свою очередь, тоже может быть кастомный экзекьютор, как, например, у @MainActor
, то выполняться такая таска будет на кастомном экзекьюторе актора, игнорируя executorPreference
родительской таски.
Правила выбора экзекьютора в Swift 5:
[ func / closure ] - /* where should it execute? */
|
+--------------+ +==========================+
+- no - | is isolated? | - yes -> | default (actor) executor |
| +--------------+ +==========================+
|
| +==========================+
+-------------------------------> | on global conc. executor |
+==========================+
Новые правила для Swift 6:
[ func / closure ] - /* where should it execute? */
|
+--------------+ +===========================+
+-------- | is isolated? | - yes -> | actor has unownedExecutor |
| +--------------+ +===========================+
| | |
| yes no
| | |
| v v
| +=======================+ /* task executor preference? */
| | on specified executor | | |
| +=======================+ yes no
| | |
| | v
| | +==========================+
| | | default (actor) executor |
| v +==========================+
v +==============================+
/* task executor preference? */ ---- yes ----> | on Task's preferred executor |
| +==============================+
no
|
v
+===============================+
| on global concurrent executor |
+===============================+
Dynamic actor isolation enforcement from non-strict-concurrency contexts
Proposal: SE-0423
Это небольшой пропозал, в котором добавляется возможность использовать @preconcurrency
-атрибут не только для импортов библиотек, но и для конформансов протоколам из этих библиотек.
Такая функциональность очень полезна для постепенного перехода на Swift 6 со strict concurrency checking
. Особенно если вы импортите библиотеки, которые ещё не поддерживают strict concurrency checking
или в принципе не могут её поддерживать, потому что написаны на C, C++ или Objective-C.
Например, такая библиотека имеет следующий протокол и гарантирует его использование только с MainThread
:
public protocol ViewDelegateProtocol {
func respondToUIEvent()
}
Тогда его конформанс-классом, изолированным на @MainActor
, будет выдавать ошибку компиляции, хотя никаких проблем с изоляцией здесь нет:
import NotMyLibrary
@MainActor
class MyViewController: ViewDelegateProtocol {
// ERROR: @MainActor function cannot satisfy a nonisolated requirement.
func respondToUIEvent() { ... }
}
Да, это можно исправить, добавив рантайм проверки:
import NotMyLibrary
@MainActor
class MyViewController: ViewDelegateProtocol {
nonisolated func respondToUIEvent() {
MainActor.assumeIsolated { ... }
}
}
Но это едва ли хорошее решение, потому что теперь вы потеряете все компайл-тайм-проверки при вызове этой функции, причём даже внутри своего модуля. А значит, любое обращение к этой функции из асинхронного контекста больше не будет переводить её на MainActor
и, следовательно, перестанет вызывать креши в рантайме.
Поэтому, чтобы не потерять потокобезопасность и при этом всё-таки конформить такому протоколу, в Swift 6 можно пометить конформанс как @preconcurrency
:
import NotMyLibrary
@MainActor
class MyViewController: @preconcurrency ViewDelegateProtocol {
func respondToUIEvent() { ... }
}
Тогда все компайл-тайм-проверки при вызове этой функции из текущего модуля останутся на месте, это позволит и дальше продолжать писать безопасный асинхронный код, не задумываясь о том, из какого контекста он вызывается. А вне текущего модуля проверки будут перенесены в рантайм, благодаря чему останется возможность для дебага этого кода.
Remove Actor Isolation Inference caused by Property Wrappers
Proposal: SE-0401
В этом пропозале убирается неявное наследование изоляции для классов или структур из @propertyWrapper
, в которых wrappedValue
изолировано на каком-то глобальном акторе.
Для понимания проблемы рассмотрим пример из Swift 5. В нём DBConnection
будет наследовать изоляцию от приватного свойства connectionID
, которое изолировано на DatabaseActor
.
// A property wrapper for use with our database library.
@propertyWrapper
struct DBParameter<T> {
@DatabaseActor public var wrappedValue: T
}
// Inferred `@DatabaseActor` isolation because of use of `@DBParameter`.
struct DBConnection {
@DBParameter private var connectionID: Int
func executeQuery(_ query: String) -> [DBRow] { ... }
}
И если где-то в другом файле будет использоваться DBConnection
, то он будет изолирован на DatabaseActor
неявно.
@DatabaseActor
func fetchOrdersFromDatabase() async -> [Order] {
let connection = DBConnection()
// No `await` needed here, because `connection` is also isolated to `DatabaseActor`.
connection.executeQuery("...")
}
И самое интересное: если изменить @propertyWrapper
у connectionID
, то код перестанет компилироваться. Это первый прецедент в Swift, когда изменение в приватном свойстве приводит к ошибкам компиляции в отдельном файле, вообще никак не связанном с этим свойством. Поэтому в Swift 6 такое наследование и было удалено.
Isolated default value expressions
Proposal: SE-0411
В этом пропозале частично сняли ограничения для дефолтных параметров функций, имеющих изоляцию на каком-то акторе.
Например, класс, изолированный на @MainActor
, нельзя передать как дефолтный параметр функции, которая также изолирована на @MainActor
:
@MainActor class C {}
@MainActor func f(c: C = C()) {} // ERROR: Call to main actor-isolated initializer `init()` in a synchronous nonisolated context.
@MainActor func useFromMainActor() {
f()
}
Даже несмотря на то, что этот код полностью безопасен, в Swift 5 он не компилировался, а в Swift 6 эти чрезмерные ограничения были сняты.
Также в пропозале добавили больше ограничений для хранимых свойств. До этого компилятор игнорировал изоляцию переменных и при инициализации создавал начальные значения свойств синхронно:
@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }
class C {
@MainActor var x1 = requiresMainActor()
@AnotherActor var x2 = requiresAnotherActor()
nonisolated init() {} // No Error!
}
В Swift 6 предыдущий пример будет выдавать ошибку: переменные не инициализированы. И чтобы её исправить, нужно написать асинхронный .init()
:
class C {
@MainActor var x1 = requiresMainActor()
@AnotherActor var x2 = requiresAnotherActor()
nonisolated init() async {
self.x1 = await requiresMainActor()
self.x2 = await requiresAnotherActor()
}
}
Интересный кейс здесь ещё в том, что про memberwise
инициализаторы структур тоже не забыли. Теперь они будут синтезированы уже с нужным актором, и их не придётся писать вручную:
class NonSendable {}
@MainActor struct MyModel {
// @MainActor inferred from annotation on enclosing struct.
var value: NonSendable = .init()
/* Compiler-synthesized memberwise init is @MainActor.
@MainActor
init(value: NonSendable = .init()) {
self.value = value
}
*/
}
Inferring Sendable for methods and key path literals
Proposal: SE-0418
В этом пропозале добавляется @Sendable
-конформанс функциям для Sendable
-типов. Очень логичное изменение, и непонятно, почему раньше это было не так:
struct SendableType: Sendable {
func someMethod() {}
}
func test() {
let value = SendableType()
// WARNING: Converting non-sendable function value to `@Sendable () async -> Void` may introduce data races.
Task<Void, Never>(operation: value.someMethod)
}
В Swift 6 код из примера выше больше не выдаёт варнингов.
Также изменяется поведение, добавленное в [SE-0302] Sendable
and @Sendable
closures. Теперь все KeyPath
в Swift вместо того, чтобы быть по умолчанию Sendable
, будут наследовать это от своих аргументов. Что опять же очень логично и уберёт ещё пачку надоедливых варнингов:
class Info : Hashable {
// Some information about the user.
}
public struct Entry {}
public struct User {
public subscript(info: Info) -> Entry {
// Find entry based on the given info.
}
}
// WARNING: Cannot form key path that captures non-sendable type 'Info'.
let entry: KeyPath<User, Entry> = \.[Info()]
Generalize effect polymorphism for AsyncSequence and AsyncIteratorProtocol
Proposal: SE-0421
В этом пропозале добавили полиморфизм для возвращаемых ошибок и типов изоляции, связанных с AsyncSequence
и AsyncIteratorProtocol
. Что всё это значит? Давайте разберёмся по порядку, начнём с ошибок.
До Swift 6 определение AsyncIteratorProtocol
выглядело так:
@rethrows
protocol AsyncIteratorProtocol {
associatedtype Element
mutating func next() async throws -> Element?
}
Это означало, что мы не можем типизировать ошибки, возвращаемые этим итератором, и всегда будем получать any Error
— даже в случае, если итератор никаких ошибок не выбрасывает в принципе. Чтобы исправить последнее и не писать везде try
, в Swift 5 использовался экспериментальный флаг @rethrows
, который скорее скрывал проблему, чем её исправлял. А ещё он не давал AsyncSequence
использовать primary associated types
, из-за чего нельзя было писать some/any AsyncSequence<Element>
.
На помощь пришли Typed throws и решили все проблемы с возвращаемыми ошибками. В Swift 6 AsyncSequence
и AsyncIteratorProtocol
выглядят законченными:
protocol AsyncIteratorProtocol<Element, Failure> {
associatedtype Element
@available(SwiftStdlib 6.0, *)
associatedtype Failure: Error = any Error
@available(SwiftStdlib 6.0, *)
mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element?
}
Теперь про проблему с акторами. В коде выше видно, что функция next(isolation:)
поддерживает динамическую изоляцию. Это нужно, чтобы исправить надоедливый варнинг с возвращением non-Sendable
-типов из этой функции:
class NotSendable { ... }
@MainActor
func iterate(over stream: AsyncStream<NotSendable>) {
for await element in stream { // Warning: non-sendable type 'NotSendable?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary.
}
}
В Swift 6 этот код больше не создаёт варнингов.
Правда, у всех этих улучшений есть один нюанс:
@available(SwiftStdlib 6.0, *)
. Другими словами, нам нужен рантайм Swift 6, а значит, фиксы будут работать только с iOS 18, пу-пу-пу...
Custom isolation checking for SerialExecutor
Proposal: SE-0424
В предыдущем пропозале [SE-0392] Custom Actor Executors в Structured Concurrency добавили кастомные экзекьюторы для акторов, что открыло дополнительные возможности для адаптации Structured Concurrency под крупные проекты.
Но большую кодовую базу невозможно мигрировать на новую Concurrency-модель сразу целиком, поэтому очень важно иметь возможность использовать обе модели одновременно.
[SE-0424] Custom isolation checking for SerialExecutor как раз расширяет возможности такого использования. Он позволяет Structured Concurrency всегда знать о том, на каком экзекьюторе происходит выполнение кода — неважно, находимся ли мы сейчас внутри какой-то Task
или нет.
Рантайм Structured Concurrency следит за текущим экзекьютором Task
, храня его в thread-local
-сторадже. Так как все возможные async
-вызовы всегда обновляют текущего актора в этом сторадже, то assertIsolated
и assumeIsolated
работают корректно, если мы находимся внутри Task
.
Но всё меняется, если мы используем своего кастомного экзекьютора, например, DispatchSerialQueue
. Ведь на него мы можем шедулить джобы не только из контекста Task
, но и через старый добрый DispatchQueue.async
, из-за чего рантайм Structured Concurrency ничего не будет знать о текущем экзекьюторе и все проверки будут крешить.
Для примера можно рассмотреть код:
actor Caplin {
let queue: DispatchSerialQueue = .init(label: "CoolQueue")
var num: Int // Actor isolated state.
// Use the queue as this actor's `SerialExecutor`.
nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}
nonisolated func connect() {
queue.async {
// Guaranteed to execute on queue which is the same as self's serial executor.
self.queue.assertIsolated() // CRASH: Incorrect actor executor assumption.
self.assumeIsolated { caplin in // CRASH: Incorrect actor executor assumption.
caplin.num += 1
}
}
}
}
Чтобы решить эту проблему в Swift 6, был добавлен дополнительный метод к SerialExecutor
. Теперь кастомные экзекьюторы могут проверить, выполняют ли они текущий код или нет, и сообщить об этом Structured Concurrency с помощью креша:
extension DispatchSerialQueue {
public func checkIsolated() {
dispatchPrecondition(condition: .onQueue(self)) // Existing Dispatch API.
}
}
Кстати, интересный факт: с iOS 17 Apple открыли инициализатор у DispatchSerialQueue
, и теперь последовательные очереди можно создавать не только через DispatchQueue.init(label:)
. Более того, теперь все последовательные очереди соответствуют протоколу SerialExecutor
, а значит, их можно использовать как экзекьюторы акторов без каких-либо обёрток.
Usability of global-actor-isolated types
Proposal: SE-0434
Этот пропозал приносит в Structured Concurrency три улучшения, связанных с использованием глобальных акторов.
Первое улучшение касается Value Types
, у которых изоляция на глобальном акторе. Для примера возьмём структуру, у которой все свойства — Sendable
-типы. В таком случае let
-переменные будут неявно наследовать nonisolated
, а переменные, объявленные через var
, — нет.
Такое поведение накладывает много ограничений, особенно при работе с протоколами:
@MainActor struct S {
nonisolated(unsafe) var x: Int = 0
}
extension S: Equatable {
static nonisolated func ==(lhs: S, rhs: S) -> Bool {
return lhs.x == rhs.x
}
}
Единственный выход из ситуации — использовать nonisolated(unsafe)
(хотя на самом деле ничего небезопасного здесь нет). Так как свойство соответствует Sendable
, при его использовании не должны возникать никакие датарейсы в принципе. Поэтому все датарейсы, которые потенциально могут возникнуть, будут связаны не с самим свойством, а со структурой, которая его содержит. Например, если мы решим эту структуру изменить с двух разных потоков одновременно. Но так как эта структура изолирована на глобальном акторе, то её параллельные изменения невозможны.
Поэтому до тех пор, пока Swift гарантирует безопасный доступ к самой структуре, доступ ко всем её Sendable
-полям, неважно — let
или var
, также становится безопасным. В Swift 6 nonisolated(unsafe)
из примера выше больше не нужен.
Второе улучшение касается замыканий, изолированных на глобальных акторах. Теперь они неявно наследуют @Sendable
-атрибут, что позволяет коду из следующего примера больше не выдавать ошибок:
func test(globallyIsolated: @escaping @MainActor () -> Void) {
Task {
// ERROR: capture of `globallyIsolated` with non-sendable type `@MainActor () -> Void` in a `@Sendable` closure.
await globallyIsolated()
}
}
А ещё такие замыкания могут при этом захватывать non-Sendable
переменные:
class NonSendable {}
func test() {
let ns = NonSendable()
let closure = { @MainActor in
print(ns)
}
Task {
await closure() // OK.
}
}
Третье улучшение касается наследников non-Sendable
-классов. Раньше наследники таких классов могли быть только non-isolated
:
class NonSendable {}
@MainActor
class Subclass: NonSendable {} // ERROR: main actor-isolated class `Subclass` has different actor isolation from nonisolated superclass `NonSendable`.
Теперь их изоляция на глобальном акторе — это ок. Такие наследники больше не наследуют неявный конформанс Sendable
и могут и дальше жить свою спокойную потоко-небезопасную жизнь.
Итак, это был последний пропозал. И чтобы подвести какой-то итог и посмотреть целиком на то, что получилось, давайте разберём вижн улучшений или работу над ошибками для Structured Concurrency.
Improving the approachability of data-race safety
Vision: approachable-concurrency
Краткое содержание вижна о том, какой набор возможностей и компонентов должна иметь модель Swift Concurrency в будущем.
Возможность для библиотек по умолчанию быть «однопоточными»
Это означает, что если в модуле будет включён такой флаг, то все сущности будут неявно изолированы на @MainActor
. В вижне говорится, что такой флаг будет очень полезен для Command-line tools или скриптов.
Изолированные протоколы
Здесь есть несколько идей, что можно улучшить.
Во-первых, дать протоколам, как и функциям, возможность быть динамически изолированными, примерно как у замыканий сейчас:
() -> Bool // Not Sendable and can have any kind of isolation.
@MainActor () -> Bool // Isolated to a specific global actor.
@isolated(any) () -> Bool // Might be isolated to a specific actor that it carries around with it dynamically.
Во-вторых, дать возможность использовать isolated-конформансы для nonisolated-протоколов:
@MainActor
final class MainActorClass: Equatable {
// OK. Treated as Equatable only under @MainActor.
static func == (lhs: MainActorClass, rhs: MainActorClass) -> Bool {
/* ... */
}
}
Но все эти идеи пока очень туманны и пестрят непродуманными деталями. Например, непонятно, как работают такие протоколы с дженериками. Тем не менее замыслы достаточно амбициозны и полезны.
Изолированные сабклассы и оверрайды
Здесь предлагается расширить [SE-0434] Usability of global-actor-isolated types и разрешить изолированные оверрайды для Sendable
-классов. Для этого нужно будет трекать все ссылки на такие сабклассы и запрещать их конвертацию к суперклассу. Казалось бы, при чём тут SOLID?_
Реверт изменений для nonisolated-функций
Подразумевает отмену изменений сделанных в [SE-0338] Clarify the Execution of Non-Actor-Isolated Async Functions. А именно: вернуть неявное наследование актора неизолированными асинхронными функциями для консистентности с синхронными. А чтобы функция не наследовала актора, нужно будет явно указать это при помощи атрибута @concurrent
.
Подробнее о том, почему даже для разработчиков Swift Concurrency это стало фатальной ошибкой, можно почитать в Inherit isolation by default for async functions.
Добавление бриджей асинхронного и синхронного кода
Примерно как DispatchQueue.asyncAndWait
, чтобы было проще переходить на Structured Concurrency.
Уменьшение количества ложно-положительных срабатываний рантайм-ассертов проверяющих изоляцию
Здесь предлагается улучшить работу рантайм-ассертов, которые были добавлены в [SE-0423]: Dynamic actor isolation enforcement from non-strict-concurrency contexts.
Выводы
На этом точно всё. Теперь, как и обещал, делюсь ссылкой на ConcurrencyPlayground. В этом проекте по каждому пропозалу вы можете найти тесты, которые проверяют его функциональность, и сравнить поведение кода для Swift 5 и Swift 6. Некоторые изменения также будут заметны при смене таргета с iOS 18 на iOS 17.
Надеюсь, статья была для вас полезной и вы узнали для себя много нового. И теперь переход на Swift 6 станет для вас чуть проще. Я также стараюсь писать заметки про iOS в своем телеграм-канале — подписывайтесь.