Do Tasks scheduled from an actor-isolated function start in the order in which they were scheduled? Based on numerous articles on non-deterministic Task execution (e.g. Is Order of Task Execution Deterministic), I started out build some sort of Sequencer that would guarantee in-order delivery of calls from UI code running on MainActor types to an actor-based stateful reporting adapter.
However, as I was trying to TDD my sequencer, I discovered that simply making my test subject a @MainActor made the sequencing problems go away. What am i missing?
Here is my code:
// Models an actor that receives UI events from the main thread and requires in-order arrival
actor SequenceChecker {
var expectedNext: Int = 0
func receive(_ value: Int) {
#expect(value == expectedNext)
expectedNext += 1
}
}
// Models a simple UI component that sends events to an actor for processing.
// Each event is sent on a separate Task, which according to all the articles in the world,
// should result in out-of-order execution.
@MainActor
class SequenceSource {
let sink = SequenceChecker()
init() {}
func send(quantity: Int) async {
let range = 0..<quantity
var tasks: [Task<Void, Never>] = []
for i in range {
let t = Task {
await sink.receive(i)
}
tasks.append(t)
}
await Task {
for task in tasks {
// Wait for all tasks to complete
_ = await task.value
}
}.value
}
}
// Simple test suite that exercises the behavior
@Suite("Sequencing")
struct SequencingSuite {
@Test
func sequencerTest() async throws {
let source = await SequenceSource()
await source.send(quantity: 1000)
}
}
As expected, if I comment out the @MainActor annotation, the expectation in SequenceChecker fails about 30% of the time. Here's the surprising puzzle: if SequenceSource is @MainActor, then the test passes just fine. I have run hundreds of thousands of iterations with zero failures.
Has something changed recently in how Swift handles this? Are the articles about non-deterministic execution outdated? What am I missing?
Yes, I see the same behavior as you on the main actor.
You note that if you remove the main actor qualifier, it starts to fail. It also fails if you use a normal actor, too. So it will also fail if you replace β¦
@MainActor
class SequenceSource {β¦}
β¦ with:
actor SequenceSource {β¦}
So you say that βactors are unexpectedly deterministic.β It might be more accurate to hypothesize that it is only the main actor that is deterministic, but actors in general are not.
This is not new behavior, it comes from the fact that Task{} inherits isolation from the enclosing actor context -- so in this case, tasks become MainActor isolated and are enqueued on it and get to execute in order. Because there's no other task escalation happening here either, they do end up executing in the expected order.
The difference between @MainActor and actor here is unfortunately a bit confusing but well defined: you must explicitly close over the isolated self of an actor for the closure and therefore Task to be isolated to that actor. You don't have to do this explicit closing over with global actors.
The sample would work with actor if you did:
Task {
_ = self
}
and exhibit the same semantics as the global actor code.
I get the same deterministic behavior with both @MainActor class and just actor. To @ktoso's point about explicitly closing over self - is it sufficient to reference self implicitly? In this case the closure references self.sink:
Task {
await sink.receive(i)
}
From my observation, it appears that for both local and global actors (including MainActor), the task always gets enqueued immediately, and thus implicitly in the order in which the tasks are created.
Wow, ok! Apparently all the angst out there about deterministic Task enqueuing only applies to nonisolated code?
I have changed the Topic title accordingly (from "Tasks from Actors are Unexpectedly Deterministic" to "Tasks Created in an Actor-Isolated Context are Enqueued Deterministically")
It's a bit brittle so sometimes things can get wrong.
Like code doing let other: Other; Task { other.hello(1) }; Task { other.hello(2) }, you don't have any enqueue order with those.
Task.immediate new in 6.2 helps a little bit with that as well, but there is no way to do explicit closure isolation control with instance actors, as pitched here Closure isolation control but not yet implemented.
one caveat β aliasing isolated parameters via local variables seems to currently be an exception to this, which is quite confusing IMO (and i think i've finally located Swift Evolution-based evidence that it is a bug).
'all the angst out there' is perhaps rooted in the fact that until the Swift 6.0 compiler (released ~8 months ago), the Task initializer would have to first switch to the concurrent executor before scheduling its operation on the 'right' executor. this required a new language feature (@isolated(any)) to address. so Tasks wouldn't necessarily run in the order in which they were declared even if their work had the same isolation. if for whatever reason you find yourself still compiling code with an older compiler, things will (i think) still work that way.