Liam0205 / liam0205.github.io

Deployment of my weblog.
https://liam0205.github.io
35 stars 5 forks source link

谈谈 C/C++ 中的 volatile | 始终 #220

Open Liam0205 opened 5 years ago

Liam0205 commented 5 years ago

https://liam.page/2018/01/18/volatile-in-C-and-Cpp/

最近在讨论多线程编程中的一个可能的 false sharing 问题时,有人提出加 volatile 可能可以解决问题。这种错误的认识荼毒多年,促使我写下这篇文章。

Liam0205 commented 5 years ago

回复 @longjianjiang 在 https://github.com/Liam0205/liam0205.github.io/issues/283#issue-417713862 中提出的问题。

std::atomic<bool> 和原生的 bool 不同之处不仅在于对 std::atomic<bool> 的读写操作是有原子性保证的,还在于 std::atomic<bool> 提供了内存屏障。其中 std::atomic<bool>operator= 运算符,相当于 std::atomic<bool>::store(bool flag, std::memory_order_seq_cst)。这里保证了不会乱序。

线程安全问题,是一个「烫手山芋」,你不能狭隘地理解为数据竞争就是线程安全的全部。线程安全的本质,是在单线程下对变量状态的合理假设,在多线程状态下由于编译器优化、CPU 乱序执行、线程调度等因素而被打破。数据竞争只是其中一种可能的结果罢了。

——事实上,你可以直接在文章最下面评论的。close #283

longjianjiang commented 5 years ago

受教了,多谢 @Liam0205 。

对了,我这里访问文章,没有看到可以评论的地方呢。 2019-03-06 6 33 50

Liam0205 commented 5 years ago

@longjianjiang

奇怪,大概是 Gitalk 没有正确加载……

我这里看是这样的: image

wclin88 commented 5 years ago

-- 此处说的「读取内存」,包括了读取 CPU 缓存和读取计算机主存。 也就说如果是2个跑在2个cpu的线程,一个线程写,一个线程读,那就算加了volatile,也有可能读写在各自的CPU缓存,而不能马上得到最新的值吗?

Liam0205 commented 5 years ago

@wclin88 那不会的。缓存一致性由缓存一致性协议保证,比如 MESI 协议。跟这里讨论的是两回事。

wclin88 commented 5 years ago

@Liam0205 @wclin88 那不会的。缓存一致性由缓存一致性协议保证,比如 MESI 协议。跟这里讨论的是两回事。

好的,谢谢。我看你上边的flag的例子是因为需要一定的顺序才会有问题,那如果我有一个线程负责对一个数据写,其他的线程负责读这个数据,至于读的是新数据还是旧数据是无关紧要的,那加volatile应该就可以了,不需要用atomic吧

caonann commented 5 years ago

有点疑惑,例子一中说”在 thread1 中,flag = false 赋值之后,在 while 死循环里,没有任何机会修改 flag 的值“,但是自己实现了个能运行的,并没测试出什么问题,请问是我哪里理解有误呢?

bool flag = false;
using Type=int;

void test_volatile_demo()
{
    flag = false;
    Type* value = new Type(1);

    auto func_thread2 = [](Type* value)->void {
        // do some evaluations
        cout<<"func_thread2 begin "<<endl;
        *value = 222;
        flag = true;
    };

    std::thread t(func_thread2,value);
    while (true)
    {
        if (flag == true)
        {
            cout<<"apply value:"<<*value<<endl;
            break;
        }
    }

    t.join();
    if (nullptr != value)
    {
        delete value;
    }
}

int main()
{

    for(unsigned int i =0 ;i<100000000;i++)
    {
        test_volatile_demo();
    }
    return 0;
}
omlib-lin commented 5 years ago

"编译器仍有可能在优化时将 thread2 中的 update 和对 flag 的赋值交换顺序",请问换成atomic能解决这个问题吗?编译器如果随便更改执行顺序,那代码没法写了,怎么写都可能出bug,这个交换明显违背as-if原则,是可观察的变化。 对volatile bool更多的是编译器、CPU差异导致的约定不一致,是不是可能有问题的担忧。选择atomic是从更标准考虑,赋值顺序交换这个原因牵强说不通。

Liam0205 commented 5 years ago

@omlib-lin 换成 std:;atomic 可以解决这个问题。std::atomic 对内存屏障可以有更精细的控制。

fyniny commented 5 years ago

由于对 std::atomic 的操作是原子的,同时构建了良好的内存屏障,因此整个代码的行为在标准下是良定义的。

我想请教以下,换成原子操作之后就可以保证flag=true在update()之后运行么,是否原子操作可以解决cpu运行乱序问题?

yanglwd commented 4 years ago

