Do update to @Observable properties have to be done on the main thread?

According to this old thread @Observable macro conflicting with @MainActor

the answer is no. But I never understood why.

In the old world. It was always required that you make changes to @Published properties on the main thread. In fact compiler would complain.

In the main world, can you just update that in the background thread? And then SwiftUI take cares of refreshing the views on the main thread? So I guess that begs that question, why did it used to require it for @Published?

1 Like

As I understand it, @Observable is not directly related to SwiftUI. While SwiftUI wants state to be synchronized on the main thread, other systems using @Observable can have different ways of handling observations, perhaps synchronizing updates some other way or not synchronizing them at all.

In practice, however, @Observable is currently mostly just used (usable) with SwiftUI. If your use case for @Observable is as a model for SwiftUI, you want to ensure state of your @Observable type is modified on the main thread, for example, by using @MainActor.

2 Likes

To confirm, while @Observable is itself thread-safe and seems to work fine when updates are written and consumed from different threads, the way SwiftUI does it definitely does not comply with this expectation, and it can randomly skip update events on Sendable types if they happen "too often" (more that once within the same run loop iteration) — even when the changes are recorded in compliance with the proposal as written here.

I've recently filed feedback with Apple for this, but unfortunately I don't have the ticket number for it anymore. In any case, you should keep writing updates only from within the main actor.

1 Like

Today I tried exploring Apple's "AVCam" example project, and it's rife with @Observable and @Published updates being mutated off-main (within an actor, or within no particular isolation at all). Neither the Swift 6 compiler nor the runtime diagnostics are complaining about it.

@nkbelov I would really like to know if you receive feedback from Apple on your ticket, because if they confirm that UI-backing properties of Observables should only be mutated on the main actor---then this sample code is going lead many people astray.

Unfortunately, I've filed the ticked from an account I don't have access to anymore.

But the reproducible example that I've given is quite easy to set up if you want to track if it has been resolved: you need an observable Sendable data structure with internal sync just like in the proposal I've linked above (or use mutexes):

@Observable
public class Model: @unchecked Sendable {
    @ObservationIgnored
    fileprivate let _scoreStorage = Mutex<Int>(0)

    public var score: Int {
        get {
            return _scoreStorage.withLock { value in
                self.access(keyPath: \.score)
                return value
            }
        }
        set {
            self.withMutation(keyPath: \.score) {
                _scoreStorage.withLock { value in
                    value = newValue
                }
            }
        }
    }
}

Sidenote: I don't remember the specifics of where the calls into the registrar go (i.e. outside or inside the withLock operations), but I remember that if it's set up incorrectly it would deadlock, so it's a fair indicator.

Then construct a SwiftUI view that would read from it (I've used different colours for the numbers 0, 1 and 2) and update it repeatedly from outside the main actor:

.task {
    while true {
            model.score = 0
            model.score = 1
            try? await Task.sleep(for: .milliseconds(100))
            model.score = 2
        }
}

The SwiftUI view reading it will typically not be able to react to the 0 -> 1 transition because these are two mutations that both call into the registrar, but they happen within the same run loop turn. It essentially rendered random colours each time.

If you can repro this, I think it would be fair to file an additional ticket so that it perhaps gets more attention and re-confirms the issue.

I'm not sure whether your example code should be taken verbatim or as a rough approximation (hey, it's a forum post), but for the sake of posterity and any little birdies observing our thread here, I'll comment on it as-written before following up with edits to it and how that behaves.

The task modifier you suggest above:

.task {
    while true {
            model.score = 0
            model.score = 1
            try? await Task.sleep(for: .milliseconds(100))
            model.score = 2
        }
}

In practice that that task body is isolated to the main actor. I can't tell why, I just know that it is. Even on iOS 18 minimum APIs the public declaration doesn't appear to capture the caller's isolation:

@inlinable nonisolated public func task(
    priority: TaskPriority = .userInitiated, 
    _ action: @escaping @Sendable () async -> Void
) -> some View

But in practice it seems to be isolated to the main actor. So as-written its not going to produce examples of off-main mutations, but that's simple enough to fix:

.task {
    await Task.detached {
        while true {
            assert(Thread.isMainThread == false)
            await model.score = 0
            await model.score = 1
            try? await Task.sleep(for: .milliseconds(100))
            await model.score = 2
        }
    }.value
}

That does what we want: makes a hellacious number of mutations of the Model's property outside the isolation of the main actor. Running the code that way, the consuming SwiftUI now looks like this:

struct ContentView: View {
    @State var model = Model()

    var body: some View {
        Text("\(model.score)")
            .foregroundStyle(color)
            .task {
                await Task.detached {
                    while true {
                        assert(Thread.isMainThread == false)
                        await model.score = 0
                        await model.score = 1
                        try? await Task.sleep(for: .milliseconds(100))
                        await model.score = 2
                    }
                }.value
            }
    }

    var color: Color {
        switch model.score {
        case 0: .red
        case 1: .green
        case 2: .blue
        default: .black
        }
    }
}

