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.