So far, we’ve been looking into actors, global actors, and standard available actors like @MainActor. There’s another part to look into: custom actor executors.
By default, actors in Swift are isolated using a standard executor—typically a cooperative thread pool managed by the system. But sometimes, you need more control.
When to use a custom actor executor
Before we dive into the specifics of defining a custom actor executor, it’s good to know when you should consider using one. By default, actors run their tasks on a shared global thread pool. This pool doesn’t stick to a specific thread or queue, so an actor’s tasks can hop between different threads as they run.
There are situations where you want more control over how an actor runs its tasks. For example, if an actor performs heavy, blocking work—like I/O—you probably don’t want it clogging up the global pool and slowing down other actors. Or maybe you’re handling low-latency tasks, want to stick to a specific serial queue, or need to hook into existing thread management in your app. That’s where custom actor executors come in.
Custom executors let you define exactly how an actor runs its tasks. Instead of relying on the system’s default, you can plug in your own scheduling logic—like a specific DispatchQueue, a task queue that prioritizes certain tasks, or even integrate with C++ concurrency systems.
How to use a custom serial actor executor
In this example, we’re going to create a custom serial actor executor by using a serial DispatchQueue.
Note
The contents from this lesson are advanced use cases of Swift Concurrency. I believe it’s exceptional to write a custom executor and this shouldn’t be seen as a default way to go. In general, you wouldn’t need a DispatchQueue when working with Swift Concurrency.
Imagine working with a 3rd party library that requires you to perform work on a certain serial DispatchQueue. You want to prepare your code for the future, so you prefer working with an actor rather than mixing Swift Concurrency with GCD everywhere throughout your code. This prepares your code for the future as you’ll only have to update the actor eventually.
To work with a serial queue, we’re going to define a custom SerialExecutor:
final class DispatchQueueExecutor: SerialExecutor {
private let dispatchQueue: DispatchQueue
init(dispatchQueue: DispatchQueue) {
self.dispatchQueue = dispatchQueue
}
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let unownedExecutor = asUnownedSerialExecutor()
dispatchQueue.async {
unownedJob.runSynchronously(on: unownedExecutor)
}
}
}
The serial executor takes a DispatchQueue as an argument and assumes it’s a serial queue. We could improve this code to only work with serial queues, but for the sake of this example I’m leaving that as a little piece of homework.
We can now use this custom serial executor inside an actor as follows:
actor LoggingActor {
private let executor: DispatchQueueExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
init(dispatchQueue: DispatchQueue) {
executor = DispatchQueueExecutor(dispatchQueue: dispatchQueue)
}
func log(_ message: String) {
print("[\(Thread.current)] \(message)")
}
}
This example actor prints out a log message together with the current thread. This allows us to verify that our serial executor is actually being used:
let dispatchQueue = DispatchQueue(label: "com.logger.queue", qos: .utility)
dispatchQueue.sync {
/// Give the thread a name so we can verify it in our print statements.
Thread.current.name = "Logging Queue"
}
let actor = LoggingActor(dispatchQueue: dispatchQueue)
await actor.log("Example message")
// Prints: [<NSThread: 0x600003ad2940>{number = 2, name = Logging Queue}] Example message
We’ve now created an actor that accepts a DispatchQueue as an argument.
Retaining the executor
It’s important to point out that we’re using:
executor.asUnownedSerialExecutor()
This returns an UnownedSerialExecutor, a lightweight type that’s optimized for Swift’s core scheduling system. It avoids extra reference counting, even when you’re working with actors in a more abstract way. Because of that, there are some extra rules to make things safe—for example, if the executor is a separate object, the actor needs to hold a strong reference to it to make sure it stays alive as long as the actor does. That’s why we store the executor as a property of LoggingActor:
private let executor: DispatchQueueExecutor
Sharing an executor across actors
There could be cases where you want to share the same custom executor across multiple actors. In those cases, you can define a global property for the given executor:
extension DispatchQueueSerialExecutor {
/// An example of a globally available executor to use inside any actor.
static let loggingExecutor = DispatchQueueSerialExecutor(dispatchQueue: DispatchQueue(label: "com.logger.queue", qos: .utility))
}
And use it accordingly inside any actor:
actor SharedExecutorLoggingActor {
nonisolated var unownedExecutor: UnownedSerialExecutor {
DispatchQueueSerialExecutor.loggingExecutor.asUnownedSerialExecutor()
}
func log(_ message: String) {
print("[\(Thread.current)] \(message)")
}
}
This means all SharedExecutorLoggingActor instances share the same serial executor, so only one of them can run at a time. That can be useful if your code needs to run on a specific thread—like when you’re working with non-Swift runtimes that have those kinds of requirements.
Using a custom task executor
By default, if you don’t set a preferred executor, async functions that aren’t isolated—as well as methods on regular actors without a custom executor—will run on Swift’s global concurrent executor. This is the shared thread pool the runtime uses for any tasks that don’t need to run on a specific executor.
However, it could be useful to provide an executor preference for tasks. In other words, to configure an executor for asynchronous functions that aren’t isolated to an actor. We can do this using methods like:
withTaskExecutorPreference(_:operation:)Task(executorPreference:)group.addTask(executorPreference:)
By using a task executor preference, the task and all of its child tasks (unless they configure a preference) will be preferring to execute on the provided TaskExecutor. The latter is a protocol that defines an executor that may be used as preferred executor by a task. Note, that unstructured tasks never inherit the task executor.
Following the earlier example of using a DispatchQueue, we could create the following TaskExecutor:
final class DispatchQueueTaskExecutor: TaskExecutor {
private let dispatchQueue: DispatchQueue
init(dispatchQueue: DispatchQueue) {
self.dispatchQueue = dispatchQueue
}
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let unownedExecutor = asUnownedTaskExecutor()
dispatchQueue.async {
unownedJob.runSynchronously(on: unownedExecutor)
}
}
}
If we would use it using the same dispatch queue, the output would look as follows:
let dispatchQueue = DispatchQueue(label: "com.logger.queue", qos: .utility)
dispatchQueue.sync {
/// Give the thread a name so we can verify it in our print statements.
Thread.current.name = "Logging Queue"
}
let taskExecutor = DispatchQueueTaskExecutor(dispatchQueue: dispatchQueue)
Task(executorPreference: taskExecutor) {
print("[\(Thread.currentThread)] Task Executor example")
// Prints: [<NSThread: 0x60000062c980>{number = 2, name = Logging Queue}] Task Executor example
}
Note that there’s no requirement here to use a serial queue, it’s perfectly fine to use a concurrenct queue. It’s also important to realize we’re configuring a preference and tasks will execute on the given executor when possible.
Combining a TaskExecutor and SerialExecutor
Finally, you can decide to combine both executors into one type. However, you need to ensure you’re using a serial DispatchQueue since the executor might be used as an isolated context by an actor.
The code would look as follows:
final class DispatchQueueTaskSerialExecutor: TaskExecutor, SerialExecutor {
private let dispatchQueue: DispatchQueue
init(dispatchQueue: DispatchQueue) {
self.dispatchQueue = dispatchQueue
}
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
dispatchQueue.async {
unownedJob.runSynchronously(
isolatedTo: self.asUnownedSerialExecutor(),
taskExecutor: self.asUnownedTaskExecutor()
)
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
}
Most of the code looks similar, but we’re using a different runSynchronously method inside the enqueue method. We provide a specific isolation and task executor to conform to both protocol scenarios.
Summary
Custom executors help us write performant escape routes when default executors aren’t working out. They should be an exceptional solution to cases where you’re in need of more precise control. In most cases, however, using the default available executors should be enough.
That’s it for this module, it’s time for the assessment.