0

I wrote some multithreaded but lock-free code that compiled and apparently executed fine on an earlier C++11-supporting GCC (7 or older). The atomic fields were ints and so on. To the best of my recollection, I used normal C/C++ operations to operate on them (a=1;, etc.) in places where atomicity or event ordering wasn't a concern.

Later I had to do some double-width CAS operations, and made a little struct with a pointer and counter as is common. I tried doing the same normal C/C++ operations, and errors came that the variable had no such members. (Which is what you'd expect from most normal templates, but I half-expected atomic to work differently, in part because normal assignments to and from were supported, to the best of my recollection, for ints.).

So two part question:

  1. Should we use the atomic methods in all cases, even (say) initialization done by one thread with no race conditions? 1a) so once declared atomic there's no way to access unatomically? 1b) we also have to use the verboser verbosity of the atomic<> methods to do so?

  2. Otherwise, if for integer types at least, we can use normal C/C++ operations. But in this case will those operations be the same as load()/store() or are they merely normal assignments?

And a semi-meta question: is there any insight as to why normal C/C++ operations aren't supported on atomic<> variables? I'm not sure if the C++11 language as spec'd has the power to write code that does that, but the spec can certainly require the compiler to do things the language as spec'd isn't powerful enough to do.

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
Swiss Frank
  • 1,985
  • 15
  • 33
  • 1
    Provide some [MRE] in your question. Read [more about C++](https://en.cppreference.com/w/cpp) then the C++11 standard [n3337](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3337.pdf) – Basile Starynkevitch May 21 '20 at 05:22

1 Answers1

6

You're maybe looking for C++20 std::atomic_ref<T> to give you the ability to do atomic ops on objects that can also be accessed non-atomically. Make sure your non-atomic T object is declared with sufficient alignment for atomic<T>. e.g.

alignas(std::atomic_ref<long long>::required_alignment)
   long long  sometimes_shared_var;

But that requires C++20, and nothing equivalent is available in C++17 or earlier. Once an atomic object is constructed, I don't think there's any guaranteed portable safe way to modify it other than its atomic member functions.

Its internal object representation isn't guaranteed by the standard so memcpy to get the struct sixteenbyte object out of an atomic<sixteenbyte> efficiently isn't guaranteed by the standard to be safe even if no other thread has a reference to it. You'd have to know how a specific implementation stores it. Checking sizeof(atomic<T>) == sizeof(T) is a good sign, though, and mainstream implementations do in practice just have a T as the object-representation for atomic<T>.

Related: How can I implement ABA counter with c++11 CAS? for a nasty union hack ("safe" in GNU C++) to give efficient access to a single member, because compilers don't optimize foo.load().ptr to just atomically load that member. Instead GCC and clang will lock cmpxchg16b to load the whole pointer+counter pair, then just the first member. C++20 atomic_ref<> should solve that.


Accessing members of atomic<struct foo>: one reason for not allowing shared.x = tmp; is that it's the wrong mental model. If two different threads are storing to different members of the same struct, how does the language define any ordering for what other threads see? Plus it was probably considered too easy for programmer to design their lockless algorithms incorrectly if stuff like that were allowed.

Also, how would you even implement that? Return an lvalue-reference? It can't be to the underlying non-atomic object. And what if the code captures that reference and keeps using it long after calling some function that's not load or store?

Remember that ISO C++'s ordering model works in terms of synchronizes-with, not in terms of local reordering and a single cache-coherent domain like the way real ISAs define their memory models. The ISO C++ model is always strictly in terms of reading, writing, or RMWing the entire atomic object. So a load of the object can always sync-with any store of the whole object.

In hardware that would actually still work for a store to one member and a load from a different member if the whole object is in one cache line, on real-world ISAs. At least I think so, although possibly not on some SMT systems. (Being in one cache line is necessary for lock-free atomic access to the whole object to be possible on most ISAs.)


we also have to use the verboser verbosity of the atomic<> methods to do so?

The member functions of atomic<T> include overloads of all the operators, including operator= (store) and cast back to T (load). a = 1; is equivalent to a.store(1, std::memory_order_seq_cst) for atomic<int> a; and is the slowest way to set a new value.

Should we use the atomic methods in all cases, even (say) initialization done by one thread with no race conditions?

You don't have any choice, other than passing args to the constructors of std::atomic<T> objects.

You can use mo_relaxed loads / stores while your object is still thread-private, though. Avoid any RMW operators like +=. e.g. a.store(a.load(relaxed) + 1, relaxed); will compile about the same as for non-atomic objects of register-width or smaller.

(Except that it can't optimize away and keep the value in a register, so use local temporaries instead of actually updating the atomic object).

But for atomic objects too large to be lock-free, there's not really anything you can do efficiently except construct them with the right values in the first place.


The atomic fields were ints and so on. ...
and apparently executed fine

If you mean plain int, not atomic<int> then it wasn't portably safe.

Data-race UB doesn't guarantee visible breakage, the nasty thing with undefined behaviour is that happening to work in your test case is one of the things that's allowed to happen.

And in many cases with pure load or pure store, it won't break, especially on strongly ordered x86, unless the load or store can hoist or sink out of a loop. Why is integer assignment on a naturally aligned variable atomic on x86?. It'll eventually bite you when a compiler manages to do cross-file inlining and reorder some operations at compile time, though.


why normal C/C++ operations aren't supported on atomic<> variables?
... but the spec can certainly require the compiler to do things the language as spec'd isn't powerful enough to do.

This in fact was a limitation of C++11 through 17. Most compilers have no problem with it. For example implementation of the <atomic> header for gcc/clang's uses __atomic_ builtins which take a plain T* pointer.

The C++20 proposal for atomic_ref is p0019, which cites as motivation:

An object could be heavily used non-atomically in well-defined phases of an application. Forcing such objects to be exclusively atomic would incur an unnecessary performance penalty.

3.2. Atomic Operations on Members of a Very Large Array

High-performance computing (HPC) applications use very large arrays. Computations with these arrays typically have distinct phases that allocate and initialize members of the array, update members of the array, and read members of the array. Parallel algorithms for initialization (e.g., zero fill) have non-conflicting access when assigning member values. Parallel algorithms for updates have conflicting access to members which must be guarded by atomic operations. Parallel algorithms with read-only access require best-performing streaming read access, random read access, vectorization, or other guaranteed non-conflicting HPC pattern.

All of these things are a problem with std::atomic<>, confirming your suspicion that this is a problem for C++11.

Instead of introducing a way to do non-atomic access to std::atomic<T>, they introduced a way to do atomic access to a T object. One problem with this is that atomic<T> might need more alignment than a T would get by default, so be careful.

Unlike with giving atomic access to members of T, you could plausible have a .non_atomic() member function that returned an lvalue reference to the underlying object.

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
  • Epic as usual!! – Swiss Frank May 24 '20 at 09:22
  • _one reason for not allowing shared.x = tmp; is that it's the wrong mental model. If two different threads are storing to different members of the same struct, how does the language define any ordering for what other threads see?_ You answer this question later: _An object could be heavily used non-atomically in well-defined phases of an application. Forcing such objects to be exclusively atomic would incur an unnecessary performance penalty._ – Swiss Frank May 24 '20 at 09:23
  • @SwissFrank: No, those two cases are different. I'm talking about having two threads writing `shared.x` and `shared.y`, then reasoning about what a reader would see if it loaded the whole `shared` object *atomically*. All of these operations need to be `atomic` for it to be safe so it's unrelated to the use-cases for `atomic_ref`. – Peter Cordes May 24 '20 at 09:43
  • OK, in that case, the answer to your question is that the program has well-defined periods in which multiple threads will not be accessing the data structure in question from multiple threads, so if there was a way to avoid the verbosity (and performance overhead) of accessing the entire 16 byte structure via load and store atomically, and instead just refer to struct members with normal C/C++ syntax, I would do so at times. – Swiss Frank May 25 '20 at 00:41
  • @SwissFrank: I know that's your overall goal; that's what this answer is about. But you asked in your question about accessing members of `T` through `atomic`, which would imply *atomic* access. Or was that part of your question still based on the misunderstanding that `std::atomic::operator=()` did non-atomic assignment? As I already established, `atomic` doesn't allow lvalue-references to the whole or any part of the `T` object to escape, otherwise code using it could hold onto that reference indefinitely = bad.. Exposing members via wrapper funcs would require introspection. – Peter Cordes May 25 '20 at 00:44
  • 1
    @SwissFrank: The only way to do what you want is by having non-atomic `T` and only doing atomic ops on it via `atomic_ref`. The rest of my answer is just explaining the design decisions for `std::atomic`. – Peter Cordes May 25 '20 at 00:47