在这篇学习总结中,我们先给出在C++类中定义线程函数的方法,然后讲述在多线程下很容易发生的资源竞争的特例——析构竞态。
在C++类中定义线程函数
在多线程的开发中,一般都是把线程函数写成全局函数来使用。但是如果要把线程操作写成类,线程函数放在类里面呢?
下意识地会把线程函数写作普通的类函数,但这样子是有问题的。因为在创建线程的 api 中传入的线程函数需要在编译时确定地址,如果是普通的类函数,编译时不能确定地址,需要创建类的对象才能获取。我们可以把线程的执行函数写成 static 函数,或者是全局函数,因为这两者的函数地址在编译时是确定的。
两者的区别:static 在形式上能够体现“包装”性,能够保全类的封装性;全局函数貌似通过命名空间也能提供“包装性”呢,破坏类的封装性。实际业务中,我个人更倾向于前者——使用类的静态函数作为线程入口函数。
static 函数
1 | // CThread.h |
1 | // CThread.cpp |
1 | // main.cpp |
全局函数
对 CThread 类做出修改,放弃静态函数,使用全局函数作为线程入口函数。并且需要“暴露”类中有关成员,以便线程入口函数使用。(例子中直接将有关成员的访问属性改为了 public
,其它方案比如,可以将全局函数声明为 CThread 的友元函数)
1 | // CThread.h |
1 | // CThread.cpp |
将 join() 放入析构函数
RAII,一般译为“资源获取即初始化”,虽然此中文表达理解起来很不直观,但无奈其广泛使用。我更喜欢《EC++》中侯捷老师的翻译“资源取得时机便是初始化时机”(P63)。
因为在 main()
中进行 sleep()
操作以等待线程很丑陋,我们使用 join()
做出修改。
1 | // CThread.cpp 类新增 wait() 接口 |
1 | // main.cpp 使用 obj.wait() 替换 sleep() |
到目前为止还是正确的。
析构竞态
在这里我们不对“析构竞态”进行详细的分析、讲解,只需要先了解其概念就可以。简单来说,析构竞态就是一个线程在析构某对象时 & 另一线程在访问此对象,此时发生的不良现象。
关于析构竞态的延伸阅读:
- 先看析构竞态的现象:C++析构竞态
- 深入分析析构竞态的问题,推荐 当析构函数遇到多线程 - 陈硕的 Blog。作者在文中给出了线程安全的解决方案
线程基类
为了避免重复造轮子,我们将 CThread 作为线程基类,将 Print()
设为虚函数。这样子只要在派生类中实现(override)不同的操作就可以获得一个新的线程类。very nice 的想法!
1 |
|
但这样子就会有问题了!
如果我们如下使用我们新定义的派生类 CPrintB,会发现输出很奇怪——一直在打印 a,而非预期的 b。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17int main(int argc, char **argv)
{
CPrintB obj;
obj.start();
//sleep(5);
return 0;
}
````
- 如果我们之前将 `Print()` 声明为纯虚函数,那么运行上述代码会直接崩溃报错:
```console
cts@babj-srv01:~/niel/git_test/test> ./a.out > output.txt
pure virtual method called
terminate called without an active exception
Aborted
cts@babj-srv01:~/niel/git_test/test>而如果我们取消
main()
中对sleep()
的注释,就会打印 b,而非 a。
析构竞态
存在资源竞争的代码,永远意味着输出的不确定性。
上述继承体系 + main 函数就是滋生“析构竞态”的温床,只是 sleep()
人为地让主线程等待避免了竞争。但主线程 sleep()
结束之后,子线程如果未执行完毕……虽然此场景隐藏得比较深,但其的确是“析构竞态”的例子。
将类定义和 main 函数中的 sleep()
全部注释掉(这样子更容易发生竞争),并且循环创建 & 销毁 CPrintB 对象(通过多次执行保证小概率结果能够出现),就会看到打印输出的内容,实际上是不确定的。
其中的原因在于,如果将 pthread_join()
放在(基类)析构函数中,那么执行 pthread_join()
的时机(子类)对象【很可能】已经不完整了,具体取决于(子类)对象析构的时机(也就是 sleep()
带来的影响)。
C++ 的析构流程和构造流程完全相逆:
- 构造流程:基类构造 - 列表初始化(与初始化顺序无关,只与数据成员定义的顺序有关)(即便无显式初始化列表,也会发生成员变量的初始化) - 调用构造函数
- 析构流程:调用派生类析构函数 - 成员变量析构 - 基类析构
- 延伸阅读: 理解构造函数、析构函数执行顺序、构造析构顺序
子类对象的析构是在主线程中发生的,在另一线程中执行 threadFunc()
发生动态绑定,执行子类的成员函数。如果析构先发生,那么派生类对象的派生类部分已经释放掉了,(然后基类析构的时候才会调用 pthread_join()
,阻塞主线程以等待子线程)此后的行为是未定义的,发生什么都可能。需要强调的是,如果此时访问派生类对象的成员变量(我们的例子中没有访问类成员变量),程序是非常容易崩溃的:成员函数可能脱离对象存在,但成员变量声明周期肯定同对象。
ps:如果真的碰到这么写的代码,可以考虑在派生类析构函数中调用 wait()
,但需要作出修改保证不会对线程重复 join。
在 stackoverflow 上针对同样问题的问答:Race between virtual function and pthread_create
多线程是不好的?
线程同步、线程竞争造成的应用程序错误往往难以调试:不易复现,可能在某台机器频繁出现,在另外的机器就不会出现;而且即便应用程序不出错,在同步方面也是隐含着问题的,只是其出错条件苛刻,但其线程上下文的错误路径是存在的。
接触多线程后,容易滥用,造成过多地创建线程,在 cpu 切换线程时浪费资源。
我们认为必须引入多线程,单线程解决不了的场景,多是因为同步 IO 造成的效率瓶颈,结合异步 IO 能够带来很好的提升。但异步 IO 掌握起来更难,调试也并不容易。