线程:基础

  • Post author:
  • Post category:其他




线程



6.1 线程概述



6.1.1 线程概念


什么是线程?


线程是参与系统调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。一个进程中可以创建多个线程,多个线程实现并发运行,每个线程执行不同的任务。譬如某应用程序设计了两个需要并发运行的任务 task1 和 task2,可将两个不同的任务分别放置在两个线程中。


线程是如何创建起来的?


当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序一开始时就运行的线程。应用程序都是以 main()做为入口开始运行的,所以 main()函数就是主线程的入口函数,main()函数所执行的任务就是主线程需要执行的任务。

所以由此可知,任何一个进程都包含一个主线程,只有主线程的进程称为单线程进程,譬如前面章节内容中所编写的所有应用程序都是单线程程序,它们只有主线程;既然有单线程进程,那自然就存在多线程进程,所谓多线程指的是除了主线程以外,还包含其它的线程,其它线程通常由主线程来创建(调用pthread_create 创建一个新的线程),那么创建的新线程就是主线程的子线程。

主线程的重要性体现在两方面:

⚫ 其它新的线程(也就是子线程)是由主线程创建的;

⚫ 主线程通常会在最后结束运行,执行各种清理工作,如回收各个子线程。


线程的特点?


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

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

在多线程应用程序中,通常一个进程中包括了多个线程,每个线程都可以参与系统调度、被 CPU 执行,

线程具有以下一些特点:

⚫ 线程不单独存在、而是包含在进程中;

⚫ 线程是参与系统调度的基本单位;

⚫ 可并发执行。同一进程的多个线程之间可并发执行,在宏观上实现同时运行的效果;

⚫ 共享进程资源。同一进程中的各个线程,可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的地址空间(进程的地址空间),这意味着,线程可以访问该地址空间的每一个虚地址;此外,还可以访问进程所拥有的已打开文件、定时器、信号量等等。


线程与进程?


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

多进程编程的劣势:

⚫ 进程间切换开销大。多个进程同时运行(指宏观同时运行,无特别说明,均指宏观上),微观上依然是轮流切换运行,进程间切换开销远大于同一进程的多个线程间切换的开销,通常对于一些中小型应用程序来说不划算。

⚫ 进程间通信较为麻烦。每个进程都在各自的地址空间中、相互独立、隔离,处在于不同的地址空间中,因此相互通信较为麻烦。

解决方案便是使用多线程编程,多线程能够弥补上面的问题:

⚫ 同一进程的多个线程间切换开销比较小。

⚫ 同一进程的多个线程间通信容易。它们共享了进程的地址空间,所以它们都是在同一个地址空间中,通信容易。

⚫ 线程创建的速度远大于进程创建的速度。

⚫ 多线程在多核处理器上更有优势!

多线程也有它的缺点、劣势,譬如多线程编程难度高,对程序员的编程功底要求比较高,因为在多线程环境下需要考虑很多的问题,例如线程安全问题、信号处理的问题等,编写与调试多线程程序比单线程程序困难得多。



6.1.2 并发和并行

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

在这里插入图片描述

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

在这里插入图片描述

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

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

在这里插入图片描述

在网络上看到了很多比较有意思、形象生动的比喻,用来说明串行、并行以及并发这三个概念的区别,这里截取其中的一个:

⚫ 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接电话,这就说明你不支持并发也不支持并行,仅仅只是串行。

⚫ 你吃饭吃到一半,电话来了,你停下吃饭去接了电话,电话接完后继续吃饭,这说明你支持并发。

⚫ 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

这里再次进行总结:

⚫ 串行:一件事、一件事接着做

⚫ 并发:交替做不同的事;

⚫ 并行:同时做不同的事

对于单核处理器系统来说,它只有一个执行单元(譬如 I.MX6U 硬件平台,单核 Cortex-A7 SoC),只能采用并发运行系统中的线程,而肯定不可能是串行。


同时运行


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

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



6.2 线程 ID

就像每个进程都有一个进程 ID 一样,每个线程也有其对应的标识,称为线程 ID。进程 ID 在整个系统中是唯一的,但线程 ID 不同,线程 ID 只有在它所属的进程上下文中才有意义。

