Atomic pointers in c++ and passing objects between threads
Asked Answered
S

2

15

My question involves std::atomic<T*> and the data that this pointer points to. If in thread 1 I have

Object A;
std:atomic<Object*> ptr;
int bar = 2;
A.foo = 4;  //foo is an int;
ptr.store(*A);

and if in thread 2 I observe that ptr points to A, can I be guaranteed that ptr->foo is 4 and bar is 2?

Does the default memory model for the atomic pointer (sequentially consistent) guarantee that assignments on non-atomic (in this case A.foo) that happen before an atomic store will be seen by other threads before it sees the assignment of the same atomic.store for both cases?

If it helps or matters, I am using x64 (and I only care about this platform), gcc (with a version that supports atomics).

Sining answered 6/11, 2014 at 18:45 Comment(2)
I think you mean ptr.store(&A)? ptr.store(*A) makes no sense (unless Object defines Object * Object::operator*();...).Gape
Related: C++11 introduced a standardized memory model. What does it mean? And how is it going to affect C++ programming?Saurischian
C
22

The answer is yes and perhaps no

The memory model principles:

C++11 atomics use by default the std::memory_order_seq_cst memory ordering, which means that operations are sequentially consistent.

The semantics of this is that ordering of all operations are as if all these operations were performed sequentially :

  • C++ standard section 29.3/3 explains how this works for atomics: "There shall be a single total order S on all memory_order_seq_cst operations, consistent with the “happens before” order and modification orders for all affected locations, such that each memory_order_seq_cst operation that loads a value observes either the last preceding modification according to this order S, or the result of an operation that is not memory_order_seq_cst."

  • The section 1.10/5 explains how this impacts also non-atomics: "The library defines a number of atomic operations (...) that are specially identified as synchronization operations. These operations play a special role in making assignments in one thread visible to another."

The answer to your question is yes !

Risk with non-atomic data

You shall however be aware that in reality the consistency guarantee is more limited for the non-atomic values.

Suppose a first execution scenario:

(thread 1) A.foo = 10; 
(thread 1) A.foo = 4;     //stores an int
(thread 1) ptr.store(&A); //ptr is set AND synchronisation 
(thread 2) int i = *ptr;  //ptr value is safely accessed (still &A) AND synchronisation

Here, i is 4. Because ptr is atomic, thread (2) safely gets the value &A when it reads the pointer. The memory ordering ensures that all assignments made BEFORE ptr are seen by the other threads ("happens before" constraint).

But suppose a second execution scenario:

(thread 1) A.foo = 4;     //stores an int
(thread 1) ptr.store(&A); //ptr is set AND synchronisation 
(thread 1) A.foo = 8;     // stores int but NO SYNCHRONISATION !! 
(thread 2) int i = *ptr;  //ptr value is safely accessed (still &A) AND synchronisation

Here the result is undefined. It could be 4 because of the memory ordering guaranteed that what happens before the ptr assignement is seen by the other threads. But nothing prevents assignments made afterwards to be seen as well. So it could be 8.

If you would have had *ptr = 8; instead of A.foo=8; then you would have certainty again: i would be 8.

You can verify this experimentally with this for example:

void f1() {  // to be launched in a thread
    secret = 50; 
    ptr = &secret; 
    secret = 777; 
    this_thread::yield();
}
void f2() { // to be launched in a second thread
    this_thread::sleep_for(chrono::seconds(2));
    int i = *ptr; 
    cout << "Value is " << i << endl;
}

Conclusions

To conclude, the answer to your question is yes, but only if no other change to the non atomic data happens after the synchronisation. The main risk is that only ptr is atomic. But this does not apply to the values pointed to.

To be noted that especially pointers bring further synchronisation risk when you reassign the atomic pointer to a non atomic pointer.

Example:

// Thread (1): 
std::atomic<Object*> ptr;
A.foo = 4;  //foo is an int;
ptr.store(*A);

// Thread (2): 
Object *x; 
x=ptr;      // ptr is atomic but x not !  
terrible_function(ptr);   // ptr is atomic, but the pointer argument for the function is not ! 
Coltoncoltsfoot answered 6/11, 2014 at 20:53 Comment(8)
I think, there is an ':' missing in line 1Interoffice
Object *x = ptr; is safe, it's doing an atomic load the same as ptr.load() to get a plain Object* (the T in std::atomic<T>). Deref of the resulting x pointer is exactly the same as your *ptr, which is equivalent to *(ptr.load()). (std::atomic defines conversion operator to its value type as equivalent to .load().) I think you're calling it unsafe because you're thinking it works like reinterpret_cast<Object**>( &atomic_ptr ) or something, which would do a non-atomic access to the shared memory if you dereferenced it. But that's not the case.Swampy
In your "second scenario" where the writer thread does another store to the payload after updating the pointer, it's not just the result that's undefined. The behaviour is undefined, of the whole program, because that's data-race UB. In practice on real CPUs, yes it's extremely likely that you'll just get either 4 or 8, but unsynchronized write+read is UB in C++.Swampy
BTW, in your last code block, ptr.store(*A); is supposed to be ptr.store(&A); - the code in the question got that wrong but you didn't comment on it.Swampy
Also, the other way for this to break is if the reader did ptr.load(std::memory_order_relaxed). Which in theory doesn't guarantee ordering since that's even weaker than memory_order_consume, but in practice the data dependency will create ordering except on DEC Alpha. Or if the compiler was able to prove that the only possible value Object *x could have was &A, and optimized away the data dependency. e.g. if you did if(x == &A){ return x->foo; } it could optimize to A.foo.Swampy
@PeterCordes can you please provide the reference to the C++ standard that guarantees that Object *x = ptr; is atomic ?Coltoncoltsfoot
std::atomic::operator T() is "equivalent to load()". en.cppreference.com/w/cpp/atomic/atomic/operator_T . Dereferencing the plain pointer you get from the load is not atomic, just like it's not when you do it as part of the same expression like *ptr or *ptr.load(). The atomic object holds a pointer, accesses to that pointer value are atomic. Deref of the resulting pointer to get to the final Object are not atomic.Swampy
That's why it's data-race UB for the writer to do A.foo = 8 after publishing a pointer to that non-atomic object, not just an uncertain result like if you had std::atomic<Object> A.Swampy
T
7

By default, C++-11 atomic operations have acquire/release semantics.

So a thread that see's your store will also see all operations performed before it.

You can find some more details here.

Thrombocyte answered 6/11, 2014 at 18:49 Comment(1)
+1. (Actually the default is std::memory_order_seq_cst, which has even stronger guarantees than acquire/release, but indeed that's all that would be required here anyway.)Bone

© 2022 - 2024 — McMap. All rights reserved.