How is @Observable
intended to interact with mutable state protected by a lock?
Consider a class like:
final class Counter: Sendable {
private let _value = Mutex(0)
var value: Int {
_value.withLock { $0 }
}
func increment() {
_value.withLock { $0 += 1 }
}
}
I’d like to be able to observe Counter.value
while keeping it Sendable
. The approach I’ve been using is to just keep it isolated to the main actor:
@Observable
@MainActor
final class Counter {
private(set) var value: Int
func increment() {
value += 1
}
}
I would think invoking access(keyPath:)
and withMutation(keyPath:_:)
manually would solve the issue, but it leads to some ambiguities:
@Observable
final class Counter: Sendable {
private let _value = Mutex(0)
var value: Int {
access(keyPath: \.value)
return _value.withLock { $0 }
}
func increment() {
// Should `withMutation` go inside or outside the lock?
withMutation(keyPath: \.value) {
_value.withLock { $0 += 1 }
}
// or
_value.withLock {
withMutation(keyPath: \.value) {
$0 += 1
}
}
}
}
The relative ordering of how willSet(keyPath:)
/lock()
and didSet(keyPath:)
/unlock()
execute is the problem. Upon further thinking, it seems like neither of these two options are particular safe.
withMutation(keyPath:)
then withLock(_:)
Consider the case where withMutation(keyPath:)
wraps withLock(_:)
. The relative ordering of operations would look something like:
func increment() {
willSet(keyPath: \.value)
_value.lock() // Doesn't actually exist, but for the sake of the example
value += 1
_value.unlock()
didSet(keyPath: \.value)
}
willSet(keyPath:)
synchronously invokes any observers, including any consumers using withObservationTracking(_:onChange:)
. Looking at the proposal and implementation for the new Observations
async sequence, it’s implemented such that it schedules values to be yielded on the next suspension of the observing isolation, which may or may not be the isolation on which increment()
is being called:
let counter = Counter()
@MainActor
func increment() {
counter.increment()
}
final actor AnotherActor {
func observe() {
Observations { counter.value }
}
}
In this example, Counter.increment()
is being called on the main actor, but then counter.value
is being observed on AnotherActor
. That implies a counter.value
will be yielded whenever AnotherActor
yields. But then if the Counter._value
lock happens to be highly contentious, there’s a chance that the order of operations is:
Counter.increment()
is called on the main actorwillSet(keyPath: \.value)
is called, invoking all observers usingwithObservationTracking
Observations
is one such observer and schedulescounter.value
to be read and yielded.- The
_value
mutex is highly contentious andincrement()
does not acquire the lock yet, blocking the main actor from updating the value. AnotherActor
yields, allowing theObservations
block to observecounter.value
at its pre-incremented state, causingObservations
to yield the wrong value.- The
_value
mutex is eventually acquired and the value is incremented. didSet(keyPath: \.value)
is eventually called, butwithObservationTracking
is explicitly implemented to only respond towillSet
, so theObservations
stream never sees the new value and never yields it.
This isn’t an issue when @Observable
is restricted to a single actor since reading from the value can only be done from that same actor and the order of await
suspensions ensures everything works smoothly. But when it’s a lock that allows for synchronous access on another actor, the guarantee that the value is actually mutated on time before it gets observed is not upheld. This quote from SE-0475 implies Observations should be compatible with locks:
in the
Sendable
cases the mutation is guarded by some sort of mechanism like a lock
withLock(_:)
then withMutation(keyPath:)
Reversing the order of operations (sort of) solves the issue above but introduces another. The relative order of operations would look like:
func increment() {
_value.lock() // Doesn't actually exist, but for the sake of the example
willSet(keyPath: \.value)
value += 1
didSet(keyPath: \.value)
_value.unlock()
}
By ensuring the lock is held before calling willSet(keyPath:)
, it makes it much more likely that the value actually be incremented on time. willSet(keyPath:)
will tell the Observations
to schedule up another observation, but then value += 1
is incremented immediately after. Technically, since the two could be running on different threads, it’d be possible to run into the same sequence of events where AnotherActor
yields and Observations
observes value
before it gets incremented, but it’s at least extremely unlikely.
A bigger problem arises from this code
withObservationTracking {
counter.value
} onChange: {
print("counter.value is about to change from \(counter.value)")
}
It doesn’t seem particularly conspicuous, but this results in a runtime crash. withObservationTracking
‘s onChange
runs synchronously on the same isolation as where willSet(keyPath:)
is called. That means the print
is going to be executed with this relative ordering:
func increment() {
_value.lock()
willSet(keyPath: \.value)
// Synchronously calls:
print("counter.value is about to change from \(counter.value)")
value += 1
didSet(keyPath: \.value)
_value.unlock()
}
The onChange
is accessing counter.value
! Which is protected by the same lock that was just acquired, resulting in attempting to take the lock again on the same thread, which leads to a crash if you’re not particularly careful to use NSRecursiveLock
instead of Mutex
.
Mutex
and most other locks don’t support recursively locking themselves, and it isn’t particularly clear that this would be an issue to begin with. Going back to the case from before where Observations
attempts to read counter.value
from another actor than where it’s being incremented, it means it’s still possible (albeit unlikely) to trigger this runtime crash even if you’re careful to never access the observed value from within onChange
directly. If the lock hasn’t been yielded by the time someone else tries to read the value it’s protecting, it’s going to result in a crash.
Conclusion
Is there a way to use @Observable
safely with locks??
It seems like one of the root issues is that the withObservationTracking
is implemented such that onChange
is called on the willSet
, but there’s just no way to guarantee the accompanying didSet
is ever actually called on time without resorting to putting everything on the same isolation. If that’s the case, it should probably be called out that @Observable
is just incompatible with synchronization techniques other than using actors.