C++服务器开发精髓
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

3.2 线程的基本操作

本节介绍对线程的一些基本操作。

3.2.1 创建线程

在使用线程之前,我们首先要学会创建一个新的线程。不管是哪个库还是哪种高级语言(如Java),线程的创建最终还是通过调用操作系统的API进行的。这里先介绍操作系统的接口,分 Linux和Windows两种常用的操作系统来介绍。当然,这里并不是照本宣科地把Linux man手册或者msdn上的函数签名搬过来,而是介绍实际开发中常用的参数和需要注意的重难点。

1.Linux线程的创建

在Linux平台上使用pthread_create这个API来创建线程,其函数签名如下:

其中,参数 thread是一个输出参数,如果线程创建成功,则通过这个参数可以得到创建成功的线程 ID(本章很快会介绍线程 ID 的知识);参数 attr 指定了该线程的属性,一般被设置为NULL,表示使用默认的属性;参数start_routine指定了线程函数,这个函数的调用方式必须是__cdecl,这是C Declaration的缩写,__cdecl是在C/C++中定义全局函数时默认的调用方式。在 Windows 操作系统上使用 CreateThread 创建线程函数时要求线程函数必须使用__stdcall调用方式,但是定义的函数默认是__cdecl 调用方式,所以必须显式声明 Windows 的线程函数为__stdcall 调用方式(下文很快会介绍到)。也就是说,定义出来的全局函数可以作为Linux pthread_create的线程函数,但不能作为Windows的CreateThread的线程函数,这是因为如下函数调用方式是等价的:

参数 arg 用于在创建线程时将某个参数传入线程函数中,由于它是 void*类型,所以可以方便我们最大化地传入任意信息给线程函数;对于代码的返回值,如果成功创建线程,则返回 0;如果创建失败,则返回响应的错误码,常见的错误码有 EAGAIN、EINVAL、EPERM。

下面是一个使用pthread_create创建线程的简单示例:

2.Windows线程的创建

在Windows上创建线程要用到CreateThread,其函数签名如下:

其中,参数 lpThreadAttributes 表示线程的安全属性,一般被设置为 NULL;参数dwStackSize 指线程的栈空间大小,单位为字节数,一般被指定为 0,表示使用默认的大小;参数 lpStartAddress 为线程函数,其类型是 LPTHREAD_START_ROUTINE(函数指针类型),其定义如下:

上文提到过,在 Windows 上使用 CreateThread 创建的线程函数,其调用方式必须是__stdcall,因此将如下函数设置成CreateThread的线程函数是不行的:

上文对其原因进行了解释:如果不指定函数的调用方式,则默认使用__cdecl 调用方式,而这里的线程函数要求是__stdcall调用方式,因此必须在函数名前面显式指定函数调用方式为__stdcall:

在Windows上,WINAPI和CALLBACK这两个宏的值都是__stdcall,因此在很多项目中看到的线程函数签名大多有如下两种写法:

参数lpParameter是传给线程函数的参数,和Linux下pthread_create函数的arg一样,实际上都是void*类型(LPVOID类型实际上是用typedef包装后的void*类型):

参数dwCreationFlags是一个32位的无符号整型(DWORD),一般被设置为0,表示创建好线程后立即启动线程的运行;对于一些特殊情况,比如我们不希望创建线程后立即开始执行,则可以将这个值设置为 4(对应 Windows 定义的宏 CREATE_SUSPENDED),在需要时再使用ResumeThread这个API运行线程。

参数 lpThreadId表示线程创建成功时返回的线程 ID,也表示一个 32位无符号整数(DWORD)的指针(LPDWORD)。

在Windows上使用句柄(HANDLE类型)来管理线程对象,句柄在本质上是内核句柄表中的索引值。如果成功创建线程,则返回该线程的句柄,否则返回NULL。

下面的代码片段演示了如何在Windows上创建一个线程:

3.Windows CRT提供的线程创建函数

CRT(C Runtime,C运行时)通俗地说就是C函数库。在Windows上,微软实现的C库也提供了一套用于创建线程的函数(当然,这个函数底层还是调用相应的操作系统的线程创建 API)。在实际项目开发中推荐使用这个函数来创建线程,而不是使用CreateThread函数。

Windows C库创建线程时常用的函数是_beginthreadex,其声明位于process.h头文件中,签名如下:

_beginthreadex函数签名和Windows的CreateThread API函数签名基本一致,这里不再赘述。

以下是一个使用_beginthreadex创建线程的例子:

