在C++类中创建线程函数

在这篇学习总结中,我们先给出在C++类中定义线程函数的方法,然后讲述在多线程下很容易发生的资源竞争的特例——析构竞态。

在C++类中定义线程函数

参考:在C++类中定义线程函数的方法

在多线程的开发中,一般都是把线程函数写成全局函数来使用。但是如果要把线程操作写成类,线程函数放在类里面呢?

下意识地会把线程函数写作普通的类函数,但这样子是有问题的。因为在创建线程的 api 中传入的线程函数需要在编译时确定地址,如果是普通的类函数,编译时不能确定地址,需要创建类的对象才能获取。我们可以把线程的执行函数写成 static 函数,或者是全局函数,因为这两者的函数地址在编译时是确定的。

两者的区别:static 在形式上能够体现“包装”性,能够保全类的封装性;全局函数貌似通过命名空间也能提供“包装性”呢,破坏类的封装性。实际业务中,我个人更倾向于前者——使用类的静态函数作为线程入口函数。

static 函数

1
2
3
4
5
6
7
8
9
10
11
12
// CThread.h

class CThread
{
private:
int Print();
pthread_t m_tid;
public:
int start(); //线程启动
static void* threadFunc(void *arg); //静态函数。使用类中的成员
// static void* threadFunc2(); //静态函数。用不到类中的成员
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// CThread.cpp

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#incldue "CThread.h"

int CThread::Print()
{
for(int i=0; i<10; ++i)
{
sleep(1);
printf("aaaaaaaaaa-----\n");
}
return 0;
}
int CThread::start()
{
pthread_create(&m_tid, NULL, threadFunc, (void*)this);
// pthread_create(&m_tid, NULL, threadFunc2, NULL);
return 0;
}
void* CThread::threadFunc(void *arg)
{
CThread *obj = static_cast<CThread*>(arg);
obj->Print();
}
1
2
3
4
5
6
7
8
9
10
11
// main.cpp

#include "CThread.h"

int main()
{
CThread obj;
obj.start();
sleep(20);
return 0;
}

全局函数

对 CThread 类做出修改,放弃静态函数,使用全局函数作为线程入口函数。并且需要“暴露”类中有关成员,以便线程入口函数使用。(例子中直接将有关成员的访问属性改为了 public,其它方案比如,可以将全局函数声明为 CThread 的友元函数)

1
2
3
4
5
6
7
8
9
10
11
// CThread.h

class CThread
{
public:
int Print();
private:
pthread_t m_tid;
public:
int start(); //线程启动
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// CThread.cpp

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#incldue "CThread.h"

void* threadFunc(void *arg); //全局函数。使用类中的成员
void* threadFunc2(); //全局函数。用不到类中的成员

int CThread::Print()
{
for(int i=0; i<10; ++i)
{
sleep(1);
printf("aaaaaaaaaa-----\n");
}
return 0;
}
int CThread::start()
{
pthread_create(&m_tid, NULL, threadFunc, (void*)this);
// pthread_create(&m_tid, NULL, threadFunc2, NULL);
return 0;
}
void* threadFunc(void *arg)
{
CThread *obj = (CThread*)arg;
obj->Print();
}

void* threadFunc2()
{
printf("say hello.\n");
}

将 join() 放入析构函数

RAII,一般译为“资源获取即初始化”,虽然此中文表达理解起来很不直观,但无奈其广泛使用。我更喜欢《EC++》中侯捷老师的翻译“资源取得时机便是初始化时机”(P63)。

因为在 main() 中进行 sleep() 操作以等待线程很丑陋,我们使用 join() 做出修改。

1
2
3
4
5
6
7
// CThread.cpp  类新增 wait() 接口

int CThread::wait()
{
if (m_tid)
pthread_join(m_tid, NULL);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// main.cpp  使用 obj.wait() 替换 sleep()

int main()
{
CThread obj;
obj.start();
//sleep(20);
obj.wait(); // 根据业务,一般多放在进程最后,结束的前一刻
return 0;
}
````

上述代码存在一个小瑕疵,在进程结束时(`main()` 函数结尾)要求用户记得调用 `CThread::wait()` 函数。当存在多个线程时,即便用户没有忘记或者落下 `objXXX.wait()`,在 `main()` 末尾“缀着”好几行 `wait()` 仍然是**不**优雅的。

在上述基础上,我们更进一步,将 `wait()` 函数放入到 CThread 类的析构函数中,以期达到“自动释放”(即在 `main` 函数结束,超出 obj 作用域时自动调用析构函数)时“自动调用 join”的目的。

```cpp
CThread::~CThread()
{
wait();
}

到目前为止还是正确的。

析构竞态

在这里我们不对“析构竞态”进行详细的分析、讲解,只需要先了解其概念就可以。简单来说,析构竞态就是一个线程在析构某对象时 & 另一线程在访问此对象,此时发生的不良现象。

关于析构竞态的延伸阅读:

线程基类

为了避免重复造轮子,我们将 CThread 作为线程基类,将 Print() 设为虚函数。这样子只要在派生类中实现(override)不同的操作就可以获得一个新的线程类。very nice 的想法!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h> 
#include <unistd.h>
#include <pthread.h>

class CThread
{
private:
virtual int Print();
// virtual int Print() = 0;
pthread_t m_tid;
int wait();
public:
virtual ~CThread() { wait(); }
int start(); //线程启动
static void* threadFunc(void *arg); //使用类中的成员
// static void* threadFunc2(); //用不到类中的成员
};

class CPrintB : public CThread
{
public:
virtual int Print()
{
for(int i=0; i<10; ++i)
{
sleep(1);
printf("bbbbbbbbb-----\n");
}
return 0;
}

};

但这样子就会有问题了!

  • 如果我们如下使用我们新定义的派生类 CPrintB,会发现输出很奇怪——一直在打印 a,而非预期的 b。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    	int 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>

    相同情况见:由pthread C++ wrapper引发的血案

  • 而如果我们取消 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 掌握起来更难,调试也并不容易。