线程与线程同步

上一章,学习了进程相关的知识内容,对进程有了一个比较全面的认识和理解;本章开始,将学习Linux应用编程中非常重要的编程技巧—线程(Thread);与进程类似,线程是允许应用程序并发执行多个任务的一种机制,线程参与系统调度,事实上,系统调度的最小单元是线程、而并非进程。虽然线程的概念比较简单,但是其所涉及到的内容比较多。

线程概述

线程概念

什么是线程?
线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个线程指的是进程中一个单一顺序的控制流(或者说是执行路线、执行流),一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务。譬如某应用程序设计了两个需要并发运行的任务task1 和task2,可将两个不同的任务分别放置在两个线程中。
线程是如何创建起来的?
当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以main()做为入口开始运行的,所以main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的任务。
所以由此可知,任何一个进程都包含一个主线程,只有主线程的进程称为单线程进程,譬如前面章节内容中所编写的所有应用程序都是单线程程序,它们只有主线程;既然有单线程进程,那自然就存在多线程进程,所谓多线程指的是除了主线程以外,还包含其它的线程,其它线程通常由主线程来创建(调用
pthread_create 创建一个新的线程),那么创建的新线程就是主线程的子线程。
主线程的重要性体现在两方面:
⚫ 其它新的线程(也就是子线程)是由主线程创建的;
⚫ 主线程通常会在最后结束运行,执行各种清理工作,譬如回收各个子线程。

线程的特点?
线程是程序最基本的运行单位,而进程不能运行,真正运行的是进程中的线程。当启动应用程序后,系统就创建了一个进程,可以认为进程仅仅是一个容器,它包含了线程运行所需的数据结构、环境变量等信息。

同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack,我们称为线程栈),自己的寄存器环境(registercontext)、自己的线程本地存储(thread-local storage)。

在多线程应用程序中,通常一个进程中包括了多个线程,每个线程都可以参与系统调度、被CPU 执行,线程具有以下一些特点:
⚫ 线程不单独存在、而是包含在进程中;
⚫ 线程是参与系统调度的基本单位;
⚫ 可并发执行。同一进程的多个线程之间可并发执行,在宏观上实现同时运行的效果;
⚫ 共享进程资源。同一进程中的各个线程,可以共享该进程所拥有的资源,这首先表现:所有线程都具有相同的地址空间(进程的地址空间),这意味着,线程可以访问该地址空间的每一个虚地址;此外,还可以访问进程所拥有的已打开文件、定时器、信号量等等。

线程与进程?
进程创建多个子进程可以实现并发处理多任务(本质上便是多个单线程进程),多线程同样也可以实现(一个多线程进程)并发处理多任务的需求,那我们究竟选择哪种处理方式呢?首先我们就需要来分析下多进程和多线程两种编程模型的优势和劣势。

多进程编程的劣势:

⚫进程间切换开销大。多个进程同时运行(指宏观上同时运行,无特别说明,均指宏观上),微观上依然是轮流切换运行,进程间切换开销远大于同一进程的多个线程间切换的开销,通常对于一些中小型应用程序来说不划算。
⚫ 进程间通信较为麻烦。每个进程都在各自的地址空间中、相互独立、隔离,处在于不同的地址空间中,因此相互通信较为麻烦,在上一章节给大家有所介绍。
解决方案便是使用多线程编程,多线程能够弥补上面的问题:
⚫ 同一进程的多个线程间切换开销比较小。
⚫ 同一进程的多个线程间通信容易。它们共享了进程的地址空间,所以它们都是在同一个地址空间中,通信容易。
⚫ 线程创建的速度远大于进程创建的速度。
⚫ 多线程在多核处理器上更有优势!
终上所述,多线程编程相比于多进程编程的优势是比较明显的,在实际的应用当中多线程远比多进程应用更为广泛。那既然如此,为何还存在多进程编程模型呢?难道多线程编程就不存在缺点吗?当然不是,多线程也有它的缺点、劣势,譬如多线程编程难度高,对程序员的编程功底要求比较高,因为在多线程环境下需要考虑很多的问题,例如线程安全问题、信号处理的问题等,编写与调试一个多线程程序比单线程程序困难得多。
当然除此之外,还有一些其它的缺点,这里就不再一一列举了。多进程编程通常会用在一些大型应用程序项目中,譬如网络服务器应用程序,在中小型应用程序中用的比较少。

并发和并行

在前面的内容中,曾多次提到了并发这个概念,与此相类似的概念还有并行、串行,这里和大家聊一聊这些概念含义的区别。

对于串行比较容易理解,它指的是一种顺序执行,譬如先完成task1,接着做task2、直到完成task2,然后做task3、直到完成task3……依次按照顺序完成每一件事情,必须要完成上一件事才能去做下一件事,只有一个执行单元,这就是串行运行。

在这里插入图片描述

并行与串行则截然不同,并行指的是可以并排/并列执行多个任务,这样的系统,它通常有多个执行单元,所以可以实现并行运行,譬如并行运行task1、task2、task3。

在这里插入图片描述

并行运行并不一定要同时开始运行、同时结束运行,只需满足在某一个时间段上存在多个任务被多个执行单元同时在运行着,譬如:

在这里插入图片描述

相比于串行和并行,并发强调的是一种时分复用,与串行的区别在于,它不必等待上一个任务完成之后在做下一个任务,可以打断当前执行的任务切换执行下一个任何,这就是时分复用。在同一个执行单元上,将时间分解成不同的片段(时间片),每个任务执行一段时间,时间一到则切换执行下一个任务,依次这样轮训(交叉/交替执行),这就是并发运行。如下图所示:

在这里插入图片描述

笔者在网络上看到了很多比较有意思、形象生动的比喻,用来说明串行、并行以及并发这三个概念的区别,这里笔者截取其中的一个:
⚫ 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接电话,这就说明你不支持并发也不支持并行,仅仅只是串行
⚫ 你吃饭吃到一半,电话来了,你停下吃饭去接了电话,电话接完后继续吃饭,这说明你支持并发
⚫ 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行

这里再次进行总结:
⚫ 串行:一件事、一件事接着做
⚫ 并发:交替做不同的事;
⚫ 并行:同时做不同的事。
需要注意的是,并行运行情况下的多个执行单元,每一个执行单元同样也可以以并发方式运行。
从通用角度上介绍完这三个概念之后,类比到计算机系统中,首先我们需要知道两个前提条件:
⚫ 多核处理器和单核处理器:对于单核处理器来说,只有一个执行单元,同时只能执行一条指令;而对于多核处理起来说,有多个执行单元,可以并行执行多条指令,譬如8 核处理器,那么可以并行执行8 条不同的指令。
⚫ 计算机操作系统中,通常同时运行着几十上百个不同的线程,在单核或多核处理系统中都是如此!
对于单核处理器系统来说,它只有一个执行单元(譬如I.MX6U 硬件平台,单核Cortex-A7 SoC),只能采用并发运行系统中的线程,而肯定不可能是串行,而事实上确实如此。内核实现了调度算法,用于控制系统中所有线程的调度,简单点来说,系统中所有参与调度的线程会加入到系统的调度队列中,它们由内核控制,每一个线程执行一段时间后,由系统调度切换执行调度队列中下一个线程,依次进行。在前面章节内容中也给大家有简单地提到过系统调用的问题,关于更加详细的内容,这里便不再介绍了,我们只需有个大概的认识、了解即可!
对于多核处理器系统来说,它拥有多个执行单元,在操作系统中,多个执行单元以并行方式运行多个线程,同时每一个执行单元以并发方式运行系统中的多个线程。

同时运行
计算机处理器运行速度是非常快的,在单个处理核心虽然以并发方式运行着系统中的线程(微观上交替/交叉方式运行不同的线程),但在宏观上所表现出来的效果是同时运行着系统中的所有线程,因为处理器的运算速度太快了,交替轮训一次所花费的时间在宏观上几乎是可以忽略不计的,所以表示出来的效果就是同时运行着所有线程。

这就好比现实生活中所看到的一些事情,它所给带来的视角效果,譬如一辆车在高速上行驶,有时你会感觉到车的轮毂没有转动,一种视角暂留现象,因为车轮转动速度太快了,人眼是看不清的,会感觉车轮好像是静止的,事实上,车轮肯定是在转动着。

线程

就像每个进程都有一个进程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 作为动态数据结构的标签,这某些应用场合颇为有用,既可以用来标识整个数据结构的创建者或属主线程,又可以确定随后对该数据结构执行操作的具体线程。

创建线程

启动程序时,创建的进程只是一个单线程的进程,称之为初始线程或主线程,本小节我们讨论如何创建一个新的线程。

主线程可以使用库函数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 系统中,多核线程可能会在不同的核心上同时执行)?如果程序对执行顺序有强制要求,那么就必须采用一些同步技术来实现。这与前面学习父、子进程时也出现了这个问题,无法确定父进程、子进程谁先被系统调度。

使用示例

#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 testApp testApp.c -lpthread