class StopExample { public: void Start() { _thrd = std::thread([=](){ while(!_stop) { std::cout << "Running" << std::endl; std::this_thread::sleep_for(std::chrono::seconds(1)); } }); }

void Stop()
{
    _stop = true;
    _thrd.join();

    std::cout << "Stopped" << std::endl;
}

private: std::thread _thrd; bool _stop{false}; }; 你好,我编写了如上的代码。 在gcc7.5和vs2015下在gcc7.5和vs2015下,发现编译器并没有对_stop执行优化(优化到寄存器作为条件测试变量)。是标准有修改么,求大神解惑

zchrissirhcz commented 4 years ago

std::atomic和std::mutex都是C++11新增的内容,如果是写C++,那么使用C++11是值得推广的。不过文章标题提到的是C和C++,C语言中的具体解法有所不同。

Liam0205 commented 4 years ago

std::atomic和std::mutex都是C++11新增的内容,如果是写C++,那么使用C++11是值得推广的。不过文章标题提到的是C和C++,C语言中的具体解法有所不同。

你讲的对,我回头改改。

zchrissirhcz commented 3 years ago

std::atomic<bool> flag = false;这一用法,在clang10下会报错。建议改成std::atomic<bool> flag(false);

Liam0205 commented 3 years ago

@zchrissirhcz std::atomic<bool> flag = false;这一用法,在clang10下会报错。建议改成std::atomic<bool> flag(false);

fixed.

aroncc commented 3 years ago

@caonann 有点疑惑,例子一中说”在 thread1 中,flag = false 赋值之后,在 while 死循环里,没有任何机会修改 flag 的值“,但是自己实现了个能运行的,并没测试出什么问题,请问是我哪里理解有误呢?

bool flag = false;
using Type=int;

void test_volatile_demo()
{
    flag = false;
    Type* value = new Type(1);

    auto func_thread2 = [](Type* value)->void {
        // do some evaluations
        cout<<"func_thread2 begin "<<endl;
        *value = 222;
        flag = true;
    };

    std::thread t(func_thread2,value);
    while (true)
    {
        if (flag == true)
        {
            cout<<"apply value:"<<*value<<endl;
            break;
        }
    }

    t.join();
    if (nullptr != value)
    {
        delete value;
    }
}

int main()
{

    for(unsigned int i =0 ;i<100000000;i++)
    {
        test_volatile_demo();
    }
    return 0;
}

你不用疑惑,是作者自己搞错了。实例代码中的全局变量的if判断,是不会编译优化掉的,因为这是全局变量,在多线程环境下内容不可控,不保证不变,编译器自己无法推断(如果真的优化掉了,那肯定是编译器的bug)。编译优化并不是任何条件都会触发的,需要满足特定的条件(更多内容参考编译原理教课书)。另外,关于作者说的第二点“update和flag赋值顺序”交换问题,多半也是有问题的。虽然我没有验证过,但是可以肯定的是,编译器并不会随便交换函数调用和flag赋值,除非flag是栈变量(也就是线程独占变量)。总之编译优化是有条件的,不是随便生效的。另外,绝大部分情况下,编译优化的设计上是让应用开发程序员不感知的。像这么简单的例子如果都有问题了,那应用程序员就不用写代码了,肯定一写一个错。但是作者的结论应该是正确的,只是例子举得可能没有说服力。

aroncc commented 3 years ago

作者给的第一个sample code后面的2点评论,我理解应该是不对的把。

1,关于if条件优化掉的问题,对于全局变量肯定不会发生,不管是在什么编译器版本上,因为编译器无法推断出全局变量的不变性,所以不会无端认为全局变量不变。(想了一下,加个条件,毕竟没有验证过,可以肯定的是:涉及到写的全局变量,肯定不会把if条件优化掉)

2,关于函数调用和变量赋值的乱序问题,也是不对的。对于编译优化,只有当编译器能够确定函数调用和赋值操作无关的情况下,才可能触发交换顺序。而实际上,编译器根本无法推断出函数调用和赋值操作的关系(是否互相影响),所以保守设计上,肯定不会选择交换顺序。就文中的例子而言,因为flag是全局变量,所以肯定不会交换顺序的(因为前面的函数调用显然也可以访问到这个全局变量,从而有机会影响flag的取值,如果编译器还交换顺序的话,那就大错了)。我可以给一个典型的反例:如果,上述代码放在单线程的程序中,那么也会有flag和函数调用的顺序交换吗?这显然是不可能的!如果如此,那么现在世界上的绝大部分的程序都会运行错误。

3,最后提一下cpu的乱序问题。在100%的情况下(除非极其特殊的场合),cpu的乱序不影响程序语义。这是cpu乱序设计的前提。换句话说,作为应用程序开发程序员,不应该考虑cpu乱序问题。同样的反例:如果真的有影响,那么历史上老的程序岂不是都不能正确跑了。显然,任何物理cpu都不会如此设计。

