最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

ios - How to implement thread-safe property wrapper notifications across different contexts in Swift? - Stack Overflow

programmeradmin7浏览0评论

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:

  1. Using NotificationCenter
  2. Making the property wrapper a class
  3. Using NSKeyValueObserving
  4. Using a box class that is stored within the wrapper.
  5. 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:

  1. Using NotificationCenter
  2. Making the property wrapper a class
  3. Using NSKeyValueObserving
  4. Using a box class that is stored within the wrapper.
  5. 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 0
Add a comment  | 

1 Answer 1

Reset to default 2

Basically, 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.

与本文相关的文章

发布评论

评论列表(0)

  1. 暂无评论