进程 ID 使用 pid_t 数据类型来表示,它是一个非负整数。而线程 ID 使用 pthread_t 数据类型来表示,一个线程可通过库函数 pthread_self()来获取自己的线程 ID,其函数原型如下所示:

#include <pthread.h>
pthread_t pthread_self(void);



6.3 创建线程

主线程可以使用库函数 pthread_create()负责创建一个新的线程,创建出来的新线程被称为主线程的子线程,其函数原型如下所示:

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);


thread

:pthread_t 类型指针,当 pthread_create()成功返回时,新创建的线程的线程 ID 会保存在参数 thread所指向的内存中。


attr

:pthread_attr_t 类型指针,通常参数 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()函数。

线程创建成功,新线程就会加入到系统调度队列中,获取到 CPU 之后就会立马从 start_routine()函数开始运行该线程的任务;调用 pthread_create()函数后,通常我们无法确定系统接着会调度哪一个线程来使用CPU 资源,先调度主线程还是新创建的线程呢(而在多核 CPU 或多 CPU 系统中,多核线程可能会在不同的核心上同时执行)?如果程序对

执行顺序

有强制要求,那么就必须采用一些同步技术来实现。这与前面学习父、子进程时也出现了这个问题,无法确定父进程、子进程谁先被系统调度。


使用示例


使用 pthread_create()函数创建一个除主线程之外的新线程,示例代码如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg) {
 printf("新线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());
 return (void *)0; }
int main(void) {
 pthread_t tid;
 int ret;
 ret = pthread_create(&tid, NULL, new_thread_start, NULL);
 if (ret) {
 fprintf(stderr, "Error: %s\n", strerror(ret));
 exit(-1);
 }
 printf("主线程: 进程 ID<%d> 线程 ID<%lu>\n", getpid(), pthread_self());
 sleep(1);
 exit(0);
}

主线程休眠了 1 秒钟,原因在于,如果主线程不进行休眠,它就可能会立马退出,这样可能会导致新创建的线程还没有机会运行,整个进程就结束了。

在主线程和新线程中,分别通过 getpid()和 pthread_self()来获取进程 ID 和线程 ID,将结果打印出来,运行结果如下所示:

在这里插入图片描述

编译时出现了错误,提示“对‘pthread_create’未定义的引用”,示例代码确实已经包含了<pthread.h>头文件,但为什么会出现这样的报错,仔细看,这个报错是出现在程序代码链接时、而并非是编译过程,所以可知这是链接库的文件,如何解决呢?

gcc -o testApp testApp.c -lpthread

使用-l 选项指定链接库 pthread,原因在于 pthread 不在 gcc 的默认链接库中,所以需要手动指定。再次编译便不会有问题了,如下:

在这里插入图片描述

从打印信息可知,正如前面所介绍那样,两个线程的进程 ID 相同,说明新创建的线程与主线程本来就属于同一个进程,但是它们的线程 ID 不同。从打印结果可知,Linux 系统下线程 ID 数值非常大,看起来像是一个指针。



6.4 终止线程

在示例代码 11.3.1 中,我们在新线程的启动函数(线程 start 函数)new_thread_start()通过 return 返回之后,意味着该线程已经终止了,除了在线程 start 函数中执行 return 语句终止线程外,终止线程的方式还有 pthread_exit()函数;其函数原型如下所示:

#include <pthread.h>
void pthread_exit(void *retval);

调用 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()终止之后,整个进程并没有结束,而新线程还在继续运行。



6.5 回收线程

在父、子进程当中,父进程可通过 wait()函数(或其变体 waitpid())阻塞等待子进程退出并获取其终止状态,回收子进程资源;而在线程当中,也需要如此,通过调用 pthread_join()函数来阻塞等待线程的终止,并获取线程的退出码,回收线程资源;pthread_join()函数原型如下所示:

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);


thread

:pthread_join()等待指定线程的终止,通过参数 thread(线程 ID)指定需要等待的线程;


retval

