std::atomic<int> memory_order_relaxed VS volatile sig_atomic_t in a multithreaded program
Asked Answered
I

1

9

Does volatile sig_atomic_t give any memory order guarantees? E.g. if I need to just load/store an integer is it ok to use?

E.g. here:

volatile sig_atomic_t x = 0;
...
void f() {
  std::thread t([&] {x = 1;});
  while(x != 1) {/*waiting...*/}
  //done!
}

is it correct code? Are there conditions it may not work?

Note: This is a over-simplifed example, i.e. I am not looking for a better solution for the given piece of code. I just want to understand what kind of behaviour I could expect from volatile sig_atomic_t in a multithreaded program according to the C++ standard. Or, if it is a case, understand why behaviour is undefined.

I've found the following statement here:

The library type sig_atomic_t does not provide inter-thread synchronization or memory ordering, only atomicity.

And if I compare it with this definition here:

memory_order_relaxed: Relaxed operation: there are no synchronization or ordering constraints imposed on other reads or writes, only this operation's atomicity is guaranteed

Is it not the same? What does exactly atomicity mean here? Does volatile do anything useful here? What's difference between "does not provide synchronization or memory ordering" and "no synchronization or ordering constraints"?

Intricacy answered 14/6, 2019 at 13:14 Comment(3)
It looks like the only guarantee it gives is that it's signal safe I wouldn't use it for multi-threading. Based on the documentation for std::atomic_signal_fence I'd assume there are no ordering guarantees for anything 'signal' related.Esme
Please check this: std::condition_variable it might fit better to your needs (not sure since you didn't provide a good description).Maskanonge
volatile means "give me the semantic of the underlying CPU and memory system". So the Q is inherently impl dependent (in theory). In practice, yes, it's the same.Cenesthesia
B
10

You are using an object of type sig_atomic_t that is accessed by two threads (with one modifying).
Per the C++11 memory model, this is undefined behavior and the simple solution is to use std::atomic<T>

std::sig_atomic_t and std::atomic<T> are in different leagues.. In portable code, one cannot be replaced by the other and vice versa.

The only property that both share is atomicity (indivisible operations). That means that operations on objects of these types do not have an (observable) intermediate state, but that is as far as the similarities go.

sig_atomic_t has no inter-thread properties. In fact, if an object of this type is accessed (modified) by more than one thread (as in your example code), it is technically undefined behavior (data race); Therefore, inter-thread memory ordering properties are not defined.

what is sig_atomic_t used for?

An object of this type may be used in a signal handler, but only if it is declared volatile. The atomicity and volatile guarantee 2 things:

  • atomicity: A signal handler can asynchronously store a value to the object and anyone reading the same variable (in the same thread) can only observe the before- or after value.
  • volatile: A store cannot be 'optimized away' by the compiler and is therefore visible (in the same thread) at (or after) the point where the signal interrupted the execution.

For example:

volatile sig_atomic_t quit {0};

void sig_handler(int signo)  // called upon arrival of a signal
{
    quit = 1;  // store value
}


void do_work()
{
    while (!quit)  // load value
    {
        ...
    }
}

Although this code is single-threaded, do_work can be interrupted asynchronously by a signal that triggers sig_handler and atomically changes the value of quit. Without volatile, the compiler may 'hoist' the load from quit out of the while loop, making it impossible for do_work to observe a change to quit caused by a signal.

Why can't std::atomic<T> be used as a replacement for std::sig_atomic_t?

Generally speaking, the std::atomic<T> template is a different type because it is designed to be accessed concurrently by multiple threads and provides inter-thread ordering guarantees. Atomicity is not always available at CPU level (especially for larger types T) and therefore the implementation may use an internal lock to emulate atomic behavior. Whether std::atomic<T> uses a lock for a particular type T is available through member function is_lock_free(), or class constant is_always_lock_free (C++17).

The problem with using this type in a signal handler is that the C++ standard does not guarantee that a std::atomic<T> is lock-free for any type T. Only std::atomic_flag has that guarantee, but that is a different type.

Imagine above code where the quit flag is a std::atomic<int> that happens to be not lock-free. There is a chance that when do_work() loads the value, it is interrupted by a signal after acquiring the lock, but before releasing it. The signal triggers sig_handler() which now wants to store a value to quit by taking the same lock, which was already acquired by do_work, oops. This is undefined behavior and possibly causes a dead-lock.
std::sig_atomic_t does not have that problem because it does not use locking. All that is needed is a type that is indivisible at CPU level and on many platforms, it can be as simple as:

typedef int sig_atomic_t;

The bottom line is, use volatile std::sig_atomic_t for signal handlers in a single thread and use std::atomic<T> as a data-race-free type, in a multi threaded environment.

Bruckner answered 14/6, 2019 at 14:28 Comment(7)
In short, volatile sig_atomic_t does not guarantee visibility of a change outside of handling a signal. :-)Heterophyllous
Ok. You are saying that sig_atomic ...may simply not become visible to a load... which is very close to memory_order_relaxed: Relaxed operation: there are no synchronization or ordering constraints imposed on other reads or writes, only this operation's atomicity is guaranteed. As for me, it sounds the same exactly (and yes, let's suppose it's lock_free or atomic_flag, not any atomic<>). So, could I conclude that behaviour is the same then? Or I should not do such assumptions and behaviour is undefined?Intricacy
@Intricacy ...may not become visible to a load... is just an example of how 'undefined behavior' may play out, but I'll remove that phrase because it is confusing. Undefined behavior is all one needs to know.Bruckner
@AndrewHenle atomic has very poor visibility guarantees. And poor semantics. Poor everything.Cenesthesia
Sorry for a silly question, but why "sig_handler ... taking the same lock, which was already acquired by do_work is an oops and UB? Why won't sig_handler just wait until the lock is released by do_work?Unpleasant
@Unpleasant Not a silly question.. Reason is that everything is happening in the same thread of execution.. There is only a single order in which instructions are executed. So if sig_handler is waiting to acquire the lock that is already held by do_work, neither function can make any forward progress and the entire thread is in deadlock. With multiple threads, that problem is non-existentBruckner
@LWimsey, got it. Thanks!Unpleasant

© 2022 - 2024 — McMap. All rights reserved.