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.
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.
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.