如果既不调用 std::thread::detach 也不调用 std::thread::join,为什么程序会终止?
Why program is terminated if neither calls std::thread::detach nor std::thread::join?
我知道如果既没有调用 std::thread::detach
也没有调用 std::thread::join
。线程对象在析构函数中调用 std::terminate
。我想知道这个设计选择了什么?为什么它在析构函数中调用 std::terminate
n。此外,如果它不在析构函数中调用 std::terminate
,则 no-detach 的行为与现在的 detach 行为相同。那么为什么在设计线程API时不仅保留thread::join
而去掉terminate的调用呢?背后的考虑是什么?
失去线程是不好的;你的程序在几乎所有平台上的关闭都变得非常崩溃(我的意思是你可以走运......)。
分离线程不会通过“在线程退出时准备好”futures 丢失。但是调用 detach()
而不安排同步线程结束发生在 main()
结束之前的方法意味着你的程序的行为可能变得未被 C++ 标准定义(大多数线程中的大多数代码充满了在 main 结束后不能安全地 运行 的代码,因此在 main()
结束后仍然存在的线程不是一个好主意;没有连接或等价物,线程完成和 [= 之间存在“竞争” 11=] 这样做,无论您进行了多少次“睡眠”调用。这种竞争的存在通常足以使 C++ 正式注销指定程序的行为)。
“我应该分离”的想法是错误的。线程应该默认为 detach()
的想法很疯狂。
默认为 join()
比 detach()
更合理。但是加入可以抛出。抛出析构函数是不好的,因为它们在抛出过程中被评估,如果在抛出过程中涉及展开的代码依次抛出,那么程序将终止。更重要的是,如果未经检查,该异常路径可能包含死锁条件,因为您可能正在与另一个线程进行握手,并且它不知道关闭自己。
std::thread
不是用户友好的安全线程原语;使线程对用户友好远远超出了它的范围。可以在其之上构建用户友好的安全线程原语。这是从原始 pthread 中删除的一步。例如,像 IPP 之类的库。它的作用是使在 C++ 中编写线程代码 成为可能,而无需特定于平台的扩展。
通过在销毁时终止,我们在设计阶段向程序员提供反馈,他们必须智能地处理问题,而不是忽略它们。这使得它使用起来有点困难,但是正确使用线程的 99.9% 的困难不是调用 join()
.
穿线很难正确。当您弄错时,它通常也能正常工作;然后它很少会锁定或崩溃,并且只会在其他一些用户的系统上删除符号。一旦将线程添加到程序中,就不能依赖“我试过了,它成功了”。您甚至不能通常依赖“此代码在本地是正确的”,因为大多数并发设计不会组合 - 三个成对的“正确”子程序在组合时可能变得不正确。
像 TBB 之类的库,或者你自己的库,可以稍微减少这个问题。不可变状态和功能操作也是如此。或者一堆其他严格的设计东西。所有这些最终都涉及在 std::thread
.
这样低级别的东西之上编写一个框架
这是 C++11 之前的许多争论的主题。
你的问题做了一个大胆的假设:超然显然是正确的行为。但你从不证实它。事实上,有很多反对这个想法的论点,委员会也考虑过。
我将从论文 outlining the argument against it:
中拿这个例子
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;
}
一旦开始抛出异常,默认的分离行为就不再那么有用了。实际上,您可以想象一个更复杂的情况,其中异常来自线程创建例程的非本地内容。考虑 this example from a later paper:
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
的析构行为的总体思路非常简单:
- 用户没有说要做什么(即:没有调用
join
或 detach
)。
- 显然这两个答案都不正确。
您声称简单地执行 detach
是正确的解决方案。但是为什么是对的呢?分离是一件非常不安全的事情,因为你失去了再次使用线程 join
的能力。
还有 RAII 的问题。异常可能导致某些 thread
对象被无意中破坏。如果发生这种情况,并且默认行为是分离,您的程序是否仍处于功能状态?如果您的程序的其余部分期望 join
这些线程,现在这是不可能的怎么办?
这是半个答案,只关注“为什么不分离”。
一个std::thread
表示一个执行线程。按照 RAII 范例,构造函数创建执行线程,析构函数销毁它(对当前不代表执行线程的“空”对象有适当的警告)。从线程分离并不会结束线程的执行,因此这在概念上不适合析构函数。
加入线程等待执行线程结束,而终止则强制执行线程结束。这些方法中的任何一种在概念上都与 std::thread
的析构函数相匹配。我将留给其他人讨论为什么选择一个选项而不是另一个选项。 (简短的版本是有很多争论;没有一个选项无疑比另一个更好,但必须做出决定。)
其他人给出了更好的答案。我为那些寻找简短和概念性内容的人提供这个答案。
我知道如果既没有调用 std::thread::detach
也没有调用 std::thread::join
。线程对象在析构函数中调用 std::terminate
。我想知道这个设计选择了什么?为什么它在析构函数中调用 std::terminate
n。此外,如果它不在析构函数中调用 std::terminate
,则 no-detach 的行为与现在的 detach 行为相同。那么为什么在设计线程API时不仅保留thread::join
而去掉terminate的调用呢?背后的考虑是什么?
失去线程是不好的;你的程序在几乎所有平台上的关闭都变得非常崩溃(我的意思是你可以走运......)。
分离线程不会通过“在线程退出时准备好”futures 丢失。但是调用 detach()
而不安排同步线程结束发生在 main()
结束之前的方法意味着你的程序的行为可能变得未被 C++ 标准定义(大多数线程中的大多数代码充满了在 main 结束后不能安全地 运行 的代码,因此在 main()
结束后仍然存在的线程不是一个好主意;没有连接或等价物,线程完成和 [= 之间存在“竞争” 11=] 这样做,无论您进行了多少次“睡眠”调用。这种竞争的存在通常足以使 C++ 正式注销指定程序的行为)。
“我应该分离”的想法是错误的。线程应该默认为 detach()
的想法很疯狂。
默认为 join()
比 detach()
更合理。但是加入可以抛出。抛出析构函数是不好的,因为它们在抛出过程中被评估,如果在抛出过程中涉及展开的代码依次抛出,那么程序将终止。更重要的是,如果未经检查,该异常路径可能包含死锁条件,因为您可能正在与另一个线程进行握手,并且它不知道关闭自己。
std::thread
不是用户友好的安全线程原语;使线程对用户友好远远超出了它的范围。可以在其之上构建用户友好的安全线程原语。这是从原始 pthread 中删除的一步。例如,像 IPP 之类的库。它的作用是使在 C++ 中编写线程代码 成为可能,而无需特定于平台的扩展。
通过在销毁时终止,我们在设计阶段向程序员提供反馈,他们必须智能地处理问题,而不是忽略它们。这使得它使用起来有点困难,但是正确使用线程的 99.9% 的困难不是调用 join()
.
穿线很难正确。当您弄错时,它通常也能正常工作;然后它很少会锁定或崩溃,并且只会在其他一些用户的系统上删除符号。一旦将线程添加到程序中,就不能依赖“我试过了,它成功了”。您甚至不能通常依赖“此代码在本地是正确的”,因为大多数并发设计不会组合 - 三个成对的“正确”子程序在组合时可能变得不正确。
像 TBB 之类的库,或者你自己的库,可以稍微减少这个问题。不可变状态和功能操作也是如此。或者一堆其他严格的设计东西。所有这些最终都涉及在 std::thread
.
这是 C++11 之前的许多争论的主题。
你的问题做了一个大胆的假设:超然显然是正确的行为。但你从不证实它。事实上,有很多反对这个想法的论点,委员会也考虑过。
我将从论文 outlining the argument against it:
中拿这个例子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;
}
一旦开始抛出异常,默认的分离行为就不再那么有用了。实际上,您可以想象一个更复杂的情况,其中异常来自线程创建例程的非本地内容。考虑 this example from a later paper:
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
的析构行为的总体思路非常简单:
- 用户没有说要做什么(即:没有调用
join
或detach
)。 - 显然这两个答案都不正确。
您声称简单地执行 detach
是正确的解决方案。但是为什么是对的呢?分离是一件非常不安全的事情,因为你失去了再次使用线程 join
的能力。
还有 RAII 的问题。异常可能导致某些 thread
对象被无意中破坏。如果发生这种情况,并且默认行为是分离,您的程序是否仍处于功能状态?如果您的程序的其余部分期望 join
这些线程,现在这是不可能的怎么办?
这是半个答案,只关注“为什么不分离”。
一个std::thread
表示一个执行线程。按照 RAII 范例,构造函数创建执行线程,析构函数销毁它(对当前不代表执行线程的“空”对象有适当的警告)。从线程分离并不会结束线程的执行,因此这在概念上不适合析构函数。
加入线程等待执行线程结束,而终止则强制执行线程结束。这些方法中的任何一种在概念上都与 std::thread
的析构函数相匹配。我将留给其他人讨论为什么选择一个选项而不是另一个选项。 (简短的版本是有很多争论;没有一个选项无疑比另一个更好,但必须做出决定。)
其他人给出了更好的答案。我为那些寻找简短和概念性内容的人提供这个答案。