在多线程程序中,std::atomic<int> memory_order_relaxed 与 volatile sig_atomic_t 有何区别?

9

volatile sig_atomic_t会提供任何内存顺序保证吗?例如,如果我只需要加载/存储一个整数,是否可以使用?

例如,在这里:

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

这段代码正确吗?有哪些情况下它可能不起作用?

注意:这是一个过于简化的例子,即我不是在寻找更好的解决方案。我只想了解按照C++标准,在多线程程序中可以期望从volatile sig_atomic_t获得何种行为。如果存在未定义的行为,则需要理解其原因。

我在这里找到了以下声明:

库类型sig_atomic_t不提供线程间同步或内存排序,仅提供原子性。

如果将其与此处的定义进行比较:

memory_order_relaxed: 松散操作:对其他读取或写入没有同步或排序约束,仅保证此操作的原子性

这不是一样的吗?这里的原子性具体是什么意思?volatile在这里有用吗?“不提供同步或内存排序”和“没有同步或排序约束”之间有什么区别?


1
看起来它唯一的保证是 它是信号安全的,我不会在多线程中使用它。根据 std::atomic_signal_fence 的文档,我认为与 'signal' 相关的任何顺序保证都不存在。 - Mgetz
请查看此链接:std::condition_variable,可能更适合你的需要(不确定,因为你没有提供一个好的描述)。 - Marek R
“volatile” 的意思是“给我底层 CPU 和内存系统的语义”。因此,理论上 Q 是固有的实现相关性。但在实践中,是的,它是相同的。 - curiousguy
1个回答

10
您正在使用一个被两个线程(一个进行修改)访问的 sig_atomic_t 类型的对象。
根据 C++11 内存模型,这是未定义的行为,简单的解决方法是使用 std::atomic<T>

std::sig_atomic_tstd::atomic<T> 不同级别。在可移植代码中,它们不能互换使用。

它们唯一共享的属性是原子性(不可分割的操作)。这意味着对这些类型的对象的操作没有(可观察到的)中间状态,但仅限于此。

sig_atomic_t 没有跨线程的属性。实际上,如果多个线程(如您的示例代码中)访问(修改)此类型的对象,则技术上是未定义行为(数据竞争); 因此,未定义交叉线程内存排序属性。

什么是 sig_atomic_t 用于?

此类型的对象可以在信号处理程序中使用,但只有在声明为 volatile 时才能使用。原子性和 volatile 保证两件事:

  • 原子性:信号处理程序可以异步地将值存储到对象中,并且在同一线程中读取同一变量的任何人只能观察到之前或之后的值。
  • volatile:存储不能被编译器“优化”,因此在信号中断执行后(或之后)点可见(在同一线程中)。

例如:

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
    {
        ...
    }
}

尽管此代码是单线程的,但是 do_work 可以被异步中断,触发 sig_handler 并原子性地更改 quit 的值。
如果没有使用 volatile,编译器可能会将从 while 循环中的 quit 加载“提升”出来,这样 do_work 就无法观察到由信号引起的对 quit 的更改。 为什么不能用 std::atomic<T> 替换 std::sig_atomic_t 一般来说,std::atomic<T> 模板是一种不同的类型,因为它被设计为可以同时被多个线程访问并提供线程间排序保证。原子性并不总是在 CPU 级别上可用(特别是对于较大的类型 T),因此实现可能使用内部锁来模拟原子行为。是否对特定类型 T 使用锁可以通过成员函数 is_lock_free() 或类常量 is_always_lock_free(C++17)获得。
在信号处理程序中使用此类型的问题在于,C++ 标准不保证 std::atomic<T> 对于任何类型 T 都是无锁的。只有 std::atomic_flag 有这个保证,但那是不同的类型。
想象一下上面的代码,其中 quit 标志是一个 std::atomic<int>,它恰好不是无锁的。当 do_work() 加载值时,它可能会被信号中断,在释放锁之前已经获取了锁。信号触发 sig_handler(),现在它想通过获取与 do_work 已经获取的相同的锁来存储一个值到 quit 中,糟糕了。这是未定义的行为,可能会导致死锁。 std::sig_atomic_t 没有这个问题,因为它不使用锁定。所需的只是一种在 CPU 级别上不可分割的类型,在许多平台上,它可以非常简单:
typedef int sig_atomic_t;

总的来说,在单线程环境下使用 volatile std::sig_atomic_t 处理信号处理程序,在多线程环境下使用 std::atomic<T> 作为无数据竞争类型。


1
@kan ...可能不会对负载可见... 只是“未定义行为”可能发挥作用的一个例子,但我将删除该短语,因为它很令人困惑。只需了解未定义行为即可。 - LWimsey
@AndrewHenle atomic 的可见性保证非常差。而且语义也很差。整体来说都很差。 - curiousguy
抱歉问一个愚蠢的问题,但为什么“sig_handler...获取已经被do_work获取的相同锁是一种错误和未定义行为?为什么sig_handler不等待do_work释放锁呢? - Evg
1
@Evg 不是一个愚蠢的问题.. 原因是所有事情都发生在同一个执行线程中.. 指令执行的顺序是唯一的。 因此,如果 sig_handler 正在等待获取已经被 do_work 持有的锁,那么两个函数都无法向前推进,整个线程就会死锁。 使用多个线程,这个问题就不存在了。 - LWimsey
@LWimsey,明白了。谢谢! - Evg
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接