4.C++11提供的std::thread类

无论是在Linux上还是在Windows上创建线程的API,都有一个非常不方便的地方,就是线程函数签名必须使用规定的格式(对参数的个数和类型、返回值类型都有要求)。C++11新标准引入了一个新的类std::thread(需要包含头文件<thread>),使用这个类可以将任意签名形式的函数作为线程函数。以下代码分别创建了两个线程,线程函数签名不一样:

当然,std::thread 在使用上容易出错,即std::thread 对象在线程函数运行期间必须是有效的。什么意思呢?我们来看一个例子:

以上代码在 main 函数中调用了func函数,在func函数中创建了一个线程,乍一看好像没什么问题,但在实际运行时会崩溃。崩溃的原因是,在func函数调用结束后,func中的局部变量 t(线程对象)被销毁,而此时线程函数仍在运行。所以在使用 std::thread类时,必须保证在线程函数运行期间其线程对象有效。std::thread 对象提供了一个 detach方法,通过这个方法可以让线程对象与线程函数脱离关系,这样即使线程对象被销毁,也不影响线程函数的运行。我们只需在func函数中调用detach方法即可,代码如下:

然而在实际开发中并不推荐这么做,原因是我们可能需要使用线程对象去控制和管理线程的运行和生命周期。所以,我们的代码应该尽量保证线程对象在线程运行期间有效,而不是单纯地调用detach方法使线程对象与线程函数的运行分离。

3.2.2 获取线程ID

在1个线程创建成功以后,我们可以拿到一个线程ID。线程ID在整个操作系统范围内是唯一的。我们可以使用线程ID来标识和区分线程,例如在日志文件中输出日志的同时将输出日志的线程ID一起输出,这样可以方便我们判断和排查问题。上面也介绍了创建线程时可以通过pthread_create函数的第1个参数thread (Linux平台)和CreateThread函数的最后一个参数 lpThreadId(Windows 平台)得到线程的ID。大多数时候,我们需要在当前调用线程中获取当前线程的ID,在Linux平台上可以调用pthread_self函数获取,在Windows平台上可以调用GetCurrentThreadID函数获取,其函数签名分别如下:

这两个函数都比较简单,这里不再介绍。pthread_t和DWORD类型在本质上都是32位无符号整型。

在Windows 7中可以在任务管理器中查看某个进程的线程数量。在下图中框住的是每个进程的线程数量,例如对于 vmware-tray.exe 进程一共有三个线程。如果在打开任务管理器时没有看到线程数这一列,则可以单击任务管理器的“查看”→“选择列”菜单,在弹出的对话框中勾选线程数即可显示。

1.pstack命令

在Linux系统中可以通过pstack命令查看一个进程的线程数量和每个线程的调用堆栈情况:

这时将 pid设置为要查看的进程 ID即可。以笔者机器上 Nginx的 worker进程为例,首先使用ps命令查看Nginx进程ID,然后使用pstack即可查看该进程每个线程的调用堆栈(这里演示的Nginx只有1个线程,如果有多个线程,则会显示每个线程的调用堆栈):

使用pstack命令查看的程序必须有调试符号,用户必须有相应的查看权限。

2.利用pstack命令排查Linux进程CPU使用率过高的问题

在实际开发中,我们经常需要排查和定位一个进程 CPU占用率过高的问题,这时可以结合使用Linux top和pstack命令来排查。来看一个具体的例子。如下图所示,我们使用top命令后发现机器上进程ID为4427的qmarket进程的CPU使用率达到22.8%。

我们使用top-H命令再次输出系统的进程列表,top命令的-H选项的作用是显示每个进程各个线程的运行状态(线程模式)。执行结果如下图所示。

如上图所示,top命令第 1栏的输出虽然还是PID,但显示的实际上是每个线程的线程 ID。我们可以发现 qmarket 线程 ID 为 4429、4430、4431、4432、4433、4434、4445的线程其CPU使用率较高。那么这几个线程到底做了什么导致CPU使用率高呢?我们使用pstack 4427命令来看一下这几个线程(4427是qmarket的进程ID):

在 pstack 输出的各个线程中,只要逐一对照我们的程序源码,梳理该线程中是否有大多数时间处于空转状态的逻辑,然后修改和优化这些逻辑,就可以解决 CPU使用率过高的问题。在一般情况下,对不工作的线程应尽量使用锁对象让其挂起,而不是空转,这样可以提高系统资源利用率。

3.Linux系统线程ID的本质

