为什么程序在既不调用 std::thread::d etach 也不调用 std::thread::join 的情况下终



我知道,如果没有std::thread::detach也没有std::thread::join被调用。线程对象调用析构函数中的std::terminate。我想知道这个的设计选择什么?为什么它在析构函数中调用std::terminaten。此外,如果它不在析构函数中调用std::terminate,则 no-detach 的行为与 detach 现在的行为相同。那么,为什么在设计线程 API 时不仅保留thread::join并删除对 terminate 的调用呢?引擎盖下的考虑是什么?

这是C++11之前争论的主题。

你的问题做了一个大胆的假设:超脱显然是正确的行为。但你从来没有证实过它。事实上,有许多反对这一想法的论据,委员会考虑了这些论点。

我将从概述反对它的论点的论文中举这个例子:

int fib(int n) {
if (n <= 1) return n;
int fib1, fib2;

std::thread t([=, &fib1]{fib1 = fib(n-1);});
fib2 = fib(n-2);
if (fib2 < 0) throw ...
t.join();
return fib1 + fib2;
}

一旦你开始抛出异常,默认的分离行为就不再那么有用了。实际上,您可以想象一个更复杂的情况,其中异常来自线程创建例程的非本地内容。考虑后面一篇论文中的这个例子:

std::vector<std::pair<unsigned int, unsigned int>> partitions =
utils::partition_indexes(0, size-1, num_threads);
std::vector<std::thread> threads;
LOG(LOG_DEBUG, "controller::reload_all: starting reload threads...");
for (unsigned int i=0; i<num_threads-1; i++) {
threads.push_back(std::thread(reloadrangethread(this,
partitions[i].first, partitions[i].second, size, unattended)));
}
LOG(LOG_DEBUG, "controller::reload_all: starting my own reload...");
this->reload_range(partitions[num_threads-1].first,
partitions[num_threads-1].second, size, unattended);
LOG(LOG_DEBUG, "controller::reload_all: joining other threads...");
for (size_t i=0; i<threads.size(); i++) {
threads[i].join();
}   

push_back可能会由于缺少用于重新分配阵列的内存而失败。如果发生这种情况,您将无法访问所有这些线程,并且您的程序将损坏。

这两种情况都会导致程序损坏,无论您默认为分离还是默认为terminate。但是,如果程序要被破坏,最好在问题发生时立即破坏它,而不是在代码中的某个稍后位置。

现在,更安全的解决方案是在析构函数中join。但由于其他各种原因,这并没有发生。(不幸的)共识是,如果你不说你想做什么,那么你的代码就坏了,应该爆炸。

幸运的是,C++20 给了我们std::jthread,默认情况下,它在其析构函数中加入。

std::thread的析构函数行为的一般思路非常简单:

  1. 用户没有说该怎么做(即:没有打电话给joindetach)。
  2. 这两个答案显然都不是正确的。

你声称简单地做一个detach是正确的解决方案。但为什么是对的呢?分离是一件非常不安全的事情,因为您将失去再次与线程join的能力。

还有RAII的问题。异常可能会导致某些thread对象被无意中销毁。如果发生这种情况,并且默认行为是分离,您的程序是否仍处于功能状态?如果程序的其余部分期望join这些线程,而现在这是不可能的怎么办?

丢失线程是不好的;你的程序的关闭在几乎每个平台上都变得非常崩溃(我的意思是你可以很幸运......

分离的线程不会通过"线程退出时做好准备"期货丢失。 但是调用detach()而不安排在main()结束之前同步线程结束的方法意味着程序的行为可能不受C++标准定义(大多数线程中的大多数代码都充满了无法在主端之后安全运行的代码,因此在main()结束后存活的线程不是一个好主意;没有连接或等效项, 在螺纹精加工和main()之间存在着一场"竞赛",无论您打多少"睡眠"电话。 这种种族的存在通常足以使C++正式注销指定程序的行为)。

"我应该只是分离"的想法是错误的。 线程应该默认为detach()的想法是疯狂的。

默认为join()是比detach()更合理的立场。 但是加入可以扔。抛出析构函数是不好的,因为它们是在抛出期间被评估的,如果在抛出过程中涉及的解开代码依次抛出,那么程序就会终止。 更重要的是,该异常路径如果不经过检查,则可能包含死锁条件,因为您可能正在与另一个线程握手的一半,并且它不知道关闭自己。

std::thread不是一个用户友好的安全线程原语;使线程用户友好远远超出了它的范围。 用户可以在其上构建用户友好的安全线程原语。 它是从原始线程中删除的一个步骤。 例如,像IPP之类的库。 它的作用是使编写线程代码C++成为可能,而无需特定于平台的扩展。

通过在销毁时终止,我们在设计阶段向程序员提供反馈,他们必须智能地处理问题,而不是忽略它们。 这使得它更难使用,但正确使用线程的 99.9% 的难度不是调用join()

线程很难正确。 当你弄错时,它通常也倾向于工作正常;然后它很少锁定或崩溃,并且仅在去除符号的其他用户系统上。 一旦您将线程添加到程序中,您就不能依赖"我尝试过并且它有效"。 您甚至通常不能依赖"此代码在本地正确",因为大多数并发设计不会组合- 三个成对的"正确"子程序组合在一起时可能会变得不正确。

像TBB之类的库,或者自己滚动,可以稍微减少这个问题。 不可变的状态和功能操作也是如此。 或者一堆其他严格的设计东西。 所有这些最终都涉及在像std::thread这样低级的东西上编写一个框架。

这是一个半答案,只关注"为什么不分离"。

std::thread表示执行线程。按照 RAII 范式,构造函数创建执行线程,析构函数销毁它(对于当前不表示执行线程的"空"对象有适当的警告)。从线程分离不会结束执行线程,因此这在概念上不适合析构函数。

加入线程会等待执行线程的结束,而终止会强制执行线程的结束。这两种方法中的任何一种在概念上都与std::thread的析构函数匹配。我将留给其他人来讨论为什么选择一个选项而不是另一个选项。(简短的版本是有很多争论;毫无疑问,这两种选择都不比另一种更好,但必须做出决定。

其他人给出了更好的答案。我为那些寻求简短和概念性的东西的人提供这个答案。

最新更新