:如果参数 retval 不为 NULL,则 pthread_join()将目标线程的退出状态(即目标线程通过pthread_exit()退出时指定的返回值或者在线程 start 函数中执行 return 语句对应的返回值)复制到

retval 所指向的内存区域;如果目标线程被 pthread_cancel()取消,则将 PTHREAD_CANCELED 放在

retval 中。如果对目标线程的终止状态不感兴趣,则可将参数 retval 设置为 NULL。


使用示例

#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 所

指向的内存中。测试结果如下:

在这里插入图片描述



6.6 取消线程

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



6.6.1 取消一个线程

通过调用 pthread_cancel()库函数向一个指定的线程发送取消请求,其函数原型如下所示:

#include <pthread.h>
int pthread_cancel(pthread_t thread);

发出取消请求之后,函数 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()等待新线程终止、获取其终止状态,将线程退出码打印出来。测试结果如下:

在这里插入图片描述



6.6.2 线程可取消性的检测

假设线程执行的是一个不含取消点的循环(譬如 for 循环、while 循环),那么这时线程永远也不会响应取消请求,也就意味着除了线程自己主动退出,其它线程将无法通过向它发送取消请求而终止它,就如上小节最后给大家列举的例子(Sleep()函数为取消点函数)。

在实际应用程序当中,确实会遇到这种情况,线程最终运行在一个循环当中,该循环体内执行的函数不存在任何一个取消点,但实际项目需求是:该线程必须可以被其它线程通过发送取消请求的方式终止,那这个时候怎么办?此时可以使用 pthread_testcancel(),该函数目的很简单,就是产生一个取消点,线程如果已有处于挂起状态的取消请求,那么只要调用该函数,线程就会随之终止。其函数原型如下所示:

#include <pthread.h>
void pthread_testcancel(void);

功能测试

接下来进行一个测试,主线程创建一个新的进程,新进程的取消性状态和类型置为默认,新进程最终执行的是一个不含取消点的循环;主线程向新线程发送取消请求,示例代码如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg) {
 printf("新线程--start run\n");
 for ( ; ; ) {
 }
 return (void *)0; }
int main(void) {
 pthread_t tid;
 void *tret;
 int ret;
 /* 创建新线程 */
 ret = pthread_create(&tid, NULL, new_thread_start, NULL);
 if (ret) {
 fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
 exit(-1);
 }
 sleep(1);
 /* 向新线程发送取消请求 */
 ret = pthread_cancel(tid);
 if (ret) {
 fprintf(stderr, "pthread_cancel error: %s\n", strerror(ret));
 exit(-1);
 }
 /* 等待新线程终止 */
 ret = pthread_join(tid, &tret);
 if (ret) {
 fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
 exit(-1);
 }
 printf("新线程终止, code=%ld\n", (long)tret);
 exit(0);
}

新线程的 new_thread_start()函数中是一个 for 死循环,没有执行任何函数,所以是一个没有取消点的循环体,主线程调用 pthread_cancel()是无法将其终止的,接下来测试下结果是否如此:

在这里插入图片描述

执行完之后,程序一直会没有退出,说明主线程确实无法终止新线程。接下来再做一个测试,在new_thread_start 函数的 for 循环体中执行 pthread_testcancel()函数,如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void *new_thread_start(void *arg) {
 printf("新线程--start run\n");
 for ( ; ; ) {
 pthread_testcancel();
 }
 return (void *)0; }
int main(void) {
 pthread_t tid;
 void *tret;
 int ret;
 /* 创建新线程 */
 ret = pthread_create(&tid, NULL, new_thread_start, NULL);
 if (ret) {
 fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
 exit(-1);
 }
 sleep(1);
 /* 向新线程发送取消请求 */
 ret = pthread_cancel(tid);
 if (ret) {
 fprintf(stderr, "pthread_cancel error: %s\n", strerror(ret));
 exit(-1);
 }
 /* 等待新线程终止 */
 ret = pthread_join(tid, &tret);
 if (ret) {
 fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
 exit(-1);
 }
 printf("新线程终止, code=%ld\n", (long)tret);
 exit(0);
}

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

