V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
blacksmith
V2EX  ›  C++

c++中多线程操作 string 引发的 coredump,栈中比较奇怪的一点

  •  1
     
  •   blacksmith · 2021-02-03 20:23:24 +08:00 · 4093 次点击
    这是一个创建于 1422 天前的主题,其中的信息可能已经有所发展或是发生改变。

    源代码如下:

    #include <iostream>
    #include <string>
    #include <vector>
    #include <thread>
    
    struct A {
        std::string name = "blacksmith";
        int age = 100;
    };
    
    struct B1 {
        std::string local_name = "jd-B1";
    
        void func1(A* a) {
            while (true) {
                a->name = local_name;
                std::string key = local_name + "**";
            }
        }
    };
    
    struct B2 {
        std::string local_name = "jd-B2";
    
        void func1(A* a) {
            while (true) {
                a->name = local_name;
                std::string key = local_name + "**";
            }
        }
    };
    
    
    int main() {
        /**
         * 探测是否支持 COW
         */
        std::string* test = new std::string("blacksmith");
        std::string name = *test;
        std::cout << "test:" << test->data() << ", name=" << name.data() << std::endl;
        if (test->data() == name.data()) {
            std::cout << "COW(Copy On Write) support!" << std::endl;
        } else {
            std::cout << "COW(Copy On Write) NOT support!" << std::endl;
        }
        delete test;
    
    
        /**
         * 多线程操作
         */
        std::vector<std::thread> th_vec;
        int thread_count = 4;
        A a;
        B1 b1;
        B2 b2;
        for (int i = 0; i < thread_count; i++) {
            th_vec.emplace_back([&](){
                b1.func1(&a);
            });
            th_vec.emplace_back([&](){
                b2.func1(&a);
            });
        }
    
        for (auto& item : th_vec) {
            item.join();
        }
    
        std::cout << "=========END==========" << std::endl;
    
        return 0;
    }
    

    编译:

    g++ --std=c++11 string-test.cc -g -lpthread
    

    查看 coredump 栈:

    (gdb) bt
    #0  0x00007f5613350e20 in __memcpy_ssse3 () from /usr/lib64/libc.so.6
    #1  0x00007f5613ba8650 in std::string::_Rep::_M_clone(std::allocator<char> const&, unsigned long) () from /usr/lib64/libstdc++.so.6
    #2  0x00007f5613ba86d4 in std::string::reserve(unsigned long) () from /usr/lib64/libstdc++.so.6
    #3  0x00007f5613ba893f in std::string::append(char const*, unsigned long) () from /usr/lib64/libstdc++.so.6
    #4  0x0000000000402808 in std::operator+<char, std::char_traits<char>, std::allocator<char> > (
        __lhs="jd-B2", '\000' <repeats 11 times>, "!\000\000\000\000\000\000\000@9@\000\000\000\000\000(I\213\071\375\177\000\000\060I\213\071\375\177\000\000Q\002\000\000\000\000\000\000\"", '\000' <repeats 15 times>, "\001", '\000' <repeats 15 times>, "\377\377\377\377\377\377\377\377\000\000\000\000\000\000\000\000\377\377\377\377\377\377\377\377", '\000' <repeats 88 times>..., __rhs=0x40386a "**")
        at /opt/rh/devtoolset-7/root/usr/include/c++/7/bits/basic_string.h:5917
    #5  0x000000000040263f in B2::func1 (this=0x7ffd398b4920, a=0x7ffd398b4930) at string-test.cc:28
    #6  0x0000000000401108 in <lambda()>::operator()(void) const (__closure=0x19a5368) at string-test.cc:62
    #7  0x0000000000401fde in std::__invoke_impl<void, main()::<lambda()> >(std::__invoke_other, <lambda()> &&) (__f=...) at /opt/rh/devtoolset-7/root/usr/include/c++/7/bits/invoke.h:60
    #8  0x0000000000401cb0 in std::__invoke<main()::<lambda()> >(<lambda()> &&) (__fn=...) at /opt/rh/devtoolset-7/root/usr/include/c++/7/bits/invoke.h:95
    #9  0x0000000000402392 in std::thread::_Invoker<std::tuple<main()::<lambda()> > >::_M_invoke<0>(std::_Index_tuple<0>) (this=0x19a5368) at /opt/rh/devtoolset-7/root/usr/include/c++/7/thread:234
    #10 0x000000000040233f in std::thread::_Invoker<std::tuple<main()::<lambda()> > >::operator()(void) (this=0x19a5368) at /opt/rh/devtoolset-7/root/usr/include/c++/7/thread:243
    #11 0x00000000004022fe in std::thread::_State_impl<std::thread::_Invoker<std::tuple<main()::<lambda()> > > >::_M_run(void) (this=0x19a5360) at /opt/rh/devtoolset-7/root/usr/include/c++/7/thread:186
    #12 0x000000000040343f in execute_native_thread_routine ()
    #13 0x00007f5613df8dd5 in start_thread () from /usr/lib64/libpthread.so.0
    #14 0x00007f5613302ead in clone () from /usr/lib64/libc.so.6
    

    比较疑惑的一点是,多线程写 string,为什么不是在写入那一行 core,而是在后面拼接成员变量?

    a->name = local_name; // 我理解应该是这一行报 core

    std::string key = local_name + "**"; // 实际在操作 local_name 的时候 core,并且看栈,local_name 内存乱了

    辛苦各位大佬,有时间的帮忙看看,很是疑惑。 谢谢。

    25 条回复    2021-02-09 21:21:02 +08:00
    yianing
        1
    yianing  
       2021-02-03 20:57:58 +08:00 via Android
    a->name 写入的时候只是拷贝了 header 部分,虽然也是多写但是写入都是同一份数据,没报错只能说运气好吧,下面 appen 的地方就是多个线程操作一份指针数据了
    yianing
        2
    yianing  
       2021-02-03 21:05:06 +08:00
    @yianing golang 里面的 string 是不可变的,我用 int 测试一下
    ```go
    package main

    import "time"

    type A struct {
    age int
    }

    type B struct {
    age int
    }

    func (b *B) Op(a *A) {
    for {
    a.age++
    b.age++
    }
    }

    func main() {
    b := &B{}
    a := &A{}
    for i := 0; i < 3; i++ {
    go b.Op(a)
    }
    time.Sleep(100 * time.Millisecond)
    }
    ```
    go run -race 的时候两个++都是会报错的
    secondwtq
        3
    secondwtq  
       2021-02-03 21:35:28 +08:00 via iPhone
    多线程写入,结果就是写入的数据不可靠,不是直接给你报错。
    只有你再使用写入的数据时才会把问题暴露出来,而在实际程序中很难知道是谁什么时候写入的数据,这是并发错误难调试的原因之一,报错的点不一定是 data race 发生的点。

    有时候也会故意这么做,性能会好一些,
    hxndg
        4
    hxndg  
       2021-02-03 22:46:21 +08:00
    首先,你这个应该不会只出现一种 core 的结果
    27 行应该也可能出现 core,但 core 的原因应该是多个线程 free 同一个地方导致的。

    另外 28 行出现 core 的原因看流程像是拷贝的时候生成的临时变量都是在 local_name 上,然后不同线程操作导致拷贝的长度无效导致的。

    当然以上结论需要事实+观察寄存器传参确定,我忘了 X86_64 位下寄存器的值代表的含义了,不做任何正确性保证。
    matrixji
        5
    matrixji  
       2021-02-04 00:18:32 +08:00
    楼主你确定这个问题不是混用了 devtoolset-7 和系统的 libstdc++导致的。
    从 C++11 本省来讲 a->name = local_name; 走的是 operator= 由于 local_name 的长度 大于 A::name 实际上这里都不会发生内存的释放和申请,只会有 Copy 操作。所以这里应该不会有内存错误才对。
    至于 std::string key = local_name + "**"; 就应该更加没有问题了。
    imjamespond
        6
    imjamespond  
       2021-02-04 00:42:15 +08:00 via Android
    string 本质上好像是个 vector
    Wirbelwind
        7
    Wirbelwind  
       2021-02-04 01:12:22 +08:00
    升级一下编译器

    msvc 没能复现出来

    每个线程读取的都是线程内 local_name,而且 local_name 没有被写入过,
    应该不会有这种情况
    blacksmith
        8
    blacksmith  
    OP
       2021-02-04 09:36:03 +08:00
    @yianing 谢谢详细的讲解。在 append 的地方,都是只读的成员变量 local_name,并没有去写。但是栈中显示的这个变量内存乱了,比较让人诧异。按说应该是 a->name 的内存有问题才对。
    blacksmith
        9
    blacksmith  
    OP
       2021-02-04 09:38:35 +08:00
    @secondwtq
    是的,我开始也认为写入会导致数据不准确。但是 local_name 变量是一个成员变量,并没有去修改它。开始怀疑是 cow 的一些机制导致的,但是我找不到任何的证据。线上发生了类似的 core,栈的地方和实际的操作有问题的地方不一致,导致排查的时候需要通览一下代码,我在想有没有什么方法可以直接定位到写错误的地方?
    blacksmith
        10
    blacksmith  
    OP
       2021-02-04 09:41:00 +08:00
    @hxndg 非常感谢。确实会有两种 coredump 发生。
    第一种 27 行的那个,比较好理解。
    发生在 28 行的这个 core 其实不太符合预期,如果拷贝的临时变量不是存储在左边的值,而是右边的值,那么可以说的通。但是我确实没有找到类似的证据,证明这一点。
    谢谢了。
    blacksmith
        11
    blacksmith  
    OP
       2021-02-04 09:42:26 +08:00
    @matrixji 应该不是的,我开始的版本没有使用 devtooset-7,也有问题,后面想升级 gcc 版本,发现也是类似的问题。
    coredump 的内容确实如我帖子里的。很是奇怪为啥 std::string key = local_name + "**";这一行会有问题。
    谢谢回复。
    blacksmith
        12
    blacksmith  
    OP
       2021-02-04 09:43:14 +08:00
    @imjamespond 怀疑是 cow 做了什么动作,可是我没有证据:)
    谢谢回复。
    blacksmith
        13
    blacksmith  
    OP
       2021-02-04 09:44:47 +08:00
    @Wirbelwind
    可能跟编译器有关吧。我用 4.8.5 和 7 的版本都试了下,都是有问题的。目前我这没有 msvc 的环境。
    这个现象确实在 linux 下发生了。所以百思不得其解。
    谢谢回复。
    Monad
        14
    Monad  
       2021-02-04 10:21:02 +08:00


    我这边是在 operator=的时候,g++4.8.5
    Monad
        15
    Monad  
       2021-02-04 10:54:30 +08:00
    @Monad #14 不一定会在这里 上面的图不对 补一个图
    hxndg
        16
    hxndg  
       2021-02-04 16:41:51 +08:00
    建议还是上 libc 源码看看吧,这个明显跟编译器行为有关了。

    不过没明白干嘛要干这种事情呢?一般这种多线程操作都是极度小心的。
    blacksmith
        17
    blacksmith  
    OP
       2021-02-05 09:46:31 +08:00
    @Monad 会有两种 core 。一种是你尝试的这个,还有一种是我发的那种。
    blacksmith
        18
    blacksmith  
    OP
       2021-02-05 09:48:56 +08:00
    @hxndg 线上系统有个类似的问题被发现了,不过栈看着比较奇怪,我按照那个逻辑写了这个来复现。问题已经修复了,但是还是没能找到一个比较信服的解释,来说明 std::string key = local_name + "**";这行会 core 的原因。
    确实多线程操作不小心导致的问题。
    matrixji
        19
    matrixji  
       2021-02-05 10:28:25 +08:00
    @blacksmith 重新看了一下 libstdc++的源码。baseic_string::operator=的 实现,不同版本不一样。所以我的环境永远不会 codedump 。

    https://github.com/gcc-mirror/gcc/blob/releases/gcc-4.8.5/libstdc%2B%2B-v3/include/bits/basic_string.h 是 Centos 对应的版本,实现很简单:
    basic_string&
    operator=(const basic_string& __str)
    { return this->assign(__str); }
    无条件地去 assign 新的内容,assign 里面的逻辑就是 free 老的,clone 新的。

    https://github.com/gcc-mirror/gcc/blob/releases/gcc-9.3.0/libstdc++-v3/include/bits/basic_string.h 你可以找下新版的实现就不一样了,如果当前的长度够了,就不会去 free,而是直接在当前 buffer 上 Copy 。

    由于是多线程操作,所以会造成两个线程同时执行 assign 的操作。
    那么有可能出现:
    同一个地址被 free 两次,照成 double free,那就是 @Monad 提到的第一个错误。
    被 free 掉了继续使用,那就是你出现的这种情况:
    线程 1:Free -> New -> 使用(实际已经被 Free 掉了)
    线程 2:..........................Free.............

    所以 coredump 的时机也就不一定了。如果楼主要细究,可以用 valgrind 跑一下就清楚了。
    hxndg
        20
    hxndg  
       2021-02-05 11:30:31 +08:00
    local_name + "**"和 key 必然是放在栈上申请的临时变量,按照道理来说不应该有问题,所以我做了个尝试:

    我把你 B1.func1 核 B2.func1 里面的`a->name = local_name`去掉以后,试了下就一直没出现 core 的现象了。

    估计又是编译器做的一些“好事”导致的问题,感觉还是跟利用了 a->name 有关系,和生命周期什么的有关。

    我司之所以不用 C++,用 C 一部分原因也是因为避免编译器的操作。。。。
    hxndg
        21
    hxndg  
       2021-02-05 11:37:16 +08:00
    @matrixji
    不,你这个说法没有解释清楚为什么 local_name + "**"为什么会 core,
    每个线程都是在自己的栈上操作,local_name+"**"的结构应该是在本地栈上分配的,即使 assign 的是直接这个地址也不应该 core 才对。
    Wirbelwind
        22
    Wirbelwind  
       2021-02-06 05:03:22 +08:00
    @hxndg 上面 a->name = local_name 之后,编译器一定程度上可能会直接使用保存了 a->name 的寄存器(应该是 a)来替代 local_name 的寄存器
    hxndg
        23
    hxndg  
       2021-02-06 11:12:22 +08:00 via Android
    @Wirbelwind 嗯,我也怀疑是这个,但是没把代码下下来看并不确定
    YouLMAO
        24
    YouLMAO  
       2021-02-07 19:59:12 +08:00
    @blacksmith sse3 很明显异常呀, 因为内存没对齐呀, 不是一个个字节拷贝是一块块拷贝的
    vduang
        25
    vduang  
       2021-02-09 21:21:02 +08:00
    @blacksmith 堆内存只要一乱,程序可能在任何使用堆内存的地方崩溃,崩溃的地方和 bug 的地方可能没有任何关联,这个现象是正常的,也是这样的问题难以排查的原因。

    你这段代码的问题在于多个线程中 a->name 被并发赋值,导致 a->name (同时也是 local_name )指向的原来的堆内存被多次释放了,如果这段内存在被释放后又被重新分配出去被写入的话,local_name 指向的就是一堆垃圾了,所以即使你是在读取 localname,并没有修改 localname,程序也会在这里崩溃。

    所以这段代码什么时候崩溃在哪崩溃纯看运气。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5342 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 20ms · UTC 07:54 · PVG 15:54 · LAX 23:54 · JFK 02:54
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.