Improved control over non-Sendable types in isolated contexts

Hi, everyone!

With latest Swift updates I find myself a bit confused. I also fear that with Swift 6 and no alternatives, the issues I face currently will highly affect the development and broader scale of apps design by introducing more complexity. At the current state of concurrency, writing non-Sendable classes is a much more complex task than it ever was, and it seems to either result in overuse of other concepts, like actors, or make developers struggle.

The following is my vision of what is missing from the language at the current stage and an attempt to propose a solution. I've been reading and re-reading proposals, forum topics, experimented with several solutions on both release and main versions of Swift, experimented on a large project I'm working on, and turned on Swift 6 mode (which made my a bit confused), over past two weeks to understand better, form some vision and make sure I am not missing something. Still, I consider myself new to the concurrency, despite more successful than not usage it almost on daily basis for a last year, so any criticism and alternative approaches to the overall design of code will be highly welcomed.


Motivation

The way I see most of the code that is written, it should not be safe to use in a concurrent environment as long as it is used from single isolation domain. Once something in such type becomes async, and in a lot of use cases it will, you inevitably have to think about type acting in that concurrent environment. Which is clearly a good thing in overall, it makes explicit issues that previously were silent. The problem with that is that currently you have to make isolation part of the type, instead of allowing it to be in the way it is - non-Sendable and unsafe to use from concurrent environments. That's dramatically reduces options on types design and throws us into a viral dependencies chain.

Consider the following examples, which include isolated and non-isolated types, and how they interact with each other.

protocol DataLoadingApi: Sendable {
    func load<T>(
        _ listType: ApiListType
    ) async throws -> [T] where T: Decodable & Sendable
}

protocol Database: Sendable {
    func save<T>(_ content: T) async throws where T: Sendable
}

// Non-sendable class that used to handle lists
final class List<T>: ObservableObject where T: Decodable & Sendable {
    private let api: any DataLoadingApi
    private let db: any Database
    public private(set) var data: [T] = []

    public func fetch() async throws {
        let data = try await api.load(.feed)
        try await db.save(data)
        self.data = data
    }
}

@MainActor
struct ListView: View {
    let list: List<URL>

    var body: some View {
        EmptyView()
            .task {
                // this currently produces a data race warning
                try await commands.load()
            }
    }
}

The code above will produce a warning in Swift 5.10 due to List being non-Sendable, and calling async method will make it leave main actor and execute on generic executor. To resolve that, we have two options:

Solution 1. Isolate List to the @MainActor:

@MainActor 
final class List<T>: ObservableObject where T: Decodable & Sendable {
    // ...
}

That is currently suggested way by Apple in SwiftUI: make such types isolated to the main actor. That works, but with downsides (yep) of it being sendable, since from the design perspective List should not be sendable, it is a non-Sendable on purpose. It also goes wiral in two directions. Firstly, it requires everything it interacts to be sendable as well. Seems about right, for example, with protocols that were defined for API and database. But then, as second direction, we have a category of types that we either cannot simply conform to Sendable, requiring to opt-out for actor, or the only way to solve is to isolate it on main actor as well - and sometimes even that not a feasible option. Isolating on main actor is also the most simple solution, so it is just explodes all over the project, ironing majority of the types to the main-actor. Introducing your own global actor doesn't differs much from that, so this is equivalent solution in this context.

Solution 2. Pass isolation parameter

public func fetch(_: isolated (any Actor)? = #isolation) async throws {
    let data = try await api.load(.feed)
    try await db.save(data)
    self.data = data
}

In that way we won't pin type to some specific actor by design, but allow to pass actor isolation dynamically on call site. However, there are downsides as well. In some library API, in some functions or methods it's OK to introduce such parameter, while when it is part of many classes with many methods inside a project it become a problem of lots of code being written, and passing an actor here and there, significantly distrupting types. And since all these isolations has to be part of the type, it needs to be incorporated as part of the design, which makes it complex to implement and fallback to easier ways, but also limits options with types that developers cannot modify, e.g. in libraries.

Apart from SwiftUI, in just actors world it has generally the same complications:

actor Pipeline {
    struct Input: Sendable {
        // ...
    }

    let preProcessing: PreProcessing
    let stage1: Stage<Input, IRInput>
    let stage2: Stage<IRInput, Output>
    let postProcessing: PostProcessing
    let metadataStore: MetadataStore

    func initialize() async {
        await preProcessing.start()
        await metadataStore.sync()
    }

    func run(on input: Input) async {
        let preparedInput = await preProcessing.run(on: input)
        let irState = await stage1.process(preparedInput)
        let output = await stage2.process(irState)
        postProcessing.run(on: output)
    }
}

final class PreProcessing {
    struct ProcessedInput: Sendable {
    }

    func start() async {
    }

    func run(on input: Pipeline.Input) async -> ProcessedInput {
    }
}

final class Stage<I, O> where I: Sendable, O: Sendable {
    func process(_ input: I) async -> O {
    }
}

final class PostProcessing {
    struct ProcessedOutput: Sendable {
    }

    func run(on output: Output) async -> ProcessedOutput {
    }
}

final class MetadataStore {
    func sync() async {
    }
}

In the same way, it will produce warnings under Swift 5.10 about using non-Sendable types. What options to solve that?

Solution 1. Pass isolation with each method

final class MetadataStore {
    func sync(_: isolated Actor = #isolation) async {
    }
}