在这里插入图片描述



6.7 注册线程清理处理函数

之前学习了 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_cleanup_push()向清理函数栈中添加一个清理函数,第一个参数 routine 是一个函数指针,指向一个需要添加的清理函数,routine()函数无返回值,只有一个 void *类型参数;第二个参数 arg,当调用清理函数 routine()时,将 arg 作为 routine()函数的参数。

既然有添加,自然就会伴随着删除,就好比对应入栈和出栈,调用函数 pthread_cleanup_pop()可以将清理函数栈中最顶层(也就是最后添加的函数,最后入栈)的函数移除。当线程执行以下动作时,清理函数栈中的清理函数才会被执行:

⚫ 线程调用 pthread_exit()退出时;

⚫ 线程响应取消请求时;

⚫ 用非 0 参数调用 pthread_cleanup_pop()

函数 pthread_cleanup_pop()的 execute 参数,可以取值为 0,也可以为非 0;如果为 0,清理函数不会被调用,只是将清理函数栈中最顶层的函数移除;如果参数 execute 为非 0,则除了将清理函数栈中最顶层的函数移除之外,还会该清理函数。

尽管上面我们将 pthread_cleanup_push()和 pthread_cleanup_pop()称之为函数,但它们是通过宏来实现,可展开为分别由{和}所包裹的语句序列,所以必须在与线程相同的作用域中以匹配对的形式使用,必须一一对应着来使用,譬如:

pthread_cleanup_push(cleanup, NULL);
pthread_cleanup_push(cleanup, NULL);
pthread_cleanup_push(cleanup, NULL);
......
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);

否则会编译报错。


使用示例


示例代码 11.8.1 给出了一个使用线程清理函数的例子,虽然例子并没有什么实际作用,当它描述了其中所涉及到的清理机制。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void cleanup(void *arg) {
 printf("cleanup: %s\n", (char *)arg);
}
static void *new_thread_start(void *arg) {
 printf("新线程--start run\n");
 pthread_cleanup_push(cleanup, "第 1 次调用");
 pthread_cleanup_push(cleanup, "第 2 次调用");
 pthread_cleanup_push(cleanup, "第 3 次调用");
 sleep(2);
 pthread_exit((void *)0); //线程终止
 /* 为了与 pthread_cleanup_push 配对,不添加程序编译会通不过 */
 pthread_cleanup_pop(0);
 pthread_cleanup_pop(0);
 pthread_cleanup_pop(0);
}
int main(void) {
 pthread_t tid;
 void *tret;
 int ret;
 /* 创建新线程 */
 ret = pthread_create(&tid, NULL, new_thread_start, NULL);
 if (ret) {
 fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
 exit(-1);
 }
 /* 等待新线程终止 */
 ret = pthread_join(tid, &tret);
 if (ret) {
 fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
 exit(-1);
 }
 printf("新线程终止, code=%ld\n", (long)tret);
 exit(0);
}

主线程创建新线程之后,调用 pthread_join()等待新线程终止;新线程调用 pthread_cleanup_push()函数添加线程清理函数,调用了三次,但每次添加的都是同一个函数,只是传入的参数不同;清理函数添加完成,休眠一段时间之后,调用 pthread_exit()退出。之后还调用了 3 次pthread_cleanup_pop(),在这里的目的仅仅只是为了与 pthread_cleanup_push()配对使用,否则编译不通过。接下来编译运行:

在这里插入图片描述

从打印结果可知,先添加到线程清理函数栈中的函数会后被执行,添加顺序与执行顺序相反。

将新线程中调用的 pthread_exit()替换为 return,在进行测试,发现并不会执行清理函数。

