Ordered access to actor executor from nonisolated code

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:

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

1 Like

welcome to the Swift forums!

the cases in which the preference applies (or does not) are outlined in a nice diagram within SE-0417, reproduced below, which is also present in the withTaskExecutorPreference documentation.

Click for chart
[ func / closure ] - /* where should it execute? */
                               |
                     +--------------+          +===========================+
           +-------- | is isolated? | - yes -> | actor has unownedExecutor |
           |         +--------------+          +===========================+
           |                                       |                |      
           |                                      yes               no
           |                                       |                |
           |                                       v                v
           |                  +=======================+    /* task executor preference? */
           |                  | on specified executor |        |                   |
           |                  +=======================+       yes                  no
           |                                                   |                   |
           |                                                   |                   v
           |                                                   |    +==========================+
           |                                                   |    | default (actor) executor |
           |                                                   v    +==========================+
           v                                   +==============================+
/* task executor preference? */ ---- yes ----> | on Task's preferred executor |
           |                                   +==============================+
           no
           |
           v
  +===============================+
  | on global concurrent executor |
  +===============================+

per the flow chart, if a Task executor preference is set, it will apply to both nonisolated functions and 'default' actors[1] that run within the Task's operation (assuming no additional executor preferences are subsequently applied).

in your example, since the DemoActor type specifies its own executor, the preference does not actually apply to the calls made to it within the Task. however, the preference does apply to the nonisolated closure passed to the Task initializer. IIUC, since that executor is the same one used by the actor itself, the flow of execution at runtime should be something like:

  1. Task.init called – synchronously enqueues the nonisolated closure on the custom executor specified by the preference parameter
  2. async() method's work parameter called – this is also nonisolated and async, so will run on the preferred executor
  3. compute() called – the runtime will check if the current executor (i.e. the preference) is appropriate for running the actor's instance method. since things happen to 'line up' correctly in this case (the actor's custom executor matches the Task's executor preference), no dynamic suspension should occur and the method can be invoked without any executor switching (i'm not 100% sure this is exactly how it works, but that is roughly my current mental model)

so your approach should have the desired behavior that the order of calls to the nonisolated actor methods will be preserved when the 'real' isolated implementations are subsequently run (assuming no inherent races between those initial calls).

one potentially important point to note though is that with the proposed approach, 'downstream' code running in the Task can end up being serialized when you may not expect it to be. e.g. if some logic in the Task with the serial executor preference used a Task group or async let to try and parallelize work, it would end up being serialized due to the custom executor being a serial queue.

you alluded to it a bit, but i think when it comes to ensuring ordering, something like AsyncStream can be nice to use, since it allows you to bridge from a synchronous to async context and preserve order. it would probably add more boilerplate and complicate the Promise API a bit, but you could also likely implement the logic to iterate over the stream with a single Task rather than the current approach which would potentially spawn many of them.

if the Task-based approach is more appealing, the upcoming Task.immediate API may also be of interest. it will synchronously run its operation in the calling context up to the first suspension point. this will presumably provide a very similar approach for your given use case, but without the need to introduce custom executors.


  1. i.e. those which do not themselves provide a custom executor â†Šī¸Ž

3 Likes

Thank you for the detailed response! It's very helpful.

This documentation is awesome.

Yes, this is also how I'm thinking about what's happening here, and I plan to tinker with this and add some print statements for threads, etc. to see if that's truly what's happening.

This is definitely of interest! Reading through it, it seems like it would fit this particular use case very well, because the only reason I'm using Task here is to have a way to synchronously await onto the actor. Thanks so much for pointing out this API review. I've read some today but definitely planning to read more and try to understand the proposal more.

1 Like