总结:作者给的结论应该是对的,只是举了一个不合适的例子,不知道能不能找到一个真实的例子。

因为像flag检查这样的代码,在程序开发中非常常见,所以,我有个问题,到底能不能这样用?似乎不用volatile程序也可以正常运行。我们到底可不可以这样写代码呢?能不能找一个使这样的代码失效的c++代码啊?作者大佬!

Jamishon commented 2 years ago

C++ primer (第五版)对volatile的解释是:volatile的确切含义和当前的机器有关,当对象在程序控制或检测之外的可能会被改变时,如果不希望这种改变,可以将变量声明为volatile

jordan5226 commented 9 months ago

帮作者平反一下,最后一段代码其实作者提到的观念没有问题,分为两个部分:
Q1: if (flag == true) 的优化
Q2: flag = true; 执行顺序的优化

这里我需要把代码稍微修改一下比较合适,也可以简化问题:

#include <stdio.h>
#include <thread>

// global shared data
bool flag = false;

void thread2(int* value) {
    // do some evaluations
    int var = 0;
    var++;
    *value = var + 1;
    flag = true;    //Q2
    return;
}

void thread1() {
    flag = false;
    int value = 0;
    std::thread t2(thread2, &value);
    while (true) {
        if (flag == true) {    // Q1
            printf("%d\n", value);
            break;
        }
    }
    t2.join();

    return;
}

int main()
{
    thread1();

    return 0;
}

这段代码使用 g++ -g test.cpp -o test -lpthread 编译一切正常;
g++ -g test.cpp -O2 -o test -lpthread 启用优化编译后就会卡死在while回圈里出不来。

针对Q1: if (flag == true),编译器的确认为flag在while回圈中被判断一次后再也不会用到,于是就把整段if忽略。 我们可以通过asm代码来分析while那一段。
优化前:
g++ -S -fverbose-asm test.cpp -lpthread

.L15:
# test.cpp:21:         if (flag == true) {    // Q1
        movzbl  flag(%rip), %eax        # flag, flag.0_1
        movzbl  %al, %eax       # flag.0_1, _2
# test.cpp:21:         if (flag == true) {    // Q1
        cmpl    $1, %eax        #, _2
        jne     .L15    #,
# test.cpp:22:             printf("%d\n", value);
        movl    -44(%rbp), %eax # value, value.1_3
        movl    %eax, %esi      # value.1_3,
        leaq    .LC0(%rip), %rdi        #,
        movl    $0, %eax        #,
.LEHB1:
        call    printf@PLT      #
# test.cpp:26:     t2.join();
        leaq    -40(%rbp), %rax #, tmp89
        movq    %rax, %rdi      # tmp89,
        call    _ZNSt6thread4joinEv@PLT #

优化后:
g++ -S -fverbose-asm test.cpp -O2 -lpthread

.L8:
# test.cpp:21:         if (flag == true) {    // Q1
        cmpb    $0, flag(%rip)  #, flag
        jne     .L28    #,
.L25:
        jmp     .L25    #
        .p2align 4,,10
        .p2align 3
.L28:
# /usr/include/x86_64-linux-gnu/bits/stdio2.h:107:   return __printf_chk (__USE_FORTIFY_LEVEL - 1, __fmt, __va_arg_pack ());
        movl    4(%rsp), %edx   # value,
        leaq    .LC0(%rip), %rsi        #,
        movl    $1, %edi        #,
        xorl    %eax, %eax      #
.LEHB2:
        call    __printf_chk@PLT        #
# test.cpp:26:     t2.join();
        movq    %rbp, %rdi      # tmp112,
        call    _ZNSt6thread4joinEv@PLT #

可以看到cmp结束后就一直卡在这里跳不出去

.L25:
        jmp     .L25    #

证实了编译器的优化可能会造成问题。

jordan5226 commented 9 months ago

再来说明一下Q2代码改写的原因

var++;
*value = var + 1;
flag = true;    //Q2

因为*value = var + 1;需要等待var++;执行完才能执行,在此之前可能会被阻塞,
CPU可能会为了不浪费等待时间而重排序,让flag = true;先执行,执行完之后也许*value = var + 1;就得以执行了。
但我在这个案例中没有观察到CPU乱序或编译器乱序的现象,可能还需要改代码。

Liam0205 commented 9 months ago

但我在这个案例中没有观察到CPU乱序或编译器乱序的现象,可能还需要改代码。

在 x86 或者 x64 上,你看不到这种现象。CPU OOO 是有条件的,根据 CPU arch 的不同而不同。x86/x64 不允许这种类型的乱序。