使用-l 选项指定链接库pthread,原因在于pthread 不在gcc 的默认链接库中,所以需要手动指定。再次编译便不会有问题了,如下:
在这里插入图片描述
从打印信息可知,正如前面所介绍那样,两个线程的进程ID 相同,说明新创建的线程与主线程本来就属于同一个进程,但是它们的线程ID 不同。从打印结果可知,Linux 系统下线程ID 数值非常大,看起来像是一个指针。

终止线程

在示例代码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);
}

新线程中调用sleep()休眠,保证主线程先调用pthread_exit()终止,休眠结束之后新线程也调用pthread_exit()终止,编译测试看看打印结果:

在这里插入图片描述

正如上面介绍到,主线程调用pthread_exit()终止之后,整个进程并没有结束,而新线程还在继续运行。

回收线程

在父、子进程当中,父进程可通过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);
}

主线程调用pthread_create()创建新线程之后,新线程执行new_thread_start()函数,而在主线程中调用pthread_join()阻塞等待新线程终止,新线程终止后,pthread_join()返回,将目标线程的退出码保存在*tret 所指向的内存中。测试结果如下:

在这里插入图片描述

取消线程

在通常情况下,进程中的多个线程会并发执行,每个线程各司其职,直到线程的任务完成之后,该线程中会调用pthread_exit()退出,或在线程start 函数执行return 语句退出

有时候,在程序设计需求当中,需要向一个线程发送一个请求,要求它立刻退出,我们把这种操作称为取消线程,也就是向指定的线程发送一个请求,要求其立刻终止、退出。譬如,一组线程正在执行一个运算,一旦某个线程检测到错误发生,需要其它线程退出,取消线程这项功能就派上用场了。

取消一个线程

通过调用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);
}

主线程创建新线程,新线程new_thread_start()函数直接运行for 死循环;主线程休眠一段时间后,调用pthread_cancel()向新线程发送取消请求,接着再调用pthread_join()等待新线程终止、获取其终止状态,将线程退出码打印出来。测试结果如下:

在这里插入图片描述

由打印结果可知,当主线程发送取消请求之后,新线程便退出了,而且退出码为-1,也就是PTHREAD_CANCELED。

取消状态以及类型

默认情况下,线程是响应其它线程发送过来的取消请求的,响应请求然后退出线程。
当然,线程可以选择不被取消或者控制如何被取消,通过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);
}

新线程new_thread_start()函数中调用pthread_setcancelstate()将自己设置为不可被取消,主线程延时1 秒钟之后调用pthread_cancel()向新线程发送取消请求,那么此时新线程是不会终止的,pthread_cancel()立刻返回之后进入到pthread_join()函数,那么此时会被阻塞等待新线程终止,接下来运行测试看看,结果会不会是这样:

在这里插入图片描述
测试结果确实如此,将一直重复打印"新线程–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。

取消点

若将线程的取消性类型设置为PTHREAD_CANCEL_DEFERRED 时(线程可以取消状态下),收到其它线程发送过来的取消请求时,仅当线程抵达某个取消点时,取消请求才会起作用。

那什么是取消点呢?所谓取消点其实就是一系列函数,当执行到这些函数的时候,才会真正响应取消请求,这些函数就是取消点;在没有出现取消点时,取消请求是无法得到处理的,究其原因在于系统认为,但没有到达取消点时,线程此时正在执行的工作是不能被停止的,正在执行关键代码,此时终止线程将可能会导致出现意想不到的异常发生。

取消点函数包括哪些呢?下表给大家简单地列出了一些:

在这里插入图片描述

除了表11.6.1 所列函数之外,还有大量的函数,系统实现可以将其作为取消点,这里便不再一一列举出来了,大家也可以通过man 手册进行查询,命令为"man 7 pthreads",如下所示:

在这里插入图片描述

线程在调用这些函数时,如果收到了取消请求,那么线程便会遭到取消;除了这些作为取消点的函数之外,不得将任何其它函数视为取消点(亦即,调用这些函数不会招致取消)。

示例代码11.6.1 中,新线程处于for 循环之中,调用sleep()休眠,由表11.6.1 可知,sleep()函数可以作为取消点(printf 可能也是),当新线程接收到取消请求之后,便会立马退出,当如果将其修改为如下

static void *new_thread_start(void *arg)
{
	printf("新线程--running\n");
	for ( ; ; ) {
	}
	return (void *)0;
}

那么线程将永远无法被取消,因为这里不存在取消点。大家可以将代码进行修改测试,看结果是不是如此!

手动设置取消点函数 pthread_testcancel()

假设线程执行的是一个不含取消点的循环(譬如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 死循环,没有执行任何函数,所以是一个没有取消点的循环体(循环里面加入sleep函数,能退出,因为sleep是取消点函数),主线程调用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);
}

如果pthread_testcancel()可以产生取消点,那么主线程便可以终止新线程,测试结果如下:

在这里插入图片描述

从打印结果可知,确实如上面介绍那样,pthread_testcancel()函数就是取消点。

分离线程

默认情况下,当线程终止时,其它线程可以通过调用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);
}

示例代码中,主线程创建新的线程之后,休眠1 秒钟,调用pthread_join()等待新线程终止;新线程调用pthread_detach(pthread_self())将自己分离,休眠2 秒钟之后pthread_exit()退出线程;主线程休眠1 秒钟是能够确保调用pthread_join()函数时新线程已经将自己分离了,所以按照上面的介绍可知,此时主线程调用pthread_join()必然会失败,测试结果如下:

在这里插入图片描述

打印结果正如我们所料,主线程调用pthread_join()确实会出错,错误提示为“Invalid argument”。

注册线程清理函数(需自己实现清理函数)

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);// 不应该是1吗?
}
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()已经将顶层的清理函数移除栈中了,自然在退出时就不会再执行了。

结合线程取消的一个例子:

#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<pthread.h>
void clean_fun1(void * arg)
{
	printf("this is clean fun1\n");
}
void clean_fun2(void * arg)
{
	printf("this is clean fun2\n");
}
void * thread_fun(void * arg)
{
	pthread_cleanup_push(clean_fun1,NULL);
	pthread_cleanup_push(clean_fun2,NULL);
	//sleep(100);
	//这里要注意,如果将sleep(100);换成while(1);的话,程序会一直暂停.push和pop要成对出现.
	//因为while(1);运行的太快,线程不接受cancel信号
	while(1){
        //sleep(1);             没有这两个取消点函数,线程不会退出。
        pthread_testcancel();
    }
	pthread_cleanup_pop(0);
	pthread_cleanup_pop(0);
	return NULL;
}
int main()
{
	pthread_t tid1;
	int err;
	err=pthread_create(&tid1,NULL,thread_fun,NULL);
	if(err!=0)
	{
		perror("pthread_create");
		exit(0);
	}
	sleep(3);
	//printf("test\n");
	err=pthread_cancel(tid1);
	if(err!=0)
	{
		perror("cancel error:");
		exit(0);
	}
	err=pthread_join(tid1,NULL);
	if(err!=0)
	{
		perror("pthread_join  error:");
		exit(0);
	}
	
	return 0;
}

三秒后打印:

this is clean fun2
this is clean fun1

线程属性

如前所述,调用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。

#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 给出了以分离状态启动线程的示例。

#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 都不会影响其它线程。

#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);
}

运行结果:
在这里插入图片描述

可重入函数

要解释可重入(Reentrant)函数为何物,首先需要区分单线程程序和多线程程序。本章开头部分已向各位读者进行了详细介绍,单线程程序只有一条执行流(一个线程就是一条执行流),贯穿程序始终;而对于多线程程序而言,同一进程却存在多条独立、并发的执行流。
进程中执行流的数量除了与线程有关之外,与信号处理也有关联。因为信号是异步的,进程可能会在其运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含信号处理)中形成了两条(即主程序和信号处理函数)独立的执行流。
接下来再来介绍什么是可重入函数,如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。
Tips:上面所说的同时指的是宏观上同时调用,实质上也就是该函数被多个执行流并发/并行调用,无特别说明,本章内容所提到的同时均指宏观上的概念。
重入指的是同一个函数被不同执行流调用,前一个执行流还没有执行完该函数、另一个执行流又开始调用该函数了,其实就是同一个函数被多个执行流并发/并行调用,在宏观角度上理解指的就是被多个执行流同时调用。
看到这里大家可能会有点不解,我们使用示例进行讲解。示例代码11.10.2 是一个单线程与信号处理关联的程序。main()函数中调用signal()函数为SIGINT 信号注册了一个信号处理函数sig_handler,信号处理函数sig_handler 会调用func 函数;main()函数最终会进入到一个循环中,循环调用func()。

#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 指的是本地,很容易理解,通常该类函数传入了指针,前面也提到了传入了指针的可重入函数应该要满足什么样的条件才是可重入的,这里也不再重述!
本小节内容写得有点多了,笔者觉得讲的是比较清楚了,下小节给大家介绍线程安全函数。

线程安全函数

了解了可重入函数之后,再来看看线程安全函数。
一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数。线程安全函数包括可重入函数,可重入函数是线程安全函数的一个真子集,也就是说可重入函数一定是线程安全函数,但线程安全函数不一定是可重入函数,它们之间的关系如下:

