Yeah, this is a common and problematic pitfall in the current model. Swift actors are neither FIFO, or do they give you the usual actor operation of "enqueue this thing please", that is the baseline operation in other actor runtimes; Instead, Swift focused on always awaiting calls and allowing boosting of priority of awaited on tasks... In many ways this is very nice and interesting, but there definitely are cases where it is quite problematic.
There are IMHO multiple ways to approach this problem, and we'll need to argue and figure out which to surface (though I'd personally argue that all those are important and worth surfacing):
- custom actor executors, where one could enforce FIFO semantics; (today's Swift actors are not FIFO)
- tighter control over re-entrancy; some actor methods may prefer to opt into lack of reentrancy, in order to make actors more useful and sane for programming critical logic (today's Swift actors are always reentrant at suspension points)
- allow to actually "enqueue" / "send" work to an actor, without awaiting on it; (today it is impossible to "not wait" on async calls, forcing us to create new
Task {}
in order to access actors, and thus hit the scheduling problem you noticed).- even if we changed the
Task{}
enqueue semantics, this would still be "scary" ordering wise, because being able toawait
ontask.value
means it could get reordered again, due to priority boosting (and the non-FIFO nature of default actors).
- even if we changed the
Your example specifically, hits the third point.
Note: This isn't a promise of a direction, but a personal opinion, based on previous work using actors I did, as well as using them in Swift a lot ever since we shipped them. Actual solutions we end up with might differ.
For sake of this discussion, let us how how a "send" can be easily simulated in today's runtime and how it does guarantee what you are looking for in this test.
Specifically, an execution of your test using Task{}
might look like this:
// Task
Task{}: 1
Task{}: 2
...
Task{}:100
run:1
run:2
...
run:13
run:15
: Precondition failed: counter:14 != iteration:15
but if we allowed for a send
/ "enqueue" like operation, we'd always get the ordering you expected:
actor DeterministicOrderThanksToSend {
var counter = 0
func test_mainActor_taskOrdering() async {
var tasks = [(Int, Task<Void, Never>)]()
for iteration in 1...100 {
fputs("send:\(iteration)\n", stderr)
self.send {
fputs("run:\(iteration)\n", stderr)
self.counter += 1
precondition(counter == iteration, "counter:\(counter) != iteration:\(iteration)") // often fails
}
}
while self.counter < 100 {
try? await Task.sleep(until: .now.advanced(by: .milliseconds(100)), clock: .continuous)
}
}
}
We always reliably get the right result:
// send
send: 1
send: 2
...
send:100
run:1
run:2
...
run:99
run:100
So conceptually, this is implementable right now, today. But we need to have a wider discussion how we want to approach this problem at large, with the concurrency team.
@John_McCall was just replying to another thread here Swift project focus areas in 2023 - #11 by John_McCall about how that guarantee is pretty weak and may not be enough across multiple "hops", but personally I disagree that the guarantee is too weak to be useful. It is the usual way a lot of actor code is built, and it would also tremendously help bridging non-async and async worlds of actors. Very frequently we just need this ordering between a pair of "streams" or otherwise happens-before related pieces of code: make sure the "init" is enqueued before the "done", both done from a synchronous context, which we have an incredibly hard time getting right nowadays (examples include streams, task cancellation handlers, non-async code).
So a send IMHO would be very useful; it remains to be seen how we'll solve these issues in Swift though.
A form of this I'm personally truly wishing for, because it'd help in many other places (including task cancellation handlers as well as interop with streams, and because it is the important uni-directional "I don't need a reply" concept in networked (or IPC) actor systems) is something like this:
actor DeterministicOrderThanksToSend {
var counter = 0
func test_mainActor_taskOrdering() async {
for iteration in 1...100 {
send increment(iteration: iteration)
// guaranteed enqueue order (1>2>3>...>100)
// can't await result though, not to risk reordering by escalation
// can be used from non-async code as well
}
while self.counter < 100 {
try? await Task.sleep(until: .now.advanced(by: .milliseconds(100)), clock: .continuous)
}
}
func increment(iteration: Int) async {
// yes, this method is async, but we don't await it
fputs("run:\(iteration)\n", stderr)
self.counter += 1
precondition(counter == iteration, "counter:\(counter) != iteration:\(iteration)") // often fails
}
}
So... not much of an immediate solution and answer for your problem, but it was yet another case showcasing the need of more primitives, and since I'm on a day off I figured might as well spend the time to write it up -- hope this was interesting and I hope we'll get to discussing such semantics in depth in the future with the concurrency team