We have all the same issues we have had in the first example. Situation a bit improved due to probably much limited scope in which we have to address the issue. But passing around actor parameter, that goes viral in all the methods, is not a good solution.

Solution 2. Make all of them actors and share executor

actor MetadataStore {
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        executor.asUnownedSerialExecutor()
    }
    private let executor: any SerialExecutor

    init(executor: any SerialExecutor) {
        self.executor = executor
    }
}

This is more efficient way to address the issue, since we only need to pass an executor, yet it still requires types to incorporate that by design, and it is easier to make a mistake whenever you have to make more changes in various types.

Now, to just get back to what is end goal after this long examples introduction, the main idea is to introduce a mechanism to isolate non-Sendable type on an actor in the same way as isolated keyword does when passed to the functions.

Proposed solution

Allow explicitly state isolation of the non-Sendable type in the isolation context by introduing @isolated(local) attribute to be applied within property declarations, and extend @isolated(any) to be applicable to properties as well. That will allow to bound such types to the isolation context, without making them sendable, which is not required at all in such cases.

@isolated(local)

This attribute states that the instance itself and all access to its members, as well as methods calls are isolated to an actor, re-enabling "sticky" behaviour in explicit way. Actor is defined by the context in which the property is being declared, resulting in restriction of attribute allowed only in actor-isolated contexts:

final class NonSendable {
    var i: Int = 0

    func inc() async {
        i += 1
    }
}

actor A {
    // ok, instance isolated to an actor A
    @isolated(local)
    private let nsOnA = NonSendable()

    func inc() async {
        // ok, still isolated
        await nsOnA.inc()
    }
}

@MainActor
final class B {
    // ok, instance isolated to the MainActor
    @isolated(local)
    private let nsOnMain = NonSendable()

    func inc() async {
        // ok, still isolated
        await nsOnMain.inc()
    }
}

final class C {
    @isolated(local) // error: local isolation cannot be applied in non-isolated context
    private let ns = NonSendable()
}

Combined with region based isolation, this non-Sendable type will be safely isolated on an actor, prohibiting passing it outside of the isolation context.

@isolated(any)

The extension of this attribute is aimed to address issue with chains of non-Sendable types, such as:

final class NonSendableA {
    @isolated(any)
    private let b = NonSendableB()

    func inc() async {
        await b.inc()
    }
}

final class NonSendableB {
    var i: Int = 0

    func inc() async {
        i += 1
    }
}

That tells the compiler that b should inherit whatever isolation its enclosing type has. Opposite to @isolated(local), use of @isolated(any) is allowed only within the non-isolated contexts, so the following will produce an error:

actor A {
    @isolated(any) // error: the attribute is allowed only in non-isolated context
    private let ns = NonSendable()
}

Implementation

And the last point to address is implementation. That seems to be a big feature, and my knowledge about compilers are limited to evaluate its complexity. The assumptions I have put into the design is that Swift has (and has had) features that propagate actors isolation, so it is should be possible at least, and with local isolation attributes it seems to be possible to get isolation context and extend it to the type, and allow ensure safety of the code at the same time. Also, if I understood correctly, the proposal SE-0338 has stated some similar options to that in both alternative and future direction sections.


EDIT: updated structure, simplifed examples.

3 Likes

Hi, I fall into the same issues. My thoughts that as community we need more robust guidelines and best practices to address these issues, though. Seems that Swift 6 will also help to eliminate many of them.

actor A and class B performs their Tasks concurrently, so there will be concurrent calls to nonSendable.inc() and data race as a result.
Can you please specify how @isolated(local) defined in actor A and class B will help prevent data races caused by concurrent calls to instance of class NonSendable (nonSendable.inc() method)?

There is also active review of @isolated(any) now: SE-0431: `@isolated(any)` Function Types

Thank you for the response! :slight_smile:

Definitely, guidelines are important. Currently there are a lot of fragmented information, and I see with the forum discussions even experienced users are having troubles understanding it as a whole. There were several notions that the work is going on in that direction, so I hope it will be published around Swift 6.

That was partly inspired from pre-review discussions on this attribute. However, neither this proposal nor region based isolation (which hopefully is going to address huge part of complications with non-Sendable types) is addressing part of non-Sendable types I described here, at least to my understanding.

First, I feel important to stress that actor A and class B own different instances of the same type. And this instances, by being isolated to actors (A in the first case and MainActor` in the second) should not be able to leave this isolation.

So we left with the case of actor methods called reentrantly, leading to a data race inside NonSendable. To avoid that, we have to somehow pass the isolation to the inc method on this type. And currently, the only dynamic way to do so is to add isolation parameter to the signature. The proposed attribute is aimed to flip this dependency, by allowing type declaration remain unchanged, and isolation added on the caller side. In that way, reentrant calls, say, to actor A method that uses inc in implementation, pass actor isolation down to the NonSendable, eliminating data race. In other words, an instance becomes isolated to an actor as a whole, restricting access to it only from the actor it is declared as part of, and eliminating any possible concurrent access to it.

Imagine inc being synchronous (pretty easy, heh). Synchronous calls are automatically isolated on the calling actor. For async calls this option is a bit hard to achieve. But why it should be hard? If I have a class that I want to make a part for some actor, having async methods should not prevent me from this.

1 Like

Oh sorry, I've missed that they own different instances. Nevertheless, additional explanations also help to understand better the overall idea of @isolated(local).

In my understanding of concurrency and recent proposals these cases not addressed too.