Execute Task using Serial Executor

I am trying to execute the submission of tickets in serial order however it prints it in random sequence.

actor TicketSubmission {
    let executor: TicketExecutor
    let queueManager: TicketExectionManager
    
    let queueIdentifier = "com.happy.ticket.queue"
    
    init() {
        self.executor = TicketExecutor(queue: DispatchQueue(label: queueIdentifier))
        self.queueManager = TicketExectionManager(ticketExecutor: executor)
    }
    
    func submitTicket(ticketId: String) async {
        await queueManager.execute {
            print("Ticket id \(ticketId)")
        }
    }
}

final class TicketExecutor: SerialExecutor {
    private let queue: DispatchQueue
    
    init(queue: DispatchQueue) {
        self.queue = queue
    }
    
    func asUnownedSerialExecutor() -> UnownedSerialExecutor {
        UnownedSerialExecutor(ordinary: self)
    }
    
    func enqueue(_ job: consuming ExecutorJob) {
        let unownedJob = UnownedJob(job)
        let unownedExecutor = asUnownedSerialExecutor()
        queue.async {
            unownedJob.runSynchronously(on: unownedExecutor)
        }
        
    }
}

actor TicketExectionManager {
    private let ticketExecutor: TicketExecutor
    
    init(ticketExecutor: TicketExecutor) {
        self.ticketExecutor = ticketExecutor
    }
    
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        ticketExecutor.asUnownedSerialExecutor()
    }
    
    func execute(action: () async -> Void ) async {
        await action()
    }
}


let ticketSubmission = TicketSubmission()

print("Start")
Task {
    await ticketSubmission.submitTicket(ticketId: "1")
}

Task {
    await ticketSubmission.submitTicket(ticketId: "2")
}

Task {
    await ticketSubmission.submitTicket(ticketId: "3")
}

print("End")

It should print in order 1 , 2 and 3.

1 Like

I think you have been misled by "serial" part here, which in fact refers to the serial execution in the way that only one job is executed at the time, not the order guarantee.

2 Likes

Yes, as @vns said. Serial doesn't mean FIFO, it just means "not concurrent".

For this specific pattern you're waiting on Closure isolation control to happen.

1 Like

If you add some log before await action() you will see it is FIFO on entrance of this method. However, for the system it's unknown whether action closure has the same isolation of TicketExectionManager, so there could be random hopping.

Nowadays, apart from using global actors, you have to rely on @_inheritActorContext to specify the isolation of a closure. Here's an modified version of your example.

actor TicketSubmission {
    let executor: TicketExecutor
    let queueManager: TicketExectionManager

    let queueIdentifier = "com.happy.ticket.queue"

    init() {
        self.executor = TicketExecutor(queue: DispatchQueue(label: queueIdentifier))
        self.queueManager = TicketExectionManager(ticketExecutor: executor)
    }

    // this `isolation` parameter is to make sure the below _inheritActorContext works
    func submitTicket(ticketId: String, _ isolation: isolated TicketExectionManager) async {
        await queueManager.execute(ticketId) {
            // the parameter should also be captured to make _inheritActorContext work
            _ = isolation
            print("Ticket id \(ticketId)")
        }
    }
}

// The same as before
final class TicketExecutor: SerialExecutor {
    private let queue: DispatchQueue

    init(queue: DispatchQueue) {
        self.queue = queue
    }

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

    func enqueue(_ job: consuming ExecutorJob) {
        let unownedJob = UnownedJob(job)
        let unownedExecutor = asUnownedSerialExecutor()
        queue.async {
            unownedJob.runSynchronously(on: unownedExecutor)
        }

    }
}

actor TicketExectionManager {
    private let ticketExecutor: TicketExecutor

    init(ticketExecutor: TicketExecutor) {
        self.ticketExecutor = ticketExecutor
    }

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        ticketExecutor.asUnownedSerialExecutor()
    }

    // utilize _inheritActorContext
    func execute(_ id: String, @_inheritActorContext action: @Sendable () async -> Void ) async {
        print("before execute \(id)")
        await action()
    }
}

// caller:
@MainActor
func testExecutor() async
    Task {
        await ticketSubmission.submitTicket(ticketId: "1", ticketSubmission.queueManager)
    }

    Task {
        await ticketSubmission.submitTicket(ticketId: "2", ticketSubmission.queueManager)
    }

    Task {
        await ticketSubmission.submitTicket(ticketId: "3", ticketSubmission.queueManager)
    }
}

Note this is not a real world solution, because you don't want to pass the internal queueManager around. It's just a demonstration for the importance of closure isolation control.

There’s a discrepancy here.

At SerialExecutor, the documentation says “ Alternatively, you can also use existing serial executor implementations, such as Dispatch’s DispatchSerialQueue or others.”

Then at DispatchSerialQueue, that type is defined as “A dispatch queue that executes tasks serially in first-in, first-out (FIFO) order.”

Based on that wording, OP’s understanding seems reasonable to me.

The issue isn't that the dispatch queue doesn't execute in FIFO order but that:

  • The three Tasks that are started are running concurrently so the submitTicket calls can be happening in any order.
  • The action closure that is run by TicketExectionManager is nonisolated and is run on the global concurrent executor. So the action closures are started in FIFO order but can then execute concurrently.

Just for the sake of thoroughness, I think the issue is also related to higher-impact language like actor (stage fright, frivolity, poverty threat) and executor (morbidity, estate planning, conflict) creeping in where more banal concepts like queue and class were previously favoured.

Perhaps it’s a generational difference in taste but the language’s aesthetics seem increasingly set to stun.

Yes, that might be a bit confusing. That’s why I think all should write once a custom executor for an actor just to see how work is submitted, that helps to grasp a better understanding how everything works

The difference here is that you try to reason at the level of DispatchQueue, while it is only used as a backend for an executor, and only thing important there is serial execution of jobs, because scheduling happens differently.

Thanks this works completely fine, did not have any issues.

I changed the method to private as shown below.
quick question on how does @_inheritActorContext help here? How can we use global actor in such case?

Also regarding the isolation comment do we need to capture it? As without that it worked completely fine? Is there a better way to code the execute block? Do

actor TicketSubmission {
.....

        private func submitTicket(ticketId: String, _ isolation: isolated TicketExectionManager) async {
            await queueManager.execute(ticketId) {
                // the parameter should also be captured to make _inheritActorContext work
                _ = isolation
                print("Ticket id \(ticketId)")
            }
        }
    
    func submitTicket(ticketId: String) async {
        await submitTicket(ticketId: ticketId, self.queueManager)
    }
}

After which I can call like the expected the method call.

Task {
    await ticketSubmission.submitTicket(ticketId: "1")
}

Task {
    await ticketSubmission.submitTicket(ticketId: "2")
}

Task {
    await ticketSubmission.submitTicket(ticketId: "3")
}

@_inheritActorContext is an underscored (internally-used-by-the-swift-compiler) attribute. You can find its goal in swift/docs/ReferenceGuides/UnderscoredAttributes.md at main · swiftlang/swift · GitHub.

The Task.init API utilize this attribute to complete its job.

For this attribute to work, there's some additional subtle requirements. you can search for more info in this forum, and there exist kind-of more "official" discussions in certain documents, like this one: swift-evolution/proposals/0420-inheritance-of-actor-isolation.md at main · swiftlang/swift-evolution · GitHub.

Document says the use of this attribute should be avoided outside the swift mono repo, so it might not be a good idea to use it.

Do we have any plan to implement Sequential(FIFO) TaskExecutor on later swift version?
I think it can be useful when we run the Task on Test target.