有时在线程功能设计中,线程清理函数并不一定需要在线程退出时才执行,譬如当完成某一个步骤之后,就需要执行线程清理函数,此时我们可以调用 pthread_cleanup_pop()并传入非 0 参数,来手动执行线程清理函数,示例代码如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
static void cleanup(void *arg) {
 printf("cleanup: %s\n", (char *)arg);
}
static void *new_thread_start(void *arg) {
 printf("新线程--start run\n");
 pthread_cleanup_push(cleanup, "第 1 次调用");
 pthread_cleanup_push(cleanup, "第 2 次调用");
 pthread_cleanup_push(cleanup, "第 3 次调用");
 pthread_cleanup_pop(1); //执行最顶层的清理函数
 printf("~~~~~~~~~~~~~~~~~\n");
 sleep(2);
 pthread_exit((void *)0); //线程终止
 /* 为了与 pthread_cleanup_push 配对 */
 pthread_cleanup_pop(0);
 pthread_cleanup_pop(0);
 }
int main(void) {
 pthread_t tid;
 void *tret;
 int ret;
 /* 创建新线程 */
 ret = pthread_create(&tid, NULL, new_thread_start, NULL);
 if (ret) {
 fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
 exit(-1);
 }
 /* 等待新线程终止 */
 ret = pthread_join(tid, &tret);
 if (ret) {
 fprintf(stderr, "pthread_join error: %s\n", strerror(ret));
 exit(-1);
 }
 printf("新线程终止, code=%ld\n", (long)tret);
 exit(0);
}

上述代码中,在新线程调用 pthread_exit()之前,先调用 pthread_cleanup_pop(1)手动运行了最顶层的清理函数,并将其从栈中移除,测试结果:

在这里插入图片描述

从打印结果可知,调用 pthread_cleanup_pop(1)执行了最后一次注册的清理函数,调用 pthread_exit()退出线程时执行了 2 次清理函数,因为前面调用 pthread_cleanup_pop()已经将顶层的清理函数移除栈中了,自然在退出时就不会再执行了。



6.8 线程安全

当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?



6.8.1 线程栈

进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈。譬如主线程调用 pthread_create()创建了一个新的线程,那么这个新的线程有它自己独立的栈地址空间、而主线程也有它自己独立的栈地址空间。

在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况下,保持默认即可!

既然每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰。在示例代码 中,主线程创建了 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);
}

运行结果:

在这里插入图片描述



6.8.2 可重入函数

如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。


可重入函数的分类


笔者认为可重入函数可以分为两类:

⚫ 绝对的可重入函数:所谓绝对,指的是该函数不管如何调用,都刚断言它是可重入的,都能得到预期的结果。

⚫ 带条件的可重入函数:指的是在满足某个/某些条件的情况下,可以断言该函数是可重入的,不管怎么调用都能得到预期的结果。


绝对可重入函数


首先来看一下绝对可重入函数的一个例子,如下所示:

函数 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;
}

该函数内操作的变量均是函数内部定义的自动变量(局部变量),每次调用函数,都会在栈内存空间为局部变量分配内存,当函数调用结束返回时、再由系统回收这些变量占用的栈内存,所以局部变量生命周期只限于函数执行期间。

除此之外,该函数的参数和返回值均是值类型、而并非是引用类型(就是指针)。


带条件的可重入函数


带条件的可重入函数通常需要满足一定的条件时才是可重入函数,我们来看一个不可重入函数的例子,

如下所示:

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 变量进行读写操作、会导致数据不一致的问题,关于这个问题之前给大家做了详细说明。这个函数就是典型的不可重入函数,函数运行需要读取、修改全局变量 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()依然是不可重入函数。这就是有条件的可重入函数的概念。

再来看一个例子:

static void func(int *arg)
{
int local = *arg;
int j;
for (j = 0; j < 10; j++)
local++;
*arg = local;
}

这是一个参数为引用类型的函数,传入了一个指针,并在函数内部读写该指针所指向的内存地址,该函数是一个可重入函数,但同样需要满足一定的条件;如果多个执行流同时调用该函数时,所传入的指针是共享变量的地址,那么在这种情况,最终可能得不到预期的结果;因static void func(void)

{


/* 只能执行一次的代码段 */

init_once();

/***********************/





}为在这种情况下,函数 func()所读写的便是多个执行流的共享数据,会出现数据不一致的情况,所以是不安全的。

但如果每个执行流所传入的指针是其本地变量(局部变量)对应的地址,那就是没有问题的,所以呢,这个函数就是一个带条件的可重入函数。


