Hi! This is my first time posting on the Swift forumns. I wanted to start with a post on a problem I've had with code which uses Swift concurrency features (like actors), but yet lives in a code base that's mostly non Swift concurrency.
Recently I learned about a new API on Task
that I think might help solve this, so I wanted to put together my thoughts and make sure that from the Swift team's standpoint, this is a reasonable approach for solving this problem.
Problem
Say you have a protocol:
protocol Fetchable {
func purge()
func fetch() -> Promise<Void>
}
You then have a coordinator, which calls these methods:
class Manager {
func fetch<F: Fetchable>(_ f: F) -> Promise<Void> {
f.purge()
return f.fetch()
}
}
In a world where the type that's conforming to Fetchable
is just a plain class
with it's own
thread safety implementation, and using PromiseKit
as the method of providing a future, you get an ordering guarantee that happens where if code in f.purge()
happens synchronously inside of the Fetchable
type, then you can know that it's completed before you are calling f.fetch()
. You might be doing this to ensure that in certain conditions various state inside the Fetchable
is cleaned up before refetching.
The interesting bit here starts when you implement this protocol on a type that is an actor.
actor FetchableActor: Fetchable {
var state: State?
nonisolated func fetch() -> Promise<Void> {
Task {
await fetch()
// Also include any other work to ensure that this can return a promise
}
}
func fetch() async {
state = // work that actually goes and fetches
}
nonisolated func purge() {
Task {
await purge()
}
}
func purge() {
state = nil
}
}
In order to conform to the protocol, we need to provide nonisolated implementations for the methods. However, that means we can't access actor isolated state.
If we implement this naively, in the way shown above, then from the callers perpsective, there is no guarantee that the work inside of the Task
created inside of purge()
actually completes before the work inside of the Task
created in fetch()
. As I understand it, a Task
created this way can (and likely will) start concurrently on any other thread in the thread pool. This means that they can start out of order, and they could drop into the await
on the actor at different times.
For a while I haven't had a good answer to this problem, until I looked more at the APIs available on Task
and noticed this one (really the whole family of APIs with executorPreference specified):
/// Runs the given nonthrowing operation asynchronously
/// as part of a new top-level task on behalf of the current actor.
///
/// This overload allows specifying a preferred ``TaskExecutor`` on which
/// the `operation`, as well as all child tasks created from this task will be
/// executing whenever possible. Refer to ``TaskExecutor`` for a detailed discussion
/// of the effect of task executors on execution semantics of asynchronous code.
/// ..... [details removed for brevity] ...
/// - Parameters:
/// - taskExecutor: the preferred task executor for this task,
/// and any child tasks created by it. Explicitly passing `nil` is
/// interpreted as "no preference".
/// - priority: The priority of the task.
/// Pass `nil` to use the priority from `Task.currentPriority`.
/// - operation: The operation to perform.
/// - SeeAlso: ``withTaskExecutorPreference(_:operation:)``
@discardableResult
public init(executorPreference taskExecutor: consuming (any TaskExecutor)?, priority: TaskPriority? = nil, operation: sending @escaping () async -> Success)
When I experimented with this, it seems to provide the guarantee I was looking for. I wrote a little demo that can show just how easy it is to run into a case where the ordering is not guaranteed, and then I adopted a serial executor as the preference for the task, and the issue seems resolved.
I'm going to show the steps I went through to get this demo working, but I'd specifically like to ask a few pointed questions about this approach:
- Is it actually providing the sort of guarantee I'm looking for? The docs on
TaskExecutor
specify that it "prefers" the executor preference passed in. Under what conditions would it not run on the executor preference? - Is there another approach that could be used to solve this problem? It's a little different than the other problems around providing ordered non-reentrant access into an actor, which might end up being solved through an
AsyncStream
.
Walkthrough of a possible solution
Setup a utility for creating serial task executors
final class SerialTaskExecutor<E: SerialExecutor>: TaskExecutor, Sendable {
let serialExecutor: E
init(serialExecutor: E) {
self.serialExecutor = serialExecutor
}
func enqueue(_ job: consuming ExecutorJob) {
serialExecutor.enqueue(job)
}
func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
}
extension SerialExecutor {
func asTaskExecutor() -> SerialTaskExecutor<Self> {
return SerialTaskExecutor(serialExecutor: self)
}
}
Provide a method on SerialExecutor
that allows it to enqueue an async task
extension SerialExecutor {
func async(_ work: @escaping @Sendable () async -> Void) {
Task(executorPreference: self.asTaskExecutor()) {
await work()
}
}
}
Call enqueue from the nonisolated function calls
actor DemoActor {
let queue = DispatchSerialQueue(label: "InternalQueue")
nonisolated var executor : some SerialExecutor {
queue
}
private var didCompute = false
nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}
func internalDidCompute() {
print("didCompute")
self.didCompute = true
}
func internalDidNotCompute() {
print("didNotCompute")
if !didCompute {
fatalError("Compute didn't get executed first")
} else {
didCompute = false
}
}
nonisolated func compute() {
self.executor.async {
await self.internalDidCompute()
}
}
nonisolated func anotherCompute() {
self.executor.async {
await self.internalDidNotCompute()
}
}
}
@main
struct ConcurrencyDemo {
static func main() {
let actor = DemoActor()
for _ in 0..<100 {
actor.compute()
actor.anotherCompute()
}
dispatchMain()
}
}
Summarizing thoughts
The above solution works in this contrived example, but considering the documentation, and naming that this is a preference, I wonder if it's even a good practice. Are there other better ways to approach this sort of ordering problem that don't involve pushing the async context up the stack to the caller?