浅谈std::thread的可联结性

联结性

对于std::thread创建的线程对象,要么join它,要么detach它,否则会导致程序崩溃。

要想从理论上解释上述原因,就要了解一下thread的可联结性。

std::thread对象都是处于两种状态之一:可联结的和不可联结的。

可联结状态:

  • std::thread对应底层以异步方式已运行或可运行的线程
  • 对应的底层线程处于阻塞或等待调度
  • 对应的底层线程已运行至结束

不可联结状态:

  • 默认构造的std::thread:它没有可以执行的函数,因此没有对应的底层执行线程
  • 已移动的std::thread:移动的结果是,一个std::thread所对应的底层执行线程被对应到另外一个std::thread
  • 已联结的thread: 联结后,thread对象不再对应到已结束运行的底层执行线程
  • 已分离的thread: 分离操作会把thread对象和它对应的底层执行线程之间的连接断开

thread的可联结性极其重要的原因之一是,如果可联结的线程对象的析构函数被调用,则程序的执行就终止。

假设thread的析构函数不终止程序运行

如果不终止程序运行,那么它要么隐式join,要么隐式detach。

考虑下面的程序:

// 注意,这是一个有缺陷的程序示例
constexpr atuo tenMillion = 10000000;

// 执行任务如下:
// 1. 接受一个筛选器函数filter和最大值作为形参
// 2. 校验做计算的条件全部成立后,对筛选器选出的0-maxVal之间的数值实施计算
// 假设筛选和条件检验都是耗时操作,并发处理它们
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
	std::vector<int> goolVals;
	
	std::thread t([&filter, maxVal, &goodVals] {
		for (auto i = 0; i <= maxVal; ++i) {
			if (filter(i))
				goodVals.push_back(i);
		}
	});
	
	auto nh = t.nativate_handle(); // 设置线程优先级,线程t已经启动,再设置优先级无效!
	
	if (conditionAreSatisfied()) {
		t.join();
		performComputation(goodVals);
		return true;	
	}
		
	return false;
}

可以明显看出程序问题:如果conditionAreSatisfied返回true,不会有问题,如果返回false,t的析构函数会被调用,因为t还是可联结状态,程序会终止。

假设不终止程序,有以下两种可能:

  • 隐式join

    • 这种情况下,thread的析构函数会等待底层异步执行线程完成
    • 这可能导致难以追踪的性能异常
    • 如果conditionAreSatisfied早已返回了false,doWork却不立即返回,而是等待所有值上的遍历筛选,这是违反直觉的
  • 隐式detach

    • thread析构函数会分离对象与底层执行线程之间的连接,而该底层线程会继续执行
    • 这会导致更致命的调试问题
    • doWork内goodVals是通过引用捕获的局部变量,假如lambda式以异步方式运行,conditionAreSatisfied返回了false,则doWork也直接返回,它的局部变量(包括goodVals)销毁,doWork的栈帧被弹出,但线程却仍在doWork的调用方继续运行
    • 在doWork的调用方后,某个时刻,会调用其他函数,至少会有一个函数可能使用部分doWork的栈帧使用过的内存,假如f。当f运行时,doWork发起的lambda仍然在异步执行
    • 该lambda对goodVals的操作,已经是在操作f了!从f的视角看,wtf,内存被莫名的修改了!

所以,销毁一个可联结的线程实在是太恐怖了,这么看,可联结的线程的析构函数导致程序终止可能是比较合理的方式了。

使用RAII破解

但这样一来,这个黑锅就甩给了用户,你要使用std::thread对象,就得确保从它定义的作用域出去的任何路径,使它成为不可联结的状态。

任何时候,只要想在每条出向路径上都执行某动作,最常用的方法就是在局部对象的析构函数中执行该动作。这样的对象就是RAII对象(Resource Acquisition Is Initialization)。

RAII类在标准库中很常见,如STL容器(析构函数会析构容器内容并释放内存)、智能指针(析构函数调用删除器或引用计数自减)、std::fstream(析构函数会关闭对应文件)等。

但标准未提供thread的RAII实现,不过可以自己写一个:

class ThreadRAII
{
	public:
		enum class DtorAction {join, detach};
		
		ThreadRAII(std::thread&& t, DtorAction a): action(a), t(std::move(t)) {} // 先初始化析构动作,再初始化线程对象。线程对象参数是右值
		~ThreadRAII()
		{
			if (t.joinable()) { // 先判断是否可连接,对一个不可联结的对象执行以下操作会导致未定义的行为
				if (action == DtorAction::join) {
					t.join;
				} else {
					t.detach();
				}
			}
		}
		
		ThreadRAII(ThreadRAII&&) = default;
		ThreadRAII& operator=(ThreadRAII&&) = default;
		
		std::thread& get() // get接口与智能指针类似,有了这个接口,该类型的对象可用于需要直接使用std::thread类型对象的语境
		{
			return t;
		}
		
	private:
		DtorAction action;
		std::thread t;
}
小结

使用thread时,一定要注意让线程变得不可联结,否则程序会崩溃。

本文提供了一种思路,可以更好地对thread作出封装,从而保证程序的健壮性。

除了thread对象,当然也可以有其他选择,如async,boost::thread_group等。

参考资料

《Effective Modern C++》

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值