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析构函数发送中断请求。

最后,本文讨论了线程对象在析构函数中与其它数据成员析构顺序有关的问题,另一个问题是和析构函数的异常处理机制有关,限于篇幅,在下篇文章介绍。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/web/93468.shtml
繁体地址,请注明出处:http://hk.pswp.cn/web/93468.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【语法】JSON格式与基础语法

文章目录JSON 简介JSON 语法规则JSON 名称/值对JSON 值类型JSON文件存储JSON示例数据示例Python解析JSON代码JSON 简介 JSON 语法是 JavaScript 语法的子集。JSON 是存储和交换文本信息的语法。JSON: JavaScript Object Notation(JavaScript 对象表示法)。 JSON 语法规则 数…

GitHub 热榜项目 - 日榜(2025-08-16)

GitHub 热榜项目 - 日榜(2025-08-16) 生成于&#xff1a;2025-08-16 统计摘要 共发现热门项目&#xff1a;13 个 榜单类型&#xff1a;日榜 本期热点趋势总结 本期GitHub热榜呈现三大技术热点&#xff1a;1) AI应用深入垂直领域&#xff0c;SpatialLM将大语言模型应用于空间…

什么是EDA(Exploratory Data Analysis,探索性数据分析)

EDA&#xff08;Exploratory Data Analysis&#xff0c;探索性数据分析&#xff09;是一种在正式建模前&#xff0c;通过统计量和可视化方法来理解数据特征、发现模式与异常、并提出假设的过程。 这张图里你会看到&#xff1a; 直方图&#xff1a;展示单变量的分布&#xff0c;…

计算机毕业设计java的小天鹅酒店月子会所管理小天鹅酒店母婴护理中心管理系统设计小天鹅酒店产后护理会所信息化管理平台

计算机毕业设计java的小天鹅酒店月子会所管理9zl079&#xff08;配套有源码 程序 mysql数据库 论文&#xff09; 本套源码可以在文本联xi,先看具体系统功能演示视频领取&#xff0c;可分享源码参考。在当今数字化时代&#xff0c;随着人们对产后护理需求的不断增加&#xff0c;…

Docker-14.项目部署-DockerCompose

一.DockerCompose大家可以看到&#xff0c;我们部署一个简单的java项目&#xff0c;其中包含3个容器&#xff1a;MySQLNginxJava项目而稍微复杂的项目&#xff0c;其中还会有各种各样的其它中间件&#xff0c;需要部署的东西远不止3个。如果还像之前那样手动的逐一部署&#xf…

Vue组件基础解析

一、组件的核心意义 组件是Vue中实现UI复用与逻辑封装的基础单元,能将复杂UI拆分为独立、可重用的部分,最终组织成嵌套的树状结构(类似HTML元素嵌套)。Vue组件模型支持自定义内容与逻辑封装,也能兼容原生Web Component。 二、组件的定义方式 根据是否使用构建步骤,Vue…

第5问 对于数据分析领域,统计学要学到什么程度?

1. ​统计学在数据分析中的定位​​核心作用​&#xff1a;统计学是数据分析的底层方法论&#xff0c;涵盖数据描述、推断预测和模型构建。​两大分支​&#xff1a;​描述统计​&#xff08;EDA阶段&#xff09;&#xff1a;数据清洗、特征工程的基础&#xff08;如均值/分布/…

[go] 桥接模式

桥接模式 是一种结构型设计模式&#xff0c; 可将一个大类或一系列紧密相关的类拆分为抽象和实现两个独立的层次结构&#xff0c; 从而能在开发时分别使用。 模型说明抽象部分&#xff08;Abstraction&#xff09;提供高层控制逻辑&#xff0c;依赖于完成底层实际工作的实现对象…

GitHub的使用教程

第一章&#xff1a;准备工作 1.1&#xff1a;安装Git并设置你的GitHub账户 1.1.1&#xff1a;注册 GitHub 账号&#xff1a; 访问 https://github.com/ 并注册一个新账号。 可以使用qq邮箱进行注册 输入邮箱后点击sign up for GitHub,设置密码后进行注册&#xff0c;输入验…

Day56 Java面向对象10 方法重写

Day56 Java面向对象10 方法重写 1.为什么要方法重写 当子类不需要父类方法的全部内容 或 父类的方法无法满足子类的需求时,就需要在子类重写父类的方法 2.如何方法重写 重写必须发生在继承关系中,只能是子类重写父类子类重写的方法名必须和父类方法一致,方法体可以不同子类重写…

