Linux 应用编程中非常重要的编程技巧---线程(
Thread
);与进程类似,线程是允许应用程序并发执行多个任务的一种机制,线程参与系统调度,事实上,系统调度的最小单元是线程、而并非进程。虽然线程的概念比较简单,但是其所涉及到的内容比较多,所以本章篇幅会相对比较长。
11.1 线程概述
11.1.1 线程概念
什么是线程?
线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流(或者说是执行路线、执行流),一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务。譬如某应用程序设计了两个需要并发运行的任务 task1 和
task2
,可将两个不同的任务分别放置在两个线程中。
线程是如何创建起来的?
当一个程序启动时,就有一个进程被操作系统(OS
)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的
主线程
(Main Thread
),因为它是程序一开始时就运行的线程。应用程序都是以
main()
做为入口开始运行的,所以
main()函数就是主线程的入口函数
,
main()
函数所执行的任务就是主线程需要执行的任务。
所以由此可知,任何一个进程都包含一个主线程,只有主线程的进程称为
单线程进程
,譬如前面章节内容中所编写的所有应用程序都是单线程程序,它们只有主线程;既然有单线程进程,那自然就存在
多线程进程
,所谓多线程指的是除了主线程以外,还包含其它的线程,其它线程通常由主线程来创建(调用pthread_create 创建一个新的线程),那么创建的新线程就是主线程的子线程。
主线程的重要性体现在两方面:
- 其它新的线程(也就是子线程)是由主线程创建的;
- 主线程通常会在最后结束运行,执行各种清理工作,譬如回收各个子线程。
线程的特点?
线程是程序最基本的运行单位,而进程不能运行,真正运行的是进程中的线程。
当启动应用程序后,系统就创建了一个进程,可以认为进程仅仅是一个容器,它包含了线程运行所需的数据结构、环境变量等信息。
同一进程中的多个线程将共享该进程中的全部系统资源
,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有
各自的
调用栈
(call stack,我们称为
线程栈
),自己的寄存器环境(
register context)、自己的线程本地存储(
thread-local storage
)。
在多线程应用程序中,通常一个进程中包括了多个线程,每个线程都可以参与系统调度、被 CPU
执行,线程具有以下一些特点:
- 线程不单独存在、而是包含在进程中;
- 线程是参与系统调度的基本单位;
- 可并发执行。同一进程的多个线程之间可并发执行,在宏观上实现同时运行的效果;
- 共享进程资源。同一进程中的各个线程,可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的地址空间(进程的地址空间),这意味着,线程可以访问该地址空间的每一个虚地址;此外,还可以访问进程所拥有的已打开文件、定时器、信号量等等。
线程与进程?
进程创建多个子进程可以实现并发处理多任务(本质上便是多个单线程进程),多线程同样也可以实现(一个多线程进程)并发处理多任务的需求,那我们究竟选择哪种处理方式呢?首先我们就需要来分析下多进程和多线程两种编程模型的优势和劣势。
多进程编程的劣势:
- 进程间切换开销大。多个进程同时运行(指宏观上同时运行,无特别说明,均指宏观上),微观上依然是轮流切换运行,进程间切换开销远大于同一进程的多个线程间切换的开销,通常对于一些中小型应用程序来说不划算。
- 进程间通信较为麻烦。每个进程都在各自的地址空间中、相互独立、隔离,处在于不同的地址空间中,因此相互通信较为麻烦,在上一章节给大家有所介绍。
解决方案便是使用多线程编程,多线程能够弥补上面的问题:
- 同一进程的多个线程间切换开销比较小。
- 同一进程的多个线程间通信容易。它们共享了进程的地址空间,所以它们都是在同一个地址空间中,通信容易。
- 线程创建的速度远大于进程创建的速度。
- 多线程在多核处理器上更有优势!
终上所述,多线程编程相比于多进程编程的优势是比较明显的,在实际的应用当中多线程远比多进程应用更为广泛。那既然如此,为何还存在多进程编程模型呢?难道多线程编程就不存在缺点吗?当然不是,多线程也有它的缺点、劣势,譬如多线程编程难度高,对程序员的编程功底要求比较高,因为在多线程环境下需要考虑很多的问题,例如线程安全问题、信号处理的问题等,编写与调试一个多线程程序比单线程程序困难得多。
当然除此之外,还有一些其它的缺点,这里就不再一一列举了。多进程编程通常会用在一些大型应用程序项目中,譬如网络服务器应用程序,在中小型应用程序中用的比较少。
11.1.2 并发和并行
在前面的内容中,曾多次提到了并发这个概念,与此相类似的概念还有并行、串行,这里和大家聊一聊这些概念含义的区别。
对于串行比较容易理解,它指的是一种顺序执行,譬如先完成 task1
,接着做
task2
、直到完成
task2
,然后做 task3
、直到完成
task3……
依次按照顺序完成每一件事情,必须要完成上一件事才能去做下一件事,只有一个执行单元,这就是串行运行。
并行与串行则截然不同,并行指的是可以并排/
并列执行多个任务,这样的系统,它通常有多个执行单元,所以可以实现并行运行,譬如并行运行 task1
、
task2
、
task3
。
并行运行并不一定要同时开始运行、同时结束运行,只需满足在某一个时间段上存在多个任务被多个执行单元同时在运行着,譬如:
相比于串行和并行,并发强调的是一种时分复用,与串行的区别在于,它不必等待上一个任务完成之后在做下一个任务,可以打断当前执行的任务切换执行下一个任何,这就是时分复用。在同一个执行单元上,将时间分解成不同的片段(时间片),每个任务执行一段时间,时间一到则切换执行下一个任务,依次这样轮训(交叉/交替执行),这就是并发运行。如下图所示:
笔者(正点原子)在网络上看到了很多比较有意思、形象生动的比喻,用来说明串行、并行以及并发这三个概念的区别,这里笔者截取其中的一个:
- 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接电话,这就说明你不支持并发也不支持并行,仅仅只是串行。
- 你吃饭吃到一半,电话来了,你停下吃饭去接了电话,电话接完后继续吃饭,这说明你支持并发。
- 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
这里再次进行总结:
- 串行:一件事、一件事接着做
- 并发:交替做不同的事;
- 并行:同时做不同的事。
需要注意的是,并行运行情况下的多个执行单元,每一个执行单元同样也可以以并发方式运行。
从通用角度上介绍完这三个概念之后,类比到计算机系统中,首先我们需要知道两个前提条件:
- 多核处理器和单核处理器:对于单核处理器来说,只有一个执行单元,同时只能执行一条指令;而对于多核处理起来说,有多个执行单元,可以并行执行多条指令,譬如 8 核处理器,那么可以并行执行 8 条不同的指令。
- 计算机操作系统中,通常同时运行着几十上百个不同的线程,在单核或多核处理系统中都是如此!
对于单核处理器系统来说,它只有一个执行单元,只能采用并发运行系统中的线程,而肯定不可能是串行,而事实上确实如此。内核实现了调度算法,用于控制系统中所有线程的调度,简单点来说,系统中所有参与调度的线程会加入到系统的调度队列中,它们由内核控制,每一个线程执行一段时间后,由系统调度切换执行调度队列中下一个线程,依次进行。在前面章节内容中也给大家有简单地提到过系统调用的问题,关于更加详细的内容,这里便不再介绍了,我们只需有个大概的认识、了解即可!
对于多核处理器系统来说,它拥有多个执行单元,在操作系统中,多个执行单元以并行方式运行多个线程,同时每一个执行单元以并发方式运行系统中的多个线程。
同时运行
计算机处理器运行速度是非常快的,在单个处理核心虽然以并发方式运行着系统中的线程(微观上交替/交叉方式运行不同的线程),但在宏观上所表现出来的效果是同时运行着系统中的所有线程,因为处理器的运算速度太快了,交替轮训一次所花费的时间在宏观上几乎是可以忽略不计的,所以表示出来的效果就是同时运行着所有线程。
这就好比现实生活中所看到的一些事情,它所给带来的视角效果,譬如一辆车在高速上行驶,有时你会感觉到车的轮毂没有转动,一种视角暂留现象,因为车轮转动速度太快了,人眼是看不清的,会感觉车轮好像是静止的,事实上,车轮肯定是在转动着。
理解了本小节的内容,对于后面内容的将会有很大的帮助。
11.2 线程 ID
就像每个进程都有一个进程 ID
一样,每个线程也有其对应的标识,称为
线程 ID
。进程
ID
在整个系统中是唯一的,但线程 ID
不同,线程
ID
只有在它所属的进程上下文中才有意义。
进程 ID
使用
pid_t
数据类型来表示,它是一个非负整数。而线程
ID 使用 pthread_t
数据类型来表示,一个线程
可通过库函数 pthread_self()来获取自己的线程 ID
,其函数原型如下所示:
#include <pthread.h>
pthread_t pthread_self(void);
使用该函数需要包含头文件<pthread.h>
。
该函数调用总是成功,返回当前线程的线程 ID
。
可以
使用 pthread_equal()函数来检查两个线程 ID 是否相等
,其函数原型如下所示:
#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);
如果两个线程 ID t1
和
t2
相等,则
pthread_equal()
返回一个非零值;否则返回
0
。在
Linux
系统中,使用无符号长整型(unsigned long int
)来表示
pthread_t 数据类型,但是在其它系统当中,则不一定是无符号长整型,所以
我们必须将 pthread_t 作为一种不透明的数据类型加以对待,所以 pthread_equal()函数用于比较两个线程 ID 是否相等是有用的。
线程 ID
在应用程序中非常有用,原因如下:
- 很多线程相关函数,譬如后面将要学习的 pthread_cancel()、pthread_detach()、pthread_join()等,它们都是利用线程 ID 来标识要操作的目标线程;
- 在一些应用程序中,以特定线程的线程 ID 作为动态数据结构的标签,这某些应用场合颇为有用,既可以用来标识整个数据结构的创建者或属主线程,又可以确定随后对该数据结构执行操作的具体线程。
11.3 创建线程
启动程序时,创建的进程只是一个单线程的进程,称之为初始线程或主线程,本小节我们讨论如何创建一个新的线程。
主线程可以
使用库函数 pthread_create()负责创建一个新的线程
,创建出来的新线程被称为主线程的子线程,其函数原型如下所示:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
使用该函数需要包含头文件<pthread.h>
。
函数参数和返回值含义如下:
thread:pthread_t 类型指针,当
pthread_create()
成功返回时,新创建的线程的线程
ID
会保存在参数
thread所指向的内存中,后续的线程相关函数会使用该标识来引用此线程。
attr:
pthread_attr_t
类型指针,指向
pthread_attr_t
类型的缓冲区,
pthread_attr_t
数据类型定义了线程的各种属性,关于线程属性将会在 11.8
小节介绍。如果将参数
attr
设置为
NULL
,那么表示将线程的所有属性设置为默认值,以此创建新线程。
start_routine:
参数
start_routine
是一个函数指针,指向一个函数,新创建的线程从
start_routine()
函数开始运行,该函数返回值类型为void *
,并且该函数的参数只有一个
void *
,其实这个参数就是
pthread_create() 函数的第四个参数 arg
。如果需要向
start_routine()
传递的参数有一个以上,那么需要把这些参数放到一个结构体中,然后把这个结构体对象的地址作为 arg
参数传入。
arg:
传递给
start_routine()
函数的参数。一般情况下,需要将
arg
指向一个全局或堆变量,意思就是说在线程的生命周期中,该 arg
指向的对象必须存在,否则如果线程中访问了该对象将会出现错误。当然也可将参数 arg
设置为
NULL
,表示不需要传入参数给
start_routine()
函数。
返回值:成功返回
0
;失败时将返回一个错误号,并且参数
thread
指向的内容是不确定的。
注意 pthread_create()
在调用失败时通常会返回错误码,它并不像其它库函数或系统调用一样设置
errno
,每个线程都提供了全局变量 errno
的副本,这只是为了与使用
errno
到的函数进行兼容,在线程中,从函数中返回错误码更为清晰整洁,不需要依赖那些随着函数执行不断变化的全局变量,这样可以把错误的范围限制在引起出错的函数中。
线程创建成功,新线程就会加入到系统调度队列中,获取到 CPU
之后就会立马从
start_routine()
函数开始运行该线程的任务;调用 pthread_create()
函数后,通常我们无法确定系统接着会调度哪一个线程来使用CPU 资源,先调度主线程还是新创建的线程呢(而在多核
CPU
或多
CPU
系统中,多核线程可能会在不同的核心上同时执行)?如果程序对执行顺序有强制要求,那么就必须采用一些同步技术来实现。这与前面学习父、子进程时也出现了这个问题,无法确定父进程、子进程谁先被系统调度。
使用示例
使用 pthread_create()
函数创建一个除主线程之外的新线程,示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());
return (void *)0;
}
int main(void)
{
pthread_t tid;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "Error: %s\n", strerror(ret));
exit(-1);
}
printf("主线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());
sleep(1);
exit(0);
}
应该将 pthread_t
作为一种不透明的数据类型加以对待,但是在示例代码中需要打印线程
ID
,所以要明确其数据类型,示例代码中使用了 printf()
函数打印线程
ID
时,将其作为
unsigned long int
数据类型,在
Linux系统下,确实是使用 unsigned long int
来表示
pthread_t
,所以这样做没有问题!
主线程休眠了 1
秒钟,原因在于,如果主线程不进行休眠,它就可能会立马退出,这样可能会导致新创建的线程还没有机会运行,整个进程就结束了。
在主线程和新线程中,分别通过 getpid()
和
pthread_self()
来获取进程
ID
和线程
ID
,将结果打印出来,运行结果如下所示:
编译时出现了错误,提示“对‘pthread_create
’未定义的引用”,示例代码确实已经包含了
<pthread.h> 头文件,但为什么会出现这样的报错,仔细看,这个报错是出现在程序代码链接时、而并非是编译过程,所以可知这是链接库的文件,如何解决呢?
gcc -o test test.c -lpthread
使用-l
选项指定链接库
pthread
,原因在于
pthread 不在 gcc
的默认链接库中,所以需要手动指定。再次编译便不会有问题了,如下:
从打印信息可知,正如前面所介绍那样,两个线程的进程 ID
相同,说明新创建的线程与主线程本来就属于同一个进程,但是它们的线程 ID
不同。从打印结果可知,
Linux
系统下线程
ID
数值非常大,看起来像是一个指针。
11.4 终止线程
在示例代码 11.3.1
中,我们在新线程的启动函数(线程
start
函数)
new_thread_start()
通过
return
返回之后,意味着该线程已经终止了,除了在线程 start
函数中执行
return
语句终止线程外,终止线程的方式还有多种,可以通过如下方式终止线程的运行:
- 线程的 start 函数执行 return 语句并返回指定值,返回值就是线程的退出码;
- 线程调用 pthread_exit()函数;
- 调用 pthread_cancel()取消线程(将在 11.6 小节介绍);
如果进程中的任意线程调用 exit()
、
_exit()
或者
_Exit()
,那么将会导致整个进程终止,这里需要注意!
pthread_exit()函数将终止调用它的线程,其函数原型如下所示:
#include <pthread.h>
void pthread_exit(void *retval);
使用该函数需要包含头文件<pthread.h>
。
参数 retval
的数据类型为
void *
,指定了线程的返回值、也就是线程的退出码,该返回值可由另一个线程通过调用 pthread_join()
来获取;同理,如果线程是在
start
函数中执行
return
语句终止,那么
return
的返回值也是可以通过 pthread_join()
来获取的。
参数 retval
所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效;出于同样的理由,也不应在线程栈中分配线程 start
函数的返回值。(线程栈没看懂)
调用 pthread_exit()
相当于在线程的
start
函数中执行
return
语句,不同之处在于,可在线程
start
函数所调用的任意函数中调用 pthread_exit()
来终止线程。如果主线程调用了
pthread_exit()
,那么主线程也会终止,但其它线程依然正常运行,直到进程中的所有线程终止才会使得进程终止。
使用示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程 start\n");
sleep(1);
printf("新线程 end\n");
pthread_exit(NULL);
}
int main(void)
{
pthread_t tid;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "Error: %s\n", strerror(ret));
exit(-1);
}
printf("主线程 end\n");
pthread_exit(NULL);
exit(0);
}
正如上面介绍到,主线程调用 pthread_exit()
终止之后,整个进程并没有结束,而新线程还在继续运行。
11.5 回收线程
在父、子进程当中,父进程可通过 wait()
函数(或其变体
waitpid()
)阻塞等待子进程退出并获取其终止状态,回收子进程资源;而在线程当中,也需要如此,通过调用 pthread_join()
函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源;pthread_join()
函数原型如下所示:
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
使用该函数需要包含头文件<pthread.h>
。
函数参数和返回值含义如下:
thread:
pthread_join()
等待指定线程的终止,通过参数
thread
(线程
ID
)指定需要等待的线程;
retval:
如果参数
retval
不为
NULL
,则
pthread_join()
将目标线程的退出状态(即目标线程通过pthread_exit()退出时指定的返回值或者在线程
start
函数中执行
return
语句对应的返回值)复制到
*retval
所指向的内存区域;如果目标线程被 pthread_cancel()
取消,则将
PTHREAD_CANCELED
放在
*retval
中。如果对目标线程的终止状态不感兴趣,则可将参数 retval
设置为
NULL
。
返回值:成功返回
0
;失败将返回错误码。
调用 pthread_join()
函数将会以阻塞的形式等待指定的线程终止,如果该线程已经终止,则
pthread_join()立刻返回。如果多个线程同时尝试调用 pthread_join()
等待指定线程的终止,那么结果将是不确定的。
若线程并未分离(detached
,将在
11.6.1
小节介绍),则必须使用
pthread_join()
来等待线程终止,回收线程资源;如果线程终止后,其它线程没有调用 pthread_join()
函数来回收该线程,那么该线程将变成僵尸线程,与僵尸进程的概念相类似;同样,僵尸线程除了浪费系统资源外,若僵尸线程积累过多,那么会导致应用程序无法创建新的线程。
当然,如果进程中存在着僵尸线程并未得到回收,当进程终止之后,进程会被其父进程回收,所以僵尸线程同样也会被回收。
所以,通过上面的介绍可知,pthread_join()
执行的功能类似于针对进程的
waitpid()
调用,不过二者之间存在一些显著差别:
- 线程之间关系是对等的。进程中的任意线程均可调用 pthread_join()函数来等待另一个线程的终止。譬如,如果线程 A 创建了线程 B,线程 B 再创建线程 C,那么线程 A 可以调用 pthread_join()等待线程 C 的终止,线程 C 也可以调用 pthread_join()等待线程 A 的终止;这与进程间层次关系不同,父进程如果使用 fork()创建了子进程,那么它也是唯一能够对子进程调用 wait()的进程,线程之间不存在这样的关系。
- 不能以非阻塞的方式调用 pthread_join()。对于进程,调用 waitpid()既可以实现阻塞方式等待、也可以实现非阻塞方式等待。
使用示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程 start\n");
sleep(2);
printf("新线程 end\n");
pthread_exit((void *)10);
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
ret = pthread_join(tid, &tret);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}
11.6 取消线程
在通常情况下,进程中的多个线程会并发执行,每个线程各司其职,直到线程的任务完成之后,该线程中会调用 pthread_exit()退出,或在线程
start
函数执行
return
语句退出。
有时候,在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,我们把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。譬如,一组线程正在执行一个运算,一旦某个线程检测到错误发生,需要其它线程退出,取消线程这项功能就派上用场了。
本小节就来讨论 Linux
系统下的线程取消机制。
11.6.1 取消一个线程
通过调用 pthread_cancel()
库函数向一个指定的线程发送取消请求,其函数原型如下所示:
#include <pthread.h>
int pthread_cancel(pthread_t thread);
使用该函数需要包含头文件<pthread.h>
,参数
thread
指定需要取消的目标线程;成功返回
0
,失败将返回错误码。
发出取消请求之后,函数 pthread_cancel()
立即返回,不会等待目标线程的退出。默认情况下,目标线程也会立刻退出,其行为表现为如同调用了参数为 PTHREAD_CANCELED
(其实就是
(void *)-1
)的pthread_exit()函数,但是,线程可以设置自己不被取消或者控制如何被取消(
11.6.2
小节介绍),所以pthread_cancel()并不会等待线程终止,仅仅只是提出请求。
使用示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程--running\n");
for ( ; ; )
sleep(1);
return (void *)0;
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
sleep(1);
/* 向新线程发送取消请求 */
ret = pthread_cancel(tid);
if (ret) {
fprintf(stderr, "pthread_cancel error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, &tret);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}
由打印结果可知,当主线程发送取消请求之后,新线程便退出了,而且退出码为-1
,也就是PTHREAD_CANCELED。
11.6.2 取消状态以及类型
默认情况下,线程是响应其它线程发送过来的取消请求的,响应请求然后退出线程。当然,线程可以选择不被取消或者控制如何被取消,通过 pthread_setcancelstate()和
pthread_setcanceltype()
来设置线程的取消性状态和类型。
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);
使用这些函数需要包含头文件<pthread.h>
,
pthread_setcancelstate()
函数会将调用线程的取消性状态设置为参数 state
中给定的值,并将线程之前的取消性状态保存在参数
oldstate
指向的缓冲区中,如果对之前的状态不感兴趣,Linux
允许将参数
oldstate
设置为
NULL
;
pthread_setcancelstate()
调用成功将返回
0
,失败返回非 0
值的错误码。
pthread_setcancelstate()函数执行的设置取消性状态和获取旧状态操作,这两步是一个原子操作。
参数 state
必须是以下值之一:
- PTHREAD_CANCEL_ENABLE:线程可以取消,这是新创建的线程取消性状态的默认值,所以新建线程以及主线程默认都是可以取消的。
- PTHREAD_CANCEL_DISABLE:线程不可被取消,如果此类线程接收到取消请求,则会将请求挂起,直至线程的取消性状态变为 PTHREAD_CANCEL_ENABLE。
使用示例
修改示例代码 11.6.1
,在新线程的
new_thread_start()
函数中调用
pthread_setcancelstate()
函数将线程的取消性状态设置为 PTHREAD_CANCEL_DISABLE
,我们来试试,此时主线程还能不能取消新线程,示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
/* 设置为不可被取消 */
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
for ( ; ; ) {
printf("新线程--running\n");
sleep(2);
}
return (void *)0;
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
sleep(1);
/* 向新线程发送取消请求 */
ret = pthread_cancel(tid);
if (ret) {
fprintf(stderr, "pthread_cancel error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, &tret);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}
测试结果确实如此,将一直重复打印"
新线程
--running"
,因为新线程是一个死循环(测试完成按
Ctrl+C退出)。
pthread_setcanceltype()函数
如果线程的取消性状态为 PTHREAD_CANCEL_ENABLE
,那么对取消请求的处理则取决于线程的取消性类型,该类型可以通过调用 pthread_setcanceltype()
函数来设置,它的参数
type
指定了需要设置的类型,而线程之前的取消性类型则会保存在参数 oldtype
所指向的缓冲区中,如果对之前的类型不敢兴趣,
Linux下允许将参数 oldtype
设置为
NULL
。同样
pthread_setcanceltype()
函数调用成功将返回
0
,失败返回非
0
值的错误码。
pthread_setcanceltype()函数执行的设置取消性类型和获取旧类型操作,这两步是一个原子操作。
参数 type
必须是以下值之一:
- PTHREAD_CANCEL_DEFERRED:取消请求到来时,线程还是继续运行,取消请求被挂起,直到线程到达某个取消点(cancellation point,将在 11.6.3 小节介绍)为止,这是所有新建线程包括主线程默认的取消性类型。
- PTHREAD_CANCEL_ASYNCHRONOUS:可能会在任何时间点(也许是立即取消,但不一定)取消线程,这种取消性类型应用场景很少,不再介绍!
当某个线程调用 fork()
创建子进程时,子进程会继承调用线程的取消性状态和取消性类型,而当某线程调用 exec
函数时 , 会将新程序 主 线 程 的 取 消 性 状 态 和 类 型 重 置 为 默 认 值 , 也 就 是PTHREAD_CANCEL_ENABLE 和
PTHREAD_CANCEL_DEFERRED
。
11.6.3 取消点
若将线程的取消性类型设置为 PTHREAD_CANCEL_DEFERRED
时(线程可以取消状态下),收到其它线程发送过来的取消请求时,仅当线程抵达某个取消点时,取消请求才会起作用。
那什么是取消点呢?所谓取消点其实就是一系列函数,当执行到这些函数的时候,才会真正响应取消请求,这些函数就是取消点;在没有出现取消点时,取消请求是无法得到处理的,究其原因在于系统认为,但没有到达取消点时,线程此时正在执行的工作是不能被停止的,正在执行关键代码,此时终止线程将可能会导致出现意想不到的异常发生。
取消点函数包括哪些呢?下表给大家简单地列出了一些:
除了表中所列函数之外,还有大量的函数,系统实现可以将其作为取消点,这里便不再一一列举出来了,大家也可以通过 man
手册进行查询,命令为
"man 7 pthreads"
,如下所示:
线程在调用这些函数时,如果收到了取消请求,那么线程便会遭到取消;除了这些作为取消点的函数之外,不得将任何其它函数视为取消点(亦即,调用这些函数不会招致取消)。
sleep()函数可以作为取消点(printf
可能也是),当新线程接收到取消请求之后,便会立马退出,当如果将其修改为如下:
static void *new_thread_start(void *arg)
{
printf("新线程--running\n");
for ( ; ; ) {
}
return (void *)0;
}
那么线程将永远无法被取消,因为这里不存在取消点。大家可以将代码进行修改测试,看结果是不是如此!
11.6.4 线程可取消性的检测
假设线程执行的是一个不含取消点的循环(譬如 for
循环、
while
循环),那么这时线程永远也不会响应取消请求,也就意味着除了线程自己主动退出,其它线程将无法通过向它发送取消请求而终止它,就如上小节最后给大家列举的例子。
在实际应用程序当中,确实会遇到这种情况,线程最终运行在一个循环当中,该循环体内执行的函数不存在任何一个取消点,但实际项目需求是:该线程必须可以被其它线程通过发送取消请求的方式终止,那这个时候怎么办?此时可以使用 pthread_testcancel(),该函数目的很简单,就是产生一个取消点,线程如果已有处于挂起状态的取消请求,那么只要调用该函数,线程就会随之终止。其函数原型如下所示:
#include <pthread.h>
void pthread_testcancel(void);
功能测试
接下来进行一个测试,主线程创建一个新的进程,新进程的取消性状态和类型置为默认,新进程最终执行的是一个不含取消点的循环;主线程向新线程发送取消请求,示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程--start run\n");
for ( ; ; ) {
}
return (void *)0;
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
sleep(1);
/* 向新线程发送取消请求 */
ret = pthread_cancel(tid);
if (ret) {
fprintf(stderr, "pthread_cancel error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, &tret);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}
新线程的 new_thread_start()
函数中是一个
for
死循环,没有执行任何函数,所以是一个没有取消点的循环体,主线程调用 pthread_cancel()
是无法将其终止的,接下来测试下结果是否如此:
执行完之后,程序一直会没有退出,说明主线程确实无法终止新线程。接下来再做一个测试,在new_thread_start 函数的
for
循环体中执行
pthread_testcancel()
函数,如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
printf("新线程--start run\n");
for ( ; ; ) {
pthread_testcancel();
}
return (void *)0;
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
sleep(1);
/* 向新线程发送取消请求 */
ret = pthread_cancel(tid);
if (ret) {
fprintf(stderr, "pthread_cancel error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, &tret);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}
11.7 分离线程
默认情况下,当线程终止时,其它线程可以通过调用 pthread_join()
获取其返回状态、回收线程资源,有时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动回收线程资源并将其移除。在这种情况下,可以调用 pthread_detach()
将指定线程进行分离,也就是分离线程,
pthread_detach()
函数原型如下所示:
#include <pthread.h>
int pthread_detach(pthread_t thread);
使用该函数需要包含头文件<pthread.h>
,参数
thread
指定需要分离的线程,函数
pthread_detach()
调用成功将返回 0
;失败将返回一个错误码。
一个线程既可以将另一个线程分离,同时也可以将自己分离,譬如:
pthread_detach(pthread_self());
一旦线程处于分离状态,就不能再使用 pthread_join()
来获取其终止状态,此过程是不可逆的,一旦处于分离状态之后便不能再恢复到之前的状态。处于分离状态的线程,当其终止后,能够自动回收线程资源。
使用示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
int ret;
/* 自行分离 */
ret = pthread_detach(pthread_self());
if (ret) {
fprintf(stderr, "pthread_detach error: %s\n", strerror(ret));
return NULL;
}
printf("新线程 start\n");
sleep(2); //休眠 2 秒钟
printf("新线程 end\n");
pthread_exit(NULL);
}
int main(void)
{
pthread_t tid;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
sleep(1); //休眠 1 秒钟
/* 等待新线程终止 */
ret = pthread_join(tid, NULL);
if (ret)
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
pthread_exit(NULL);
}
打印结果正如我们所料,主线程调用 pthread_join()
确实会出错,错误提示为“
Invalid argument
”。
11.8 注册线程清理处理函数
9.1.2 小节学习了
atexit()
函数,使用
atexit()
函数注册进程终止处理函数,当进程调用
exit()
退出时就会执行进程终止处理函数;其实,当线程退出时也可以这样做,当线程终止退出时,去执行这样的处理函数,我们把这个称为线程清理函数(thread cleanup handler)。
与进程不同,一个线程可以注册多个清理函数,这些清理函数记录在栈中,每个线程都可以拥有一个清理函数栈,栈是一种先进后出的数据结构,也就是说它们的执行顺序与注册(添加)顺序相反,当执行完所有清理函数后,线程终止。
线程通过函数 pthread_cleanup_push()
和
pthread_cleanup_pop()
分别负责向调用线程的清理函数栈中添加和移除清理函数,函数原型如下所示:
#include <pthread.h>
void pthread_cleanup_push(void (*routine)(void *), void *arg);
void pthread_cleanup_pop(int execute);
使用这些函数需要包含头文件<pthread.h>
。
调用 pthread_cleanup_push()
向清理函数栈中添加一个清理函数,第一个参数
routine
是一个函数指针,指向一个需要添加的清理函数,routine()
函数无返回值,只有一个
void *
类型参数;第二个参数
arg
,当调用清理函数 routine()
时,将
arg
作为
routine()
函数的参数。
既然有添加,自然就会伴随着删除,就好比对应入栈和出栈,调用函数 pthread_cleanup_pop()
可以将清理函数栈中最顶层(也就是最后添加的函数,最后入栈)的函数移除。
当线程执行以下动作时,清理函数栈中的清理函数才会被执行:
- 线程调用 pthread_exit()退出时;
- 线程响应取消请求时;
- 用非 0 参数调用 pthread_cleanup_pop()
除了以上三种情况之外,其它方式终止线程将不会执行线程清理函数,譬如在线程 start
函数中执行return 语句退出时不会执行清理函数。
函数 pthread_cleanup_pop()
的
execute
参数,可以取值为
0
,也可以为非
0
;如果为
0
,清理函数不会被调用,只是将清理函数栈中最顶层的函数移除;如果参数 execute
为非
0
,则除了将清理函数栈中最顶层的函数移除之外,还会该清理函数。
尽管上面我们将 pthread_cleanup_push()
和
pthread_cleanup_pop()
称之为函数,但它们是通过宏来实现,可展开为分别由{
和
}
所包裹的语句序列,所以必须在与线程相同的作用域中以匹配对的形式使用,必须一一对应着来使用,譬如:
pthread_cleanup_push(cleanup, NULL);
pthread_cleanup_push(cleanup, NULL);
pthread_cleanup_push(cleanup, NULL);
......
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
否则会编译报错,如下所示:
使用示例
示例代码 11.8.1
给出了一个使用线程清理函数的例子,虽然例子并没有什么实际作用,当它描述了其中所涉及到的清理机制。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void cleanup(void *arg)
{
printf("cleanup: %s\n", (char *)arg);
}
static void *new_thread_start(void *arg)
{
printf("新线程--start run\n");
pthread_cleanup_push(cleanup, "第 1 次调用");
pthread_cleanup_push(cleanup, "第 2 次调用");
pthread_cleanup_push(cleanup, "第 3 次调用");
sleep(2);
pthread_exit((void *)0); //线程终止
/* 为了与 pthread_cleanup_push 配对,不添加程序编译会通不过 */
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, &tret);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}
主线程创建新线程之后,调用 pthread_join()
等待新线程终止;新线程调用
pthread_cleanup_push()
函数添加线程清理函数,调用了三次,但每次添加的都是同一个函数,只是传入的参数不同;清理函数添加完成,休眠一段时间之后,调用 pthread_exit()
退出。之后还调用了
3
次
pthread_cleanup_pop()
,在这里的目的仅仅只是为了与 pthread_cleanup_push()
配对使用,否则编译不通过。
从打印结果可知,先添加到线程清理函数栈中的函数会后被执行,添加顺序与执行顺序相反。
将新线程中调用的 pthread_exit()
替换为
return
,再进行测试,发现并不会执行清理函数。
有时在线程功能设计中,线程清理函数并不一定需要在线程退出时才执行,譬如当完成某一个步骤之后,就需要执行线程清理函数,此时我们可以调用 pthread_cleanup_pop()并传入非
0
参数,来手动执行线程清理函数,示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void cleanup(void *arg)
{
printf("cleanup: %s\n", (char *)arg);
}
static void *new_thread_start(void *arg)
{
printf("新线程--start run\n");
pthread_cleanup_push(cleanup, "第 1 次调用");
pthread_cleanup_push(cleanup, "第 2 次调用");
pthread_cleanup_push(cleanup, "第 3 次调用");
pthread_cleanup_pop(1); //执行最顶层的清理函数
printf("~~~~~~~~~~~~~~~~~\n");
sleep(2);
pthread_exit((void *)0); //线程终止
/* 为了与 pthread_cleanup_push 配对 */
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
}
int main(void)
{
pthread_t tid;
void *tret;
int ret;
/* 创建新线程 */
ret = pthread_create(&tid, NULL, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, &tret);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
printf("新线程终止, code=%ld\n", (long)tret);
exit(0);
}
上述代码中,在新线程调用 pthread_exit()
之前,先调用
pthread_cleanup_pop(1)
手动运行了最顶层的清理函数,并将其从栈中移除,测试结果:
从打印结果可知,调用 pthread_cleanup_pop(1)
执行了最后一次注册的清理函数,调用
pthread_exit()
退出线程时执行了 2
次清理函数,因为前面调用
pthread_cleanup_pop()
已经将顶层的清理函数移除栈中了,自然在退出时就不会再执行了。
11.9 线程属性
如前所述,调用 pthread_create()
创建线程,可对新建线程的各种属性进行设置。在
Linux
下,使用 pthread_attr_t 数据类型定义线程的所有属性,本书并不打算详细讨论这些属性,以介绍为主,简单地了解下线程属性。
调用 pthread_create()
创建线程时,参数
attr
设置为
NULL
,表示使用属性的默认值创建线程。如果不使用默认值,参数 attr
必须要指向一个
pthread_attr_t
对象,而不能使用
NULL
。当定义
pthread_attr_t
对象之后 ,需要 使用 pthread_attr_init()
函 数 对 该对象进行初始化操作 ,当对象不再使用时, 需要使用pthread_attr_destroy()函数将其销毁,函数原型如下所示:
#include <pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);
使用这些函数需要包含头文件<pthread.h>
,参数
attr
指向一个
pthread_attr_t
对象,即需要进行初始化的线程属性对象。在调用成功时返回 0
,失败将返回一个非
0
值的错误码。
调用 pthread_attr_init()
函数会将指定的
pthread_attr_t
对象中定义的各种线程属性初始化为它们各自对应的默认值。
pthread_attr_t 数据结构中包含的属性比较多,本小节并不会一一点出,可能比较关注属性包括:线程栈的位置和大小、线程调度策略和优先级,以及线程的分离状态属性等。Linux
为
pthread_attr_t
对象的每种属性提供了设置属性的接口以及获取属性的接口。
11.9.1 线程栈属性
每个线程都有自己的栈空间,pthread_attr_t
数据结构中定义了栈的起始地址以及栈大小,调用函数 pthread_attr_getstack()可以获取这些信息,函数
pthread_attr_setstack()
对栈起始地址和栈大小进行设置,其函数原型如下所示:
#include <pthread.h>
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
int pthread_attr_getstack(const pthread_attr_t *attr, void **stackaddr, size_t *stacksize);
使用这些函数需要包含头文件<pthread.h>
,函数
pthread_attr_getstack()
,参数和返回值含义如下:
attr:
参数
attr
指向线程属性对象。
stackaddr:
调用
pthread_attr_getstack()
可获取栈起始地址,并将起始地址信息保存在
*stackaddr
中;
stacksize:
调用
pthread_attr_getstack()
可获取栈大小,并将栈大小信息保存在参数
stacksize
所指向的内存中;
返回值:成功返回
0
,失败将返回一个非
0
值的错误码。
函数 pthread_attr_setstack()
,参数和返回值含义如下:
attr:
参数
attr
指向线程属性对象。
stackaddr:
设置栈起始地址为指定值。
stacksize:
设置栈大小为指定值;
返回值:成功返回
0
,失败将返回一个非
0
值的错误码。
如果想单独获取或设置栈大小、栈起始地址,可以使用下面这些函数:
#include <pthread.h>
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);
int pthread_attr_setstackaddr(pthread_attr_t *attr, void *stackaddr);
int pthread_attr_getstackaddr(const pthread_attr_t *attr, void **stackaddr);
使用示例
创建新的线程,将线程的栈大小设置为 4Kbyte
。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void *new_thread_start(void *arg)
{
puts("Hello World!");
return (void *)0;
}
int main(int argc, char *argv[])
{
pthread_attr_t attr;
size_t stacksize;
pthread_t tid;
int ret;
/* 对 attr 对象进行初始化 */
pthread_attr_init(&attr);
/* 设置栈大小为 4K */
pthread_attr_setstacksize(&attr, 4096);
/* 创建新线程 */
ret = pthread_create(&tid, &attr, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
/* 等待新线程终止 */
ret = pthread_join(tid, NULL);
if (ret) {
fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
exit(-1);
}
/* 销毁 attr 对象 */
pthread_attr_destroy(&attr);
exit(0);
}
11.9.2 分离状态属性
前面介绍了线程分离的概念,如果对现已创建的某个线程的终止状态不感兴趣,可以使用pthread_detach()函数将其分离,那么该线程在退出时,操作系统会自动回收它所占用的资源。
如果我们在创建线程时就确定要将该线程分离,可以修改 pthread_attr_t
结构中的
detachstate
线程属性,让线程一开始运行就处于分离状态。调用函数 pthread_attr_setdetachstate()
设置
detachstate
线程属性,调用pthread_attr_getdetachstate()获取
detachstate
线程属性,其函数原型如下所示:
#include <pthread.h>
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);
需要包含头文件<pthread.h>
,参数
attr
指向
pthread_attr_t
对象;调用
pthread_attr_setdetachstate()
函数将detachstate 线程属性设置为参数
detachstate
所指定的值,参数
detachstate
取值如下:
- PTHREAD_CREATE_DETACHED:新建线程一开始运行便处于分离状态,以分离状态启动线程,无法被其它线程调用 pthread_join()回收,线程结束后由操作系统收回其所占用的资源;
- PTHREAD_CREATE_JOINABLE:这是 detachstate 线程属性的默认值,正常启动线程,可以被其它线程获取终止状态信息。
函数 pthread_attr_getdetachstate()
用于获取
detachstate
线程属性,将
detachstate
线程属性保存在参数detachstate 所指定的内存中。
使用示例
示例代码 11.9.2
给出了以分离状态启动线程的示例。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
static void *new_thread_start(void *arg)
{
puts("Hello World!");
return (void *)0;
}
int main(int argc, char *argv[])
{
pthread_attr_t attr;
pthread_t tid;
int ret;
/* 对 attr 对象进行初始化 */
pthread_attr_init(&attr);
/* 设置以分离状态启动线程 */
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
/* 创建新线程 */
ret = pthread_create(&tid, &attr, new_thread_start, NULL);
if (ret) {
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
exit(-1);
}
sleep(1);
/* 销毁 attr 对象 */
pthread_attr_destroy(&attr);
exit(0);
}
11.10 线程安全
当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?带着这些问题,本小节将讨论线程安全相关的话题。
Tips:在阅读本小节内容之前,建议先阅读第十二章内容,这章内容原本计划是放在本小节内容之前的,但由于排版问题,不得不将其单独列为一章。
11.10.1 线程栈
进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈。譬如主线程调用pthread_create()
创建了一个新的线程,那么这个新的线程有它自己独立的栈地址空间、而主线程也有它自己独立的栈地址空间。通过 11.9.1
小节可知,在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况下,保持默认即可!
既然每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰。在示例代码 11.10.1 中,主线程创建了
5
个新的线程,这
5
个线程使用同一个 start
函数
new_thread
,该函数中定义了局部变量
number
和
tid
以及
arg
参数,意味着这
5个线程的线程栈中都各自为这些变量分配了内存空间,任何一个线程修改了 number
或
tid
都不会影响其它线程。
//示例代码 11.10.1 线程栈示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
static void *new_thread(void *arg)
{
int number = *((int *)arg);
unsigned long int tid = pthread_self();
printf("当前为<%d>号线程, 线程 ID<%lu>\n", number, tid);
return (void *)0;
}
static int nums[5] = {0, 1, 2, 3, 4};
int main(int argc, char *argv[])
{
pthread_t tid[5];
int j;
/* 创建 5 个线程 */
for (j = 0; j < 5; j++)
pthread_create(&tid[j], NULL, new_thread, &nums[j]);
/* 等待线程结束 */
for (j = 0; j < 5; j++)
pthread_join(tid[j], NULL);//回收线程
exit(0);
}
11.10.2 可重入函数
要解释可重入(Reentrant
)函数为何物,首先需要区分单线程程序和多线程程序。本章开头部分已向各位读者进行了详细介绍,单线程程序只有一条执行流(一个线程就是一条执行流),贯穿程序始终;而对于多线程程序而言,同一进程却存在多条独立、并发的执行流。
进程中执行流的数量除了与线程有关之外,与信号处理也有关联。因为信号是异步的,进程可能会在其运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含信号处理)中形成了两条(即主程序和信号处理函数)独立的执行流。
接下来再来介绍什么是可重入函数,如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。
Tips:上面所说的同时指的是宏观上同时调用,实质上也就是该函数被多个执行流并发/并行调用,无特别说明,本章内容所提到的同时均指宏观上的概念。
重入指的是同一个函数被不同执行流调用,前一个执行流还没有执行完该函数、另一个执行流又开始调用该函数了,其实就是同一个函数被多个执行流并发/并行调用,在宏观角度上理解指的就是被多个执行流同时调用。
看到这里大家可能会有点不解,我们使用示例进行讲解。示例代码 11.10.2
是一个单线程与信号处理关联的程序。main()
函数中调用
signal()
函数为
SIGINT
信号注册了一个信号处理函数
sig_handler
,信号处理函数 sig_handler
会调用
func
函数;
main()
函数最终会进入到一个循环中,循环调用
func()
。
//示例代码 11.10.2 信号与可重入问题
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
static void func(void)
{
/*...... */
}
static void sig_handler(int sig)
{
func();
}
int main(int argc, char *argv[])
{
sig_t ret = NULL;
ret = signal(SIGINT, (sig_t)sig_handler);
if (SIG_ERR == ret) {
perror("signal error");
exit(-1);
}
/* 死循环 */
for ( ; ; )
func();
exit(0);
}
当 main()
函数正在执行
func()
函数代码,此时进程收到了
SIGINT
信号,便会打断当前正常执行流程、跳转到 sig_handler()
函数执行,进而调用
func
、执行
func()
函数代码;这里就出现了主程序与信号处理函数并发调用 func()
的情况,示意图如下所示:
在信号处理函数中,执行完 func()
之后,信号处理函数退出、返回到主程序流程,也就是被信号打断的位置处继续运行。如果每次出现这种情况执行 func()
函数都能产生正确的结果,那么
func()
函数就是一个可重入函数。
接着再来看看在多线程环境下,示例代码 11.10.1
是一个多线程程序,主线程调用
pthread_create()
函数创建了 5
个新的线程,这
5
个线程使用同一个入口函数
new_thread
;所以它们执行的代码是一样的,除了参数 arg
不同之外;在这种情况下,这
5
个线程中的多个线程就可能会出现并发调用
pthread_self()
函数的情况。
以上举例说明了函数被多个执行流同时调用的两种情况:
- 在一个含有信号处理的程序当中,主程序正执行函数 func(),此时进程接收到信号,主程序被打断,跳转到信号处理函数中执行,信号处理函数中也调用了 func()。
- 在多线程环境下,多个线程并发调用同一个函数。
所以由此可知,在多线程环境以及信号处理有关应用程序中,需要注意不可重入函数的问题,如果多条执行流同时调用一个不可重入函数则可能会得不到预期的结果、甚至有可能导致程序崩溃!不止是在应用程序中,在一个包含了中断处理的裸机应用程序中亦是如此!所以不可重入函数通常存在着一定的安全隐患。
可重入函数的分类
笔者认为可重入函数可以分为两类:
- 绝对的可重入函数:所谓绝对,指的是该函数不管如何调用,都刚断言它是可重入的,都能得到预期的结果。
- 带条件的可重入函数:指的是在满足某个/某些条件的情况下,可以断言该函数是可重入的,不管怎么调用都能得到预期的结果。
绝对可重入函数
笔者查阅过很多的书籍以及网络文章,并未发现有提出过这种分类,所以这完全是笔者个人对此的一个理解,首先来看一下绝对可重入函数的一个例子,如下所示:
函数
func()
就是一个标准的绝对可重入函数:
static int func(int a)
{
int local;
int j;
for (local = 0, j = 0; j < 5; j++) {
local += a * a;
a += 2;
}
return local;
}
该函数内操作的变量均是函数内部定义的自动变量(局部变量),每次调用函数,都会在栈内存空间为局部变量分配内存,当函数调用结束返回时、再由系统回收这些变量占用的栈内存,所以局部变量生命周期只限于函数执行期间。
除此之外,该函数的参数和返回值均是值类型、而并非是引用类型(就是指针)。
如果多条执行流同时调用函数 func()
,那必然会在栈空间中存在多份局部变量,每条执行流操作各自的局部变量,相互不影响,所以即使函数同时被调用,依然每次都能得到正确的结果。所以上面列举的函数func()就是一个非常标准的绝对可重入函数,函数内部仅操作了函数内定义的局部变量,除了使用栈上的变量以外不依赖于任何环境变量,这样的函数就是 purecode
(纯代码)可重入,可以允许该函数的多个副本同时在运行,由于它们使用的是分离的栈,所以不会相互干扰!
总结下绝对可重入函数的特点:
- 函数内所使用到的变量均为局部变量,换句话说,该函数内的操作的内存地址均为本地栈地址;
- 函数参数和返回值均是值类型;
- 函数内调用的其它函数也均是绝对可重入函数。
带条件的可重入函数
带条件的可重入函数通常需要满足一定的条件时才是可重入函数,我们来看一个不可重入函数的例子,如下所示:
static int glob = 0;
static void func(int loops)
{
int local;
int j;
for (j = 0; j < loops; j++) {
local = glob;
local++;
glob = local;
}
}
当多个执行流同时调用该函数,全局变量 glob
的最终值将不得而知,最终可能会得不到正确的结果,因为全局变量 glob
将成为多个线程间的共享数据,它们都会对
glob 变量进行读写操作、会导致数据不一致的问题,关于这个问题在 12.1 小节中给大家做了详细说明。这个函数就是典型的不可重入函数,函数运行需要读取、修改全局变量 glob,该变量并非在函数自己的栈上,意味着该函数运行依赖于外部环境变量。
但如果对上面的函数进行修改,函数
func()
内仅读取全局变量
glob
的值,而不更改它的值
static int glob = 0;
static void func(int loops)
{
int local;
int j;
for (j = 0; j < loops; j++) {
local = glob;
local++;
printf("local=%d\n", local);
}
}
修改完之后,函数 func()
内仅读取了变量
glob
,而并未更改
glob
的值,那么此时函数
func()
就是一个可重入函数了;但是这里需要注意,它需要满足一个条件,这个条件就是:当多个执行流同时调用函数 func()时,全局变量 glob
的值绝对不会在其它某个地方被更改;譬如线程
1
和线程
2
同时调用了函数
func()
,但是另一个线程 3
在线程
1
和线程
2
同时调用了函数
func()
的时候,可能会发生更改变量
glob
值的情况,如果是这样,那么函数 func()
依然是不可重入函数。这就是有条件的可重入函数的概念,这通常需要程序员本身去规避这类问题,标准 C
语言函数库中也存在很多这类带条件的可重入函数,后面给大家看一下。
再来看一个例子:
static void func(int *arg)
{
int local = *arg;
int j;
for (j = 0; j < 10; j++)
local++;
*arg = local;
}
这是一个参数为引用类型的函数,传入了一个指针,并在函数内部读写该指针所指向的内存地址,该函数是一个可重入函数,但同样需要满足一定的条件;如果多个执行流同时调用该函数时,所传入的指针是共享变量的地址,那么在这种情况,最终可能得不到预期的结果;因为在这种情况下,函数 func()所读写的便是多个执行流的共享数据,会出现数据不一致的情况,所以是不安全的。
总结
相信笔者列举了这么多例子,大家应该明白了什么是可重入函数以及绝对可重入函数和带条件的可重入函数的区别,还有很多的例子这里就不再一一列举了,相信通过笔者的介绍大家应该知道如何去判断它们了。
很多的 C
库函数有两个版本:可重入版本和不可重入版本,可重入版本函数其名称后面加上了“_r”,用于表明该函数是一个可重入函数;而不可重入版本函数其名称后面没有“_r”,前面章节内容中也已经遇到过很多次了,譬如 asctime()/asctime_r()
、
ctime()/ctime_r()、
localtime()/localtime_r()
等。
通过 man
手册可以查询到它们“
ATTRIBUTES
”信息,譬如执行
"man 3 ctime"
,在帮助页面上往下翻便可以找到,如下所示:
可以看到上图中有些函数 Value
这栏会显示
MT-Unsafe
、而有些函数显示的却是
MT-Safe
。
MT
指的是multithreaded(多线程),所以
MT-Unsafe
就是多线程不安全、
MT-Safe
指的是多线程安全,通常习惯上将 MT-Safe 和
MT-Unsafe
称为线程安全或线程不安全。
Value 值为
MT-Safe
修饰的函数表示该函数是一个线程安全函数,使用
MT-Unsafe
修饰的函数表示它是 一 个 线 程 不 安 全 函 数 , 下 一 小 节 会 给 大 家 介 绍 什 么 是 线 程 安 全 函 数 。 从 上 图 可 以 看 出 ,asctime_r()/ctime_r()/gmtime_r()/localtime_r()这些可重入函数都是线程安全函数,但这些函数都是带条件的可重入函数,可以发现在 MT-Safe
标签后面会携带诸如
env
或
locale
之类的标签,这其实就表示该函数需要在满足 env
或
locale
条件的情况下才是可重入函数;如果是绝对可重入函数,
MT-Safe
标签后面不会携带任何标签,譬如数学库函数 sqrt
:
诸如 env
或
locale
等标签,可以通过
man
手册进行查询,命令为
"man 7 attributes"
,这文档里边的内容反正笔者是没太看懂,不知所云;但是经过我的对比 env
或
locale
这两个标签还是很容易理解的。这两个标签在 man
测试里边出现的频率相对于其它的标签要大,这里笔者就简单地提一下:
- env:这个标签指的是该函数内部会读取进程的某个/某些环境变量,譬如 getenv()函数,前面也给大家介绍过,进程的环境变量其实就是程序的一个全局变量,前面也讲了,对于这类读取(但没更改)了全局变量的可重入函数应该要满足的条件,这里就不再重述了;
- local:local 指的是本地,很容易理解,通常该类函数传入了指针,前面也提到了传入了指针的可重入函数应该要满足什么样的条件才是可重入的,这里也不再重述!
11.10.3 线程安全函数
了解了可重入函数之后,再来看看线程安全函数。
一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数。线程安全函数包括可重入函数,可重入函数是线程安全函数的一个真子集,也就是说可重入函数一定是线程安全函数,但线程安全函数不一定是可重入函数,它们之间的关系如下:
譬如下面这个函数是一个不可重入函数,同样也是一个线程不安全函数(上小节的最后一个例子):
static int glob = 0;
static void func(int loops)
{
int local;
int j;
for (j = 0; j < loops; j++) {
local = glob;
local++;
glob = local;
}
}
如果对该函数进行修改,使用线程同步技术(譬如互斥锁)对共享变量 glob
的访问进行保护,在读写该变量之前先上锁、读写完成之后在解锁。这样,该函数就变成了一个线程安全函数,但是它依然不是可重入函数,因为该函数更改了外部全局变量的值。
可重入函数只是单纯从语言语法角度分析它的可重入性质,不涉及到一些具体的实现机制,譬如线程同步技术,这是判断可重入函数和线程安全函数的区别,因为你单从概念上去分析的话,其实可以发现可重入函数和线程安全函数好像说的是同一个东西,“一个函数被多个线程同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数”,多个线程指的就是多个执行流(不包括信号处理函数执行流),所以从这里看跟可重入函数的概念是很相似的。
POSIX.1-2001 和
POSIX.1-2008
标准中规定的所有函数都必须是线程安全函数,但以下函数除外:
以上所列举出的这些函数被认为是线程不安全函数,大家也可以通过 man
手册查询到这些函数,
"man 7 pthreads",如下所示:
man 手册可以查看库函数的
ATTRIBUTES
信息,如果函数被标记为
MT-Safe
,
则表示该函数是一个线程安全函数,如果被标记为 MT-Unsafe,则意味着该函数是一个非线程安全函数,对于非线程安全函数,在多线程编程环境下尤其要注意,如果某函数可能会被多个线程同时调用时,该函数不能是非线程安全函数,一定要是线程安全函数,否则将会出现意想不到的结果、甚至使得整个程序崩溃!
对于一个中大型的多线程应用程序项目来说,能够保证整个程序的安全性,这是非常重要的,程序员必须要正确对待线程安全以及信号处理等这类在多线程环境下敏感的问题,这通常对程序员提出了更高的要求。
11.10.4 一次性初始化
在多线程编程环境下,有些代码段只需要执行一次,譬如一些初始化相关的代码段,通常比较容易想到的就是将其放在 main()主函数进行初始化,这样也就是意味着该段代码只在主线程中被调用,只执行过一次。大家想一下这样的问题:当你写了一个 C
函数
func()
,该函数可能会被多个线程调用,并且该函数中有一段初始化代码,该段代码只能被执行一次(无论哪个线程执行都可以)、如果执行多次会出现问题,如下所示:
static void func(void)
{
/* 只能执行一次的代码段 */
init_once();
/***********************/
.....
.....
}
大家可能会问,怎么会有这样的需求呢?当然有,譬如下小节将要介绍的线程特有数据就需要有这样的需求,那我们如何去保证这段代码只能被执行一次呢(被进程中的任一线程执行都可以)?本小节向大家介绍 pthread_once()函数,该函数原型如下所示:
#include <pthread.h>
pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
在多线程编程环境下,尽管 pthread_once()
调用会出现在多个线程中,但该函数会保证
init_routine()
函数仅执行一次,究竟在哪个线程中执行是不定的,是由内核调度来决定。函数参数和返回值含义如下:
once_control:
这是一个
pthread_once_t
类型指针,在调用
pthread_once()
函数之前,我们需要定义了一个 pthread_once_t
类型的静态变量,调用
pthread_once()
时参数
once_control
指向该变量。通常在定义变量时会使用 PTHREAD_ONCE_INIT
宏对其进行初始化,譬如:
pthread_once_t once_control = PTHREAD_ONCE_INIT;
init_routine:
一个函数指针,参数
init_routine
所指向的函数就是要求只能被执行一次的代码段,pthread_once()函数内部会调用
init_routine()
,即使
pthread_once()
函数会被多次执行,但它能保证
init_routine()仅被执行一次。
返回值:调用成功返回
0
;失败则返回错误编码以指示错误原因。
如果参数 once_control
指向的
pthread_once_t
类型变量,其初值不是
PTHREAD_ONCE_INIT
,pthread_once()的行为将是不正常的;
PTHREAD_ONCE_INIT
宏在
<pthread.h>
头文件中定义。
如果在一个线程调用 pthread_once()
时,另外一个线程也调用了
pthread_once
,则该线程将会被阻塞等待,直到第一个完成初始化后返回。换言之,当调用 pthread_once
成功返回时,调用总是能够肯定所有的状态已经初始化完成了。
使用示例
接下来我们测试下,当 pthread_once()
被多次调用时,
init_routine()
函数是不是只会被执行一次,示例代码如下所示:
//示例代码 11.10.3 pthread_once()函数使用示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void initialize_once(void)
{
printf("initialize_once 被执行: 线程 ID<%lu>\n", pthread_self());
}
static void func(void)
{
pthread_once(&once, initialize_once);//执行一次性初始化函数
printf("函数 func 执行完毕.\n");
}
static void *thread_start(void *arg)
{
printf("线程%d 被创建: 线程 ID<%lu>\n", *((int *)arg), pthread_self());
func(); //调用函数 func
pthread_exit(NULL); //线程终止
}
static int nums[5] = {0, 1, 2, 3, 4};
int main(void)
{
pthread_t tid[5];
int j;
/* 创建 5 个线程 */
for (j = 0; j < 5; j++)
pthread_create(&tid[j], NULL, thread_start, &nums[j]);
/* 等待线程结束 */
for (j = 0; j < 5; j++)
pthread_join(tid[j], NULL);//回收线程
exit(0);
}
程序中调用 pthread_create()
创建了
5
个子线程,新线程的入口函数均为
thread_start()
,
thread_start()
函数会调用 func()
,并在
func()
函数调用
pthread_once()
,需要执行的一次性初始化函数为
initialize_once()
,换言之,pthread_once()
函数会被执行
5
次,每个子线程各自执行一次。
从打印信息可知,initialize_once()
函数确实只被执行了一次,也就是被编号为
0
的线程所执行,其它线程均未执行该函数。
11.10.5 线程特有数据
线程特有数据也称为线程私有数据,简单点说,就是为每个调用线程分别维护一份变量的副本(copy
),每个线程通过特有数据键(key
)访问时,这个特有数据键都会获取到本线程绑定的变量副本。这样就可以避免变量成为多个线程间的共享数据。
C 库中有很多函数都是非线程安全函数,非线程安全函数在多线程环境下,被多个线程同时调用时将会发生意想不到的结果,得不到预期的结果。譬如很多库函数都会返回一个字符串指针,譬如 asctime()
、
ctime()
、localtime()等,返回出来的字符串可以被调用线程直接使用,但该字符串缓冲区通常是这些函数内部所维护的静态数组或者是某个全局数组(这里笔者只是猜测,具体是哪一种我也不清楚,没有翻看这些函数内部的实现)。
既然如此,多次调用这些函数返回的字符串其实指向的是同一个缓冲区,每次调用都会刷新缓冲区中的数据。这些函数是非线程安全的,譬如当 ctime()被多个线程同时调用时,返回的字符串中的数据可能是混乱的,因为某一线程调用它时,缓冲区中的数据可能被另一个调用线程修改了。针对这些非线程安全函数,可以使用线程特有数据将其变为线程安全函数,线程特有数据通常会在编写一些库函数的时使用到,后面我们会演示如何使用线程特有数据。
线程特有数据的核心思想其实非常简单,就是为每一个调用线程(调用某函数的线程,该函数就是我们要通过线程特有数据将其实现为线程安全的函数)分配属于该线程的私有数据区,为每个调用线程分别维护一份变量的副本。
线程特有数据主要涉及到 3
个函数:
pthread_key_create()
、
pthread_setspecific()
以及
pthread_getspecific()
,接下来一一向大家进行介绍。
pthread_key_create()函数
在为线程分配私有数据区之前,需要调用 pthread_key_create()
函数创建一个特有数据键(
key
),并且只需要在首个调用的线程中创建一次即可,所以通常会使用到上小节所学习的 pthread_once()
函数。pthread_key_create()函数原型如下所示:
#include <pthread.h>
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
使用该函数需要包含头文件<pthread.h>
。
函数参数和返回值含义如下:
key:
调用该函数会创建一个特有数据键,并通过参数
key
所指向的缓冲区返回给调用者,参数
key
是一个 pthread_key_t
类型的指针,可以把
pthread_key_t
称为
key
类型。调用
pthread_key_create()
之前,需要定义一个 pthread_key_t
类型变量,调用
pthread_key_create()
时参数
key
指向
pthread_key_t
类型变量。
destructor:
参数
destructor
是一个函数指针,指向一个自定义的函数,其格式如下:
void destructor(void *value)
{
/* code */
}
调用 pthread_key_create()
函数允许调用者指定一个自定义的解构函数(类似于
C++
中的析构函数),使用参数 destructor
指向该函数;该函数通常用于释放与特有数据键关联的线程私有数据区占用的内存空间,当使用线程特有数据的线程终止时,destructor()
函数会被自动调用。
返回值:成功返回
0
;失败将返回一个错误编号以指示错误原因,返回的错误编号其实就是全局变量errno,可以使用诸如
strerror()
函数查看其错误字符串信息。
pthread_setspecific()
函数
调用 pthread_key_create()
函数创建特有数据键(
key
)后通常需要为调用线程分配私有数据缓冲区,譬如通过 malloc()
(或类似函数)申请堆内存,每个调用线程分配一次,且只会在线程初次调用此函数时分配。
为线程分配私有数据缓冲区之后,通常需要调用 pthread_setspecific()
函数,
pthread_setspecific()
函数其实完成了这样的操作:首先保存指向线程私有数据缓冲区的指针,并将其与特有数据键以及当前调用线程关联起来;其函数原型如下所示:
#include <pthread.h>
int pthread_setspecific(pthread_key_t key, const void *value);
函数参数和返回值含义如下:
key:
pthread_key_t
类型变量,参数
key
应赋值为调用
pthread_key_create()
函数时创建的特有数据键,也就是 pthread_key_create()
函数的参数
key
所指向的
pthread_key_t
变量。
value:
参数
value
是一个
void
类型的指针,指向由调用者分配的一块内存,作为线程的私有数据缓冲区,当线程终止时,会自动调用参数 key
指定的特有数据键对应的解构函数来释放这一块动态申请的内存空间。
返回值:调用成功返回
0
;失败将返回一个错误编码,可以使用诸如
strerror()
函数查看其错误字符串信息。
pthread_getspecific()函数
调用 pthread_setspecific()
函数将线程私有数据缓冲区与调用线程以及特有数据键关联之后,便可以使用pthread_getspecific()函数来获取调用线程的私有数据区了。其函数原型如下所示:
#include <pthread.h>
void *pthread_getspecific(pthread_key_t key);
参数 key
应赋值为调用
pthread_key_create()
函数时创建的特有数据键,也就是
pthread_key_create()
函数的参数 key
指向的
pthread_key_t
变量。
pthread_getspecific()函数应返回当前调用线程关联到特有数据键的私有数据缓冲区,返回值是一个指针,指向该缓冲区。如果当前调用线程并没有设置线程私有数据缓冲区与特有数据键进行关联,则返回值应为NULL,函数中可以利用这一点来判断当前调用线程是否为初次调用该函数,如果是初次调用,则必须为该线程分配私有数据缓冲区。
pthread_key_delete()
函数
除了以上介绍的三个函数外,如果需要删除一个特有数据键(key
)可以使用函数
pthread_key_delete()
,pthread_key_delete()函数删除先前由
pthread_key_create()
创建的键。其函数原型如下所示:
#include <pthread.h>
int pthread_key_delete(pthread_key_t key);
参数 key
为要删除的键。函数调用成功返回
0
,失败将返回一个错误编号。
调用 pthread_key_delete()
函数将释放参数
key
指定的特有数据键,可以供下一次调用
pthread_key_create() 时使用;调用 pthread_key_delete()
时,它并不将查当前是否有线程正在使用该键所关联的线程私有数据缓冲区,所以它并不会触发键的解构函数,也就不会释放键关联的线程私有数据区占用的内存资源,并且调用 pthread_key_delete()后,当线程终止时也不再执行键的解构函数。所以,通常在调用
pthread_key_delete()
之前,必须确保以下条件:
- 所有线程已经释放了私有数据区(显式调用解构函数或线程终止)。
- 参数 key 指定的特有数据键将不再使用。
任何在调用 pthread_key_delete()
之后使用键的操作都会导致未定义的行为,譬如调用
pthread_setspecific()或 pthread_getspecific()
将会以错误形式返回。
使用示例
接下来编写一个使用线程特有数据的例子,很多书籍上都会使用 strerror()
函数作为例子,这个函数曾在3.2 小节向大家介绍过,通过
man
手册查询到
strerror()
函数是一个非线程安全函数,其实它有对应的可重入版本 strerror_r()
,可重入版本
strerror_r()
函数则是一个线程安全函数。
这里暂且不管 strerror_r()
函数,我们来聊一聊
strerror()
函数,函数内部的实现方式,这里简单地提一下:调用 strerror()
函数,需要传入一个错误编号,错误编号赋值给参数
errnum
,在
Linux
系统中,每一个错误编号都会对应一个字符串,用于描述该错误,strerror()
函数会根据传入的
errnum
找到对应的字符串,返回指向该字符串的指针。
事实上,在 Linux
的实现中,标准
C
语言函数库(
glibc
)提供的
strerror()
函数是线程安全的,但在
man 手册中记录它是一个非线程安全函数,笔者猜测可能在某些操作系统的 C
语言函数库实现中,该函数是非线程安全函数的;但在 glibc
库中,它确实是线程安全函数,为此笔者还特意去查看了
glibc
库中
strerror
函数的源码,证实了这一点,这里大家一定要注意。
以下是 strerror()
函数以非线程安全方式实现的一种写法(具体的写法不止这一种,这里只是以此为例):
//示例代码 11.10.4 strerror()函数以非线程安全方式实现的一种写法
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#define MAX_ERROR_LEN 256
static char buf[MAX_ERROR_LEN];
static char *strerror(int errnum)
{
if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
else {
strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
buf[MAX_ERROR_LEN - 1] = '\0';//终止字符
}
return buf;
}
再次说明,glibc
库中
strerror()
是线程安全函数,本文为了向大家介绍
/
使用线程特有数据,以非线程安全方式实现了 strerror()
函数。
首先在源码中需要定义_GNU_SOURCE
宏,
_GNU_SOURCE
宏在前面章节已有介绍,这里不再重述!源码中需要定义_GNU_SOURCE
宏,不然编译源码将会提示
_sys_nerr
和
_sys_errlist
找不到。该函数利用了glibc 定义的一对全局变量:
_sys_errlist
是一个指针数组,其中的每一个元素指向一个与
errno
错误编号相匹配的描述性字符串;_sys_nerr
表示
_sys_errlist
数组中元素的个数。
可以看到该函数返回的字符串指针,其实是一个静态数组,当多个线程同时调用该函数时,那么 buf
缓冲区中的数据将会出现混乱,因为前一个调用线程拷贝到 buf
中的数据可能会被后一个调用线程重写覆盖等情况。
对此,我们可以对示例代码
11.10.4
进行测试,让多个线程都调用它,看看测试结果,测试代码如下:
//示例代码 11.10.5 非线程安全版 strerror 测试
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#define MAX_ERROR_LEN 256
static char buf[MAX_ERROR_LEN];
/**********************************
* 为了避免与库函数 strerror 重名
* 这里将其改成 my_strerror
**********************************/
static char *my_strerror(int errnum)
{
if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
else {
strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
buf[MAX_ERROR_LEN - 1] = '\0';//终止字符
}
return buf;
}
static void *thread_start(void *arg)
{
char *str = my_strerror(2); //获取错误编号为 2 的错误描述信息
printf("子线程: str (%p) = %s\n", str, str);
pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
pthread_t tid;
char *str = NULL;
int ret;
str = my_strerror(1); //获取错误编号为 1 的错误描述信息
/* 创建子线程 */
if (ret = pthread_create(&tid, NULL, thread_start, NULL)) {
fprintf(stderr, "pthread_create error: %d\n", ret);
exit(-1);
}
/* 等待回收子线程 */
if (ret = pthread_join(tid, NULL)) {
fprintf(stderr, "pthread_join error: %d\n", ret);
exit(-1);
}
printf("主线程: str (%p) = %s\n", str, str);
exit(0);
}
主线程首先调用 my_strerror()
获取到了编号为
1
的错误描述信息,接着创建了一个子线程,在子线程中调用 my_strerror()
获取编号为
2
的错误描述信息,并将其打印出来,包括字符串的地址值;子线程结束后,主线程也打印了之前获取到的错误描述信息。我们想看到的结果是,主线程和子线程打印的错误描述信息是不一样的,因为错误编号不同,但上面的测试结果证实它们打印的结果是相同的:
从以上测试结果可知,子线程和主线程锁获取到的错误描述信息是相同的,字符串指针指向的是同一个缓冲区;原因就在于,my_strerror()函数是一个非线程安全函数,函数内部修改了全局静态变量、并返回了它的指针,每一次调用访问的都是同一个静态变量,所以后一次调用会覆盖掉前一次调用的结果。
接下来我们使用本小节所介绍的线程特有数据技术对示例代码 11.10.4
中
strerror()
函数进行修改,如下所示:
//示例代码 11.10.6 使用线程特有数据实现线程安全的 strerror()函数
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
#define MAX_ERROR_LEN 256
static pthread_once_t once = PTHREAD_ONCE_INIT;
static pthread_key_t strerror_key;
static void destructor(void *buf)
{
free(buf); //释放内存
}
static void create_key(void)
{
/* 创建一个键(key),并且绑定键的解构函数 */
if (pthread_key_create(&strerror_key, destructor))
pthread_exit(NULL);
}
/******************************
* 对 strerror 函数重写
* 使其变成为一个线程安全函数
******************************/
static char *strerror(int errnum)
{
char *buf;
/* 创建一个键(只执行一次 create_key) */
if (pthread_once(&once, create_key))
pthread_exit(NULL);
/* 获取 */
buf = pthread_getspecific(strerror_key);
if (NULL == buf) { //首次调用 my_strerror 函数,则需给调用线程分配线程私有数据
buf = malloc(MAX_ERROR_LEN);//分配内存
if (NULL == buf)
pthread_exit(NULL);
/* 保存缓冲区地址,与键、线程关联起来 */
if (pthread_setspecific(strerror_key, buf))
pthread_exit(NULL);
}
if (errnum < 0 || errnum >= _sys_nerr || NULL == _sys_errlist[errnum])
snprintf(buf, MAX_ERROR_LEN, "Unknown error %d", errnum);
else {
strncpy(buf, _sys_errlist[errnum], MAX_ERROR_LEN - 1);
buf[MAX_ERROR_LEN - 1] = '\0';//终止字符
}
return buf;
}
改进版的 strerror()
所做的第一步是调用
pthread_once()
,以确保只会执行一次
create_key()
函数,而在create_key()函数中便是调用
pthread_key_create()
创建了一个键、并绑定了相应的解构函数
destructor()
,解构函数用于释放与键关联的所有线程私有数据所占的内存空间。
接着,函数 strerror()
调用
pthread_getspecific()
以获取该调用线程与键相关联的私有数据缓冲区地址,如果返回为 NULL
,则表明该线程是首次调用
strerror()
函数,因为函数会调用
malloc()
为其分配一个新的私有数据缓冲区,并调用 pthread_setspecific()
来保存缓冲区地址、并与键以及该调用线程建立关联。如果pthread_getspecific()函数的返回值并不等于
NULL
,那么该值将指向以存在的私有数据缓冲区,此缓冲区由之前对 strerror()
的调用所分配。
剩余部分代码与示例代码 11.10.4
非线程安全版的
strerror()
实现类似,唯一的区别在于,
buf
是线程特有数据的缓冲区地址,而非全局的静态变量。
改进版的 strerror
就是一个线程安全函数,编写一个线程安全函数当然要保证该函数中调用的其它函数也必须是线程安全的,那如何确认自己调用的函数是线程安全函数呢?其实非常简单,前面也给大家介绍过,譬如通过 man
手册查看函数的
ATTRIBUTES
描述信息,或者查看
man
手册中记录的非线程安全函数列表(执行"man 7 pthreads"
命令查看)、进行对比。
Tips:有时会发现 ATTRIBUTES 描述信息与非线程安全函数列表不一致,譬如 ATTRIBUTES 描述信息中显示该函数是 MT-Unsafe(非线程安全函数)标识的,但是却没记录在非线程安全函数列表中,此时我们应该以列表为准!默认该函数是线程安全的。
大家可以去测试下改进版的 strerror
,这里笔者便不再给大家演示了,需要注意的是,在测试代码中定义的 strerror
函数其名字需要改成其它的名称,避免与库函数
strerror
重名。
11.10.6 线程局部存储
通常情况下,程序中定义的全局变量是进程中所有线程共享的,所有线程都可以访问这些全局变量;而线程局部存储在定义全局或静态变量时,使用__thread 修饰符修饰变量,此时,每个线程都会拥有一份对该变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储。
线程局部存储的主要优点在于,比线程特有数据的使用要简单。要创建线程局部变量,只需简单地在全局或静态变量的声明中包含__thread 修饰符即可!譬如:
static __thread char buf[512];
但凡带有这种修饰符的变量,每个线程都拥有一份对变量的拷贝,意味着每个线程访问的都是该变量在本线程的副本,从而避免了全局变量成为多个线程的共享数据。
关于线程局部变量的声明和使用,需要注意以下几点:
- 如果变量声明中使用了关键字 static 或 extern,那么关键字__thread 必须紧随其后。
- 与一般的全局或静态变量申明一样,线程局部变量在申明时可设置一个初始值。
- 可以使用 C 语言取值操作符(&)来获取线程局部变量的地址。
Tips:线程局部存储需要内核、Pthreads 以及 GCC 编译器的支持。
使用示例
我们编写一个简单的程序来测试线程局部存储,示例代码如下所示:
//示例代码 11.10.7 线程局部存储测试
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
static __thread char buf[100];
static void *thread_start(void *arg)
{
strcpy(buf, "Child Thread\n");
printf("子线程: buf (%p) = %s", buf, buf);
pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
pthread_t tid;
int ret;
strcpy(buf, "Main Thread\n");
/* 创建子线程 */
if (ret = pthread_create(&tid, NULL, thread_start, NULL)) {
fprintf(stderr, "pthread_create error: %d\n", ret);
exit(-1);
}
/* 等待回收子线程 */
if (ret = pthread_join(tid, NULL)) {
fprintf(stderr, "pthread_join error: %d\n", ret);
exit(-1);
}
printf("主线程: buf (%p) = %s", buf, buf);
exit(0);
}
程序中定义了一个全局变量 buf
,使用
__thread
修饰,使其变为线程局部变量;主线程中首先调用
strcpy拷贝了字符串到 buf
缓冲区中,随后创建了一个子线程,子线程也调用了
strcpy()
向
buf
缓冲区拷贝了数据;并调用 printf
打印
buf
缓冲区存储的字符串以及
buf
缓冲区的指针值。
子线程终止后,主线程也打印 buf
缓冲区中存储的字符串以及
buf
缓冲区的指针值,运行结果如下所示:
从地址便可以看出来,主线程和子线程中使用的 buf
绝不是同一个变量,这就是线程局部存储,使得每个线程都拥有一份对变量的拷贝,各个线程操作各自的变量不会影响其它线程。
大家可以使用线程局部存储方式对示例代码 11.10.4 strerror
函数进行修改,使其成为一个线程安全函数。
11.11 更多细节问题
本小节将对线程各方面的细节做深入讨论,其主要包括线程与信号之间牵扯的问题、线程与进程控制(fork()、
exec()
、
exit()
等)之间的交互。之所以出现了这些问题,其原因在于线程技术的问世晚于信号、进程控制等,然而线程的出现必须要能够兼容现有的这些技术,不能出现冲突,这就使得线程与它们之间的结合使用将会变得比较复杂!当中所涉及到的细节问题也会比较多。
11.11.1 线程与信号
Linux 信号模型是基于进程模型而设计的,信号的问世远早于线程;自然而然,线程与信号之间就会存在一些冲突,其主要原因在于:信号既要能够在传统的单线程进程中保持它原有的功能、特性,与此同时,又需要设计出能够适用于多线程环境的新特性!
信号与多线程模型之间结合使用,将会变得比较复杂,需要考虑的问题将会更多,在实际应用开发当中,如果能够避免我们应尽量避免此类事情的发生;但尽管如此,事实上,信号与多线程模型确实存在于实际的应用开发项目中。本小节我们就来讨论信号与线程之间牵扯的问题。
⑴、信号如何映射到线程
信号模型在一些方面是属于进程层面(由进程中的所有线程线程共享)的,而在另一些方面是属于单个线程层面的,以下对其进行汇总:
- 信号的系统默认行为是属于进程层面。8.3 小节介绍到,每一个信号都有其对应的系统默认动作,当进程中的任一线程收到任何一个未经处理(忽略或捕获)的信号时,会执行该信号的默认操作,信号的默认操作通常是停止或终止进程。
- 信号处理函数属于进程层面。进程中的所有线程共享程序中所注册的信号处理函数;
- 信号的发送既可针对整个进程,也可针对某个特定的线程。在满足以下三个条件中的任意一个时,信号的发送针对的是某个线程:
- 产生了硬件异常相关信号,譬如 SIGBUS、SIGFPE、SIGILL 和 SIGSEGV 信号;这些硬件异常信号在某个线程执行指令的过程中产生,也就是说这些硬件异常信号是由某个线程所引起;那么在这种情况下,系统会将信号发送给该线程。
- 当线程试图对已断开的管道进行写操作时所产生的 SIGPIPE 信号;
- 由函数 pthread_kill()或 pthread_sigqueue()所发出的信号,稍后介绍这两个函数;这些函数允许线程向同一进程下的其它线程发送一个指定的信号。
除了以上提到的三种情况之外,其它机制产生的信号均属于进程层面,譬如其它进程调用 kill()或 sigqueue()所发送的信号;用户在终端按下
Ctrl+C
、
Ctrl+\
、
Ctrl+Z 向前台进程发送的 SIGINT
、SIGQUIT 以及
SIGTSTP
信号。
- 当一个多线程进程接收到一个信号时,且该信号绑定了信号处理函数时,内核会任选一个线程来接收这个信号,意味着由该线程接收信号并调用信号处理函数对其进行处理,并不是每个线程都会接收到该信号并调用信号处理函数;这种行为与信号的原始语义是保持一致的,让进程对单个信号接收重复处理多次是没有意义的。
- 信号掩码其实是属于线程层面的,也就是说信号掩码是针对每个线程而言。8.9 小节向大家介绍了信号掩码的概念,并介绍了 sigprocmask()函数,通过 sigprocmask()可以设置进程的信号掩码,事实上,信号掩码是并不是针对整个进程来说,而是针对线程,对于一个多线程应用程序来说,并不存在一个作用于整个进程范围内的信号掩码(管理进程中的所有线程);那么在多线程环境下,各个线程可以调用 pthread_sigmask()函数来设置它们各自的信号掩码,譬如设置线程可以接收哪些信号、不接收哪些信号,各线程可独立阻止或放行各种信号。
-
针对整个进程所挂起的信号,以及针对每个线程所挂起的信号,内核都会分别进行维护、记录。 8.11.1 小节介绍到,调用 sigpending() 会返回进程中所有被挂起的信号,事实上, sigpending() 会返回针对整个进程所挂起的信号,以及针对每个线程所挂起的信号的并集。
⑵、线程的信号掩码
对于一个单线程程序来说,使用 sigprocmask()
函数设置进程的信号掩码,在多线程环境下,使用pthread_sigmask()函数来设置各个线程的信号掩码,其函数原型如下所示:
#include <signal.h>
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);
pthread_sigmask()函数就像
sigprocmask()
一 样 ,不 同 之 处 在 于 它 在 多线 程 程 序 中 使用,所以pthread_sigmask()函数的用法与
sigprocmask()
完全一样,这里就不再重述!
每个刚创建的线程,会从其创建者处继承信号掩码,这个新的线程可以调用 pthread_sigmask()
函数来改变它的信号掩码。
⑶、向线程发送信号
调用 kill()
或
sigqueue()
所发送的信号都是针对整个进程来说的,它属于进程层面,具体该目标进程中的哪一个线程会去处理信号,由内核进行选择。事实上,在多线程程序中,可以通过 pthread_kill()
向同一进程中的某个指定线程发送信号,其函数原型如下所示:
#include <signal.h>
int pthread_kill(pthread_t thread, int sig);
参数 thread
,也就是线程
ID
,用于指定同一进程中的某个线程,调用
pthread_kill()
将向参数
thread
指定的线程发送信号 sig
。
如果参数 sig
为
0
,则不发送信号,但仍会执行错误检查。函数调用成功返回
0
,失败将返回一个错误编号,不会发送信号。
除了 pthread_kill()
函数外,还可以调用
pthread_sigqueue()
函数;
pthread_sigqueue()
函数执行与
sigqueue 类似的任务,但它不是向进程发送信号,而是向同一进程中的某个指定的线程发送信号。其函数原型如下所示:
#include <signal.h>
#include <pthread.h>
int pthread_sigqueue(pthread_t thread, int sig, const union sigval value);
参数 thread
为线程
ID
,指定接收信号的目标线程(目标线程与调用
pthread_sigqueue()
函数的线程是属于同一个进程),参数 sig
指定要发送的信号,参数
value
指定伴随数据,与
sigqueue()
函数中的
value
参数意义相同。
pthread_sigqueue()函数的参数的含义与
sigqueue()
函数中对应参数相同意义相同。它俩的唯一区别在于,sigqueue()函数发送的信号针对的是整个进程,而
pthread_sigqueue()
函数发送的信号针对的是某个线程。
⑷、异步信号安全函数
应用程序中涉及信号处理函数时必须要非常小心,因为信号处理函数可能会在程序执行的任意时间点被调用,从而打断主程序。接下来介绍一个概念---异步信号安全函数(
async-signal-safe function
)。
前面介绍了线程安全函数,作为线程安全函数可以被多个线程同时调用,每次都能得到预期的结果,但是这里有前提条件,那就是没有信号处理函数参与;换句话说,线程安全函数不能在信号处理函数中被调用,否则就不能保证它一定是安全的。所以就出现了异步信号安全函数。
异步信号安全函数指的是可以在信号处理函数中可以被安全调用的线程安全函数,所以它比线程安全函数的要求更为严格!可重入函数满足这个要求,所以可重入函数一定是异步信号安全函数。而线程安全函数则不一定是异步信号安全函数了。
举个例子,下面列举出来的一个函数是线程安全函数:
static pthread_mutex_t mutex;
static int glob = 0;
static void func(int loops)
{
int local;
int j;
for (j = 0; j < loops; j++) {
pthread_mutex_lock(&mutex); //互斥锁上锁
local = glob;
local++;
glob = local;
pthread_mutex_unlock(&mutex);//互斥锁解锁
}
}
该函数虽然对全局变量进行读写操作,但是在访问全局变量时进行了加锁,避免了引发竞争冒险;它是一个线程安全函数,假设线程 1 正在执行函数
func
,刚刚获得锁(也就是刚刚对互斥锁上锁),而这时进程收到信号,并分派给线程 1
处理,线程
1
接着跳转去执行信号处理函数,不巧的是,信号处理函数中也调用了 func()
函数,同样它也去获取锁,由于此时锁处于锁住状态,所以信号处理函数中调用
func()
获取锁将会陷入休眠、等待锁的释放。这时线程 1
就会陷入死锁状态,线程
1
无法执行,锁无法释放;如果其它线程也调用 func()
,那它们也会陷入休眠、如此将会导致整个程序陷入死锁!
通过上面的分析,可知,涉及到信号处理函数时要非常小心。之所以涉及到信号处理函数时会出现安全问题,笔者认为主要原因在以下两个方面:
- 信号是异步的,信号可能会在任何时间点中断主程序的运行,跳转到信号处理函数处执行,从而形成一个新的执行流(信号处理函数执行流)。
- 信号处理函数执行流与线程执行流存在一些区别,信号处理函数所产生的执行流是由执行信号处理函数的线程所触发的,它俩是在同一个线程中,属于同一个线程执行流。
在异步信号安全函数、可重入函数以及线程安全函数三者中,可重入函数的要求是最严格的,所以通常会说可重入函数一定是线程安全函数、也一定是异步信号安全函数。通常对于上面所列举出的线程安全函数 func(),如果想将其实现为异步信号安全函数,可以在获取锁之前通过设置信号掩码,在锁期间禁止接收该信号,也就是说将函数实现为不可被信号中断。经过这样处理之后,函数 func()
就是一个异步信号安全函数了。
Linux 标准
C
库和系统调用中以下函数被认为是异步信号安全函数:
表
11.11.1
异步信号安全函数
上表所列举出的这些函数被认为是异步信号安全函数,可以通过 man
手册查询,执行命令
"man 7 signal"
,如下所示:(没找到)
大家可以通过对比 man
手册查询到的这些异步信号安全函数,来确定自己调用的库函数或系统调用是不是异步信号安全函数,这里需要说,在本书的示例代码中,并没有完全按照安全性要求,在信号处理函数中使用异步信号安全函数,譬如在本书中的示例代码中,信号处理函数中调用了 printf()
用于打印信息,事实上这个函数是一个非异步信号安全函数,当然在一个实际的项目应用程序当中不能这么用,但是本书只是为了方便输出打印信息而已。
所以对于一个安全的信号处理函数来说,需要做到以下几点:
- 首先确保信号处理函数本身的代码是可重入的,且只能调用异步信号安全函数;
- 当主程序执行不安全函数或是去操作信号处理函数也可能会更新的全局数据结构时,要阻塞信号的传递。