线程属性
如前所述,调用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对象的每种属性提供了设置属性的接口以及获取属性的接口。
线程栈属性
每个线程都有自己的栈空间,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。
示例代码 12.9.1 设置线程栈大小pthread_attr_getstack()
#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);
}
分离状态属性
前面介绍了线程分离的概念,如果对现已创建的某个线程的终止状态不感兴趣,可以使用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给出了以分离状态启动线程的示例。
示例代码 12.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);
}
线程安全
当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?带着这些问题,本小节将讨论线程安全相关的话题。
Tips:在阅读本小节内容之前,建议先阅读第十二章内容,这章内容原本计划是放在本小节内容之前的,但由于排版问题,不得不将其单独列为一章。
线程栈
进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈。譬如主线程调用pthread_create()创建了一个新的线程,那么这个新的线程有它自己独立的栈地址空间、而主线程也有它自己独立的栈地址空间。通过11.9.1小节可知,在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况下,保持默认即可!
既然每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰。在示例代码 11.10.1中,主线程创建了5个新的线程,这5个线程使用同一个start函数new_thread,该函数中定义了局部变量number和tid以及arg参数,意味着这5个线程的线程栈中都各自为这些变量分配了内存空间,任何一个线程修改了number或tid都不会影响其它线程。
示例代码 12.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);
}
运行结果:
图 12.10.1 测试结果
可重入函数
要解释可重入(Reentrant)函数为何物,首先需要区分单线程程序和多线程程序。本章开头部分已向各位读者进行了详细介绍,单线程程序只有一条执行流(一个线程就是一条执行流),贯穿程序始终;而对于多线程程序而言,同一进程却存在多条独立、并发的执行流。
进程中执行流的数量除了与线程有关之外,与信号处理也有关联。因为信号是异步的,进程可能会在其运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含信号处理)中形成了两条(即主程序和信号处理函数)独立的执行流。
接下来再来介绍什么是可重入函数,如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。
Tips:上面所说的同时指的是宏观上同时调用,实质上也就是该函数被多个执行流并发/并行调用,无特别说明,本章内容所提到的同时均指宏观上的概念。
重入指的是同一个函数被不同执行流调用,前一个执行流还没有执行完该函数、另一个执行流又开始调用该函数了,其实就是同一个函数被多个执行流并发/并行调用,在宏观角度上理解指的就是被多个执行流同时调用。
看到这里大家可能会有点不解,我们使用示例进行讲解。示例代码 11.10.2是一个单线程与信号处理关联的程序。main()函数中调用signal()函数为SIGINT信号注册了一个信号处理函数sig_handler,信号处理函数sig_handler会调用func函数;main()函数最终会进入到一个循环中,循环调用func()。
示例代码 12.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()的情况,示意图如下所示:
图 12.10.2 被信号打断
在信号处理函数中,执行完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”,在帮助页面上往下翻便可以找到,如下所示:
图 12.10.3 asctime()/asctime_r()函数的ATTRIBUTES信息
可以看到上图中有些函数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:
图 12.10.4 sqrt函数的ATTRIBUTES信息
诸如env或locale等标签,可以通过man手册进行查询,命令为”man 7 attributes”,这文档里边的内容反正笔者是没太看懂,不知所云;但是经过我的对比env或locale这两个标签还是很容易理解的。这两个标签在man测试里边出现的频率相对于其它的标签要大,这里笔者就简单地提一下:
- env:这个标签指的是该函数内部会读取进程的某个/某些环境变量,譬如getenv()函数,前面也给大家介绍过,进程的环境变量其实就是程序的一个全局变量,前面也讲了,对于这类读取(但没更改)了全局变量的可重入函数应该要满足的条件,这里就不再重述了;
- local:local指的是本地,很容易理解,通常该类函数传入了指针,前面也提到了传入了指针的可重入函数应该要满足什么样的条件才是可重入的,这里也不再重述!
本小节内容写得有点多了,笔者觉得讲的是比较清楚了,下小节给大家介绍线程安全函数。
线程安全函数
了解了可重入函数之后,再来看看线程安全函数。
一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数。线程安全函数包括可重入函数,可重入函数是线程安全函数的一个真子集,也就是说可重入函数一定是线程安全函数,但线程安全函数不一定是可重入函数,它们之间的关系如下:
图 12.10.5 线程安全函数与可重入函数
譬如下面这个函数是一个不可重入函数,同样也是一个线程不安全函数(上小节的最后一个例子):
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标准中规定的所有函数都必须是线程安全函数,但以下函数除外:
asctime() |
basename() |
catgets() |
crypt() |
ctermid() |
ctime() |
dbm_clearerr() |
dbm_close() |
dbm_delete() |
dbm_error() |
dbm_fetch() |
dbm_firstkey() |
dbm_nextkey() |
dbm_open() |
dbm_store() |
dirname() |
dlerror() |
drand48() |
ecvt() |
encrypt() |
endgrent() |
endpwent() |
endutxent() |
fcvt() |
ftw() |
gcvt() |
getc_unlocked() |
getchar_unlocked() |
getdate() |
getenv() |
getgrent() |
getgrgid() |
getgrnam() |
gethostbyaddr() |
gethostbyname() |
gethostent() |
getlogin() |
getnetbyaddr() |
getnetbyname() |
getnetent() |
getopt() |
getprotobyname() |
getprotobynumber() |
getprotoent() |
getpwent() |
getpwnam() |
getpwuid() |
getservbyname() |
getservbyport() |
getservent() |
getutxent() |
getutxid() |
getutxline() |
gmtime() |
hcreate() |
hdestroy() |
hsearch() |
inet_ntoa() |
l64a() |
lgamma() |
lgammaf() |
lgammal() |
localeconv() |
localtime() |
lrand48() |
mrand48() |
nftw() |
nl_langinfo() |
ptsname() |
putc_unlocked() |
putchar_unlocked() |
putenv() |
pututxline() |
rand() |
readdir() |
setenv() |
setgrent() |
setkey() |
setpwent() |
setutxent() |
strerror() |
strsignal() |
strtok() |
system() |
tmpnam() |
ttyname() |
unsetenv() |
wcrtomb() |
wcsrtombs() |
wcstombs() |
wctomb() |
表 11.10.1 POSIX.1-2001和POSIX.1-2008中列出的线程不安全函数
以上所列举出的这些函数被认为是线程不安全函数,大家也可以通过man手册查询到这些函数,”man 7 pthreads”,如下所示:
图 12.10.6 通过man手册查询到线程不安全函数
如果想确认某个函数是不是线程安全函数可以
上小节给大家提到过,man手册可以查看库函数的ATTRIBUTES信息,如果函数被标记为MT-Safe,则表示该函数是一个线程安全函数,如果被标记为MT-Unsafe,则意味着该函数是一个非线程安全函数,对于非线程安全函数,在多线程编程环境下尤其要注意,如果某函数可能会被多个线程同时调用时,该函数不能是非线程安全函数,一定要是线程安全函数,否则将会出现意想不到的结果、甚至使得整个程序崩溃!
对于一个中大型的多线程应用程序项目来说,能够保证整个程序的安全性,这是非常重要的,程序员必须要正确对待线程安全以及信号处理等这类在多线程环境下敏感的问题,这通常对程序员提出了更高的要求。
一次性初始化
在多线程编程环境下,有些代码段只需要执行一次,譬如一些初始化相关的代码段,通常比较容易想到的就是将其放在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()函数是不是只会被执行一次,示例代码如下所示:
示例代码 12.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次,每个子线程各自执行一次。
编译运行:
图 12.10.7 测试结果
从打印信息可知,initialize_once()函数确实只被执行了一次,也就是被编号为1的线程所执行,其它线程均未执行该函数。
线程特有数据
线程特有数据也称为线程私有数据,简单点说,就是为每个调用线程分别维护一份变量的副本(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()函数以非线程安全方式实现的一种写法(具体的写法不止这一种,这里只是以此为例):
示例代码 12.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进行测试,让多个线程都调用它,看看测试结果,测试代码如下:
示例代码 12.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的错误描述信息,并将其打印出来,包括字符串的地址值;子线程结束后,主线程也打印了之前获取到的错误描述信息。我们想看到的结果是,主线程和子线程打印的错误描述信息是不一样的,因为错误编号不同,但上面的测试结果证实它们打印的结果是相同的:
图 12.10.8 测试结果
从以上测试结果可知,子线程和主线程锁获取到的错误描述信息是相同的,字符串指针指向的是同一个缓冲区;原因就在于,my_strerror()函数是一个非线程安全函数,函数内部修改了全局静态变量、并返回了它的指针,每一次调用访问的都是同一个静态变量,所以后一次调用会覆盖掉前一次调用的结果。
接下来我们使用本小节所介绍的线程特有数据技术对示例代码 11.10.4中strerror()函数进行修改,如下所示:
示例代码 12.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重名。
线程局部存储
通常情况下,程序中定义的全局变量是进程中所有线程共享的,所有线程都可以访问这些全局变量;而线程局部存储在定义全局或静态变量时,使用__thread修饰符修饰变量,此时,每个线程都会拥有一份对该变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储。
线程局部存储的主要优点在于,比线程特有数据的使用要简单。要创建线程局部变量,只需简单地在全局或静态变量的声明中包含__thread修饰符即可!譬如:
static __thread char buf[512];
但凡带有这种修饰符的变量,每个线程都拥有一份对变量的拷贝,意味着每个线程访问的都是该变量在本线程的副本,从而避免了全局变量成为多个线程的共享数据。
关于线程局部变量的声明和使用,需要注意以下几点:
- 如果变量声明中使用了关键字static或extern,那么关键字__thread必须紧随其后。
- 与一般的全局或静态变量申明一眼,线程局部变量在申明时可设置一个初始值。
- 可以使用C语言取值操作符(&)来获取线程局部变量的地址。
Tips:线程局部存储需要内核、Pthreads以及GCC编译器的支持。
使用示例
我们编写一个简单的程序来测试线程局部存储,示例代码如下所示:
示例代码 12.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缓冲区的指针值,运行结果如下所示:
图 12.10.9 测试结果
从地址便可以看出来,主线程和子线程中使用的buf绝不是同一个变量,这就是线程局部存储,使得每个线程都拥有一份对变量的拷贝,各个线程操作各自的变量不会影响其它线程。
大家可以使用线程局部存储方式对示例代码 11.10.4 strerror函数进行修改,使其成为一个线程安全函数。
更多细节问题
本小节将对线程各方面的细节做深入讨论,其主要包括线程与信号之间牵扯的问题、线程与进程控制(fork()、exec()、exit()等)之间的交互。之所以出现了这些问题,其原因在于线程技术的问世晚于信号、进程控制等,然而线程的出现必须要能够兼容现有的这些技术,不能出现冲突,这就使得线程与它们之间的结合使用将会变得比较复杂!当中所涉及到的细节问题也会比较多。
线程与信号
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库和系统调用中以下函数被认为是异步信号安全函数:
_Exit() |
_exit() |
abort() |
accept() |
access() |
aio_error() |
aio_return() |
aio_suspend() |
alarm() |
bind() |
cfgetispeed() |
cfgetospeed() |
cfsetispeed() |
cfsetospeed() |
chdir() |
chmod() |
chown() |
clock_gettime() |
close() |
connect() |
creat() |
dup() |
dup2() |
execle() |
execve() |
fchmod() |
fchown() |
fcntl() |
fdatasync() |
fork() |
execl() |
fstat() |
fsync() |
ftruncate() |
getegid() |
geteuid() |
getgid() |
getgroups() |
getpeername() |
getpgrp() |
getpid() |
getppid() |
getsockname() |
getsockopt() |
getuid() |
kill() |
link() |
listen() |
lseek() |
lstat() |
mkdir() |
mkfifo() |
open() |
execv() |
pause() |
pipe() |
poll() |
posix_trace_event() |
pselect() |
raise() |
read() |
readlink() |
recv() |
recvfrom() |
recvmsg() |
rename() |
rmdir() |
select() |
sem_post() |
send() |
sendmsg() |
sendto() |
setgid() |
setpgid() |
setsid() |
setsockopt() |
setuid() |
shutdown() |
sigaction() |
sigaddset() |
sigdelset() |
sigemptyset() |
sigfillset() |
sigismember() |
signal() |
sigpause() |
sigpending() |
sigprocmask() |
sigqueue() |
sigset() |
sigsuspend() |
sleep() |
sockatmark() |
socket() |
socketpair() |
stat() |
symlink() |
faccessat() |
tcdrain() |
tcflow() |
tcflush() |
tcgetattr() |
tcgetpgrp() |
tcsendbreak() |
tcsetattr() |
tcsetpgrp() |
time() |
timer_getoverrun() |
timer_gettime() |
timer_settime() |
times() |
umask() |
uname() |
unlink() |
utime() |
wait() |
waitpid() |
write() |
fchmodat() |
fchownat() |
fexecve() |
fstatat() |
futimens() |
linkat() |
mkdirat() |
mkfifoat() |
mknod() |
mknodat() |
openat() |
readlinkat() |
renameat() |
symlinkat() |
unlinkat() |
utimensat() |
utimes() |
fchdir() |
pthread_kill() |
pthread_self() |
pthread_sigmask() |
表 11.10.2 异步信号安全函数
上表所列举出的这些函数被认为是异步信号安全函数,可以通过man手册查询,执行命令”man 7 signal”,如下所示:
图 12.11.1 异步信号安全函数
大家可以通过对比man手册查询到的这些异步信号安全函数,来确定自己调用的库函数或系统调用是不是异步信号安全函数,这里需要说,在本书的示例代码中,并没有完全按照安全性要求,在信号处理函数中使用异步信号安全函数,譬如在本书中的示例代码中,信号处理函数中调用了printf()用于打印信息,事实上这个函数是一个非异步信号安全函数,当然在一个实际的项目应用程序当中不能这么用,但是本书只是为了方便输出打印信息而已。
所以对于一个安全的信号处理函数来说,需要做到以下几点:
- 首先确保信号处理函数本身的代码是可重入的,且只能调用异步信号安全函数;
- 当主程序执行不安全函数或是去操作信号处理函数也可能会更新的全局数据结构时,要阻塞信号的传递。
关于异步信号安全函数就给大家介绍这么多,多线程环境下涉及到信号处理时尤其要注意这些问题。
2024最新激活全家桶教程,稳定运行到2099年,请移步至置顶文章:https://sigusoft.com/99576.html
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。 文章由激活谷谷主-小谷整理,转载请注明出处:https://sigusoft.com/16029.html