关于线程安全的疑惑

3

我对并发世界还不是很了解,但从我所读的内容来看,我理解下面的程序在执行时是未定义的。如果我理解正确,这个程序不是线程安全的,因为我同时以非原子方式并发读写shared_ptr和计数器变量。

#include <string>
#include <memory>
#include <thread>
#include <chrono>
#include <iostream>


struct Inner {
    Inner() {
        t_ = std::thread([this]() {
            counter_ = 0;
            running_ = true;
            while (running_) {
                counter_++;
                std::this_thread::sleep_for(std::chrono::milliseconds(10));
            }
        });
    }

    ~Inner() {
        running_ = false;
        if (t_.joinable()) {
            t_.join();
        }
    }


    std::uint64_t counter_;
    std::thread t_;
    bool running_;
};


struct Middle {

    Middle() {
        data_.reset(new Inner);
        t_ = std::thread([this]() {
            running_ = true;
            while (running_) {
                data_.reset(new Inner());
                std::this_thread::sleep_for(std::chrono::milliseconds(1000));
            }
        });
    }

    ~Middle() {
        running_ = false;
        if (t_.joinable()) {
            t_.join();
        }
    }

    std::uint64_t inner_data() {
        return data_->counter_;
    }

    std::shared_ptr<Inner> data_;
    std::thread t_;
    bool running_;
};

struct Outer {

    std::uint64_t data() {
        return middle_.inner_data();
    }

    Middle middle_;
};




int main() {

    Outer o;
    while (true) {
        std::cout << "Data: " << o.data() << std::endl;
    }

    return 0;
}

我的疑惑来自以下内容:

  1. Middle::inner_data 中的 data_->counter 的访问是否安全?
  2. 如果线程 A 有一个成员 shared_ptr<T> sp,并决定在线程 B 执行 shared_ptr<T> sp = A::sp 时对其进行更新,那么复制和销毁是否是线程安全的?或者我会因为对象正在被销毁而导致复制失败。

在什么情况下(我可以用某些工具检查此项?)未定义的行为很可能是指 std::terminate? 我怀疑类似上面的问题发生在我的一些生产代码中,但我不能确定,因为我对1和2感到困惑,但自从我写了这个小程序以来,它已经运行了几天,什么也没有发生。

代码可以在此处检查:https://godbolt.org/g/saHz94


