C++析构函数会在返回语句之前还是之后调用?直接返回共享对象是否会产生竞争条件?

Sunday, May 21, 2023
本文共1412字
3分钟阅读时长

⚠️本文是作者P3troL1er原创,首发于https://peterliuzhi.top/posts/c++%E6%9E%90%E6%9E%84%E5%87%BD%E6%95%B0%E4%BC%9A%E5%9C%A8%E8%BF%94%E5%9B%9E%E8%AF%AD%E5%8F%A5%E4%B9%8B%E5%89%8D%E8%BF%98%E6%98%AF%E4%B9%8B%E5%90%8E%E8%B0%83%E7%94%A8/。商业转载请联系作者获得授权,非商业转载请注明出处!

If you want to succeed you should strike out on new paths, rather than travel the worn paths of accepted success. — John Locke

问题引入

当我在学习C++标准库中的线程锁的时候,对以下代码出现了问题:

int read_data()
{
    int tmp;
    std::shared_lock<std::shared_mutex> lock(rw_mutex);
    tmp = shared_data;
    return tmp;
}

当我使用一个shared_lock来托管读写锁,因为析构函数会执行解锁,那么问题就来了,这个解锁是在return之前发生的还是在}处发生的?虽然直觉告诉我是在return之前发生的,但还是隐隐地有些不放心,如果在}处调用的话,那么就可能造成死锁问题;如果是在return之前发生的话,那以下代码中返回值直接使用共享对象的值是否有可能在析构函数解锁到return这一小段时间内发生改变?

int read_data()
{
    std::shared_lock<std::shared_mutex> lock(rw_mutex);
    return shared_data;
}

验证程序

编译以下程序后使用gdb调试:

#include <iostream>

class A {
public:
    A() { std::cout << "A constructor" << std::endl; }
    ~A() {
        std::cout << "A destructor" << std::endl;
        __asm__ volatile(
            "mov $1, %rax"
        );
    }
};

int main(int argc, char const* argv[])
{
    A a;
    std::cout << "Before return" << std::endl;
    return 0x111111;
}

这里刻意让析构函数有多条语句方便观察,同时刻意使用了内嵌汇编改变rax的值(因为返回值保存在rax寄存器里面),然后在std::cout << "A destructor" << std::endl;处打上断点用gdb查看:

文内图片

显然这时候进入了析构函数,并且rax的值并没有被设置为主函数的返回值

文内图片

文内图片

然后经过内嵌汇编的更改,rax成功变成了1

文内图片

再次单步执行之后就发现返回值设置为主函数返回值了,也就是说析构函数可以看作一定是在return语句之前执行的

另一个程序

#include <iostream>

class A {
public:
    A() { std::cout << "A constructor" << std::endl; }
    ~A()
    {
        std::cout << "A destructor" << std::endl;
        __asm__ volatile(
            "mov $1, %rax");
    }
};

int main(int argc, char const* argv[])
{
    A a;
    std::cout << "Before return" << std::endl;
    return puts("111");
}

这个程序的输出是:

文内图片

可以看到,被我们写在“return语句”中的函数puts在析构函数之前调用了,说明所谓的return语句其实只是对rax的设置,可以理解为退出状态,如果在return语句中使用函数,编译后都会放在前面执行完毕,然后在设置退出状态之前调用析构函数

退出状态是存在哪里的

现在就有一个很重要的问题,退出状态是存在哪里的?是否会涉及到内存地址的访问?如果涉及到内存地址的访问,是否有可能在读入的时候内存单元内容被其他线程改变?

在C/C++的高度抽象层面我们是得不到答案的,因此我们使用GCC获取它的汇编代码:

gcc -S test_destructor.cpp -o test_destructor.asm

然后定位到main函数的leave; ret那个位置看一看:

文内图片

发现这个设置退出状态确实有可能设计内存访问,但是这里是对栈的内存访问,如果返回值存放在函数栈上,一般来说是不会和其他线程产生竞争条件的

并且在这里我们看到析构函数是在canary的检查之前调用的(我们可以使用c++filt工具解析一下这个阴间函数):

文内图片

那么,在我们原本的读写锁源代码文件里面,是否可能出现我们担心的问题呢?我们可以将其编译成汇编代码看一下:

文内图片

文内图片

发现通过编译器的优化,这个共享内存单元的值被提前保存在了栈上,感谢编译器!

结论

也就是说,如果直接返回共享内存单元在编译后经过编译器优化(在debug模式下仍然会这么优化)后,其实际作用和将这个共享内存单元的值保存在tmp变量中是一样的,但是后者的语义更加清晰,前者的代码量更少并且更依赖于编译器。我个人比较谨慎,我选择后者的写法。

同时,如果希望控制shared_lock、unique_lock、lock_guard的析构,可以使用花括号表明他们的生存期:

int read_data()
{
    int tmp;
    {
        std::shared_lock<std::shared_mutex> lock(rw_mutex);
        tmp = shared_data;
    }
    return tmp;
}

但注意,这样的话,如果return语句中调用了其他的函数,就会在锁之外执行

同时,不建议手动调用unlock来对其解锁,因为析构函数可能还会解锁一次,这时候会抛出异常(lock函数同理):

文内图片

不过依实现其表现可能不同