Running that code on actual hardware (iPhone 16 Pro Max FWIW), what I observe is this: the text appears to flicker between 0 and 1, but lingers far longer on 1 than on 0. I think this behavior is what I expect. SwiftUI never promises to consume all incremental changes to an observed property. It just flips a dirty bit indicating that an update pass is required. If, after that dirty bit is flipped but before the next update pass, the observed state changes further, it won't trigger an additional update. When the pending update flows through, the view will pick up whatever the value happens to be at the moment the property is read.

The order of events looks like this:

Clock Main Actor Global Executor
1 count set to 0
2 view needs update
3 view updated "0"
4 count set to 1
5 view needs update sleeping
6 view updated "1" sleeping
7 sleeping
8 count set to 2
9 view needs update
10 count set to 0
11 view needs update
12 view updated "0"
... ... ...

And the loop continues. Note that the view never updates to display "2" because there's never enough time for an update pass between "2" and the next "0". Now, why we ever see the "0" at all is a mystery to me. I suppose it's just a hardware-sensitive race condition between the quality of service of that detached task and of the SwiftUI engine.

The Important Part

Now, across all my experiments, I have never once seen a message in the console about off-main mutation, or a warning from the compiler about binding view properties to mutable properties not isolated to the main actor.

This is different than with @Published properties. Well, technically @Published does not care about the distinction between main and non-main mutation. What's discouraged is driving a view update in response to a @Published property of an ObservableObject being mutated outside the main thread. That combination of things specifically will cause runtime console messages. There's nothing about @Published alone that will issue warnings or wrist-slapping console messages.

Whether the lack of such discouragement from @Observable is an oversight, or a deliberate design goal, I can't tell. I strongly suspect it is a deliberate design goal.

It is incredibly common for developers to deliver updates to view-observed @Published properties outside the main actor. Even Apple's own sample code does it. Perhaps Apple grew tired of dealing with the repercussions of those design decisions and has decided instead to just allow mutations to happen anywhere, and to bake synchronization into the SwiftUI internals so that there's no longer any reason to fret about where mutations are happening.

1 Like

Ah of course, I believe I also had a Task.sleep for one second after the final mutation in the loop. Yes, the example is an approximation (though the Model object is very likely verbatim, as I've copied it from the proposal with the exception that it's now protected by a Mutex instead), for instance, the sleep duration is potentially different, although longer than one frame (so 20+ ms at least).

What would sometimes happen is the following:

  • Model gets mutated, UI is notified
  • Render loop starts
  • The value 0 is read out
  • Model gets mutated again to 1
  • Render loop ends

With @MainActor observable objects, the penultimate step will never happen while the render loop is running, it will be queued after it. But with the off-main mutations, it seems like SwiftUI hasn't either scheduled itself to observe the value again yet or it skips re-rendering because it's already in the process — ultimately, the color for value of 1 would not render at times. This can also be seen though printing when mutating and Self._printChanges() in the view's body: the latter would appear less often.

I would really love to see a clarification from Apple on the Concurrency consideration when using Observable within SwiftUI.

I have observed a similar behavior as mentioned by @nkbelov that if you nest the withMutation within the withLock that you end up in a deadlock (as SwiftUI accesses the property in its mutation handlers).

It would be interesting if SwiftUI is only listening on the willSet handler or also on the didSet listener. The didSet is currently not available in the public interface (only as part of the SwiftUI SPI). Just listening on willSet provides no guarantee that you actually observe the changed value. The view might be refreshed before the value is even written.

A new behavior I observed today (running iOS 18 and Swift 6) is that the application crashes as SwiftUI tries to construct one of my view bodies within the mutation handler (see the stack trace below). For me withMutation is called on a serial background thread modifying an atomic property. According to the stack trace, the call to withMutation results in the DeviceTile.body.getter getting called (DeviceTile is a view in my project). Due to Swift 6 language mode swift_task_isCurrentExecutorImpl will crash as it is not running on the MainActor.

Having spent some hours on this topic I'm more and more getting the feeling, that you should avoid mutating an Observable from another actor than the MainActor.

