C++析构函数和线程退出1
线程作为程序在操作系统中的执行单元,它是活动对象,有生命周期状态,它是有始有终的。有启动就有结束,在上篇文章中讨论了线程作为数据成员启动时的顺序问题,如何避免构造函数在初始化对象时对线程启动的负面影响,是关于线程的“始”,而“作为“始”的对应方“终”,也和构造函数的对应方析构函数也密切相关,同样如果使用不当,可能也会遇到问题。
现在简单地讨论一下线程退出时和C++析构函数的关系,析构函数的语义对线程退出时的影响。
开始之前,先看一下析构函数的特点。
首先,对象的析构顺序是构造过程的反方向顺序,即:
1、首先进行子类的析构。
2、接着类成员变量的析构(按声明顺序的反向顺序,正好和构造的顺序相反)。
3、基类析构函数最后执行,按照继承顺序的反方向,也是正好和构造时顺序相反。
此外,与构造函数不同的是,析构函数不会抛出异常。如果析构函数有异常就会直接调用std::terminate()退出进程,也就是它缺省是noexcept修饰的。
例子
把上文中的例子1,改成使用两阶段模式来启动:
class thread_raii {thread th; // 线程对象先声明string name;void run() { cout << name << endl; }public:thread_raii(const string &name) : name(name) {}// 线程启动函数void start_thread() {th = thread(&thread_raii::run, this);}~thread_raii() {if (th.joinable())th.join();}
};int main() {thread_raii thr("raii12345678901234567890");thr.start_thread(); // 在类外,使用显式函数启动线程
}
线程对象创建好之后使用start_thread()函数来启动。线程的运行模式是属于RTC(Run To Complete)运行模式,即线程启动后线程函数会一直运行,不会被打断,直到遇到return语句或者函数运行结束,接着是线程退出,线程join之后它的线程对象被销毁。
析构顺序
看一下thread_raii类的析构函数,如果没有调用start_thread(),即线程还没有启动过,当在析构函数中join线程时,会导致std::system_error 异常,直接调用std::terminate()退出程序。所以在析构函数中不能无条件的join线程,需要先用joinable()来判断是否允许join操作。代码很简单:
~thread_raii() {if (th.joinable())th.join();}
在编译器眼中,加上析构name和th对象的隐含代码,应该等同于下面的代码:
~thread_raii() {if (th.joinable())th.join();name.~string(); // 编译器生成的代码th.~thread(); // 编译器生成的代码
}
显然这样的析构顺序是没有问题的,尽管name对象的析构要早于线程对象th的析构,因为这些数据成员的析构操作都是在线程对象join()执行完之后才运行的,而join之后线程就已经不再执行了,也就不会访问name,因此是安全的。如果线程就还没有启动,joinable()返回false,也就不会调用join(),因为线程没有启动,线程函数也就不会运行,也就不会访问name,name和th的析构顺序也没有问题。
前文分析过,使用两阶段模式时,对线程对象和其它数据成员的声明可以没有先后顺序的要求。因此,综合起来可以看出,在本例中,使用两阶段模式来启动线程,线程对象在初始化时构造顺序没有要求,在销毁时析构顺序也没有要求,不但保证了线程对象创建时的安全性,而且也保证了线程对象销毁时的安全性,两阶段模式似乎很完美。
std::jthread的退出错误
在C++20之后,标准库又提供了一个新的线程类:std::jthread
,作为 std::thread
的增强版,它提供了自动资源管理功能,在析构的时候会自动join线程。这样,如果使用std::jthread按照两阶段模式来实现上面的thread_raii类:
class jthread_raii {jthread jth; // 线程对象先声明string name;void run() { cout << name << endl; }public:jthread_raii(const string &name) : name(name) {}void start_thread() {jth = jthread(&jthread_raii::run, this);}
};
因为std::jthread类的析构函数自动提供了join()操作,因此,jthread_raii类也就没有提供析构函数,由编译器自动生成一个缺省析构函数,该析构函数等同于下面的代码:
~jthread_raii() {name.~string(); // name先析构jth.~jthread(); // 线程对象后析构
}
显然,数据成员对象的析构顺序出现了问题,出现了数据成员name已经销毁了,而线程可能还在运行中的情况,线程可能还会访问这个已经销毁的数据成员name。看下面的测试代码:
int main() {jthread_raii raii("raii12345678901234567890");raii.start_thread();
}
它的一个运行结果是:
���5�]w;D$�34567890
可见,确实发生了数据成员name被析构之后,线程还在访问它的错误。那么该如何解决呢?
解决方案
有两个解决方案:
方案1、在类中最后声明jthread类型的数据成员。
把数据成员jth和name的位置互换,程序就正常了。此时,编译器生成的缺省析构函数等同于下面的代码:
~jthread_raii() {jth.~jthread();name.~string();
}
先析构线程对象,在析构jthread对象jth时,会调用它的join()成员函数,等待线程执行完毕后销毁jth,再销毁name。
也就是通过调整数据成员的声明顺序来保证析构时的安全,这种方案的可行性只能依靠编程经验+文档说明来保证了。实践表明,说明文档约束性太低,人往往也是靠不住的,还得使用技术手段来避免程序员犯错。因此,可以使用下面的实现方案。
方案2、利用join()函数的同步性,提供一个安全的析构函数。
不再使用缺省的析构函数,而是手动编写一个析构函数,使用join()函数的阻塞同步性来保证name对象析构时的安全性。
~jthread_raii() {if (jth.joinable())jth.join();
}
编译器生成的缺省析构函数等同于下面的代码:
~jthread_raii() {if (jth.joinable())jth.join();name.~string();jth.~jthread();
}
如果线程启动了,在析构时就会调用join(),如果此时线程没有执行完,join()作为线程的同步点会把当前线程阻塞住,直到jth线程执行完,那么所有的数据成员都不会被析构,还都是有效的数据;如果线程没有启动过,在析构时就不会调用join(),直接析构name和jth,因为没有线程在运行,所以谁先析构谁后析构没有关系。
新的问题
前面说过thread_raii类中线程的运行模式是RTC运行模式,也就是不需要中断线程执行的场景。如果把线程对象换成了jthread类型,即jthread_raii类的实现形式,那么线程的运行模式就不是RTC模式,而是可中断运行模式了,因为在jthread对象析构时,会同时要求线程也中断当前的执行流程然后退出,那么方案2就会有潜在的问题了。
下面看一个线程在线程对象销毁时被中断退出的例子。
class jthread_raii2 {jthread jth; // 线程对象先声明string name;void run(stop_token token) {int count = 0;// 每间隔100毫秒输出一行信息,如果没有收到中断信号,// 线程就会一直运行下去,直到打印20次,即2秒后退出while (!token.stop_requested()) {cout << name << count << endl;if (count++ >= 20) break;this_thread::sleep_for(chrono::milliseconds(100));}}public:jthread_raii2(const string &name) : name(name) {}void start_thread() {jth = jthread([this](stop_token token) {run(token);});}
};
线程函数run可以一直执行到线程函数返回,循环打印20次,即2秒后退出,也就是RTC运行模式。也可以在执行过程中接收中断通知,然后结束循环,并从线程函数中提前返回,即当在循环中发现token.stop_requested()的结果为true时,就中断线程执行,提前结束运行。jthread提供了中断功能,当jthread对象失去生存期被销毁时,它的析构函数会自动向线程发送中断请求,如果线程还在执行中,就让线程提前中断返回。
下面是测试程序。
int main() {jthread_raii2 thr("jthread_raii-");thr.start_thread();this_thread::sleep_for(chrono::seconds(1));
}
jthread_raii2对象在启动线程1秒后销毁,也即它的数据成员-线程对象jth也随着销毁,析构函数发出中断请求。程序运行结果如下:
jthread_raii-0
jthread_raii-1
jthread_raii-2
....
jthread_raii-8
jthread_raii-9
线程每100毫秒打印一行信息,jthread线程对象在启动线程1秒后销毁,同时线程也被中断退出,共打印了10行信息,执行结果符合预期。
前面分析过,缺省析构函数销毁jthread_raii2对象时,因为name成员先销毁,线程不安全。因此,为了安全起见,同前面的jthread_raii实现方案一样,jthread_raii2没有使用缺省的析构函数,也提供了一个程序员实现的析构函数:
~jthread_raii2() {if (jth.joinable())jth.join();
}
运行上面的测试程序,运行结果如下:
jthread_raii-0
jthread_raii-1
jthread_raii-2
...
jthread_raii-19
jthread_raii-20
从运行结果看,尽管jthread线程对象在启动1秒之后准备销毁,可是线程却整整运行了2秒之后才正常结束,没有被中断提前退出。也就是说线程对象thr等待了2秒之后才被销毁,尽管这种情况在本例中没有产生副作用,但是延迟了线程对象的析构时间,线程资源也没有能够及时释放,本来是个可中断退出的模式,却变成了RTC模式,不符合预期。
原因及方案
为什么会发生这种情况呢?
为jthread_raii2所实现的析构函数中,jth线程对象先join线程,然后析构,如果join没有返回,jth的析构函数也就不会执行。这就意味着通知线程中断退出的中断请求无法发出,只能等到线程正常运行完退出后,程序才能从join操作的等待中返回,这时jth的析构操作才有机会发出中断请求。但此时线程已经运行结束了,也就没有必要再发送中断请求了,造成了没有必要的延迟和资源没有及时释放。当然,如果线程函数是个死循环的话,那么当jthread_raii2对象销毁时,根本就没有机会中断退出了,线程对象就一直被join()阻塞在析构函数中。
既然jthread线程的执行过程是可以被中断取消的,那么当在析构函数中添加join()调用时,先在join之前检查是否需要发送中断请求,如果需要,就先发送中断请求,然后再调用join()函数。代码修改如下:
~jthread_raii2() {if (jth.joinable()) {std::stop_source source = jth.get_stop_source();if (source.stop_possible()) {// 需要时在join前先请求线程中断退出source.request_stop();}jth.join();}}
运行上面的测试程序,结果正常,符合预期:
jthread_raii-0
jthread_raii-1
...
jthread_raii-8
jthread_raii-9
std::jthread的析构功能
C++20中的std::jthread与std::thread相比,std::jthread在析构时可以自动join已启动的线程(字母j代表了join的意思),并发出中断请求信号。可见它有两大作用:一是能够自动join线程,避免了在thread对象析构时,如果线程是joinable,但没有调用join而导致程序直接terminate。二是实现了协作式的线程退出机制,jthread的析构函数会自动给线程发出中断请求,如果线程对象析构时需要同时中断/取消线程的运行,此举能够提前中断线程退出。可见,jthread相比thread既好用又周到,然而从前面的例子可以看出,好用并不一定也能用好,使用时需要注意一些和执行顺序有关的细节,否则可能导致一些不易发现的错误。
从本文中使用jthread来实现的两个例子来看,当它用作一个类中的数据成员,在对象析构时,为了保证数据成员和线程对象的析构顺序的安全,需要提供析构函数来手动实现线程的join功能,这样就让jthread析构函数的自动join功能失去了作用,同时又让它失去了发送中断信号的机会。下面是原来的析构流程:
~jthread_raii() {name.~string();// jthread析构功能具有自动join和发送中断信号jth.~jthread();
}
现在变成了这样的流程:
~jthread_raii() {if (jth.joinable()) {std::stop_source source = jth.get_stop_source();if (source.stop_possible()) {// jthread的发送中断信号的功能放到这儿实现了source.request_stop();}// jthread的自动join功能放到这儿实现了jth.join();}name.~string();// 此时jthread的析构功能只剩下thread原有的功能jth.~jthread();
}
这是一个有趣的事情:程序为了解决问题而改进的jthread_raii析构函数,把jthread析构函数新增加的自动析构和发送中断信号的功能给提前实现了,最终让jthread的析构功能只剩下了最初thread的析构功能,即释放线程资源。
小结
从前文中线程对象的构造和本文中线程对象的析构,我们能够看到编写多线程应用程序时的复杂性,这仅仅是线程对象的创建、启动和销毁过程,就隐含了许多问题。在编写多线程应用程序时,如果考虑不到,太容易出现错误了。
在使用两阶段模式来启动线程时,需要注意的是,当在C++20以后使用std::jthread作为一个类的数据成员来编程时,如果该类提供了析构函数,要小心析构函数中数据成员的析构顺序,防止其它数据成员在jthread对象的前面析构,也要注意是否影响到了jthread析构函数发送中断请求。
最后,本文讨论了线程对象在析构函数中与其它数据成员析构顺序有关的问题,另一个问题是和析构函数的异常处理机制有关,限于篇幅,在下篇文章介绍。