I've a Swift actor that has a long-running update method that mutates a lot of shared state. I want to perform the long-running work in parallel on a background queue, but avoid the update function becoming async due to the use of an TaskGroup. As using await inside the update function would make the actor re-entrant, which would allow other actor functions to run before the update is finished.
In the past I used DispatchQueue.concurrentPerform()
to perform such CPU intensive computations on the background and this worked great.
However, when I apply this trick inside an actor in a Swift playground I get a compile time error (added as comment):
import Cocoa
actor Renderer {
var items = [Int: Int]()
func performUpdate() {
var localItems = [Int: Int]()
let lock = NSLock()
DispatchQueue.global().sync {
DispatchQueue.concurrentPerform(iterations: 10) { index in
Thread.sleep(forTimeInterval: 10) //Some very CPU intensive computation
Swift.print("Done: \(index)")
lock.lock()
localItems[index] = Int.random(in: 0...100) //Mutation of captured var 'localItems' in concurrently-executing code
lock.unlock()
}
}
items = localItems
}
}
Task {
let renderer = Renderer()
await renderer.performUpdate()
await Swift.print(renderer.items)
}
How can I 'export' the calculated results from inside the concurrentPerform()
to the performUpdate()
function?
And is using this construct even allowed under Swift's structured concurrency runtime contract? I think so, because the thread/task will always run to completion even though it might take a long while.
Is there a more Swiftly way of performing intensive calculations in parallel without making performUpdate()
async
?
The problem is that the compile-time safety checks of the actor
cannot reason about your use of locks to synchronize your access to localItems
.
You can avoid these checks by moving this synchronization into a separate class and vouch for its safety with @unchecked Sendable
:
actor Renderer {
var items: [Int: Int] = [:]
func performUpdate() {
let localItems = SynchronizedDictionary<Int, Int>()
DispatchQueue.concurrentPerform(iterations: 10) { index in
let value = … //Some very CPU intensive computation
localItems[index] = value
}
items = localItems.wrappedValue
}
}
final class SynchronizedDictionary<Key: Hashable, Value>: @unchecked Sendable {
private var values: [Key: Value] = [:]
private let lock = NSLock()
subscript(index: Key) -> Value? {
get { lock.withLock { values[index] } }
set { lock.withLock { values[index] = newValue } }
}
var wrappedValue: [Key: Value] {
get { lock.withLock { values } }
set { lock.withLock { values = newValue } }
}
}
A few unrelated observations:
I have retired the DispatchQueue.global().sync {…}
. The sync
blocks the calling thread, so it offers no benefit. Yes, we use that pattern with async
(and then give the function a completion handler) if we want to avoid blocking the calling thread, but if you are going to use sync
, then it is redundant.
I know you said you wanted to avoid task groups and making this an async
method, but should you ever reconsider that decision, it considerably simplifies our code. E.g.,
actor Renderer {
var items = [Int: Int]()
func performUpdate() async {
items = await withTaskGroup(of: (Int, Int).self) { group in
for index in 0 ..< 10 {
group.addTask {
let value = … // computationally intensive
return (index, value)
}
}
return await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
}
}
}
Now, in this example with 10 iterations, the difference between this and the concurrentPerform
example will be indistinguishable. And if you have so many iterations that the difference becomes observable, you might just have too many iterations. In those scenarios, even the concurrentPerform
rendition would benefit greatly from “striding”. E.g., if I had 10m iterations, I might stride and then do 100 iterations, each handling 100k data points.
FWIW, here is a random benchmark that I did with a computationally intensive task, comparing the performance of concurrentPerform
and a task group. The difference was indistinguishable.