【C++】标准库中用于组合多个值的数据结构pair、tuple、array...

在 C 标准库中&#xff0c;有多种数据结构可用于组合多个值&#xff0c;每种结构都有其特定的设计目的和适用场景。以下是主要组合数据结构的分类解析&#xff1a; 一、核心组合数据结构 1. std::pair (C98) 用途&#xff1a;存储两个相关值&#xff08;键值对、坐标点等&#…

深入解析C++ STL链表(List)模拟实现

目录 一、需要实现的三个类及其成员函数接口 二、结点类的模拟实现 构造函数 三、迭代器类的模拟实现 1、迭代器类的作用 2、迭代器类模板参数说明 3、构造函数 4、前置运算符重载 5、后置运算符重载 6、前置 -- 运算符重载 7、后置 -- 运算符重载 8、运算符重载 …

将mysql数据库表结构导出成DBML格式

前言 DBML&#xff08;数据库标记语言&#xff09;是一种简单易读的 DSL 语言&#xff0c;用于定义数据库结构。 因为需要分析商品模块的表设计是否合理&#xff0c;所以需要图形化表&#xff0c;并显示表之前的关系。 想来想去&#xff0c;找到了DBML。所以就需要将数据库结构…

玩转tokenizer

&#x1f31f; 案例 1&#xff1a;加载现成的 BERT 分词器from tokenizers import Tokenizer# 加载一个预训练的 BERT tokenizer&#xff08;文件需要提前下载&#xff0c;比如bert-base-uncased&#xff09; tokenizer Tokenizer.from_file("bert-base-uncased-tokenize…

Day53--图论--106. 岛屿的周长(卡码网),110. 字符串接龙(卡码网),105. 有向图的完全联通(卡码网)

Day53–图论–106. 岛屿的周长&#xff08;卡码网&#xff09;&#xff0c;110. 字符串接龙&#xff08;卡码网&#xff09;&#xff0c;105. 有向图的完全联通&#xff08;卡码网&#xff09; 106. 岛屿的周长&#xff08;卡码网&#xff09; 方法&#xff1a;深搜 思路&am…

Elasticsearch 数据建模与映射(Mapping)详解

在 Elasticsearch 中&#xff0c;数据建模与映射&#xff08;Mapping&#xff09; 是决定搜索性能、存储效率和功能支持的核心环节。合理的映射设计能让搜索更精准、聚合更高效、存储更节省。 本文将全面详解 Elasticsearch 的 数据建模原则、字段类型、动态映射、自定义分析器…

5G工业一体机汽车零部件工厂的无纸化管理

在全球数字化转型的浪潮中&#xff0c;制造业对信息化、智能化的需求日益强烈。尤其是在汽车零部件领域&#xff0c;生产线的复杂性、质量追溯的苛刻性以及对效率的高要求&#xff0c;迫切需要一种高效、可靠、可扩展的管理模式。以“5G工业一体机”为核心的无纸化管理&#xf…

项目管理工具

1、概述IT 项目生命周期通常可分为启动、规划、执行、监控与控制、收尾五个核心阶段&#xff0c;每个阶段的目标和任务不同&#xff0c;所依赖的工具也各有侧重。以下按阶段梳理常用工具&#xff0c;涵盖项目管理、协作、技术开发等多个维度。2、启动阶段&#xff1a;明确项目目…

Linux 进程、线程与 exec/系统调用详解

1. wait 与 waitpid —— 子进程资源回收1.1 waitpid_t wait(int *wstatus);功能&#xff1a;阻塞等待&#xff0c;回收任意子进程的资源空间。参数&#xff1a;wstatus&#xff1a;保存子进程退出状态的变量地址NULL&#xff1a;不保存退出状态返回值&#xff1a;成功&#xf…

Laravel 使用ssh链接远程数据库

1.创建ssh ssh -i ./id_rsa -N -L 13306:127.0.0.1:3306 -p 22 root***对上述代码的解释&#xff1a; 命令是一个SSH隧道命令&#xff0c;用于将本地端口3306转发到远程服务器上的3306端口。以下是命令的详细解释&#xff1a;# 调用SSH客户端。 ssh # 指定用于身份验证的私钥文…