对线程安全的困惑

Confusion about thread-safety

本文关键字:安全 线程      更新时间:2023-10-16

我是并发世界的新手,但从我所读到的内容来看,我理解下面的程序在执行中是未定义的。如果我理解正确,这不是线程安全的,因为我以非原子方式同时读取/写入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并决定更新它,而线程 Bshared_ptr<T> sp = A::sp复制和销毁是否是线程安全的?或者我冒着复制失败的风险,因为对象正在被销毁。

在什么情况下(我可以用一些工具检查吗?)未定义可能意味着std::terminate?我怀疑在我的一些生产代码中发生了上述情况,但我不能确定,因为我对 1 和 2 感到困惑,但这个小程序自从我编写以来已经运行了好几天,没有任何反应。

代码可以在这里检查 https://godbolt.org/g/saHz94

在 Middle::inner_data 访问 data_-> 柜台安全吗?

不,这是一个竞争条件。 根据该标准,只要您允许从多个线程不同步访问同一变量,并且至少一个线程可能会修改该变量,它就是未定义的行为。

实际上,您可能会看到以下一些不需要的行为:

  1. 由于不同的处理器内核彼此独立地缓存变量(使用 atomic_t 可以避免此问题,因此读取 counter_ 值的线程读取计数器的"旧"值(很少或从不更新),因为这样编译器就会意识到您打算以不同步的方式访问此变量, 并且它会知道采取预防措施来防止这个问题)

  2. 当线程 A 被线程 B 踢出 CPU 时,线程 A 可能会读取data_shared_pointer指向的地址,并即将取消引用该地址并从它指向的Inner结构中读取。 线程 B 执行,在线程 B 的执行期间,旧的内部结构将被删除,data_shared_pointer设置为指向新的内部结构。 然后线程 A 再次回到 CPU,但由于线程 A 在内存中已经有旧的指针值,它会取消引用旧值而不是新值,最终从释放/无效的内存中读取。 同样,这是未定义的行为,因此原则上任何事情都可能发生;在实践中,您可能会看到没有明显的不当行为,或者偶尔看到错误/垃圾值,或者可能是崩溃,这取决于。

如果线程 A 具有成员shared_ptr sp 并决定对其进行更新 而线程 B shared_ptr sp = A::sp 将复制和 破坏是线程安全的吗?还是我冒着复制失败的风险,因为 对象正在被销毁。

如果您只是重新定位shared_ptrs本身(即更改它们以指向不同的对象)而不修改它们指向的 T 对象,那应该是线程安全的 AFAIK。 但是,如果您正在修改 T 对象本身(即示例中的Inner对象)的状态,则不是线程安全的,因为您可以让一个线程从对象读取,而另一个线程正在写入它(删除对象可以被视为写入它的特殊情况,因为它肯定会改变对象的状态)

在什么情况下(我可以用一些工具检查吗?)是 未定义可能意味着 std::终止?

当你遇到未定义的行为时,它在很大程度上取决于你的程序、编译器、操作系统和硬件架构的细节会发生什么。 原则上,未定义的行为意味着任何事情(包括程序按照您的预期运行!)都可能发生,但你不能依赖任何特定行为 - 这就是使未定义行为如此邪恶的原因。

特别是,具有竞争条件的多线程程序通常会运行数小时/数天/数周,然后有一天时机恰到好处,它崩溃或计算出不正确的结果。 由于这个原因,竞争条件可能真的很难重现。

至于何时可以调用 terminate(),如果错误导致运行时环境检测到的错误状态(即它破坏了运行时环境执行完整性检查的数据结构,例如,在某些实现中,堆的元数据),则会调用 terminate()。 这是否实际发生取决于堆的实现方式(因操作系统和编译器而异)以及错误引入的损坏类型。

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

您无法在另一个线程写入变量时读取或写入变量,而另一个线程的写入与您的读取或写入之间没有同步。 这样做是未定义的行为。

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

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

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

现在,您可以对共享指针执行原子操作,以及atomic<shared_ptr<?>>