c++multithreadingvolatilememory-modelstdatomic

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


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"?


Solution

  • 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:

    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 is 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.