“未定义”是否意味着std::terminate?不是的,这意味着程序可以做任何它想做的事情。这包括std::terminate、给出错误结果、无限循环或其他各种效果。这些效果可能会因编译器版本、标志、代码更改甚至编译而发生变化。对于这段代码没有任何推理。 - nwp
这可能是你正在寻找的内容 [#include <atomic>] [std::atomicstd::uint64_t counter_;] - aj.toulan
@aj.toulan 感谢您的建议,我知道原子类型,只是在一个写入/多个读取器场景中可能会有些困惑。 - arynaq
2
@arynaq #2 'atomic_shared_ptr<>' 具有线程安全的构造/复制,但我不确定在销毁期间是否会在另一个线程上留下悬空引用。我对线程编程还比较新,如果这不能帮助你,敬请谅解。 - aj.toulan
2个回答

3
在 Middle::inner_data 中,访问 data_->counter_ 是安全的吗? 不是,这是竞态条件。 标准规定,任何时候允许多个线程从未同步访问同一变量,并且至少有一个线程可能会修改变量时,其行为是未定义的。就实际情况而言,以下是您可能会看到的一些不良行为:
1. 由于不同的处理器核心独立缓存变量(使用 atomic_t 可避免此问题),读取 counter_ 的线程读取“旧”的 counter 值(很少或从不更新)。 2. 线程 A 可能会读取 data_ shared_pointer 指向的地址,并且正要取消引用该地址并从它指向的 Inner 结构中读取时,线程 B 就将其踢出 CPU。线程 B 执行,在 Thread B 的执行期间,旧的 Inner 结构被删除,并将 data_ shared_pointer 设置为指向新的 Inner 结构。然后线程 A 再次回到 CPU 上,但是由于线程 A 已经在内存中具有旧的指针值,因此它取消引用旧值而不是新值,并最终从已释放/无效的内存中读取。再次说明,这是未定义的行为,因此原则上任何事情都可能发生;在实践中,您可能会看到没有明显的不良行为,或偶尔出现错误/垃圾值,或可能会崩溃,这取决于情况。
如果线程 A 有一个成员 shared_ptr sp 并决定在线程 B 执行 shared_ptr sp = A::sp 时更新它,那么复制和销毁是否是线程安全的?还是我冒险因对象正在被销毁而导致复制失败。 如果您只重新定位 shared_ptrs 本身(即将它们更改为指向不同的对象)而不修改它们所指向的 T 对象,那应该是线程安全的。但是,如果您修改 T 对象本身的状态(即您示例中的 Inner 对象),则不是线程安全的,因为您可能会有一个线程从对象中读取,而另一个线程正在写入它(删除对象可以视为写入它的特殊情况,因为它肯定会更改对象的状态)
在什么情况下(我可以使用某些工具检查此内容)未定义可能意味着 std::terminate? 当您遇到未定义行为时,其结果非常依赖于程序的细节、编译器、操作系统和硬件架构。原则上,未定义行为意味着任何事情都可能发生(包括程序按您打算的方式运行!),但您无法依赖任何特定的行为,这就是使未定义行为如此恶劣的原因。
特别是,多线程程序中存在竞争条件时,它通常能正常运行数小时/天/周,但有一天时间刚好到位,就会崩溃或计算出错误的结果。因此,竞争条件很难复现。
至于何时调用terminate(),如果故障导致运行时环境检测到错误状态(例如损坏了运行时环境对其进行完整性检查的数据结构,例如某些实现中的堆元数据),则会调用terminate()。是否实际发生这种情况取决于堆是如何实现的(这因操作系统和编译器而异)以及故障引入了什么样的损坏。

shared_ptr::operator=(shared_ptr) 不是线程安全的吗?这是最令我困惑的部分。如果其对象计数为1,shared_ptr如何知道它即将被复制,因此不会释放其内存? - arynaq
根据 shared_ptr 的 man 页面 [http://en.cppreference.com/w/cpp/memory/shared_ptr]:即使这些实例是副本并共享同一对象的所有权,所有成员函数(包括复制构造函数和复制赋值)都可以由不同 shared_ptr 实例上的多个线程调用而无需额外同步。如果多个执行线程在没有同步的情况下访问相同的 shared_ptr,并且其中任何一个访问使用 shared_ptr 的非 const 成员函数,则会发生数据竞争。 - Jeremy Friesner
由于shared_ptr赋值运算符以(const shared_ptr &rhs)作为其参数,因此我理解上述内容的方式是,从另一个线程复制shared_ptr应该是线程安全的,因为rhs上没有调用非const方法。(当然,这假设您可以保证rhs本身即shared_ptr对象在赋值操作期间不会被销毁!) - Jeremy Friesner
但如果它们指向不同的对象呢?因为线程B更快地更新了自己的shared_ptr,而线程A拥有的是一个旧对象(从线程B的角度来看),那么A中的对象将具有ref_count 1,B中的对象也将具有ref_count 1,并且在A尝试更新其自己的shared_ptr(其中包含旧数据)而B即将释放它的时间可能会有一些重叠。两者都具有ref_count 1意味着它们本质上是不同的对象,对吗?那么这就不是线程安全的了? - arynaq
至于shared_ptr如何实现这一点,我的猜测是它使用原子计数器来记录共享对象的使用计数。在赋值运算符中,线程A(复制线程)要做的第一件事情是增加并检查使用计数。如果增加后为1,则线程B必须已经释放了对象,因此该对象不安全可用;在这种情况下,线程A的指针可以将自己设置为NULL,然后我们就完成了。另一方面,如果增加后大于1,则线程A可以安全地读取对象,因为我们知道只要线程A的计数存在,线程B就不会丢弃它。 - Jeremy Friesner

1

线程安全是线程之间的操作,而不是一般情况下的绝对安全。

在另一个线程写入变量时,您不能读取或写入变量,而没有在其他线程的写入和您的读取或写入之间进行同步。这样做是未定义的行为。

未定义可以意味着任何事情。程序崩溃。程序读取不可能的值。程序格式化硬盘。程序将您的浏览器历史记录发送电子邮件给您的所有联系人。

未同步整数访问的常见情况是编译器将多个对值的读取优化为一个,并且不重新加载它,因为它可以证明没有定义的方式可以修改该值。或者,CPU内存缓存也会这样做,因为您没有进行同步。

对于指针,类似或更严重的问题可能会发生,包括跟随悬空指针,破坏内存,崩溃等。

现在有原子操作可用于共享指针。, 以及 atomic<shared_ptr<?>>


感谢您的回答,Yakk。这对我很有帮助。您知道吗,如果在拥有线程重新分配 shared_ptr(此时 ref_count = 1)的同时从另一个线程复制 shared_ptr 是否安全?我怀疑不是,但我找不到任何相关信息。 - arynaq

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