Jack Morris
Controlling Actors With Custom Executors
21 Nov 2023

Following some discussion on Mastodon as a follow-up to my previous post, we concluded that it might not be a great idea to perform blocking I/O (i.e. SQLite operations) on the cooperative thread pool used by actors by default.

Unfortunately I can't find this rule documented anywhere (would love to know if it is). The closest advisory is that Tasks should always be able to make forward progress (from WWDC21), however this is still the case for momentary blocking I/O. However, considering the thread pool is limited to the count of device cores, in theory a reasonable number of database connections could disrupt the operation of other Tasks by holding up the thread pool with blocking work.

Therefore, I decided to give custom actor executors a whirl (new in Swift 5.9), which in this case allows me to target the execution of an actor as required (and specifically, target it outside the standard cooperative thread pool). For my Connection actor, I want to execute each on a distinct serial DispatchQueue, which was relatively simple.

First, you need to define a SerialExecutor responsible for executing jobs. In my case, it just dispatches them onto a DispatchQueue.

/// A `SerialExecutor` that dispatches jobs onto the specified `DispatchQueue`.
extension Connection {
  final class Executor: SerialExecutor {
    init(queue: DispatchQueue) {
      self.queue = queue
    }
    
    func enqueue(_ job: consuming ExecutorJob) {
      let unownedJob = UnownedJob(job)
      let unownedExecutor = asUnownedSerialExecutor()
      queue.async {
        unownedJob.runSynchronously(on: unownedExecutor)
      }
    }

    func asUnownedSerialExecutor() -> UnownedSerialExecutor {
      UnownedSerialExecutor(ordinary: self)
    }

    private let queue: DispatchQueue
  }
}

Then, it's just a manner of exposing an instance of this Executor from the actor's unownedExecutor property. I've backed this by a retained instance of the Executor itself, since holding on to the actor must keep the executor alive (from the docs).

actor Connection {
  init(url: URL) {
    let connectionID = <whatever>
    executor = Executor(queue: DispatchQueue(label: connectionID))
    ...
  }

  nonisolated var unownedExecutor: UnownedSerialExecutor {
    executor.asUnownedSerialExecutor()
  }
  private nonisolated let executor: Executor

  ...
}

Initialization

One further bump I hit was during the actor's init. Naturally, the executor is only used for method calls post init, or for async calls made on self in init (once all properties have been initialized). Prior to this, the executor hasn't been set, so how would the actor know where to execute?

However, I want to initialize a handle for the underlying SQLite connection on the same custom executor, but since the handle is a stored property on Connection, I can't use self until it's initialized (so I can't just get the actor to use its new custom executor). To get around this, I declared another actor (Opener), with the single responsibility of opening the connection. This actor takes an Executor as a param (and sets it on itself in the same manner as above), so its .open() method can run there.

actor Connection {
  init(url: URL) async throws {
    let connectionID = <whatever>
    let executor = Executor(queue: DispatchQueue(label: connectionID))
    connectionHandle = try await Opener(executor: executor, url: url).open()
  }

  private let connectionHandle: ConnectionHandle
}

extension Connection {
  actor Opener {
    init(executor: Executor, url: URL) {
      self.executor = executor
      self.url = url
    }

    func open() throws -> ConnectionHandle { ... }

    nonisolated var unownedExecutor: UnownedSerialExecutor {
      executor.asUnownedSerialExecutor()
    }
    private nonisolated let executor: Executor
  }
}

Performance

Having to jump off the cooperative thread pool does result in a performance hit: running a benchmark of an (unrealistically) large number of concurrent reads results in a slowdown of around ~30%, however since I haven't integrated this into an actual system yet (the app that I'm procrastinating from building by instead building a database wrapper...) it's not clear how this will impact real-world usage.

What I do like about this approach however is that it's so easy to tweak. If it turns out that the cost of dispatching off from the cooperative pool is higher than just using the default executor, it's super easy to revert the behavior by just removing the custom executor declarations. I'll likely benchmark this further and tweak as needed once I've integrated.