Synchronous (non-isolated) reads of an actor’s stored property

Hi,

I would love your feedback and suggestions for the API design in the following scenario:

  1. An actor performs some work asynchronously;
  2. It caches the result;
  3. Other objects should be able to access the result synchronously from any context.

So far, I’ve come up with the following design:

actor Worker {
    
    private let cache = Cache()
    
    nonisolated var lastResult: Int? {
        cache.result // Warning: Non-sendable type 'Worker.Cache' in asynchronous access to actor-isolated property 'cache' cannot cross actor boundary
    }
    
    func doWork() async {
        let result = await …
        cache.result = result
    }
    
}

extension Worker {
    
    private class Cache {
        var result: Int?
    }
    
}

Ideally, Swift would provide a way to mark an actor’s stored property as available for non-isolated read access (with atomic writes from within the actor), but that’s not the case, of course.

How would you solve this problem?

Thanks!

The problem is that writes to an actor-isolated property can still race with the read. (As a concrete example, imagine if the cache had lastResultIndex: Int and results: [Result] properties. Reading the lastResultIndex while the cache is being updated and the results array is being made shorter could cause you to try to read an invalid index and crash). So you would still need some way to synchronize reads with writes. If you absolutely need synchronous access, one option is to use Mutex from Swift 6 (which has almost the same API as OSAllocatedUnfairLock on Apple platforms, if you need to back-deploy):

class Worker: Sendable {
    private let cache = Mutex(Cache())

    var lastResult: Int? {
        cache.withLock { $0.result }
    }
    func doWork() async {
        let result = await …
        cache.withLock { $0.result = result }
    }
}

Note that this is not a reader-writer lock, so only one task can read from the cache at a time. Depending on your requirements, this may mean that you need to rethink your architecture at a deeper level if you absolutely need concurrent reads.

3 Likes

This goes against actor as a concurrency model—objects isolating state and are only communicating with each other by messages asynchronously, both ways. As it's a model, it affects the way you're designing apps. If you're limited by design and need some syncing—as suggested class and Mutex should be fine.

2 Likes

In most caching scenarios like you described the early readers of your result would wait until the asynchronous job is done and the result becomes available, which means reading the result will be an async operation one way or another. In fact the Mutex solution would potentially cause pretty long waits in other actors while your async operation is in progress.

That is, unless you are fine with the early readers not getting the result if it's not available yet. Have you considered this?

1 Like

@j-f1, @jaleel, thank you for pointing me in the direction of Mutex! Not sure if it’s the simplest tool for solving this problem, but it’s certainly one of the ways.

In my scenario, there’s only one context which writes to the property (the actor itself), and the readers are fine with missing or outdated values. I guess the only potential issue here is the inherent non-atomicity of writes (what if someone tries to read the cache while it’s being updated), and actors don’t seem to have a built-in mechanism for this (without using something like Swift Atomics).

Thanks again!

Yep, that’s exactly my case. The early readers can live without a result, but they need to learn about its absence quickly (hence they can’t await). They’re also fine with an outdated value in case the actor hasn’t finished its next run. In other words, the readers need to get a response (which can be nil) right now rather than a correct value later.

In that case, assuming your result is a actually a structure not a single POD value, I would define my result as a reference type (class) so that updating it would mean just an atomic write of a reference. The reference itself should be protected with a mutex or semaphore depending on whether you are on Swift 5 or 6.

One caveat if you are on Swift 5, the older synchronization primitives like DispatchSemaphore are not compatible with structured concurrency which means you can't use a semaphore in an actor or any async function. So your entire Worker interface should be "old-school", just plain old functions and properties plus a semaphore that ensures atomicity of access to lastResult for both reading and writing.

1 Like

If you need synchronous reads I would really recommend to look at Mutex/OSAllocatedUnfairLock for this. It will allow you to read the values synchronously and still allow you to write the update from an async context. You don't need an actor at all.

Modern mutexes are really fast. In uncontented scenarios it basically boils down to an atomic check.

3 Likes

Thank you @FranzBusch!

And thank you @crontab for elaborating on the caveats!