在Linux系统中有三种方法可以获取1个线程的ID。

方法一,调用pthread_create函数时,在函数调用成功后,通过第1个参数可以得到线程ID:

方法二,在需要获取ID的线程中调用pthread_self函数获取:

方法三,通过系统调用获取线程ID:

方法一和方法二获取线程ID的结果是一样的,都是pthread_t类型,输出的是一块内存空间地址,示意图如下。

由于不同的进程可能有同样地址的内存块,因此通过方法一和方法二获取的线程 ID可能不是全系统唯一的,一般是一个很大的数字(内存地址)。而通过方法三获取的线程ID是系统范围内全局唯一的,一般是一个不太大的整数,也就是LWP(Light Weight Process,轻量级进程,早期Linux系统的线程是通过进程实现的,这种线程被称为轻量级进程)的ID。

来看一段具体的代码:

以上代码在新开的线程中使用上面介绍的三种方式获取线程ID并打印,输出结果如下:

tid2即LWP的ID,而tid1和tid3是一个内存地址,转换成16进制是0x7F7F5D935700。这与我们使用pstack命令看到的线程ID是一样的:

4.C++11获取当前线程ID的方法

C++11的线程库可以使用std::this_thread类的get_id获取当前线程ID,这是一个类静态方法。

当然,也可以使用std::thread的get_id获取指定线程的ID,这是一个类实例方法。

但 get_id 方法返回的是一个 std::thread::id 的包装类型,该类型不可以被直接强转成整型,C++11线程库也没有为该对象提供任何转换成整型的接口。所以,我们一般使用std::cout这样的输出流来输出,或者先转换为std::ostringstream对象,再转换为字符串类型,然后把字符串类型转换为我们需要的整型,这算是 C++11 线程库获取线程 ID 一个不太方便的地方:

在Linux x64系统上编译并运行程序,输出结果如下:

编译成Windows x86程序,运行结果如下图所示。

3.2.3 等待线程结束

在实际项目开发中,我们常常会有这样一种需求,即一个线程需要等待另一个线程执行完任务并退出后再继续执行。这在 Linux 和 Windows 中都提供了相应的 API,下面分别介绍一下。

1.在Linux下等待线程结束

Linux 线程库提供了pthread_join 函数,用来等待某线程的退出并接收它的返回值。这种操作被称为汇接(join)。pthread_join函数签名如下:

参数 thread 是需要等待的线程 ID;参数 retval 是输出参数,用于接收等待退出的线程的退出码(Exit Code),可以在调用 pthread_exit 退出线程时指定线程退出码,也可以在线程函数中通过return语句返回线程退出码。pthread_exit函数签名如下:

参数value_ptr的值可以通过pthread_join函数的第2个参数得到,如果不需要使用这个参数,则可以将其设置为NULL。

pthread_join 函数在等待目标线程退出期间会挂起当前线程(调用 pthread_join 的线程),被挂起的线程处于等待状态,不会消耗任何CPU时间片。直到目标线程退出后,调用pthread_join的线程才会被唤醒,继续执行接下来的逻辑。这里通过一个实例演示这个函数的使用方法,实例功能为:在程序启动时开启一个工作线程,工作线程将当前系统时间写入文件后退出,主线程等待工作线程退出后,从文件中读取时间并将其显示在屏幕上。

相应的代码如下:

程序执行结果如下:

2.Windows等待线程结束

在Windows下有两个非常重要的函数API:WaitForSingleObject和WaitForMultipleObjects,前者用于等待1个线程结束,后者可以同时等待多个线程结束。这两个函数不仅可以用于等待线程退出,还可以用于等待其他线程同步对象。与Linux的pthread_join函数不同,Windows的WaitForSingleObject函数提供了对可选择的等待时间的精细控制。

这里仅演示等待线程退出。将上面的Linux示例代码改写成Windows版本:

程序执行结果如下图所示。

3.C++11提供的等待线程结果的函数

可以想到,既然C++11的std::thread统一了Linux和Windows的线程创建函数,那么它应该也提供了等待线程退出的接口。确实如此,std::thread的join方法就是用来等待线程退出的方法。当然,使用这个函数时,必须保证该线程处于运行状态,也就是说等待的线程必须是可以 join 的,如果需要等待的线程已经退出,则此时调用 join 方法,程序就会崩溃。因此,C++11 的线程库同时提供了一个 joinable 方法来判断某个线程是否可以join。

将上面的代码改写成C++11版本: