One of the properties I have found absolutely necessary is the ability to add the async work to a queue synchronously . We had this property with dispatch queues, but async functions (in the general case) can't do this.
Me too, which is why I abandoned actor
s as the protection for my SerialAsyncQueue
s internal state. By using an actor
, entering the critical section to add work to the queue requires an await
, which requires an async
context. If you're not already in one, you have to spawn a Task
first, and that messes with the order in which things would get added to the queue. So I switched to Darwin locks as the protectors of the queue state.
However, thinking about this some more, I might have over-emphasized the problem in my mind. Say I'm in a non-async
function and want to schedule two work items, knowing that the first item is scheduled ahead of the second item:
func scheduleSomeWork() {
// I want the first task to run before the second task.
queue.schedule { await Task.sleep(nanoseconds: 500_000); print("First Work Item") }
queue.schedule { await Task.sleep(nanoseconds: 500_000); print("Second Work Item") }
}
If the schedule
call is async
, I'd have to wrap these calls in Task
first:
func scheduleSomeWork() {
// Now it's indeterminate which one is scheduled first
Task { await queue.schedule { await Task.sleep(nanoseconds: 500_000); print("First Work Item") } }
Task { await queue.schedule { await Task.sleep(nanoseconds: 500_000); print("Second Work Item") } }
}
But the "obvious" solution here is to spawn only one Task
:
func scheduleSomeWork() {
Task {
await queue.schedule { await Task.sleep(nanoseconds: 500_000); print("First Work Item") }
await queue.schedule { await Task.sleep(nanoseconds: 500_000); print("Second Work Item") }
}
}
Thinking through this, I might remember why I couldn't get this to work. I wasn't thinking about the signature of schedule
properly. I started with this:
final class SerialAsyncQueue: Sendable {
private actor State {
// Holds the array of scheduled blocks, etc.
}
private let _state = State()
// Await this to wait for the scheduled work to run on the queue and return its result back to you
func schedule<R>(_ work: () async throws -> R) async throws -> R
}
The above code with two await schedule
s in a sequence then means the first item is not submitted until after the first item completes, because await
ing the schedule
waits for the result of the work. The only way to schedule a second block before the first block completes is to wrap each schedule
in an individual Task
, but that also destroys the ordering of the scheduling.
So I refactored to this:
final class SerialAsyncQueue: Sendable {
private struct State {
// Holds the array of scheduled blocks, etc.
}
private let _state: Synchronized<State> // `Synchronized` protects its value with a Darwin read-write lock and is `@unchecked Sendable`
// Await the returned `Task` to wait for the scheduled work to run on the queue and return its result back to you
@discardableResult
func schedule<R>(_ work: () async throws -> R) -> Task<R, any Error>
}
Now, you can schedule and not await
. You get back a Task
you can then await .value
on if you want to wait for the scheduled work to complete, and get its result. If you don't care when it completes you just discard the returned Task
.
But I missed what I'm now thinking I should have done, which didn't occur to me because it involves two levels of asynchrony and results in an interface that first seems awkward, but on closer consideration is what you actually want:
final class SerialAsyncQueue: Sendable {
private actor State {
// Holds the array of scheduled blocks, etc.
}
private let _state = State()
// Await this to wait for the scheduled work to get *scheduled* on the queue. If you want to wait for the work to complete, you must additionally `await` the `Task`.
@discardableResult
func schedule<R>(_ work: () async throws -> R) async -> Task<R, any Error>
}
Here, it's an async
func that returns a Task
. I was originally thinking you'd have to write await
twice to wait for the result, but similar to try
you don't, a line only needs it once. So from a synchronous context, to schedule two tasks where one is only scheduled after the other completes:
Task {
let _ = await queue.schedule { ... }.value
await queue.schedule { ... }
}
But to schedule two tasks immediately but with a guaranteed order:
Task {
await queue.schedule { ... } // No `.value` at the end
await queue.schedule { ... }
}
This will be weird with work that doesn't return anything, because to wait for it to complete you need to await .value
and discard it. This is a more general issue of it being awkward to await
a Task<Void, ...>
(I define an extension called something like waitToComplete()
for this purpose).
So it's a tricky interface, and might fool people into thinking await queue.schedule
(especially for Void
returning work) waits for the work to be executed, when really it's just waiting for it to be scheduled. But that's how you get what you want: a serial queue built full out of Swift concurrency, using an actor
to protect state, that guarantees work is queued in the same order it is submitted.
In fact once you build this, you can build a non-async
flavor of schedule
as an extension that returns a Task
that internally flattens the nesting so you can immediately (synchronously) get back a Task
handle that can be cancelled (it will either cancel
the "real" inner Task
received back from the queue, or if it gets cancelled before this Task
is created it will just skip scheduling it at all). However that might be a bad idea because you can't call that multiple times in a synchronous context and expect strict ordering. You could make an extension that allows submitting an array of blocks and it can guarantee they are scheduled in order.
Now I need to go try refactoring my queue library (I mentioned elsewhere I'm working on getting it ready for publication) to see if this actually works!