Search code examples
swiftgrand-central-dispatch

Using `DispatchQueue.concurrentPerform()` inside a Swift actor


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?


Solution

  • 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:

    1. 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.

    2. 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.