I’m trying to create a property wrapper that that can manage shared state across any context, which can get notified if changes happen from somewhere else.
I'm using mutex, and getting and setting values works great. However, I can't find a way to create an observer pattern that the property wrappers can use.
The problem is that I can’t trigger a notification from a different thread/context, and have that notification get called on the correct thread of the parent object that the property wrapper is used within.
I would like the property wrapper to work from anywhere: a SwiftUI view, an actor, or from a class that is created in the background. The notification preferably would get called synchronously if triggered from the same thread or actor, or otherwise asynchronously. I don’t have to worry about race conditions from the notification because the state only needs to reach eventuall consistency.
Here's the simplified pseudo code of what I'm trying to accomplish:
// A single source of truth storage container.
final class MemoryShared<Value>: Sendable {
let state = Mutex<Value>(0)
func withLock(_ action: (inout Value) -> Void) {
state.withLock(action)
notifyObservers()
}
func get() -> Value
func notifyObservers()
func addObserver()
}
// Some shared state used across the app
static let globalCount = MemoryShared<Int>(0)
// A property wrapper to access the shared state and receive changes
@propertyWrapper
struct SharedState<Value> {
public var wrappedValue: T {
get { state.get() }
nonmutating set { // Can't set directly }
}
var publisher: Publisher {}
init(state: MemoryShared) {
// ...
}
}
// I'd like to use it in multiple places:
@Observable
class MyObservable {
@SharedState(globalCount)
var count: Int
}
actor MyBackgroundActor {
@SharedState(globalCount)
var count: Int
}
@MainActor
struct MyView: View {
@SharedState(globalCount)
var count: Int
}
What I’ve Tried
All of the examples below are using the property wrapper within a @MainActor class. However the same issue happens no matter what context I use the wrapper in: The notification callback is never called on the context the property wrapper was created with.
I’ve tried using @isolated(any) to capture the context of the wrapper and save it to be called within the state in with unchecked sendable, which doesn’t work:
final class MemoryShared<Value: Sendable>: Sendable {
// Stores the callback for later.
public func subscribe(callback: @escaping @isolated(any) (Value) -> Void) -> Subscription
}
@propertyWrapper
struct SharedState<Value> {
init(state: MemoryShared<Value>) {
MainActor.assertIsolated() // Works!
state.subscribe {
MainActor.assertIsolated() // Fails
self.publisher.send()
}
}
}
I’ve tried capturing the isolation within a task with AsyncStream. This actually compiles with no sendable issues, but still fails:
@propertyWrapper
struct SharedState<Value> {
init(isolation: isolated (any Actor)? = #isolation, state: MemoryShared<Value>) {
let (taskStream, continuation) = AsyncStream<Value>.makeStream()
// The shared state sends new values to the continuation.
subscription = state.subscribe(continuation: continuation)
MainActor.assertIsolated() // Works!
let task = Task {
_ = isolation
for await value in taskStream {
_ = isolation
MainActor.assertIsolated() // Fails
}
}
}
}
I’ve tried using multiple combine subjects and publishers:
final class MemoryShared<Value: Sendable>: Sendable {
let subject: PassthroughSubject<T, Never> // ...
var publisher: Publisher {} // ...
}
@propertyWrapper
final class SharedState<Value> {
var localSubject: Subject
init(state: MemoryShared<Value>) {
MainActor.assertIsolated() // Works!
handle = localSubject.sink {
MainActor.assertIsolated() // Fails
}
stateHandle = state.publisher.subscribe(localSubject)
}
}
I’ve also tried:
- Using NotificationCenter
- Making the property wrapper a class
- Using NSKeyValueObserving
- Using a box class that is stored within the wrapper.
- Using @_inheritActorContext.
All of these don’t work, because the event is never called from the thread the property wrapper resides in.
Is it possible at all to create an observation system that notifies the observer from the same context as where the observer was created?
Thansk!
I’m trying to create a property wrapper that that can manage shared state across any context, which can get notified if changes happen from somewhere else.
I'm using mutex, and getting and setting values works great. However, I can't find a way to create an observer pattern that the property wrappers can use.
The problem is that I can’t trigger a notification from a different thread/context, and have that notification get called on the correct thread of the parent object that the property wrapper is used within.
I would like the property wrapper to work from anywhere: a SwiftUI view, an actor, or from a class that is created in the background. The notification preferably would get called synchronously if triggered from the same thread or actor, or otherwise asynchronously. I don’t have to worry about race conditions from the notification because the state only needs to reach eventuall consistency.
Here's the simplified pseudo code of what I'm trying to accomplish:
// A single source of truth storage container.
final class MemoryShared<Value>: Sendable {
let state = Mutex<Value>(0)
func withLock(_ action: (inout Value) -> Void) {
state.withLock(action)
notifyObservers()
}
func get() -> Value
func notifyObservers()
func addObserver()
}
// Some shared state used across the app
static let globalCount = MemoryShared<Int>(0)
// A property wrapper to access the shared state and receive changes
@propertyWrapper
struct SharedState<Value> {
public var wrappedValue: T {
get { state.get() }
nonmutating set { // Can't set directly }
}
var publisher: Publisher {}
init(state: MemoryShared) {
// ...
}
}
// I'd like to use it in multiple places:
@Observable
class MyObservable {
@SharedState(globalCount)
var count: Int
}
actor MyBackgroundActor {
@SharedState(globalCount)
var count: Int
}
@MainActor
struct MyView: View {
@SharedState(globalCount)
var count: Int
}
What I’ve Tried
All of the examples below are using the property wrapper within a @MainActor class. However the same issue happens no matter what context I use the wrapper in: The notification callback is never called on the context the property wrapper was created with.
I’ve tried using @isolated(any) to capture the context of the wrapper and save it to be called within the state in with unchecked sendable, which doesn’t work:
final class MemoryShared<Value: Sendable>: Sendable {
// Stores the callback for later.
public func subscribe(callback: @escaping @isolated(any) (Value) -> Void) -> Subscription
}
@propertyWrapper
struct SharedState<Value> {
init(state: MemoryShared<Value>) {
MainActor.assertIsolated() // Works!
state.subscribe {
MainActor.assertIsolated() // Fails
self.publisher.send()
}
}
}
I’ve tried capturing the isolation within a task with AsyncStream. This actually compiles with no sendable issues, but still fails:
@propertyWrapper
struct SharedState<Value> {
init(isolation: isolated (any Actor)? = #isolation, state: MemoryShared<Value>) {
let (taskStream, continuation) = AsyncStream<Value>.makeStream()
// The shared state sends new values to the continuation.
subscription = state.subscribe(continuation: continuation)
MainActor.assertIsolated() // Works!
let task = Task {
_ = isolation
for await value in taskStream {
_ = isolation
MainActor.assertIsolated() // Fails
}
}
}
}
I’ve tried using multiple combine subjects and publishers:
final class MemoryShared<Value: Sendable>: Sendable {
let subject: PassthroughSubject<T, Never> // ...
var publisher: Publisher {} // ...
}
@propertyWrapper
final class SharedState<Value> {
var localSubject: Subject
init(state: MemoryShared<Value>) {
MainActor.assertIsolated() // Works!
handle = localSubject.sink {
MainActor.assertIsolated() // Fails
}
stateHandle = state.publisher.subscribe(localSubject)
}
}
I’ve also tried:
- Using NotificationCenter
- Making the property wrapper a class
- Using NSKeyValueObserving
- Using a box class that is stored within the wrapper.
- Using @_inheritActorContext.
All of these don’t work, because the event is never called from the thread the property wrapper resides in.
Is it possible at all to create an observation system that notifies the observer from the same context as where the observer was created?
Thansk!
Share Improve this question asked Mar 10 at 22:07 fuzzyCapfuzzyCap 4514 silver badges18 bronze badges 01 Answer
Reset to default 2Basically, you can implement a thread-safe Storage type using Swift Concurrency or using a Mutex in order to synchronise access to the underlying value.
Implementing "observability" is orthogonal to the concurrency problem. I don't think, there is a general solution, when you want to utilise this Storage type in several scenarios.
For implementing the Storage, using a mutex makes access inherently synchronous, while it will be inherently asynchronous when you would use Swift Concurrency.
Using a Mutex is straight forward. With Swift Concurrency there are more intriguing solutions which may utilise structured concurrency.
Here's an example using a Mutex:
The "Storage" becomes a reference type.
Note: I'm using the new Mutex struct from the Synchronisation framework which is only available recently. You can however use any other mutex.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
final class Storage<Value>: Sendable {
init(initialValue: consuming sending Value) {
self.protectedValue = .init(initialValue)
}
private let protectedValue: Mutex<Value>
var value: Value {
get {
protectedValue.withLock { $0 }
}
set {
protectedValue.withLock {
$0 = newValue
}
}
}
}
You can use this Storage type without further ado in a property wrapper:
@propertyWrapper
struct State<Value> {
private let storage: Storage<Value>
public var wrappedValue: Value {
get {
storage.value
}
nonmutating set {
storage.value = newValue
}
}
init(wrappedValue: consuming sending Value) {
storage = .init(initialValue: wrappedValue)
}
}
This is thread-safe independently on the isolation domain. The access is synchronous. You get something similar as with SwiftUI.State.
So, now would it be a good design choice to implement observability into the Storage or the property wrapper? IMHO, no! The reason is, that there are too many scenarios where you would come across, and each would require a specific observability solution: 1. Using Observation framework, 2. Using Combine, 3. Using ObservableObject, or 4. Using a custom closure based solution, or 5. using KVO.
Also, you could use the property wrapper within an actor. However, this would make little sense, since an actor already defines an isolation domain, where access to its members is synchronised.
Nonetheless, and with emphasising the above, here's an example how you could use the property wrapper which ensures synchronisation in an actor:
class NonSendable {
init(value: String) {
self.value = value
}
var value: String = ""
}
actor StateActor {
@State private var value: NonSendable
init(initialValue: String) {
self._value = .init(wrappedValue: NonSendable(value: initialValue))
}
func update(_ value: String) {
self.value.value = value
}
}
Again, when using an actor, you would have better implemented it as below:
actor StateActor {
private var value: String
init(initialValue: String) {
self.value = initialValue
}
func update(_ value: String) {
self.value = value
}
}
Regarding observability, you could utilise Swift Combine and provide a property published
something, or make the whole actor a Publisher
.
Now, you want to use the property wrapper in an `@Observable` annotated class? Sorry, it's currently not possible. However, you could use the Storage
class directly:
@Observable
final class ObservableState {
private var storage: Storage<NonSendable>
var value: String {
get {
storage.value.value
}
set {
storage.value.value = newValue
}
}
init(initialValue: String) {
self.storage = .init(initialValue: .init(value: initialValue))
}
func update(_ value: String) {
self.storage.value.value = value
}
}
This is thread-safe, independently on the isolation domain. The access is synchronous. Observability is implemented through the Observation framework.
Conclusion:
You already did a fair amount of research. There's no general or "one size fits all" solution. And as always, there's Pros and Cons, no matter how you are solving a problem.
There's however a completely different approach to your "shared State" problem: utilising structured concurrency. This avoids the "Singleton" issue, which can become a really nasty and undesired anti pattern in an app leading to massive issues.
Just to give you an idea to solve the "mutate State" problem in a different way:
Again, this is a different solution and requires more clear criteria which define the requirements. For example, mutation of state, what is it anyway? Well, this could be described as a certain kind of model of computation:
(State, Input) -> (State', Output)
Say, the above is your "update" function that can also be written as
func update(_ state: inout State, event: Event) -> Output
Now, when Input
are Events, we could implement an async event loop:
var state = initialState
var ouput: Output?
for try await event in eventStream {
output = update(&state, event: event)
process(output)
if state.isTerminal {
break
}
}
return output
This is an async function (not an object!), implementing a kind of FSA - and this utilises Swift Structured Concurrency.