#0	0x000000010381c448 in _dispatch_assert_queue_fail ()
#1	0x000000010381c3d0 in dispatch_assert_queue ()
#2	0x000000019ea1ab58 in swift_task_isCurrentExecutorImpl ()
#3	0x000000010b2e9b60 in DeviceTile.body.getter ()
#4	0x000000010b2ed388 in protocol witness for View.body.getter in conformance DeviceTile ()
#5	0x0000000250ebfc84 in function signature specialization <Arg[2] = Dead> of SwiftUI.ViewBodyAccessor.updateBody(of: τ_0_0, changed: Swift.Bool) -> () ()
#6	0x0000000250fca6c0 in closure #1 () -> () in SwiftUI.DynamicBody.updateValue() -> () ()
#7	0x0000000250fca0d4 in SwiftUI.DynamicBody.updateValue() -> () ()
#8	0x0000000251068940 in partial apply forwarder for implicit closure #1 (Swift.UnsafeMutableRawPointer, __C.AGAttribute) -> () in closure #1 () -> (Swift.UnsafeMutableRawPointer, __C.AGAttribute) -> () in closure #1 (Swift.UnsafePointer<τ_1_0>) -> AttributeGraph.Attribute<τ_0_0> in AttributeGraph.Attribute.init<τ_0_0 where τ_0_0 == τ_1_0.Value, τ_1_0: AttributeGraph.StatefulRule>(τ_1_0) -> AttributeGraph.Attribute<τ_0_0> ()
#9	0x00000001bf3a652c in AG::Graph::UpdateStack::update ()
#10	0x00000001bf3a60f0 in AG::Graph::update_attribute ()
#11	0x00000001bf3a5cc4 in AG::Subgraph::update ()
#12	0x00000002511a9ccc in SwiftUI.GraphHost.flushTransactions() -> () ()
#13	0x0000000250c3d208 in generic specialization <SwiftUI.ObservationGraphMutation> of SwiftUI.GraphHost.asyncTransaction<τ_0_0 where τ_0_0: SwiftUI.GraphMutation>(_: SwiftUI.Transaction, id: SwiftUI.Transaction.ID, mutation: τ_0_0, style: SwiftUI._GraphMutation_Style, mayDeferUpdate: Swift.Bool) -> () ()
#14	0x00000002511c6bb0 in closure #1 () -> () in closure #1 @Sendable (Observation.ObservationTracking) -> () in SwiftUI.installObservationSlow<τ_0_0>(accessList: Observation.ObservationTracking._AccessList, attribute: AttributeGraph.Attribute<τ_0_0>) -> () ()
#15	0x00000002511c6aa0 in closure #1 @Sendable (Observation.ObservationTracking) -> () in SwiftUI.installObservationSlow<τ_0_0>(accessList: Observation.ObservationTracking._AccessList, attribute: AttributeGraph.Attribute<τ_0_0>) -> () ()
#16	0x000000026c50477c in merged closure #1 @Sendable (Swift.AnyKeyPath) -> () in closure #1 (Observation.ObservationTracking.Entry) -> Observation.ObservationTracking.Id in static Observation.ObservationTracking._installTracking(_: Observation.ObservationTracking, willSet: Swift.Optional<@Sendable (Observation.ObservationTracking) -> ()>, didSet: Swift.Optional<@Sendable (Observation.ObservationTracking) -> ()>) -> () ()
#17	0x000000026c50a4d4 in partial apply forwarder for closure #2 @Sendable (Swift.AnyKeyPath) -> () in closure #1 (Observation.ObservationTracking.Entry) -> Observation.ObservationTracking.Id in static Observation.ObservationTracking._installTracking(_: Observation.ObservationTracking, willSet: Swift.Optional<@Sendable (Observation.ObservationTracking) -> ()>, didSet: Swift.Optional<@Sendable (Observation.ObservationTracking) -> ()>) -> () ()
#18	0x000000026c50a878 in partial apply forwarder for reabstraction thunk helper from @escaping @callee_guaranteed @Sendable (@guaranteed Swift.AnyKeyPath) -> () to @escaping @callee_guaranteed @Sendable (@in_guaranteed Swift.AnyKeyPath) -> (@out ()) ()
#19	0x000000026c508f04 in function signature specialization <Arg[0] = Dead> of Observation.ObservationRegistrar.Context.willSet<τ_0_0, τ_0_1 where τ_0_0: Observation.Observable>(_: τ_0_0, keyPath: Swift.KeyPath<τ_0_0, τ_0_1>) -> () ()
#20	0x000000026c5095e8 in function signature specialization <Arg[0] = Dead> of Observation.ObservationRegistrar.willSet<τ_0_0, τ_0_1 where τ_0_0: Observation.Observable>(_: τ_0_0, keyPath: Swift.KeyPath<τ_0_0, τ_0_1>) -> () ()
#21	0x000000026c503e80 in Observation.ObservationRegistrar.withMutation<τ_0_0, τ_0_1, τ_0_2 where τ_0_0: Observation.Observable>(of: τ_0_0, keyPath: Swift.KeyPath<τ_0_0, τ_0_1>, _: () throws -> τ_0_2) throws -> τ_0_2 ()
#22	0x000000010b4c9ce8 in PeripheralStorage.withMutation<Atomics.ManagedAtomic<SpeziBluetooth.PeripheralState>, ()>(keyPath:_:) at /var/folders/yj/zf1dn6gx3t3_m_j74zf7jpgr0000gn/T/swift-generated-sources/@__swiftmacro_14SpeziBluetooth17PeripheralStorage10ObservablefMm_.swift:13
#23	0x000000010b4cc718 in PeripheralStorage.state.setter at /Users/andi/Library/Developer/Xcode/DerivedData/ENGAGEHF-dsjfrlouvzjqxsbptsfnqrggliof/SourcePackages/checkouts/SpeziBluetooth/Sources/SpeziBluetooth/CoreBluetooth/Model/PeripheralStorage.swift:141

1 Like