Swift 6 and singletons / @Observable and data races

I recently read this article here and I have to overthink my current application:

I tried to enable strict concurrency checking and I really get the warning:

Static property 'shared' is not concurrency-safe because it is non-isolated global shared mutable state; this is an error in Swift 6

So okay, I will try to change my code now, but I am not sure, what will be the best solution. The reason, why I use a singleton, is, that I have a client application that requests data from a server. It will be displayed in several SwiftUI-views, so I choosed a @Observable-class and everytime a function requests new data from the server, this singleton will be updated and all my views too. So far, this works, although I know, that there can be data races. It's getting worse, as sometimes the server pushes new data to the clients, so there is a high chance, that there will be some data races. Therefore I really need to change my code either way.

Using an actor would be the best solution, but you cannot mark an actor as @Observable. The author of the article above suggests to mark the class as @MainActor.

First question that I cannot completely answer myself by searching the net: What exactly does @MainActor? Every article in the net tells, that the code will run on the main actor, on the main queue. The article linked above tells, that making a class @Observable will make the access serialized and therefore concurrency-safe. So is

@MainActor
@Observable
class MyClass {
   ...
}

comparable to (what is not yet possible):

@Observable
actor MyActor {
   ...
}

? What happens for example in this case?

@MainActor
@Observable
class MyClass {
    static var shared = MyClass()
    var myArray = [1, 2, 3]
}

// somewhere in the code
MyClass.shared.myArray.append(4)

// somewhere else
print(MyClass.shared.myArray.count)

Are these two functions thread serialized and thread safe?

Second question: The author in the above article writes "However, actor isolation might not always work since it complicates access from non-concurrency contexts."

Can anyone explain this further?

1 Like

Well, some more light came into this....

The pseude code at the end is not working this way. If I access the MyClass.shared, I only can do this from a function that is also marked with @MainActor. After adding this to the calling function, I have to call this function itself with await. So voila, it is serialized. (?)

This is the code I tested in Playground:

@MainActor
@Observable
class MyClass {
	static var shared = MyClass()
	var myArray = [1, 2, 3]
}

@MainActor func test() {
	MyClass.shared.myArray.append(4)
}

Task {
	await test()
}

One addition:

Also possible would be:

@MainActor
@Observable
class MyClass {
	static var shared = MyClass()
	var myArray = [1, 2, 3]
}

Task { @MainActor in
	MyClass.shared.myArray.append(4)
}

