On atomicity and tearing
Brian Goetz
brian.goetz at oracle.com
Wed Apr 26 18:13:45 UTC 2023
There has been, not surprisingly, a lot of misunderstanding about
atomicity, non-atomicity, and tearing. In particular, various syntactic
expressions of non-atomicity (e.g., a `non-atomic` class keyword) tend
to confuse users into thinking that non-atomic access is somehow a
*feature*, rather than providing more precise control over the breakage
modes of already-broken programs (to steer optimizations for non-broken
programs.)
I've written the following as an attempt to help people understand the
role of atomicity and tearing in the model; comments are welcome (though
let's steer clear of trying to paint the bikeshed in this thread.)
# Understanding non-atomicity and tearing
Almost since the beginning of Project Valhalla, the design has included some
form of "non-atomicity" or "tearability". Addressing this in the
programming
model is necessary if we are to achieve the heap flattening that
Valhalla wants
to deliver, but unfortunately this aspect of the feature set is frequently
misunderstood.
Whether non-atomicity is expressed syntactically as a class modifier,
constructor modifier, supertype, or some other means, the concept is the
same: a
class indicates its willingness to give up certain guarantees in order
to gain
additional heap flattening.
Unlike most language features, which express either the presence or
absence of
things that are at some level "normal" (e.g., the presence or absence of
`final`
means a class either can be assigned to, or cannot), non-atomicity is
different;
it is about what the possible observable effects are when an instance of
this
class is accessed with a data race. Programs with data races are _already
broken_, so rather than opting into or out of a feature, non-atomicity is
expressing a choice between "breakage mode A" and "breakage mode B".
> Non-atomicity is best thought of not as a _feature_ or the absence
thereof,
> but an alternate choice about the runtime-visible behavior of
_already-broken
> programs_.
## Background: flattening and tearing in built-in primitives
Flattening and non-atomicity have been with us since Java 1.0. The eight
built-in primitive types are routinely flattened into object layouts and
arrays.
This "direct" storage results from several design choices made about
primitives:
primitive types are non-nullable, and their zero values represent explicitly
"good" default values and therefore even "uninitialized" primitives have
useful
initial values.
Further, the two 64-bit primitive types (`long` and `double`) are explicitly
permitted to _tear_ when accessed via a data race, as if they are read and
written using two 32-bit loads and stores. When a mutable `long` or
`double` is
read with a data race, it may be seen to have the high-order 32 bits of one
previous write and the low-order 32 bits of another. This is because at the
time, atomic 64-bit loads and stores were prohibitively expensive on most
processors, so we faced a tradeoff: punish well-behaved programs with
poorly-performing numerics, or allow already-broken programs (concurrent
programs with insufficient synchronization) to be seen to produce broken
numeric
results.
In most similar situations, Java would have come down on the side of
predictability and correctness. However, numeric performance was important
enough, and data races enough of an "all bets are off" sort of thing,
that this
set of decisions was a pragmatic compromise. While tearing sounds
scary, it is
important to reiterate that tearing only happens when programs _are already
broken_, and that even if we outlawed tearing, _something else bad_
would still
happen.
Valhalla takes these implicit characteristics of primitives and
formalizes them
to explicit characteristics of value classes in the programming model,
enabling
user-defined classes to gain the runtime characteristics of primitives.
## Data races and consistency
A _data race_ is when a nonfinal heap variable (array element or
nonfinal field)
is accessed by multiple threads, at least once access is a write, and
the reads
and writes of that variable are not ordered by _happens-before_ (see JLS
Ch17 or
_Java Concurrency in Practice_ Ch16.) In the presence of a data race, the
reading thread may see a stale (out of date) value for that variable.
"Stale" doesn't sound so bad, but in a program with multiple variables, the
error states can multiply with the number and configuration of mutable
variables. Suppose we have two `Range` classes:
```
class MutableRange {
int low, high;
// obvious constructor, accessor, and updater methods
// constructor and updater methods validate invariant low <= high
}
class ImmutableRange {
final int low, high;
// obvious constructor and accessors, constructor validates invariant
}
final static MutableRange mr = new MutableRange(0, 10);
static ImmutableRange ir = new ImmutableRange(0, 10);
```
For `mr`, we have a final reference to a mutable point, so there are two
mutable
variables here (`mr.low` and `mr.high`.) We update our range value
through a
method that mutates `low` and/or `high`. By contrast, `ir` is a mutable
reference to an immutable object, with one mutable variable (`ir`), and we
update our range value by creating a new `ImmutableRange` and mutating the
reference `ir` to refer to it.
More things can go wrong when we racily access the mutable range,
because there
are more mutable variables. If Thread A writes `low` and then writes
`high`,
and Thread B reads `low` and `high`; under racy access B could see stale or
up-to-date values for either field, and even if it sees an up-to-date
value for
`high` (the one written later), that still doesn't mean it would see an
up-to-date value for `low`. This means that in addition to seeing
out-of-date
values for either or both, we could observe an instance of
`MutableRange` to not
obey the invariant that is checked by constructors and setters.
Suppose instead we racily access the immutable range. At least there
are fewer
possible error states; a reader might see a stale _reference_ to the
immutable
object. Access to `low` and `high` through that stale reference would see
out-of-date values, but those out-of-date values would at least be
consistent
with each other (because of the initialization safety guarantees of final
fields.)
When primitives other than `long` or `double` are accessed with a data
race, the
failure modes are like that of `ImmutableRange`; when we accept that
`long` or
`double` could tear under race, we are additionally accepting the
failure modes
of `MutableRange` under race for those types as well, as if the high- and
low-order 32-bit quantities were separate fields (in exchange for better
performance). Accepting non-atomicity of large primitives merely
_increases_
the number of observable failure modes for broken programs; even with atomic
access, such programs are still broken and can produce observably incorrect
results.
Note that a `long` or `double` will never tear if it is `final`, `volatile`,
only accessed from a single thread, or accessed concurrently with
appropriate
sychronization. Tearing only happens in the presence of concurrent
access to
mutable variables with insufficient synchronization.
## Non-atomicity and value types
Hardware has improved significantly since Java 1.0, so the specific tradeoff
faced by the Java designers regarding `long` and `double` is no longer
an issue,
as most processors have fast atomic 64-bit load and store operations today.
However, Valhalla will still face the same problem, as value types can
easily
exceed 64 bits in size, and whatever the limit on efficient atomic loads and
stores is, we can easily write value types that will exceed that size. This
leaves us with three choices:
- Never allow tearing of values, as with `int`;
- Always allow tearing of values under race, as with `long`;
- Allow tearing of values under race based on some sort of opt-in or
opt-out.
Note that tearing is not anything anyone ever _wants_, but it is
sometimes an
acceptable tradeoff to get more flattening. It was a sensible tradeoff for
`long` and `double` in 1995, and will continue to be a sensible tradeoff
for at
least some value types going forward.
The first choice -- values are always atomic -- offers the most safety, but
means we must forgo one of the primary goals of Valhalla for all but the
smallest value types.
This leaves us with "values are always like `long`", or "values can opt
into /
out of being like `long`." Types like `long` have the interesting
property that
all bit patterns correspond to valid values; there are no representational
invariants for `long`. On the other hand, values are classes, and can have
representation invariants that are enforced by the constructor. Having
representational invariants for immutable classes be seen to not hold
would be a
significant and novel new failure mode, and so we took the safe route,
requiring
class authors to make the tradeoff between flattening and failure modes
under
race.
Just as with `long` and `double`, a value will never tear if the
variable that
holds the value is `final`, `volatile`, only accessed from a single
thread, or
accessed concurrently with appropriate sychronization. Tearing only
happens in
the presence of concurrent access to mutable variables with insufficient
synchronization.
Further, tearing under race will only happen for non-nullable variables
of value
types that support default instances.
What remains is to offer sensible advice to authors of value classes as
to when
to opt into non-atomicity. If a class has any cross-field invariants
(such as
`ImmutableRange`), atomicity should definitely be retained. In the
remaining
cases, class authors (like the creators of `long` or `double`) must make a
tradeoff about the perceived value of atomicity vs flattening for the
expected
range of users of the class.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/valhalla-spec-experts/attachments/20230426/0d5e6072/attachment-0001.htm>
More information about the valhalla-spec-experts
mailing list