I'm working on a Swift application where I need to manage a piece of data (e.g., a CGRect representing a rectangle's position) that can be modified on a background thread/task, while concurrently being read on the main thread for UI updates or hit-testing.
My primary goals are:
Safe concurrent access : Avoid data races.
Fast reads on the main thread : Reading should be synchronised and as non-blocking as possible, and I can accept slightly stale data. Also, you may assume that, in my case, all writing operations are always performed by the same actor.
Eventually consistent : The main thread reads should eventually reflect the latest updates from the background.
Currently, I'm using a traditional NSLock approach within a regular class, which works but has some drawbacks:
class RectangleManager {
private var myRectangle: CGRect = .zero
private let lock = NSLock()
var safeRectangle: CGRect {
get {
lock.lock()
defer { lock.unlock() }
return myRectangle
}
set {
lock.lock()
defer { lock.unlock() }
myRectangle = newValue
}
}
// Example usage:
// Background task updates safeRectangle
// Main thread reads safeRectangle for UI or touch events
}
// Example of how I might use it:
let manager = RectangleManager()
// Simulate background update
DispatchQueue.global().async {
manager.safeRectangle = CGRect(x: 10, y: 10, width: 100, height: 100)
}
// Simulate main thread read
DispatchQueue.main.async {
let rect = manager.safeRectangle // This locks, which I want to avoid if possible for reads
print("Main thread read: \(rect)")
}
Concerns with the NSLock approach:
Verbosity : Requires boilerplate for get and set with locking, as I may have many similar properties.
Main thread locking : The get operation on the main thread acquires a lock. If the lock is contended by a write operation, the main thread can be blocked, which is undesirable for UI responsiveness.
My Question / Desired Pattern:
I'm exploring modern Swift Concurrency approaches to replace the lock-based system. What is an idiomatic pattern, potentially using Actors, to achieve safe background writes while allowing the main thread to perform very fast, non-blocking reads of this data, even if the data read is slightly stale?
Any examples or pointers to best practices would be greatly appreciated!
I like @ibex10's approach as it also allows you to specify a buffer policy in case your background operation might overwhelm the consumer, but the way I understand your post that seems to be no issue, right?
If so, why not simply isolate your myRectangle property to the main actor? I'd probably do this:
class RectangleManager {
@MainActor
private(set) var myRectangle = CGRect.zero
func updateFromAnyContext(_ rectangle: CGRect) async {
@MainActor func updateHelper(_ rect: CGRect) {
myRectangle = rect
}
await updateHelper(rectangle)
}
}
If you want your updating method to be synchronous you can simply start a Task { @MainActor in ... } in it to set myRectangle. That then doesn't need the helper function to make the hop.
Note there is no way to make a setter for a calculated property asynchronous, so you need a function that does the job instead, but I think that's basically by design.
At least it's the current Swift idiom, I'd say. async functions always make any suspension points clear, and in this context here they are what allows the runtime to smoothly and cleanly hop from one isolation to another, including passing data such as the rectangle.
Well, don't they technically do exactly that with the lock, too? If you set the rectangle from the BG task while the main actor/thread is reading it the lock is lock()ed, so the underlying thread blocks until the main thread calls unlock() again.
I realize swift concurrency probably introduces a little overhead, but your example points to exactly the problems you run into when rolling your own synchronization. Also, the async setter method does not necessarily block the underlying thread of the BG task, it just suspends, leaving the thread available to perform other things if needed.
I think with regular isolated asynchronous methods you can favor one context (in this case the main actor) over BG tasks. In many cases that's what you want.
If your BG work exceeds the read operations you do on the main actor you run into other problems, but as said I think @ibex10's solution of an AsyncStream can nicely resolve that by using a fitting buffer strategy (most likely bufferingNewest, it's unavoidable to throw some results away then anyway). However, @SWNS wrote that there's more reads than writes, so I'd go the "classic" way here.