总结



判断一个函数是否为线程安全函数的方法是,该函数被多个线程同时调用是否总能产生正确的结果,如果每次都能产生预期的结果则表示该函数是一个线程安全函数。



6.8.3 一次性初始化

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


使用示例


接下来我们测试下,当 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 的线程所执行,其它线程均未执行该函数。



6.8.4 线程局部存储

通常情况下,程序中定义的全局变量是进程中所有线程共享的,所有线程都可访问这些全局变量;而线程局部存储在定义全局或静态变量时,使用__thread 修饰符修饰变量,此时,每个线程都会拥有一份对该变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,会自动释放这一存储。

线程局部存储的主要优点在于,比线程特有数据的使用要简单。要创建线程局部变量,只需简单地在全局或静态变量的声明中包含__thread 修饰符即可!譬如:

static __thread char buf[512];


使用示例


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

#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 绝不是同一个变量,这就是线程局部存储,使得每个线程都拥有一份对变量的拷贝,各个线程操作各自的变量不会影响其它线程。



6.7 更多细节问题



6.7.1 线程与信号


⑴、信号如何映射到线程


信号模型在一些方面是属于进程层面(由进程中的所有线程线程共享)的,而在另一些方面是属于单个线程层面的,以下对其进行汇总:

⚫ 信号的系统默认行为是属于进程层面。之前介绍过,每一个信号都有其对应的系统默认动作,当进程中的任一线程收到任何一个未经处理(忽略或捕获)的信号时,会执行该信号的默认操作,信号的默认操作通常是停止或终止进程。

⚫ 信号处理函数属于进程层面。进程中的所有线程共享程序中所注册的信号处理函数;

⚫ 信号的发送既可针对整个进程,也可针对某个特定的线程。由函数 pthread_kill()或 pthread_sigqueue()所发出的信号,信号的发送针对的是某个线程:

⚫ 当一个多线程进程接收到一个信号时,且该信号绑定了信号处理函数时,内核会任选一个线程来接

收这个信号,意味着由该线程接收信号并调用信号处理函数对其进行处理,并不是每个线程都会接收到该信号并调用信号处理函数;这种行为与信号的原始语义是保持一致的,让进程对单个信号接收重复处理多次是没有意义的。

⚫ 信号掩码其实是属于线程层面的,也就是说信号掩码是针对每个线程而言。之前向大家介绍了信号掩码的概念,并介绍了 sigprocmask()函数,通过 sigprocmask()可以设置进程的信号掩码,事实上,信号掩码是并不是针对整个进程来说,而是针对线程,对于一个多线程应用程序来说,并不存在一个作用于整个进程范围内的信号掩码(管理进程中的所有线程);那么在多线程环境下,各个线程可以调用 pthread_sigmask()函数来设置它们各自的信号掩码,譬如设置线程可以接收哪些信号、不接收哪些信号,各线程可独立阻止或放行各种信号。


⑵、线程的信号掩码


对于一个单线程程序来说,使用 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。


⑷、异步信号安全函数


前面介绍了线程安全函数,作为线程安全函数可以被多个线程同时调用,每次都能得到预期的结果,但是这里有前提条件,那就是没有信号处理函数参与;换句话说,线程安全函数不能在信号处理函数中被调用,否则就不能保证它一定是安全的。所以就出现了异步信号安全函数。

异步信号安全函数指的是可以在信号处理函数中可以被安全调用的线程安全函数,所以它比线程安全函数的要求更为严格!可重入函数满足这个要求,所以可重入函数一定是异步信号安全函数。而线程安全函数则不一定是异步信号安全函数了。举个例子,下面列举出来的一个函数是线程安全函数:

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()就是一个异步信号安全函数了。

所以对于一个安全的信号处理函数来说,需要做到以下几点:

⚫ 首先确保信号处理函数本身的代码是可重入的,且只能调用异步信号安全函数;

⚫ 当主程序执行不安全函数或是去操作信号处理函数也可能会更新的全局数据结构时,要阻塞信号的传递。


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



版权声明:本文为weixin_48085453原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。