在这里插入图片描述
譬如下面这个函数是一个不可重入函数,同样也是一个线程不安全函数(上小节的最后一个例子):

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,则意味着该函数是一个非线程安全函数,对于非线程安全函数,在多线程编程环境下尤其要注意,如果某函数可能会被多个线程同时调用时,该函数不能是非线程安全函数,一定要是线程安全函数,否则将会出现意想不到的结果、甚至使得整个程序崩溃!
对于一个中大型的多线程应用程序项目来说,能够保证整个程序的安全性,这是非常重要的,程序员必须要正确对待线程安全以及信号处理等这类在多线程环境下敏感的问题,这通常对程序员提出了更高的要求。

一次性初始化

在多线程编程环境下,有些代码段只需要执行一次,譬如一些初始化相关的代码段,通常比较容易想到的就是将其放在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()函数是不是只会被执行一次,示例代码如下所示:

#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()函数确实只被执行了一次,也就是被编号为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()函数以非线程安全方式实现的一种写法(具体的写法不止这一种,这里只是以此为例):

#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 进行测试,让多个线程都调用它,看看测试结果,测试代码如下:

#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()函数进行修改,如下所示:

#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 编译器的支持。

使用示例
我们编写一个简单的程序来测试线程局部存储,示例代码如下所示:

#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 函数进行修改,使其成为一个线程安全函数。

更多细节问题