Will this be fine too? There is no need for await now. Will Task { @MainActor in will be thread safe?

Sorry, those things are somehow very confusing for me.

Global singletons are fine if they are Sendable. Which is the case once you annotate them with a global actor such as @MainActor. The problem here stems from the fact that your singleton is a static var. Vars can be mutated and this is what the compiler tells you is not safe. If you change the static var to static let your problem should go away.

1 Like

As completely different way to approach this, why not to pass the same instance to these views explicitly, instead of using singleton?

1 Like

Thanks for you answer.
Using a var
=> Static property 'shared' is not concurrency-safe because it is non-isolated global shared mutable state; this is an error in Swift 6

Using a let
=> Static property 'shared' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

I also thought about this: I could pass the class with .environment. But then I still have the same problem what even may not really have to do something with the main question. My main data storage class must be thread safe, but I cannot use an actor with Observable.

I also thought about this: I could pass the class with .environment.

I think what vns is suggesting here is passing it as a parameter.

1 Like

Isolation on global actor is a way to go I think. At least, before @Observable all observed objects were recommended to be isolated on main actor. More likely it is still true with macro as well.

Yes, I also against passing dependencies via environment modifier

The following code works for me and compiles with no warnings or errors under strict checking

import Observation

@MainActor
@Observable
class Foo {
    static let shared = Foo()

    var state = 0
}

func foo() {
    Task {
        print(await Foo.shared.state)
    }
}

To your original question. Marking a type with @MainActor makes all of its code isolated to this actor. For the MainActor this means it runs on the main queue of your application. This does make the class Sendable and thread safe.

As it is a class, what makes the difference? Passing as a parameter would pass the pointer. Using .environment() would just help to pass it down in every child view.

I still don't really understand how it works under the hood. What means "isolation to the actor"? Every access to the propertys is serialized, so first access A, then access B and so on? This is understandable for me in my first example and yours, because there we used await. But what is with my second example Task { @MainActor in?

And: Why can I access the properties of a @MainActor-class directly while I cannot when using an actor?

Great questions. Both types and closures can be annotated with global actors such as @MainActor. If you annotate a type with it, it means that all access to its state are serialised to that actor. You can roughly think about this as wrapping all access with DispatchQueue.main.async. When you annotate a closure with @MainActor then you isolate the closure to the main actor. This means the code inside the closure is also running roughly speaking in a DispatchQueue.main.async.

Since these annotations are visible to the compiler it can see that both the type and the closure are on the same isolation @MainActor hence you don't have to await access to the actor. This is similar to if you would be doing a check if the current queue is the main queue and then access the shared state synchronously. The big benefit here is that we don't require any runtime checks since the compiler knows this statically.

The reason for this is that normal actors are not the @MainActor so you have to serialise the access to the actor's state; thus, awaiting this serialisation. SE-0431 is enhancing this so that closures can be isolated to their captures which will allow you to spell what you did with @MainActor on the closure also for arbitrary actors. Here is an example how this proposal looks in code:

actor Foo {
    var state = 0
}

func bar() {
    let foo = Foo()
    Task { [isolated foo] in
        print(foo.state)
    }
}
1 Like

There are at least 3 2 points:

  1. Environment objects aren’t designed for this. They were designed to pass styles or presentation environment data, not models. Seems like that has been changed since I last looked.
  2. You introduce a lot of implicit dependencies if this is the main way you pass them through the views. It is often unclear what environment objects it depends on.
  3. Creating SwiftUI previews becomes complex. Due to a lot of implicit dependencies you will have in the view and in child views previews are hard to create and they might often crash if you forget one of the dependencies without meaningful crashlog.

So despite it being tempting to use environment to pass dependencies (I did use it at first when SwiftUI came out, then discovered these issues), there are more downsides to this than benefits as the project grows.

A couple of arguments to feed the discussion:

SwiftData's modelContext is in the environment

Default values for environment keys could use preview-friendly values.

I think the pattern for now is

@Observable
@MyActor
class MyModel {
}

@globalActor
actor MyActor {
    static let shared = MyActor()
}
3 Likes

CoreData (and SwiftData inherently) in views probably the worst thing that could happen to the app :slight_smile:

Yet I've re-checked docs and you are right, seems like Apple has changed this since I've looked into environment and now suggests to use it as dependency management. That dismisses my first argument, but I think that is a bad idea anyway - implicit dependencies has never been good.

Unless your view does become dependent on networking, CoreData, few configs and random type that requires three more other types to create it, and that's all for one view. Finally, when you get all of that working, at some point in the hierarchy something being added/removed and unrelated view breaks without any hint.

Plus, previews is a great tool to validate different states. If there are hard dependencies to something, your options are highly limited on emulating various states.

Thanks @FranzBusch for your detailed answer, this now explains a lot to me. So everything that happens on the MainActor happens on just one thread and will so be executed sequentially. And if I would take just the singleton, it could happen, that different threads access the singleton at the same time and that would cause a data race. Correct?

I hope, someone out there will write a very good book about all this as soon as Swift 6 is out :smiley:

2 Likes
  1. You deleted that point, but even before using @Observable, I also used .environmentObject() for this and I never had problems. For me the concept of .environment() or .environmentObject() always was just a way to prevent passing down the model through all views, even those, who don't need this model. I strictly am against passing the model through all views especially when I know, that some views will not need the model.
  2. Is it? On those views in which I don't need the object, I just don't write it to the view.
  3. Not more then in other situations. In my example with using a singleton I have a static func where I fill my model with fake data in the #Preview and then I return the view. Works perfectly and would also when adding it as .environment()

Correct. If you just have a plain class as a singleton that is neither Sendable nor isolated to a global actor then the compiler doesn't know if that class is thread safe. You can either isolate it to a global actor which will serialise all access to the class through the global actor or you can mark it Sendable. However, if you mark it Sendable you really have to make it thread safe yourself. This can be done by using locks, queues or other synchronization primitives. I would really recommend using global actor isolation here or an actor instead of a class since implementing proper synchronization via locks/queues is often non-trivial.

Your view initialization is simply calling an empty init in general when you are using environment modifier. In that case you have zero understanding about dependencies, because they are implicit. And it at some point you need a new dependency in view, you have to go through the project and manually discover where it needs to be passed, without any assist from the compiler.

I cannot guarantee the next is true now, but it was previously: if you pass environment object, child views would inherit it (most of the time), so now you are not even passing object to every view, but rely on objects propagation.

If your view depends just on some state — previews are extremely easy to create. If your view depends on passed in objects, you can mock/wrap/instantiate then in the way you need. With singletons and implicit dependencies it becomes hard. You need to fill something, work with CoreData or other complex subsystems, without the way to abstract them.

Using .environment() is akin to using a global variable, I don't fancy either.