Thanks for writing this up. I've been thinking and playing with related ideas and this gives a clear starting point for us to work off.
On Thu, May 5, 2022 at 3:21 PM Brian Goetz <brian.go...@oracle.com> wrote: > There are lots of other things to discuss here, including a discussion of > what does non-atomic B2 really mean, and whether there are additional risks > that come from tearing _between the null and the fields_. > > So, let's discuss non-atomic B2s. (First, note that atomicity is only > relevant in the heap; on the stack, everything is thread-confined, so there > will be no tearing.) > > If we have: > > non-atomic __b2 class DateTime { > long date; > long time; > } > > then the layout of a B2 (or a B3.ref) is really (long, long, boolean), not > just (long, long), because of the null channel. (We may be able to hide the > null channel elsewhere, but that's an optimization.) > > If two threads racily write (d1, t1) and (d2, t2) to a shared mutable > DateTime, it is possible for an observer to observe (d1, t2) or (d2, t1). > Saying non-atomic says "this is the cost of data races". But additionally, > if we have a race between writing null and (d, t), there is another possible > form of tearing. > > Let's write this out more explicitly. Suppose that T1 writes a non-null > value (d, t, true), and T2 writes null as (0, 0, false). Then it would be > possible to observe (0, 0, true), which means that we would be conceivably > exposing the zero value to the user, even though a B2 class might want to > hide its zero. And to be pedantic, it may also be possible to observe (d, 0, true), (0, t, true). > So, suppose instead that we implemented writing a null as simply storing > false to the synthetic boolean field. Then, in the event of a race between > reader and writer, we could only see values for date and time that were > previously put there by some thread. This satisfies the OOTA (out of thin > air) safety requirements of the JMM. If we only need to write the null channel to make something null, there still may be cases where all fields might be written when writing the null resulting in writing (g1, g2, false) and unless this is outlawed by the spec, it may still generate OOTA values. Where would we do this? Possibly in the interpreter to avoid (another) conditional branch in the putfield logic or as you say next, to null out OOPs. > The other consequence we might have from this sort of tearing is if one of > the other fields is an OOP. If the GC is unaware of the significance of the > null field (and we'd like for the GC to stay unaware of this), then it is > possible to have a null value where one of the oop fields (from a previous > write) is non-null, keeping that object reachable even when it is logically > not reachable. (As an interesting connection, the boolean here is "special" > in the same way as the synthetic boolean channel is in pattern matching -- it > dictates whether the _other_ channels are valid. Which makes nullable values > a good implementation strategy for pattern carriers.) This is important to call out because it's non-obvious - making the GC aware of the null channel is insufficient to make this safe. If the GC is aware of the null channel and doesn't keep the OOPs alive, it is possible for another thread to tear the null channel and the OOP access and read an invalid object pointer. The GC either needs the OOP fields to be nulled - exposing the zeros - or to treat them as a live. > > So we have a choice for how we implement writing nulls, with a > pick-your-poison consequence: > > - If we do a wide write, and write all the fields to zero, we risk exposing > a zero value even when the zero is a bad value; > - If we do a narrow write, and only write the null field, we risk pinning > other OOPs in memory > I'm sure we all remember when it was considered "helping the GC" to explicitly null variables out. Leaking OOP fields of null values will bring that guidance back and "performance sensitive" users will start zeroing the OOP fields before writing null. Yuck. And there will be cliffs in this design that are both implementation and runtime hardware specific where assigning null won't leak because the implementation is an indirection and others where it will leak. That's a hard performance model for users to adapt to, especially as our (implementers) ability to take advantage of non-atomic values grows exposing the leak behaviour in code that will have already been written. (ie: in the period between shipping this feature and the implementations taking advantage of the freedom). So where does that leave us? Working out very careful state diagrams to try to not tear the null bit while still zeroing OOPs? We also need to think about what the memory model says about allowing null checks to be commoned up. My assumption is that any field access is really two accesses: * a read of the null bit * a read of the real field Sometimes we can do that in a single read, sometimes we can't. Pseudo-source code will say things like: class Holder { /*non-atomic nullable value */ DateTime dt; } Holder h = ....; long date = h.dt.d; long time = h.dt.t; And this will be treated as though there are two null checks (just like with references today): bool isNull = h.dt.isNull; if (isNull) throw NPE; long date = h.dt.d; bool isNull = h.dt.isNull; if (isNull) throw NPE; long time = h.dt.t; And it would be nice to be able to common that into a single null check followed by the reads of the field. Is that legal? And how far can we float the read of the null bit from the field access it protects? Likewise, can we float the field read early so that we privatized a whole copy of `dt` and have a consistent view of it, even though it would be reading out of program order? Whichever set of behaviours we pick here, the memory model will need a careful review and update to ensure consistent behaviour across implementations. --Dan