本小节将对线程各方面的细节做深入讨论,其主要包括线程与信号之间牵扯的问题、线程与进程控制(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 库和系统调用中以下函数被认为是异步信号安全函数:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上表所列举出的这些函数被认为是异步信号安全函数,可以通过man 手册查询,执行命令"man 7 signal",如下所示:
在这里插入图片描述
大家可以通过对比man 手册查询到的这些异步信号安全函数,来确定自己调用的库函数或系统调用是不是异步信号安全函数,这里需要说,在本书的示例代码中,并没有完全按照安全性要求,在信号处理函数中使用异步信号安全函数,譬如在本书中的示例代码中,信号处理函数中调用了printf()用于打印信息,事实上这个函数是一个非异步信号安全函数,当然在一个实际的项目应用程序当中不能这么用,但是本书只是为了方便输出打印信息而已。
所以对于一个安全的信号处理函数来说,需要做到以下几点:
⚫ 首先确保信号处理函数本身的代码是可重入的,且只能调用异步信号安全函数;
⚫ 当主程序执行不安全函数或是去操作信号处理函数也可能会更新的全局数据结构时,要阻塞信号的传递。

关于异步信号安全函数就给大家介绍这么多,多线程环境下涉及到信号处理时尤其要注意这些问题。

⑸、多线程环境下信号的处理


本章来聊一聊线程同步这个话题,对于一个单线程进程来说,它不需要处理线程同步的问题,所以线程同步是在多线程环境下可能需要注意的一个问题。线程的主要优势在于,资源的共享性,譬如通过全局变量来实现信息共享,不过这种便捷的共享是有代价的,那就是多个线程并发访问共享数据所导致的数据不一致的问题。
本章来学习如何使用线程同步机制来避免这样的问题!
本章将会讨论如下主题内容。
⚫ 为什么需要线程同步;
⚫ 线程同步之互斥锁;
⚫ 线程同步之信号量;
⚫ 线程同步之条件变量;
⚫ 线程同步之读写锁。

为什么需要线程同步?

线程同步是为了对共享资源的访问进行保护。这里说的共享资源指的是多个线程都会进行访问的资源,譬如定义了一个全局变量a,线程1 访问了变量a、同样在线程2 中也访问了变量a,那么此时变量a 就是多个线程间的共享资源,大家都要访问它。

保护的目的是为了解决数据一致性的问题。当然什么情况下才会出现数据一致性的问题,根据不同的情况进行区分;如果每个线程访问的变量都是其它线程不会读取和修改的(譬如线程函数内定义的局部变量或者只有一个线程访问的全局变量),那么就不存在数据一致性的问题;同样,如果变量是只读的,多个线程同时读取该变量也不会有数据一致性的问题;但是,当一个线程可以修改的变量,其它的线程也可以读取或者修改的时候,这个时候就存在数据一致性的问题,需要对这些线程进行同步操作,确保它们在访问变量的存储内容时不会访问到无效的值。

出现数据一致性问题其本质在于进程中的多个线程对共享资源的并发访问(同时访问)。前面给大家介绍了,进程中的多个线程间是并发执行的,每个线程都是系统调用的基本单元,参与到系统调度队列中;对于多个线程间的共享资源,并发执行会导致对共享资源的并发访问,并发访问所带来的问题就是竞争(如果多个线程同时对共享资源进行访问就表示存在竞争,跟现实生活当中的竞争有一定的相似之处,譬如一个队伍当中需要选出一名队长,现在有两个人在候选名单中,那么意味着这两个人就存在竞争关系),并发访问就可能会出现数据一致性问题,所以就需要解决这个问题;要防止并发访问共享资源,那么就需要对共享资源的访问进行保护,防止出现并发访问共享资源。

当一个线程修改变量时,其它的线程在读取这个变量时可能会看到不一致的值,图12.1.1 描述了两个线程读写相同变量(共享变量、共享资源)的假设例子。在这个例子当中,线程A 读取变量的值,然后再给这个变量赋予一个新的值,但写操作需要2 个时钟周期(这里只是假设);当线程B 在这两个写周期中间读取了这个变量,它就会得到不一致的值,这就出现了数据不一致的问题。
在这里插入图片描述
我们可以编写一个简单地代码对此文件进行测试,示例代码12.1.1 展示了在2 个线程在常规方式下访问共享资源,这里的共享资源指的就是静态全局变量g_count。该程序创建了两个线程,且均执行同一个函数,该函数执行一个循环,重复以下步骤:将全局变量g_count 复制到本地变量l_count 变量中,然后递增l_count,再把l_count 复制回g_count,以此不断增加全局变量g_count 的值。因为l_count 是分配于线程栈中的自动变量(函数内定义的局部变量),所以每个线程都有一份。循环重复的次数要么由命令行参数指定,要么去默认值1000 万次,循环结束之后线程终止,主线程回收两个线程之后,再将全局变量g_count 的值打印出来。

示例代码 两个线程并发访问同一全局变量

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static int g_count = 0;

static void *new_thread_start(void *arg)
{
        int loops = *((int *)arg);
        int l_count, j;
        for (j = 0; j < loops; j++) {
                l_count = g_count;
                l_count++;
                g_count = l_count;
        }
        return (void *)0;
}
static int loops;
int main(int argc, char *argv[])
{
        pthread_t tid1, tid2;
        int ret;
        /* 获取用户传递的参数*/
        if (2 > argc)
                loops = 10000000; //没有传递参数默认为1000 万次
        else
                loops = atoi(argv[1]);
        /* 创建2 个新线程*/
        ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 等待线程结束*/
        ret = pthread_join(tid1, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_join(tid2, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 打印结果*/
        printf("g_count = %d\n", g_count);
        exit(0);
}

编译代码,进行测试,首先执行代码,传入参数1000,也就是让每个线程对全局变量g_count 递增1000次,如下所示:
在这里插入图片描述
都打印结果看,得到了我们想象中的结果,每个线程递增1000 次,最后的数值就是2000;接着我们把递增次数加大,采用默认值1000 万次,如下所示:
在这里插入图片描述
可以发现,结果竟然不是我们想看到的样子,执行到最后,应该是2000 万才对,这里其实就出现图12.1.1 中所示的问题,数据不一致。
如何解决对共享资源的并发访问出现数据不一致的问题?
为了解决图12.1.1 中数据不一致的问题,就得需要Linux 提供的一些方法,也就是接下来将要向大家介绍的线程同步技术,来实现同一时间只允许一个线程访问该变量,防止出现并发访问的情况、消除数据不一致的问题,图12.1.4 描述了这种同步操作,从图中可知,线程A 和线程B 都不会同时访问这个变量,当线程A 需要修改变量的值时,必须等到写操作完成之后(不能打断它的操作),才运行线程B 去读取。

在这里插入图片描述
线程的主要优势在于,资源的共享性,譬如通过全局变量来实现信息共享。不过这种便捷的共享是有代价的,必须确保多个线程不会同时修改同一变量、或者某一线程不会读取正由其它线程修改的变量,也就是必须确保不会出现对共享资源的并发访问。Linux 系统提供了多种用于实现线程同步的机制,常见的方法有:互斥锁、条件变量、自旋锁以及读写锁等,下面将向大家一一进行介绍。

互斥锁

互斥锁(mutex)又叫互斥量,从本质上说是一把锁,在访问共享资源之前对互斥锁进行上锁,在访问完成后释放互斥锁(解锁);对互斥锁进行上锁之后,任何其它试图再次对互斥锁进行加锁的线程都会被阻塞,直到当前线程释放互斥锁。如果释放互斥锁时有一个以上的线程阻塞,那么这些阻塞的线程会被唤醒,它们都会尝试对互斥锁进行加锁,当有一个线程成功对互斥锁上锁之后,其它线程就不能再次上锁了,只能再次陷入阻塞,等待下一次解锁。

举一个非常简单容易理解的例子,就拿卫生间(共享资源)来说,当来了一个人(线程)看到卫生间没人,然后它进去了、并且从里边把门锁住(互斥锁上锁)了;此时又来了两个人(线程),它们也想进卫生间方便,发生此时门打不开(互斥锁上锁失败),因为里边有人,所以此时它们只能等待(陷入阻塞);当里边的人方便完了之后(访问共享资源完成),把锁(互斥锁解锁)打开从里边出来,此时外边有两个人在等,当然它们都迫不及待想要进去(尝试对互斥锁进行上锁),自然两个人只能进去一个,进去的人再次把门锁住,另外一个人只能继续等待它出来。

在我们的程序设计当中,只有将所有线程访问共享资源都设计成相同的数据访问规则,互斥锁才能正常工作。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其它的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。

互斥锁使用pthread_mutex_t 数据类型表示,在使用互斥锁之前,必须首先对它进行初始化操作,可以使用两种方式对互斥锁进行初始化操作。

互斥锁初始化

1、使用PTHREAD_MUTEX_INITIALIZER 宏初始化互斥锁

互斥锁使用pthread_mutex_t 数据类型表示,pthread_mutex_t 其实是一个结构体类型,而宏PTHREAD_MUTEX_INITIALIZER 其实是一个对结构体赋值操作的封装,如下所示:

# define PTHREAD_MUTEX_INITIALIZER \
	{ { 0, 0, 0, 0, 0, __PTHREAD_SPINS, { 0, 0 } } }

所以由此可知,使用PTHREAD_MUTEX_INITIALIZER 宏初始化互斥锁的操作如下:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

PTHREAD_MUTEX_INITIALIZER 宏已经携带了互斥锁的默认属性。

2、使用pthread_mutex_init()函数初始化互斥锁

使用PTHREAD_MUTEX_INITIALIZER 宏只适用于在定义的时候就直接进行初始化,对于其它情况则不能使用这种方式,譬如先定义互斥锁,后再进行初始化,或者在堆中动态分配的互斥锁,譬如使用malloc()
函数申请分配的互斥锁对象,那么在这些情况下,可以使用pthread_mutex_init()函数对互斥锁进行初始化,其函数原型如下所示:

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);

使用该函数需要包含头文件<pthread.h>。

函数参数和返回值含义如下:
mutex:参数mutex 是一个pthread_mutex_t 类型指针,指向需要进行初始化操作的互斥锁对象;
attr:参数attr 是一个pthread_mutexattr_t 类型指针,指向一个pthread_mutexattr_t 类型对象,该对象用于定义互斥锁的属性(在12.2.6 小计中介绍),若将参数attr 设置为NULL,则表示将互斥锁的属性设置为默认值,在这种情况下其实就等价于PTHREAD_MUTEX_INITIALIZER 这种方式初始化,而不同之处在于,使用宏不进行错误检查。
返回值:成功返回0;失败将返回一个非0 的错误码。

Tips:注意,当在Ubuntu 系统下执行"man 3 pthread_mutex_init"命令时提示找不到该函数,并不是Linux下没有这个函数,而是该函数相关的man 手册帮助信息没有被安装,这时我们只需执行"sudo apt-get install manpages-posix-dev"安装即可。
使用pthread_mutex_init()函数对互斥锁进行初始化示例:

pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);

或者:

pthread_mutex_t *mutex = malloc(sizeof(pthread_mutex_t));
pthread_mutex_init(mutex, NULL);

互斥锁加锁和解锁

互斥锁初始化之后,处于一个未锁定状态,调用函数pthread_mutex_lock()可以对互斥锁加锁、获取互斥锁,而调用函数pthread_mutex_unlock()可以对互斥锁解锁、释放互斥锁。其函数原型如下所示:

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

使用这些函数需要包含头文件<pthread.h>,参数mutex 指向互斥锁对象;pthread_mutex_lock()和pthread_mutex_unlock()在调用成功时返回0;失败将返回一个非0 值的错误码。
调用pthread_mutex_lock()函数对互斥锁进行上锁,如果互斥锁处于未锁定状态,则此次调用会上锁成功,函数调用将立马返回;如果互斥锁此时已经被其它线程锁定了,那么调用pthread_mutex_lock()会一直阻塞,直到该互斥锁被解锁,到那时,调用将锁定互斥锁并返回。
调用pthread_mutex_unlock()函数将已经处于锁定状态的互斥锁进行解锁。以下行为均属错误:
⚫ 对处于未锁定状态的互斥锁进行解锁操作;
⚫ 解锁由其它线程锁定的互斥锁。
如果有多个线程处于阻塞状态等待互斥锁被解锁,当互斥锁被当前锁定它的线程调用pthread_mutex_unlock()函数解锁后,这些等待着的线程都会有机会对互斥锁上锁,但无法判断究竟哪个线程会如愿以偿!

使用示例
使用互斥锁的方式将示例代码12.1.1 进行修改,修改之后如示例代码12.2.1 所示,使用了一个互斥锁来保护对全局变量g_count 的访问。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_mutex_t mutex;
static int g_count = 0;
static void *new_thread_start(void *arg)
{
        int loops = *((int *)arg);
        int l_count, j;
        for (j = 0; j < loops; j++) {
                pthread_mutex_lock(&mutex); //互斥锁上锁
                l_count = g_count;
                l_count++;
                g_count = l_count;
                pthread_mutex_unlock(&mutex);//互斥锁解锁
        }
        return (void *)0;
}
static int loops;
int main(int argc, char *argv[])
{
        pthread_t tid1, tid2;
        int ret;
        /* 获取用户传递的参数*/
        if (2 > argc)
                loops = 10000000; //没有传递参数默认为1000 万次
        else
                loops = atoi(argv[1]);
        /* 初始化互斥锁*/
        pthread_mutex_init(&mutex, NULL);
        /* 创建2 个新线程*/
        ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 等待线程结束*/
        ret = pthread_join(tid1, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_join(tid2, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 打印结果*/
        printf("g_count = %d\n", g_count);
        exit(0);
}

在测试运行,使用默认值1000 万次,如下所示:
在这里插入图片描述
可以看到确实得到了我们想看到的正确结果,每次对g_count 的累加总是能够保持正确,但是在运行程序的过程中,明显会感觉到锁消耗的时间会比较长,这就涉及到性能的问题了,后续会介绍!

pthread_mutex_trylock()函数

当互斥锁已经被其它线程锁住时,调用pthread_mutex_lock()函数会被阻塞,直到互斥锁解锁;如果线程不希望被阻塞,可以使用pthread_mutex_trylock()函数;调用pthread_mutex_trylock()函数尝试对互斥锁进行加锁,如果互斥锁处于未锁住状态,那么调用pthread_mutex_trylock()将会锁住互斥锁并立马返回,如果互斥锁已经被其它线程锁住,调用pthread_mutex_trylock()加锁失败,但不会阻塞,而是返回错误码EBUSY。
其函数原型如下所示:

#include <pthread.h>

int pthread_mutex_trylock(pthread_mutex_t *mutex);

参数mutex 指向目标互斥锁,成功返回0,失败返回一个非0 值的错误码,如果目标互斥锁已经被其它线程锁住,则调用失败返回EBUSY。

使用示例
对示例代码12.2.1 进行修改,使用pthread_mutex_trylock()替换pthread_mutex_lock()。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_mutex_t mutex;
static int g_count = 0;
static void *new_thread_start(void *arg)
{
        int loops = *((int *)arg);
        int l_count, j;
        for (j = 0; j < loops; j++) {
                while(pthread_mutex_trylock(&mutex)); //以非阻塞方式上锁
                l_count = g_count;
                l_count++;
                g_count = l_count;
                pthread_mutex_unlock(&mutex);//互斥锁解锁
        }
        return (void *)0;
}
static int loops;
int main(int argc, char *argv[])
{
        pthread_t tid1, tid2;
        int ret;
        /* 获取用户传递的参数*/
        if (2 > argc)
                loops = 10000000; //没有传递参数默认为1000 万次
        else
                loops = atoi(argv[1]);
        /* 初始化互斥锁*/
        pthread_mutex_init(&mutex, NULL);
        /* 创建2 个新线程*/
        ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 等待线程结束*/
        ret = pthread_join(tid1, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_join(tid2, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 打印结果*/
        printf("g_count = %d\n", g_count);
        exit(0);
}

整个执行结果跟使用pthread_mutex_lock()效果是一样的,大家可以自己测试。

销毁互斥锁

当不再需要互斥锁时,应该将其销毁,通过调用pthread_mutex_destroy()函数来销毁互斥锁,其函数原型如下所示:

#include <pthread.h>

int pthread_mutex_destroy(pthread_mutex_t *mutex);

使用该函数需要包含头文件<pthread.h>,参数mutex 指向目标互斥锁;同样在调用成功情况下返回0,失败返回一个非0 值的错误码。
⚫ 不能销毁还没有解锁的互斥锁,否则将会出现错误;
⚫ 没有初始化的互斥锁也不能销毁。
被pthread_mutex_destroy()销毁之后的互斥锁,就不能再对它进行上锁和解锁了,需要再次调用pthread_mutex_init()对互斥锁进行初始化之后才能使用。

使用示例
对示例代码12.2.1 进行修改,在进程退出之前,使用pthread_mutex_destroy()函数销毁互斥锁。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_mutex_t mutex;
static int g_count = 0;
static void *new_thread_start(void *arg)
{
        int loops = *((int *)arg);
        int l_count, j;
        for (j = 0; j < loops; j++) {
                pthread_mutex_lock(&mutex); //互斥锁上锁
                l_count = g_count;
                l_count++;
                g_count = l_count;
                pthread_mutex_unlock(&mutex);//互斥锁解锁
        }
        return (void *)0;
}
static int loops;
int main(int argc, char *argv[])
{
        pthread_t tid1, tid2;
        int ret;
        /* 获取用户传递的参数*/
        if (2 > argc)
                loops = 10000000; //没有传递参数默认为1000 万次
        else
                loops = atoi(argv[1]);
        /* 初始化互斥锁*/
        pthread_mutex_init(&mutex, NULL);
        /* 创建2 个新线程*/
        ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 等待线程结束*/
        ret = pthread_join(tid1, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_join(tid2, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 打印结果*/
        printf("g_count = %d\n", g_count);
        /* 销毁互斥锁*/
        pthread_mutex_destroy(&mutex);
        exit(0);
}

互斥锁死锁

试想一下,如果一个线程试图对同一个互斥锁加锁两次,会出现什么情况?情况就是该线程会陷入死锁状态,一直被阻塞永远出不来;这就是出现死锁的一种情况,除此之外,使用互斥锁还有其它很多种方式也能产生死锁。
有时,一个线程需要同时访问两个或更多不同的共享资源,而每个资源又由不同的互斥锁管理。当超过一个线程对同一组互斥锁(两个或两个以上的互斥锁)进行加锁时,就有可能发生死锁;譬如,程序中使用一个以上的互斥锁,如果允许一个线程一直占有第一个互斥锁,并且在试图锁住第二个互斥锁时处于阻塞状态,但是拥有第二个互斥锁的线程也在试图锁住第一个互斥锁。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,会被一直阻塞,于是就产生了死锁。如下示例代码中所示:

// 线程A
pthread_mutex_lock(mutex1);
pthread_mutex_lock(mutex2);

// 线程B
pthread_mutex_lock(mutex2);
pthread_mutex_lock(mutex1);

这就好比是C 语言中两个头文件相互包含的关系,那肯定编译报错!
在我们的程序当中,如果用到了多个互斥锁,要避免此类死锁的问题,最简单的方式就是定义互斥锁的层级关系,当多个线程对一组互斥锁操作时,总是应该按照相同的顺序对该组互斥锁进行锁定。譬如在上述场景中,如果两个线程总是先锁定mutex1 在锁定mutex2,死锁就不会出现。有时,互斥锁之间的层级关系逻辑不够清晰,即使是这样,依然可以设计出所有线程都必须遵循的强制层级顺序。
但有时候,应用程序的结构使得对互斥锁进行排序是很困难的,程序复杂、其中所涉及到的互斥锁以及共享资源比较多,程序设计实在无法按照相同的顺序对一组互斥锁进行锁定,那么就必须采用另外的方法。譬如使用pthread_mutex_trylock()以不阻塞的方式尝试对互斥锁进行加锁,在这种方案中,线程先使用函数pthread_mutex_lock()锁定第一个互斥锁,然后使用pthread_mutex_trylock()来锁定其余的互斥锁。如果任一pthread_mutex_trylock()调用失败(返回EBUSY),那么该线程释放所有互斥锁,可以经过一段时间之后从头再试。与第一种按照层级关系来避免死锁的方法变比,这种方法效率要低一些,因为可能需要经历多次循环。
解决互斥锁死锁的问题还有很多方法,笔者也没详细地去学习过,当大家在实际编程应用中需要用到这些知识再去查阅相关资料、书籍进行学习。

互斥锁的属性

如前所述,调用pthread_mutex_init()函数初始化互斥锁时可以设置互斥锁的属性,通过参数attr 指定。参数attr 指向一个pthread_mutexattr_t 类型对象,该对象对互斥锁的属性进行定义,当然,如果将参数attr
设置为NULL,则表示将互斥锁属性设置为默认值。关于互斥锁的属性本书不打算深入讨论互斥锁属性的细节,也不会将pthread_mutexattr_t 类型中定义的属性一一列出。
如果不使用默认属性,在调用pthread_mutex_init()函数时,参数attr 必须要指向一个pthread_mutexattr_t
对象,而不能使用NULL。当定义pthread_mutexattr_t 对象之后,需要使用pthread_mutexattr_init()函数对该对象进行初始化操作,当对象不再使用时,需要使用pthread_mutexattr_destroy()将其销毁,函数原型如下所示:

#include <pthread.h>

int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
int pthread_mutexattr_init(pthread_mutexattr_t *attr);

参数attr 指向需要进行初始化的pthread_mutexattr_t 对象,调用成功返回0,失败将返回非0 值的错误码。
pthread_mutexattr_init()函数将使用默认的互斥锁属性初始化参数attr 指向的pthread_mutexattr_t 对象。关于互斥锁的属性比较多,譬如进程共享属性、健壮属性、类型属性等等,本书并不会一一给大家进行介绍,本小节讨论下类型属性,其它的暂时不去解释了。
互斥锁的类型属性控制着互斥锁的锁定特性,一共有4 中类型:
⚫ PTHREAD_MUTEX_NORMAL:一种标准的互斥锁类型,不做任何的错误检查或死锁检测。如果线程试图对已经由自己锁定的互斥锁再次进行加锁,则发生死锁;互斥锁处于未锁定状态,或者已由其它线程锁定,对其解锁会导致不确定结果。
⚫ PTHREAD_MUTEX_ERRORCHECK:此类互斥锁会提供错误检查。譬如这三种情况都会导致返回错误:线程试图对已经由自己锁定的互斥锁再次进行加锁(同一线程对同一互斥锁加锁两次),返回错误;线程对由其它线程锁定的互斥锁进行解锁,返回错误;线程对处于未锁定状态的互斥锁进行解锁,返回错误。这类互斥锁运行起来比较慢,因为它需要做错误检查,不过可将其作为调试工具,以发现程序哪里违反了互斥锁使用的基本原则。
⚫ PTHREAD_MUTEX_RECURSIVE:此类互斥锁允许同一线程在互斥锁解锁之前对该互斥锁进行多次加锁,然后维护互斥锁加锁的次数,把这种互斥锁称为递归互斥锁,但是如果解锁次数不等于加速次数,则是不会释放锁的;所以,如果对一个递归互斥锁加锁两次,然后解锁一次,那么这个互斥锁依然处于锁定状态,对它再次进行解锁之前不会释放该锁。
⚫ PTHREAD_MUTEX_DEFAULT :此类互斥锁提供默认的行为和特性。使用宏
PTHREAD_MUTEX_INITIALIZER 初始化的互斥锁,或者调用参数arg 为NULL 的
pthread_mutexattr_init()函数所创建的互斥锁,都属于此类型。此类锁意在为互斥锁的实现保留最大灵活性,Linux 上,PTHREAD_MUTEX_DEFAULT 类型互斥锁的行为与
PTHREAD_MUTEX_NORMAL 类型相仿。
可以使用pthread_mutexattr_gettype()函数得到互斥锁的类型属性,使用pthread_mutexattr_settype()修改/设置互斥锁类型属性,其函数原型如下所示:

#include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *attr, int *type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);

使用这些函数需要包含头文件<pthread.h>,参数attr 指向pthread_mutexattr_t 类型对象;对于
pthread_mutexattr_gettype()函数,函数调用成功会将互斥锁类型属性保存在参数type 所指向的内存中,通过它返回出来;而对于pthread_mutexattr_settype()函数,会将参数attr 指向的pthread_mutexattr_t 对象的类型属性设置为参数type 指定的类型。使用方式如下:

pthread_mutex_t mutex;
pthread_mutexattr_t attr;

/* 初始化互斥锁属性对象*/
pthread_mutexattr_init(&attr);

/* 将类型属性设置为PTHREAD_MUTEX_NORMAL */
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);

/* 初始化互斥锁*/
pthread_mutex_init(&mutex, &attr);

......

/* 使用完之后*/
pthread_mutexattr_destroy(&attr);
pthread_mutex_destroy(&mutex);

条件变量

本小节讨论第二种线程同步的方法—条件变量。
条件变量是线程可用的另一种同步机制。条件变量用于自动阻塞线程,知道某个特定事件发生或某个条件满足为止,通常情况下,条件变量是和互斥锁一起搭配使用的。使用条件变量主要包括两个动作:
⚫ 一个线程等待某个条件满足而被阻塞;
⚫ 另一个线程中,条件满足时发出“信号”。
为了说明这个问题,来看一个没有使用条件变量的例子,生产者—消费者模式,生产者这边负责生产产品、而消费者负责消费产品,对于消费者来说,没有产品的时候只能等待产品出来,有产品就使用它。
这里我们使用一个变量来表示这个这个产品,生产者生产一件产品变量加1,消费者消费一次变量减1,示例代码如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_mutex_t mutex;
static int g_avail = 0;
/* 消费者线程*/
static void *consumer_thread(void *arg)
{
        for ( ; ; ) {
                pthread_mutex_lock(&mutex);//上锁
                while (g_avail > 0)
                        g_avail--; //消费
                pthread_mutex_unlock(&mutex);//解锁
        }
        return (void *)0;
}
/* 主线程(生产者)*/
int main(int argc, char *argv[])
{
        pthread_t tid;
        int ret;
        /* 初始化互斥锁*/
        pthread_mutex_init(&mutex, NULL);
        /* 创建新线程*/
        ret = pthread_create(&tid, NULL, consumer_thread, NULL);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        for ( ; ; ) {
                pthread_mutex_lock(&mutex);//上锁
                g_avail++; //生产
                pthread_mutex_unlock(&mutex);//解锁
        }
        exit(0);
}

此代码中,主线程作为“生产者”,新创建的线程作为“消费者”,运行之后它们都回处于死循环中,所以代码中没有加入销毁互斥锁、等待回收新线程相关的代码,进程终止时会自动被处理。
上述代码虽然可行,但由于新线程中会不停的循环检查全局变量g_avail 是否大于0,故而造成CPU 资源的浪费。采用条件变量这一问题就可以迎刃而解!条件变量允许一个线程休眠(阻塞等待)直至获取到另一个线程的通知(收到信号)再去执行自己的操作,譬如上述代码中,当条件g_avail > 0 不成立时,消费者线程会进入休眠状态,而生产者生成产品后(g_avail++,此时g_avail 将会大于0),向处于等待状态的线程发出“信号”,而其它线程收到“信号”之后,便会被唤醒!
Tips:这里提到的信号并不是第八章内容所指的信号,需要区分开来!
前面说到,条件变量通常搭配互斥锁来使用,是因为条件的检测是在互斥锁的保护下进行的,也就是说条件本身是由互斥锁保护的,线程在改变条件状态之前必须首先锁住互斥锁,不然就可能引发线程不安全的问题。

条件变量初始化

条件变量使用pthread_cond_t 数据类型来表示,类似于互斥锁,在使用条件变量之前必须对其进行初始化。初始化方式同样也有两种:使用宏PTHREAD_COND_INITIALIZER 或者使用函数pthread_cond_init(),使用宏的初始化方法与互斥锁的初始化宏一样,这里就不再重述!譬如:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

pthread_cond_init()函数原型如下所示:

#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);

同样,使用这些函数需要包含头文件<pthread.h>,使用pthread_cond_init()函数初始化条件变量,当不再使用时,使用pthread_cond_destroy()销毁条件变量。
参数cond 指向pthread_cond_t 条件变量对象,对于pthread_cond_init()函数,类似于互斥锁,在初始化条件变量时设置条件变量的属性,参数attr 指向一个pthread_condattr_t 类型对象,pthread_condattr_t 数据类型用于描述条件变量的属性。可将参数attr 设置为NULL,表示使用属性的默认值来初始化条件变量,与使用PTHREAD_COND_INITIALIZER 宏相同。
函数调用成功返回0,失败将返回一个非0 值的错误码。
对于初始化与销毁操作,有以下问题需要注意:
⚫ 在使用条件变量之前必须对条件变量进行初始化操作,使用PTHREAD_COND_INITIALIZER 宏或者函数pthread_cond_init()都行;
⚫ 对已经初始化的条件变量再次进行初始化,将可能会导致未定义行为;
⚫ 对没有进行初始化的条件变量进行销毁,也将可能会导致未定义行为;
⚫ 对某个条件变量而言,仅当没有任何线程等待它时,将其销毁才是最安全的;
⚫ 经pthread_cond_destroy()销毁的条件变量,可以再次调用pthread_cond_init()对其进行重新初始化。

通知和等待条件变量

条件变量的主要操作便是发送信号(signal)和等待。发送信号操作即是通知一个或多个处于等待状态的线程,某个共享变量的状态已经改变,这些处于等待状态的线程收到通知之后便会被唤醒,唤醒之后再检查条件是否满足。等待操作是指在收到一个通知前一直处于阻塞状态。
函数pthread_cond_signal()和pthread_cond_broadcast()均可向指定的条件变量发送信号,通知一个或多个处于等待状态的线程。调用pthread_cond_wait()函数是线程阻塞,直到收到条件变量的通知。
pthread_cond_signal()和pthread_cond_broadcast()函数原型如下所示:

#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);

int pthread_cond_signal(pthread_cond_t *cond);

使用这些函数需要包含头文件<pthread.h>,参数cond 指向目标条件变量,向该条件变量发送信号。调用成功返回0;失败将返回一个非0 值的错误码。
pthread_cond_signal()和pthread_cond_broadcast()的区别在于:二者对阻塞于pthread_cond_wait()的多个线程对应的处理方式不同,pthread_cond_signal()函数至少能唤醒一个线程,而pthread_cond_broadcast()函数则能唤醒所有线程。使用pthread_cond_broadcast()函数总能产生正确的结果,唤醒所有等待状态的线程,但函数pthread_cond_signal()会更为高效,因为它只需确保至少唤醒一个线程即可,所以如果我们的程序当中,只有一个处于等待状态的线程,使用pthread_cond_signal()更好,具体使用哪个函数根据实际情况进行选择!
pthread_cond_wait()函数原型如下所示:

#include <pthread.h>

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

当程序当中使用条件变量,当判断某个条件不满足时,调用pthread_cond_wait()函数将线程设置为等待状态(阻塞)。pthread_cond_wait()函数包含两个参数:
cond:指向需要等待的条件变量,目标条件变量;
mutex:参数mutex 是一个pthread_mutex_t 类型指针,指向一个互斥锁对象;前面开头便给大家介绍了,条件变量通常是和互斥锁一起使用,因为条件的检测(条件检测通常是需要访问共享资源的)是在互斥锁的保护下进行的,也就是说条件本身是由互斥锁保护的。
返回值:调用成功返回0;失败将返回一个非0 值的错误码。
在pthread_cond_wait()函数内部会对参数mutex 所指定的互斥锁进行操作,通常情况下,条件判断以及
pthread_cond_wait()函数调用均在互斥锁的保护下,也就是说,在此之前线程已经对互斥锁加锁了。调用
pthread_cond_wait()函数时,调用者把互斥锁传递给函数,函数会自动把调用线程放到等待条件的线程列表上,然后将互斥锁解锁;当pthread_cond_wait()被唤醒返回时,会再次锁住互斥锁。
注意注意的是,条件变量并不保存状态信息,只是传递应用程序状态信息的一种通讯机制。如果调用
pthread_cond_signal()和pthread_cond_broadcast()向指定条件变量发送信号时,若无任何线程等待该条件变量,这个信号也就会不了了之。
当调用pthread_cond_broadcast()同时唤醒所有线程时,互斥锁也只能被某一线程锁住,其它线程获取锁失败又会陷入阻塞。
使用示例
使用条件变量对示例代码12.3.1 进行修改,当消费者线程没有产品可消费时,让它处于等待状态,知道生产者把产品生产出来;当生产者把产品生产出来之后,再去通知消费者。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_mutex_t mutex; //定义互斥锁
static pthread_cond_t cond; //定义条件变量
static int g_avail = 0; //全局共享资源
/* 消费者线程*/
static void *consumer_thread(void *arg)
{
        for ( ; ; ) {
                pthread_mutex_lock(&mutex);//上锁
                while (0 >= g_avail)
                        pthread_cond_wait(&cond, &mutex);//等待条件满足
                while (0 < g_avail)
                        g_avail--; //消费
                pthread_mutex_unlock(&mutex);//解锁
        }
        return (void *)0;
}
/* 主线程(生产者)*/
int main(int argc, char *argv[])
{
        pthread_t tid;
        int ret;
        /* 初始化互斥锁和条件变量*/
        pthread_mutex_init(&mutex, NULL);
        pthread_cond_init(&cond, NULL);
        /* 创建新线程*/
        ret = pthread_create(&tid, NULL, consumer_thread, NULL);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        for ( ; ; ) {
                pthread_mutex_lock(&mutex);//上锁
                g_avail++; //生产
                pthread_mutex_unlock(&mutex);//解锁
                pthread_cond_signal(&cond);//向条件变量发送信号
        }
        exit(0);
}

全局变量g_avail 作为主线程和新线程之间的共享资源,两个线程在访问它们之间首先会对互斥锁进行上锁,消费者线程中,当判断没有产品可被消费时(g_avail <= 0),调用pthread_cond_wait()使得线程陷入等待状态,等待条件变量,等待生产者制造产品;调用pthread_cond_wait()后线程阻塞并解锁互斥锁;而在生产者线程中,它的任务是生产产品(使用g_avail++来模拟),产品生产完成之后,调用pthread_mutex_unlock()
将互斥锁解锁,并调用pthread_cond_signal()向条件变量发送信号;这将会唤醒处于等待该条件变量的消费者线程,唤醒之后再次自动获取互斥锁,然后再对产品进行消费(g_avai–模拟)。

条件变量的判断条件

使用条件变量,都会有与之相关的判断条件,通常情况下,会涉及到一个或多个共享变量。譬如在示例代码12.3.2 中,与条件变量相关的判断是(0 >= g_avail)。细心的读者会发现,在这份示例代码中,我们使用了while 循环、而不是if 语句,来控制对pthread_cond_wait()的调用,这是为何呢?
必须使用while 循环,而不是if 语句,这是一种通用的设计原则:当线程从pthread_cond_wait()返回时,并不能确定判断条件的状态,应该立即重新检查判断条件,如果条件不满足,那就继续休眠等待。
从pthread_cond_wait()返回后,并不能确定判断条件是真还是假,其理由如下:
⚫ 当有多于一个线程在等待条件变量时,任何线程都有可能会率先醒来获取互斥锁,率先醒来获取到互斥锁的线程可能会对共享变量进行修改,进而改变判断条件的状态。譬如示例代码12.3.2 中,如果有两个或更多个消费者线程,当其中一个消费者线程从pthread_cond_wait()返回后,它会将全局共享变量g_avail 的值变成0,导致判断条件的状态由真变成假。
⚫ 可能会发出虚假的通知。

条件变量的属性

如前所述,调用pthread_cond_init()函数初始化条件变量时,可以设置条件变量的属性,通过参数attr 指定。参数attr 指向一个pthread_condattr_t 类型对象,该对象对条件变量的属性进行定义,当然,如果将参数
attr 设置为NULL,表示使用默认值来初始化条件变量属性。
关于条件变量的属性本书不打算深入讨论,条件变量包括两个属性:进程共享属性和时钟属性。每个属性都提供了相应的get 方法和set 方法,各位读者如果有兴趣,可自行查阅资料学习,本书不再介绍!

自旋锁

自旋锁与互斥锁很相似,从本质上说也是一把锁,在访问共享资源之前对自旋锁进行上锁,在访问完成后释放自旋锁(解锁);事实上,从实现方式上来说,互斥锁是基于自旋锁来实现的,所以自旋锁相较于互斥锁更加底层。
如果在获取自旋锁时,自旋锁处于未锁定状态,那么将立即获得锁(对自旋锁上锁);如果在获取自旋锁时,自旋锁已经处于锁定状态了,那么获取锁操作将会在原地“自旋”,直到该自旋锁的持有者释放了锁。由此介绍可知,自旋锁与互斥锁相似,但是互斥锁在无法获取到锁时会让线程陷入阻塞等待状态;而自旋锁在无法获取到锁时,将会在原地“自旋”等待。“自旋”其实就是调用者一直在循环查看该自旋锁的持有者是否已经释放了锁,“自旋”一词因此得名。
自旋锁的不足之处在于:自旋锁一直占用的CPU,它在未获得锁的情况下,一直处于运行状态(自旋),所以占着CPU,如果不能在很短的时间内获取锁,这无疑会使CPU 效率降低。
试图对同一自旋锁加锁两次必然会导致死锁,而试图对同一互斥锁加锁两次不一定会导致死锁,原因在于互斥锁有不同的类型,当设置为PTHREAD_MUTEX_ERRORCHECK 类型时,会进行错误检查,第二次加锁会返回错误,所以不会进入死锁状态。
因此我们要谨慎使用自旋锁,自旋锁通常用于以下情况:需要保护的代码段执行时间很短,这样就会使得持有锁的线程会很快释放锁,而“自旋”等待的线程也只需等待很短的时间;在这种情况下就比较适合使用自旋锁,效率高!
综上所述,再来总结下自旋锁与互斥锁之间的区别:
⚫ 实现方式上的区别:互斥锁是基于自旋锁而实现的,所以自旋锁相较于互斥锁更加底层;
⚫ 开销上的区别:获取不到互斥锁会陷入阻塞状态(休眠),直到获取到锁时被唤醒;而获取不到自旋锁会在原地“自旋”,直到获取到锁;休眠与唤醒开销是很大的,所以互斥锁的开销要远高于自旋锁、自旋锁的效率远高于互斥锁;但如果长时间的“自旋”等待,会使得CPU 使用效率降低,故自旋锁不适用于等待时间比较长的情况。
⚫ 使用场景的区别:自旋锁在用户态应用程序中使用的比较少,通常在内核代码中使用比较多;因为自旋锁可以在中断服务函数中使用,而互斥锁则不行,在执行中断服务函数时要求不能休眠、不能被抢占(内核中使用自旋锁会自动禁止抢占),一旦休眠意味着执行中断服务函数时主动交出了
CPU 使用权,休眠结束时无法返回到中断服务函数中,这样就会导致死锁!

自旋锁初始化

自旋锁使用pthread_spinlock_t 数据类型表示,当定义自旋锁后,需要使用pthread_spin_init()函数对其进行初始化,当不再使用自旋锁时,调用pthread_spin_destroy()函数将其销毁,其函数原型如下所示:

#include <pthread.h>
int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

使用这两个函数需要包含头文件<pthread.h>。
参数lock 指向了需要进行初始化或销毁的自旋锁对象,参数pshared 表示自旋锁的进程共享属性,可以取值如下:
⚫ PTHREAD_PROCESS_SHARED:共享自旋锁。该自旋锁可以在多个进程中的线程之间共享;
⚫ PTHREAD_PROCESS_PRIVATE:私有自旋锁。只有本进程内的线程才能够使用该自旋锁。
这两个函数在调用成功的情况下返回0;失败将返回一个非0 值的错误码。

自旋锁加锁和解锁

可以使用pthread_spin_lock()函数或pthread_spin_trylock()函数对自旋锁进行加锁,前者在未获取到锁时一直“自旋”;对于后者,如果未能获取到锁,就立刻返回错误,错误码为EBUSY。不管以何种方式加锁,自旋锁都可以使用pthread_spin_unlock()函数对自旋锁进行解锁。其函数原型如下所示:

#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);

使用这些函数需要包含头文件<pthread.h>。
参数lock 指向自旋锁对象,调用成功返回0,失败将返回一个非0 值的错误码。
如果自旋锁处于未锁定状态,调用pthread_spin_lock()会将其锁定(上锁),如果其它线程已经将自旋锁锁住了,那本次调用将会“自旋”等待;如果试图对同一自旋锁加锁两次必然会导致死锁。
使用示例
对示例代码12.2.1 进行修改,使用自旋锁替换互斥锁来实现线程同步,对共享资源的访问进行保护。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_spinlock_t spin;//定义自旋锁
static int g_count = 0;
static void *new_thread_start(void *arg)
{
        int loops = *((int *)arg);
        int l_count, j;
        for (j = 0; j < loops; j++) {
                pthread_spin_lock(&spin); //自旋锁上锁
                l_count = g_count;
                l_count++;
                g_count = l_count;
                pthread_spin_unlock(&spin);//自旋锁解锁
        }
        return (void *)0;
}
static int loops;
int main(int argc, char *argv[])
{
        pthread_t tid1, tid2;
        int ret;
        /* 获取用户传递的参数*/
        if (2 > argc)
                loops = 10000000; //没有传递参数默认为1000 万次
        else
                loops = atoi(argv[1]);
        /* 初始化自旋锁(私有) */
        pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
        /* 创建2 个新线程*/
        ret = pthread_create(&tid1, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_create(&tid2, NULL, new_thread_start, &loops);
        if (ret) {
                fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 等待线程结束*/
        ret = pthread_join(tid1, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        ret = pthread_join(tid2, NULL);
        if (ret) {
                fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
                exit(-1);
        }
        /* 打印结果*/
        printf("g_count = %d\n", g_count);
        /* 销毁自旋锁*/
        pthread_spin_destroy(&spin);
        exit(0);
}

运行结果:
在这里插入图片描述
将互斥锁替换为自旋锁之后,测试结果打印也是没有问题的,并且通过对比可以发现,替换为自旋锁之后,程序运行所耗费的时间明显变短了,说明自旋锁确实比互斥锁效率要高,但是一定要注意自旋锁所适用的场景。

读写锁

互斥锁或自旋锁要么是加锁状态、要么是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁有
3 种状态:读模式下的加锁状态(以下简称读加锁状态)、写模式下的加锁状态(以下简称写加锁状态)和不加锁状态(见),一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。因此可知,读写锁比互斥锁具有更高的并行性!

在这里插入图片描述

读写锁有如下两个规则:
⚫ 当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁进行加锁操作(不管是以读模式加锁还是以写模式加锁)的线程都会被阻塞。
⚫ 当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以加锁成功;但是任何以写模式对它进行加锁的线程都会被阻塞,直到所有持有读模式锁的线程释放它们的锁为止。
虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式加锁状态,而这时有一个线程试图以写模式获取锁时,该线程会被阻塞;而如果另一线程以读模式获取锁,则会成功获取到锁,对共享资源进行读操作。

所以,读写锁非常适合于对共享数据读的次数远大于写的次数的情况。当读写锁处于写模式加锁状态时,它所保护的数据可以被安全的修改,因为一次只有一个线程可以在写模式下拥有这个锁;当读写锁处于读模式加锁状态时,它所保护的数据就可以被多个获取读模式锁的线程读取。所以在应用程序当中,使用读写锁实现线程同步,当线程需要对共享数据进行读操作时,需要先获取读模式锁(对读模式锁进行加锁),当读取操作完成之后再释放读模式锁(对读模式锁进行解锁);当线程需要对共享数据进行写操作时,需要先获取到写模式锁,当写操作完成之后再释放写模式锁。
读写锁也叫做共享互斥锁。当读写锁是读模式锁住时,就可以说成是共享模式锁住。当它是写模式锁住时,就可以说成是互斥模式锁住。

读写锁初始化

与互斥锁、自旋锁类似,在使用读写锁之前也必须对读写锁进行初始化操作,读写锁使用
pthread_rwlock_t 数据类型表示,读写锁的初始化可以使用宏PTHREAD_RWLOCK_INITIALIZER 或者函数
pthread_rwlock_init(),其初始化方式与互斥锁相同,譬如使用宏PTHREAD_RWLOCK_INITIALIZER 进行初始化必须在定义读写锁时就对其进行初始化:
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
对于其它方式可以使用pthread_rwlock_init()函数对其进行初始化,当读写锁不再使用时,需要调用
pthread_rwlock_destroy()函数将其销毁,其函数原型如下所示:

#include <pthread.h>

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);

使用这两个函数同样需要包含头文件<pthread.h>,调用成功返回0,失败将返回一个非0 值的错误码。
参数rwlock 指向需要进行初始化或销毁的读写锁对象。对于pthread_rwlock_init()函数,参数attr 是一个pthread_rwlockattr_t *类型指针,指向pthread_rwlockattr_t 对象。pthread_rwlockattr_t 数据类型定义了读写锁的属性(在12.5.3 小节中介绍),若将参数attr 设置为NULL,则表示将读写锁的属性设置为默认值,在这种情况下其实就等价于PTHREAD_RWLOCK_INITIALIZER 这种方式初始化,而不同之处在于,使用宏不进行错误检查。
当读写锁不再使用时,需要调用pthread_rwlock_destroy()函数将其销毁。
读写锁初始化使用示例:

pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock, NULL);
......
pthread_rwlock_destroy(&rwlock);

读写锁上锁和解锁

以读模式对读写锁进行上锁,需要调用pthread_rwlock_rdlock()函数;以写模式对读写锁进行上锁,需要调用pthread_rwlock_wrlock()函数。不管是以何种方式锁住读写锁,均可以调用pthread_rwlock_unlock()函数解锁,其函数原型如下所示:

#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

使用这些函数需要包含头文件<pthread.h>,参数rwlock 指向读写锁对象。调用成功返回0,失败返回一个非0 值的错误码。
当读写锁处于写模式加锁状态时,其它线程调用pthread_rwlock_rdlock()或pthread_rwlock_wrlock()函数均会获取锁失败,从而陷入阻塞等待状态;当读写锁处于读模式加锁状态时,其它线程调用
pthread_rwlock_rdlock()函数可以成功获取到锁,如果调用pthread_rwlock_wrlock()函数则不能获取到锁,从而陷入阻塞等待状态。
如果线程不希望被阻塞,可以调用pthread_rwlock_tryrdlock()和pthread_rwlock_trywrlock()来尝试加锁,如果不可以获取锁时。这两个函数都会立马返回错误,错误码为EBUSY。其函数原型如下所示:

#include <pthread.h>

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

参数rwlock 指向需要加锁的读写锁,加锁成功返回0,加锁失败则返回EBUSY。
使用示例
示例代码12.5.1 演示了使用读写锁来实现线程同步,全局变量g_count 作为线程间的共享变量,主线程中创建了5 个读取g_count 变量的线程,它们使用同一个函数read_thread,这5 个线程仅仅对g_count 变量进行读取,并将其打印出来,连带打印线程的编号(1~5);主线程中还创建了5 个写g_count 变量的线程,它们使用同一个函数write_thread,write_thread 函数中会将g_count 变量的值进行累加,循环10 次,每次将g_count 变量的值在原来的基础上增加20,并将其打印出来,连带打印线程的编号(1~5)。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
static pthread_rwlock_t rwlock;//定义读写锁
static int g_count = 0;
static void *read_thread(void *arg)
{
        int number = *((int *)arg);
        int j;
        for (j = 0; j < 10; j++) {
                pthread_rwlock_rdlock(&rwlock); //以读模式获取锁
                printf("读线程<%d>, g_count=%d\n", number+1, g_count);
                pthread_rwlock_unlock(&rwlock);//解锁
                sleep(1);
        }
        return (void *)0;
}
static void *write_thread(void *arg)
{
        int number = *((int *)arg);
        int j;
        for (j = 0; j < 10; j++) {
                pthread_rwlock_wrlock(&rwlock); //以写模式获取锁
                printf("写线程<%d>, g_count=%d\n", number+1, g_count+=20);
                pthread_rwlock_unlock(&rwlock);//解锁
                sleep(1);
        }
        return (void *)0;
}
static int nums[5] = {0, 1, 2, 3, 4};
int main(int argc, char *argv[])
{
        pthread_t tid[10];
        int j;
        /* 对读写锁进行初始化*/
        pthread_rwlock_init(&rwlock, NULL);
        /* 创建5 个读g_count 变量的线程*/
        for (j = 0; j < 5; j++)
                pthread_create(&tid[j], NULL, read_thread, &nums[j]);
        /* 创建5 个写g_count 变量的线程*/
        for (j = 0; j < 5; j++)
                pthread_create(&tid[j+5], NULL, write_thread, &nums[j]);
        /* 等待线程结束*/
        for (j = 0; j < 10; j++)
                pthread_join(tid[j], NULL);//回收线程
        /* 销毁自旋锁*/
        pthread_rwlock_destroy(&rwlock);
        exit(0);
}

编译测试,其打印结果如下:
在这里插入图片描述
在这个例子中,我们演示了读写锁的使用,但仅作为演示使用,在实际的应用编程中,需要根据应用场景来选择是否使用读写锁。

读写锁的属性

读写锁与互斥锁类似,也是有属性的,读写锁的属性使用pthread_rwlockattr_t 数据类型来表示,当定义
pthread_rwlockattr_t 对象时,需要使用pthread_rwlockattr_init()函数对其进行初始化操作,初始化会将
pthread_rwlockattr_t 对象定义的各个读写锁属性初始化为默认值;当不再使用pthread_rwlockattr_t 对象时,需要调用pthread_rwlockattr_destroy()函数将其销毁,其函数原型如下所示:

#include <pthread.h>
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);

参数attr 指向需要进行初始化或销毁的pthread_rwlockattr_t 对象;函数调用成功返回0,失败将返回一个非0 值的错误码。
读写锁只有一个属性,那便是进程共享属性,它与互斥锁以及自旋锁的进程共享属性相同。Linux 下提供了相应的函数用于设置或获取读写锁的共享属性。函数pthread_rwlockattr_getpshared() 用于从
pthread_rwlockattr_t 对象中获取共享属性,函数pthread_rwlockattr_setpshared()用于设置pthread_rwlockattr_t
对象中的共享属性,其函数原型如下所示:

#include <pthread.h>
int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *attr, int *pshared);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr, int pshared);

函数pthread_rwlockattr_getpshared()参数和返回值:
attr:指向pthread_rwlockattr_t 对象;
pshared:调用pthread_rwlockattr_getpshared()获取共享属性,将其保存在参数pshared 所指向的内存中;
返回值:成功返回0,失败将返回一个非0 值的错误码。
函数pthread_rwlockattr_setpshared()参数和返回值:
attr:指向pthread_rwlockattr_t 对象;
pshared:调用pthread_rwlockattr_setpshared()设置读写锁的共享属性,将其设置为参数pshared 指定的值。参数pshared 可取值如下:
⚫ PTHREAD_PROCESS_SHARED:共享读写锁。该读写锁可以在多个进程中的线程之间共享;
⚫ PTHREAD_PROCESS_PRIVATE:私有读写锁。只有本进程内的线程才能够使用该读写锁,这是读写锁共享属性的默认值。
返回值:调用成功的情况下返回0;失败将返回一个非0 值的错误码。
使用方式如下:

pthread_rwlock_t rwlock; //定义读写锁
pthread_rwlockattr_t attr; //定义读写锁属性
/* 初始化读写锁属性对象*/
pthread_rwlockattr_init(&attr);
/* 将进程共享属性设置为PTHREAD_PROCESS_PRIVATE */
pthread_rwlockattr_setpshared(&attr, PTHREAD_PROCESS_PRIVATE);
/* 初始化读写锁*/
pthread_rwlock_init(&rwlock, &attr);
......
/* 使用完之后*/
pthread_rwlock_destroy(&rwlock); //销毁读写锁
pthread_rwlockattr_destroy(&attr); //销毁读写锁属性对象

总结

本章介绍了线程同步的几种不同的方法,包括互斥锁、条件变量、自旋锁以及读写锁,当然,除此之外,线程同步的方法其实还有很多,譬如信号量、屏障等等,如果大家有兴趣可以自己查阅相关书籍进行学习。在实际应用开发当中,用的最多的还是互斥锁和条件变量,当然具体使用哪一种线程同步方法还是得根据场景来进行选择,方能达到事半功倍的效果!

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

行稳方能走远

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值