This code is not thread-safe.
You have multiple detached tasks incrementing the same non-synchronized mutable object.
If you do this in an Xcode project it will likely warn you. If not, turn on “Strict Concurrency Checking”, and it will definitely warn you.

If you do this, it will warn you that Counter
is not Sendable
(i.e., it is not thread-safe). Note, this is an “opt in” feature right now, but reportedly Swift 6 is likely to make this a hard error.
Capture of 'counter' with non-sendable type 'Counter' in a @Sendable
closure
Alternatively, you can turn on TSAN (and do more iterations), and it can warn you of the misuse.
But, either way, this code simply is not thread-safe but is simply not performing enough iterations to manifest the problem, and you should hesitate to draw any conclusions from it.
If you want to manifest the problems arising from the lack of thread-safety here, bump the number of iterations up and try again. For example, when I did 10m iterations in a macOS target, the resulting count was 9,927,088 (!).
func experiment() async {
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0 ..< 10_000_000 {
group.addTask { counter.increment() }
}
}
print(counter.count) // 9,927,088
}
In this trivial case, the race doesn’t manifest a problem very often. But in more complicated objects, and the data race risk can be greatly magnified. But either way, in the spirit of Apple’s adage, “there is no such thing as a benign race”, just add synchronization and this problem completely disappears.
The order of execution of tasks is not guaranteed.
This is not a GCD queue, where tasks will launch in a FIFO manner. If you create multiple tasks (especially detached tasks, but even non-detached tasks) they will tend to run in the order that you create them, but there is no such formal assurances. But, again, doing it twice is unlikely to manifest the problem. Do it thousands or millions of times, and you are more likely to see this non-FIFO behavior.
So, if you solve the prior data race problem (by making Counter
an actor or adding your own synchronization), the Counter
will now increment appropriately due to its synchronization. But the tasks you launch, themselves, will not necessarily run in that order. (Generally, that is to be expected and is desirable. This is a “concurrency” system and we’re not looking for serial behavior.) But if you are concerned about the order, just appreciate that this is not a FIFO system. You might not manifest this with only a few tasks, but add a few thousands/millions, and they will largely execute in order they were created, but not entirely so.
But if you want to prove that they’re not running in the same order, just printing the count
won’t demonstrate that. Let’s say, for example, that you create three task and the third runs before the other two: The count
will still be incremented from 1, to 2, to 3 (even though the order that the tasks was not the same that you created them).
In short, tasks (even actor-isolated ones) may not execute in the order that they were created. If you want them to run in order, you have to have each await
the prior one or use some AsyncSequence
(e.g., an AsyncStream
or AsyncChannel
).
Consider this example, which compares the count
value to the index of the loop:
actor Counter {
var count: Int = 0
func increment() -> Int {
count += 1
return count
}
}
@MainActor
func experiment() {
let s = Counter()
for i in 1 ... 10_000 { // perhaps even 1_000_000 in release builds on device
Task { [i] in
let result = await s.increment()
if i != result {
print(i, "does not match", result)
}
}
}
}
And, in this case, you will see that they do not all execute in the order that one may have otherwise suspected. The same is true if it were a detached task, but I just wanted to illustrate that the issue is not simply which thread is making the call, but more fundamentally the non-FIFO nature of tasks, detached or otherwise.