The specific kind of race condition the code in the original post exhibits is often called a data race. I’m not aware of any formal semantics for Swift concurrency, so I’m using nomenclature and definitions from other modern languages, which generally have converged on a similar set of specifications for pragmatic reasons I’ll get to. I’ve done a fair amount of work with and defining such specifications in other widely used languages.
Data Races
At a high level a data race is usually defined as something like:
Two non-atomic accesses to the same memory location by two parallel streams of execution, neither of which occurs before the other in a happens-before graph, where at least one of the accesses is a write.
That definition uses a lot of terms of art I’m not going to get into the details of at the moment:
- What is meant by a “memory location” from the point of view of a high-level program is an interesting and fairly deep question, but an Int generally counts as one.
- What counts as a “parallel stream of execution” also varies. Threads are some examples, as are tasks that can run in parallel. Tasks on the same actor are not parallel streams of execution for this definition because the only way to switch from one to the other is for the first one to explicitly yield by, for example, awaiting something.
- What counts as “atomic” varies. In Swift
Atomic<Int>
are atomics. Ordinary Ints are not.
- The happens-before graph is constructed out of all of the synchronization constructs in the language used by a particular execution of a program. Common synchronization constructs that add edges to the graph include release-acquire edges, mutex edges, and synchronization provided by various system libraries. In a nondeterministic language a program typically can have many possible executions, each with its own happens-before graph.
There are many details I'm glossing over here, but that's the high-level view.
Example
The program in the original post is sufficient to demonstrate a data race, at least with the plausible assumption that print
doesn't affect the value of x
, nor does it synchronize accesses to it. The tasks are running in parallel, and a single task can see multiple values of x
without writing to it, yielding to the other tasks, or synchronizing with anything that can change x
.
Motivation
A language could resolve data races, by, for example, defining the language so that all memory accesses done in a program appear to be in some sequentially consistent order. Languages used to do that but it’s problematic on modern computers because it interferes with both compiler optimizations and modern multicore processor memory models. It’s possible for core 1 to perceive writes to x
and y
in one order while core 2 perceives the writes to x
and y
in the other order. In general there is no sequentially consistent order for all such memory operations. We could make one, but then the program would run much slower (we’d need to use sequentially consistent instructions on the target chip architecture), so we typically only do it for sequentially consistent atomics and other such synchronization primitives.
The other place this comes up is in compiler optimizations. We want to define a language’s memory model so that common optimizations are allowed by the language’s semantics. For example, an optimizer might want to turn
var x
…
let a = x
let b = x
into:
var x
…
let a = x
let b = a
This is a valid optimization as long as the compiler can assume that the program is free of data races.
A more useful scenario is turning:
var x
…
while … {
let a = x
…
}
into:
var x
…
let a = x
while … {
…
}
where nothing else in the loop modifies x
, synchronizes with other tasks that can modify x
, or gives up control to let them run, for example, yielding an actor that manages access to x
.
These are valid optimization for programs without data races. Data races can make them invalid, so we usually don't want to allow data races in a language. Some languages, such as C++, just turn them into undefined behavior and the compiler can optimize assuming that they don't happen (which itself sometimes leads to hilarious consequences for the unwary). Swift 6 seems to use a combination of compiler and run-time enforcement to prevent them, at least in code that doesn't escape into unsafe constructs. Java got the semantics badly wrong and had to make significant revisions to the definition of the language to fix them. Even C++ made mistakes in the definition which were found and corrected only recently via formal analysis.