原始同步原语——安全吗?

4

在受限设备上,我经常使用两个布尔值 "模拟" 2 个线程之间的锁。每个布尔值仅由一个线程读取,并且只由另一个线程编写。以下是我的意思:

bool quitted = false, paused = false;
bool should_quit = false, should_pause = false;

void downloader_thread() {
    quitted = false;
    while(!should_quit) {
        fill_buffer(bfr);
        if(should_pause) {
            is_paused = true;
            while(should_pause) sleep(50);
            is_paused = false;
        }
    }
    quitted = true;
}

void ui_thread() {
    // new Thread(downloader_thread).start();
    // ...
    should_pause = true;
    while(!is_paused) sleep(50);
        // resize buffer or something else non-thread-safe
    should_pause = false;
}

当然,在PC上我不会这样做,但在受限设备上,读取bool值似乎比获取锁要快得多。当需要更改缓冲区时,我会用"sleep(50)"来换取较慢的恢复速度。
问题是——它完全线程安全吗?或者在模拟锁时是否存在隐藏的陷阱?或者我根本不应该这样做?

sleep(50)非常慢的恢复-参数以秒为单位。与信号量相比,我认为这种方式可能会引入更多的平均延迟每个操作... - Steve Jessop
同意,你在这里应该明确地使用自旋锁。 - RandomNickName42
5个回答

6
使用bool值在线程之间通信可以按照您的意图工作,但是正如Vitaliy Liptchinsky在这篇博客文章中所解释的那样,确实存在两个隐藏的陷阱:
缓存一致性
CPU并不总是从RAM中获取内存值。快速内存缓存位于芯片上,是CPU设计师用来解决冯诺依曼瓶颈的技巧之一。在某些多CPU或多核架构(如英特尔的Itanium)上,这些CPU缓存不是共享的或自动保持同步的。换句话说,如果它们在不同的CPU上运行,您的线程可能会看到相同内存地址的不同值
为了避免这种情况,您需要将变量声明为volatileC++C#Java),或进行显式的volatile读/写操作,或利用锁定机制。
编译器优化
编译器或JITter可能执行不安全的优化,如果涉及多个线程。请参见链接的博客文章以获取示例。同样,您必须使用volatile关键字或其他机制来通知编译器。

Volatile并不意味着内存屏障,它没有帮助。你真正需要的是适当的同步原语,这些原语目前还不是C语言的一部分,但应该会在C1x中提供(作为_Atomic类型)。在那之前,你必须使用具有正确锁定指令的汇编代码,或者使用线程实现(pthread或其他)的原语。 - R.. GitHub STOP HELPING ICE
@R..:你是说使用volatile时,线程仍然可能看到相同内存地址的不同值吗?还是你在谈论其他问题? - Wim Coenen
是的,我是。在x86上这是不太可能的。问题仅限于一些非主流克隆品牌,据我所知,其中不包括任何英特尔、AMD、Cyrix、VIA等芯片。但在不那么文明的RISC架构中,CPU之间的内存同步可能会很麻烦。volatile关键字所做的就是确保为每个抽象机器读取生成实际负载,并为每个抽象机器写入生成实际存储。它不会强制在CPU之间进行额外的缓存同步。 - R.. GitHub STOP HELPING ICE

5

除非您详细了解设备的内存架构以及编译器生成的代码,否则此代码不安全。

仅仅因为看起来它能够工作,并不意味着它一定会。"受限制"的设备,像无限制类型一样,变得越来越强大。比如说,我不会打赌在手机上找到一个双核CPU。这意味着我不会打赌上述代码会工作。


1
“我不会打赌在手机中找不到双核CPU,例如。” - 现在看来有四核的手机已经很有趣了 :) - luk2302

0

回答问题。

这完全是线程安全的吗?我的回答是否定的,我根本不会这样做。如果不知道我们设备和编译器的细节,如果这是C++,编译器可以自由地重新排序和优化事物。例如,你写道:

is_paused = true;            
while(should_pause) sleep(50);            
is_paused = false;

但编译器可能会选择重新排序成这样:

sleep(50);
is_paused = false;

正如其他人所说,这在单核设备上可能不起作用。

与其获取锁定,您可以尝试在处理UI消息时减少UI线程的工作量而不是在中间放弃。如果您认为在UI线程上花费了太多时间,则找到一种干净的退出方式并注册异步回调。

如果您在UI线程上调用sleep(或尝试获取锁定或执行任何可能阻塞的操作),则会导致挂起和故障的UI。 50ms的睡眠足以让用户注意到。如果您尝试获取锁定或执行任何其他阻塞操作(例如I / O),则需要处理等待不确定时间以获取I / O的现实情况,这往往从故障转化为挂起。


0

在几乎所有情况下,此代码都是不安全的。在多核处理器上,由于bool读取和写入操作不是原子操作,因此您将无法拥有缓存一致性。这意味着每个核心不能保证在缓存中具有相同的值,甚至如果来自上一次写入的缓存尚未被刷新,则不能保证从内存中获取相同的值。

然而,即使在资源受限的单核设备上,这也是不安全的,因为您无法控制调度程序。以下是一个例子,为简单起见,我假装这是设备上唯一的两个线程。

当ui_thread运行时,以下代码行可能在同一时间片中运行。

// new Thread(downloader_thread).start();
// ...
should_pause = true;

接下来运行downloader_thread,在它的时间片中执行以下代码:

quitted = false;
while(!should_quit)
{
    fill_buffer(bfr);

调度程序在 fill_buffer 返回之前抢占 downloader_thread,然后激活运行 ui_thread。
while(!is_paused) sleep(50);
// resize buffer or something else non-thread-safe
should_pause = false;

在downloader_thread正在填充缓冲区的过程中执行resize buffer操作。这意味着缓冲区已经损坏,你很快就会崩溃。虽然不是每次都会发生,但在将is_paused设置为true之前填充缓冲区使得它更容易发生,但即使你在downloader_thread上交换了这两个操作的顺序,你仍然会有竞争条件,但你可能会死锁而不是破坏缓冲区。

顺便说一下,这是一种自旋锁类型,但它并不起作用。自旋锁对于可能跨越多个时间片的等待时间并不适用,因为它会让处理器自旋。你的实现使用了sleep,这样会好一些,但调度程序仍然必须运行你的线程,而线程上下文切换并不便宜。如果你在等待关键部分或信号量,调度程序在资源变为空闲之前不会再次激活你的线程。

你可能能够在特定的平台/架构上以某种形式摆脱这种情况,但很容易犯一个非常难以追踪的错误。


0
关于sleep调用,您可以始终执行sleep(0)或等效调用,以暂停您的线程并让下一个线程运行。
关于其余部分,如果您了解设备的实现细节,则是线程安全的。

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