“Once” one-time concurrent initialization with an integer
nullprogram.com
nullprogram.com
My minor objection to magic statics is that you don't have control over the size or packing of the control word, and it usually ends up being something awkwardly mid-sized like a size_t where normally you'd want it either as small as possible or cache-line sized.
I like to handle this something like this:
void once(char _Atomic *flag, void (*fn)(void *ctx), void *ctx) {
char st = atomic_load_explicit(flag, memory_order_acquire);
if (st == 0 && atomic_compare_exchange_strong_explicit(flag, &st, 1,
memory_order_acquire,
memory_order_acquire)) {
fn(ctx);
atomic_store_explicit(flag, st=2, memory_order_release);
}
while (st != 2) { st = atomic_load_explicit(flag, memory_order_acquire); }
}
There's a commented version here: https://github.com/mtklein/best/blob/main/once.c get() {
if needsinit_or_wait_if_initting() { // lock (atomics) then check
thing = init_thing()
}
return thing
}
Which is fine because the check always occurs before reads or writes.The classic double-checked lock mistake looks something like this, when following this example:
get() {
// "fast" path: get thing if it exists, because
// the code looks like that means it's done, right?
// no check because checks are slow and we move fast!
if thing != nil { // first check
return thing
}
// slow path: use slow stuff which is slow :(
if needsinit_or_wait_if_initting() { // lock then second check
thing = init_thing()
}
return thing
}
It's that un-guarded see-if-nil-and-return that makes it unsafe, because the assignment is not guaranteed to be "complete" in many cases. There is no explicit relationship between the initialization, assignment, and read, so the compiler can do stuff that doesn't match what the code looks like, e.g. reorder "save the new memory address to `thing`" before "init the thing" (so you temporarily have an uninitialized non-nil object), and at runtime you can get stuff like "only the first half of the memory synced to that CPU core so you got a busted object".But why? Atomic increments and compare-and-swap have the same performance on most hardware, and this is a cold path so it wouldn't matter if they didn't. Using a compare-and-swap you would only need one byte to store your "once" object rather than a whole int.
So even if a CAS never fails it can end up half a dozen cycles slower if this chain matters (and it often does). That aspect fails to be captured by say uops.info which just tries to isolate the effect of the instruction alone.
Of, course, the even more important reason to use unconditional ops on a contended path if you can is that they don't fail: CAS retries will be significant in that case (but this doesn't apply here because "one off cold path").
But after alignment requirements of anything around it, it will likely be the size of the platform's natural int anyway
The paper has in the appendix a description of a possible implementation (which I believe is used by all major compilers).
[1] https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n26...
And C could adopt magic statics without any particular issue.
C and C++ are very similar in all the aspects that count.
This is one of the many great improvements of C++11