当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?带着这些问题,本小节将讨论线程安全相关的话题。
1.线程栈
进程中创建的每个线程都有自己的栈地址空间,将其称为线程栈。譬如主线程调用 pthread_create()创建了一个新的线程,那么这个新的线程有它自己独立的栈地址空间、而主线程也有它自己独立的栈地址空间。
通过前面的文章可知,在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况下,保持默认即可!
既然每个线程都有自己的栈地址空间,那么每个线程运行过程中所定义的自动变量(局部变量)都是分配在自己的线程栈中的,它们不会相互干扰。在示例代码 11.10.1 中,主线程创建了 5 个新的线程,这 5 个线程使用同一个 start 函数 new_thread,该函数中定义了局部变量 number 和 tid 以及 arg 参数,意味着这 5个线程的线程栈中都各自为这些变量分配了内存空间,任何一个线程修改了 number 或 tid 都不会影响其它线程。
//示例代码 11.10.1 线程栈示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
static void *new_thread(void *arg) {
int number = *((int *)arg);
unsigned long int tid = pthread_self();
printf("当前为<%d>号线程, 线程 ID<%lu>\\n", number, tid);
return (void *)0;
}
static int nums[5] = {0, 1, 2, 3, 4};
int main(int argc, char *argv[])
{
pthread_t tid[5];
int j;
/* 创建 5 个线程 */
for (j = 0; j < 5; j++)
pthread_create(&tid[j], NULL, new_thread, &nums[j]);
/* 等待线程结束 */
for (j = 0; j < 5; j++)
pthread_join(tid[j], NULL);//回收线程
exit(0);
}
2. 可重载函数
要解释可重入(Reentrant)函数为何物,首先需要区分单线程程序和多线程程序。本章开头部分已向各位读者进行了详细介绍,单线程程序只有一条执行流(一个线程就是一条执行流),贯穿程序始终;而对于多线程程序而言,同一进程却存在多条独立、并发的执行流。进程中执行流的数量除了与线程有关之外,与信号处理也有关联。因为信号是异步的,进程可能会在其运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含信号处理)中形成了两条(即主程序和信号处理函数)独立的执行流。
接下来再来介绍什么是可重入函数,如果一个函数被同一进程的多个不同的执行流同时调用,每次函数调用总是能产生正确的结果(或者叫产生预期的结果),把这样的函数就称为可重入函数。
Tips:上面所说的同时指的是宏观上同时调用,实质上也就是该函数被多个执行流并发/并行调用,无特别说明,本章内容所提到的同时均指宏观上的概念。
重入指的是同一个函数被不同执行流调用,前一个执行流还没有执行完该函数、另一个执行流又开始调用该函数了,其实就是同一个函数被多个执行流并发/并行调用,在宏观角度上理解指的就是被多个执行流同时调用。
看到这里大家可能会有点不解,我们使用示例进行讲解。示例代码 11.10.2 是一个单线程与信号处理关联的程序。main()函数中调用 signal()函数为 SIGINT 信号注册了一个信号处理函数 sig_handler,信号处理函数 sig_handler 会调用 func 函数;main()函数最终会进入到一个循环中,循环调用 func()。
//示例代码 11.10.2 信号与可重入问题
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
static void func(void) {
/*...... */
}
static void sig_handler(int sig) {
func();
}
int main(int argc, char *argv[])
{
sig_t ret = NULL;
ret = signal(SIGINT, (sig_t)sig_handler);
if (SIG_ERR == ret) {
perror("signal error");
exit(-1);
}
/* 死循环 */
for ( ; ; )
func();
exit(0);
}
当 main()函数正在执行 func()函数代码,此时进程收到了 SIGINT 信号,便会打断当前正常执行流程、跳转到 sig_handler()函数执行,进而调用 func、执行 func()函数代码;这里就出现了主程序与信号处理函数并发调用 func()的情况,示意图如下所示:
在信号处理函数中,执行完 func()之后,信号处理函数退出、返回到主程序流程,也就是被信号打断的位置处继续运行。如果每次出现这种情况执行 func()函数都能产生正确的结果,那么 func()函数就是一个可重入函数。
接着再来看看在多线程环境下,示例代码 11.10.1 是一个多线程程序,主线程调用 pthread_create()函数创建了 5 个新的线程,这 5 个线程使用同一个入口函数 new_thread;所以它们执行的代码是一样的,除了参数 arg 不同之外;在这种情况下,这 5 个线程中的多个线程就可能会出现并发调用 pthread_self()函数的情况。
以上举例说明了函数被多个执行流同时调用的两种情况:
- 在一个含有信号处理的程序当中,主程序正执行函数 func(),此时进程接收到信号,主程序被打断,跳转到信号处理函数中执行,信号处理函数中也调用了 func()。
- 在多线程环境下,多个线程并发调用同一个函数。所以由此可知,在多线程环境以及信号处理有关应用程序中,需要注意不可重入函数的问题,如果多条执行流同时调用一个不可重入函数则可能会得不到预期的结果、甚至有可能导致程序崩溃!不止是在应用程序中,在一个包含了中断处理的裸机应用程序中亦是如此!所以不可重入函数通常存在着一定的安全隐患。
可重入函数的分类
- 绝对的可重入函数:所谓绝对,指的是该函数不管如何调用,都刚断言它是可重入的,都能得到预期的结果。
- 带条件的可重入函数:指的是在满足某个/某些条件的情况下,可以断言该函数是可重入的,不管
怎么调用都能得到预期的结果。
绝对可重入函数
笔者查阅过多的书籍以及网络文章,并未发现有提出过这种分类,所以这完全是笔者个人对此的一个
理解,首先来看一下绝对可重入函数的一个例子,如下所示:
函数 func()就是一个标准的绝对可重入函数:
static int func(int a){
int local;
int j;
for (local = 0, j = 0; j < 5; j++) {
local += a * a;
a += 2;
}
return local;
}
该函数内操作的变量均是函数内部定义的自动变量(局部变量),每次调用函数,都会在栈内存空间为局部变量分配内存,当函数调用结束返回时、再由系统回收这些变量占用的栈内存,所以局部变量生命周期只限于函数执行期间。
除此之外,该函数的参数和返回值均是值类型、而并非是引用类型(就是指针)。
如果多条执行流同时调用函数 func(),那必然会在栈空间中存在多份局部变量,每条执行流操作各自的局部变量,相互不影响,所以即使函数同时被调用,依然每次都能得到正确的结果。所以上面列举的函数func()就是一个非常标准的绝对可重入函数,函数内部仅操作了函数内定义的局部变量,除了使用栈上的变量以外不依赖于任何环境变量,这样的函数就是 purecode(纯代码)可重入,可以允许该函数的多个副本同时在运行,由于它们使用的是分离的栈,所以不会相互干扰!
总结下绝对可重入函数的特点:
- 函数内所使用到的变量均为局部变量,换句话说,该函数内的操作的内存地址均为本地栈地址;
- 函数参数和返回值均是值类型;
- 函数内调用的其它函数也均是绝对可重入函数。
带条件的可重入函数
带条件的可重入函数通常需要满足一定的条件时才是可重入函数,我们来看一个不可重入函数的例子,
如下所示:
static int glob = 0;
static void func(int loops)
{
int local;
int j;
for (j = 0; j < loops; j++) {
local = glob;
local++;
glob = local;
}
}
当多个执行流同时调用该函数,全局变量 glob 的最终值将不得而知,最终可能会得不到正确的结果,因为全局变量 glob 将成为多个线程间的共享数据,它们都会对 glob 变量进行读写操作、会导致数据不一致的问题,关于这个问题在 12.1 小节中给大家做了详细说明。这个函数就是典型的不可重入函数,函数运行需要读取、修改全局变量 glob,该变量并非在函数自己的栈上,意味着该函数运行依赖于外部环境变量。
但如果对上面的函数进行修改,函数 func()内仅读取全局变量 glob 的值,而不更改它的值:
static int glob = 0;
static void func(int loops)
{
int local;
int j;
for (j = 0; j < loops; j++) {
local = glob;
local++;
printf("local=%d\n", local);
}
}
修改完之后,函数 func()内仅读取了变量 glob,而并未更改 glob 的值,那么此时函数 func()就是一个可重入函数了;但是这里需要注意,它需要满足一个条件,这个条件就是:当多个执行流同时调用函数 func()时,全局变量 glob 的值绝对不会在其它某个地方被更改;譬如线程 1 和线程 2 同时调用了函数 func(),但是另一个线程 3 在线程 1 和线程 2 同时调用了函数 func()的时候,可能会发生更改变量 glob 值的情况,如果是这样,那么函数 func()依然是不可重入函数。这就是有条件的可重入函数的概念,这通常需要程序员本身去规避这类问题,标准 C 语言函数库中也存在很多这类带条件的可重入函数,后面给大家看一下。
再来看一个例子:
static void func(int *arg)
{
int local = *arg;
int j;
for (j = 0; j < 10; j++)
local++;
arg = local;
}
这是一个参数为引用类型的函数,传入了一个指针,并在函数内部读写该指针所指向的内存地址,该函数是一个可重入函数,但同样需要满足一定的条件;如果多个执行流同时调用该函数时,所传入的指针是共享变量的地址,那么在这种情况,最终可能得不到预期的结果;因为在这种情况下,函数 func()所读写的便是多个执行流的共享数据,会出现数据不一致的情况,所以是不安全的。
但如果每个执行流所传入的指针是其本地变量(局部变量)对应的地址,那就是没有问题的,所以呢,这个函数就是一个带条件的可重入函数。
总结
相信笔者列举了这么多例子,大家应该明白了什么是可重入函数以及绝对可重入函数和带条件的可重入函数的区别,还有很多的例子这里就不再一一列举了,相信通过笔者的介绍大家应该知道如何去判断它们了。很多的 C 库函数有两个版本:可重入版本和不可重入版本,可重入版本函数其名称后面加上了“_r”,用于表明该函数是一个可重入函数;而不可重入版本函数其名称后面没有“_r”,前面章节内容中也已经遇到过很多次了,譬如 asctime()/asctime_r()、ctime()/ctime_r()、localtime()/localtime_r()等。
通过 man 手册可以查询到它们“ATTRIBUTES”信息,譬如执行"man 3 ctime",在帮助页面上往下翻便可以找到,如下所示:图 11.10.3 asctime()/asctime_r()函数的 ATTRIBUTES 信息
可以看到上图中有些函数 Value 这栏会显示 MT-Unsafe、而有些函数显示的却是 MT-Safe。MT 指的是multithreaded(多线程),所以 MT-Unsafe 就是多线程不安全、MT-Safe 指的是多线程安全,通常习惯上将MT-Safe 和 MT-Unsafe 称为线程安全或线程不安全。
Value 值为 MT-Safe 修饰的函数表示该函数是一个线程安全函数,使用 MT-Unsafe 修饰的函数表示它是 一 个 线 程 不 安 全 函 数 , 下 一 小 节 会 给 大 家 介 绍 什 么 是 线 程 安 全 函 数 。 从 上 图 可 以 看 出 ,asctime_r()/ctime_r()/gmtime_r()/localtime_r()这些可重入函数都是线程安全函数,但这些函数都是带条件的可重入函数,可以发现在 MT-Safe 标签后面会携带诸如 env 或 locale 之类的标签,这其实就表示该函数需要在满足 env 或 locale 条件的情况下才是可重入函数;如果是绝对可重入函数,MT-Safe 标签后面不会携带任何标签,譬如数学库函数 sqrt:
诸如 env 或 locale 等标签,可以通过 man 手册进行查询,命令为"man 7 attributes",这文档里边的内容反正笔者是没太看懂,不知所云;但是经过我的对比 env 或 locale 这两个标签还是很容易理解的。这两个标签在 man 测试里边出现的频率相对于其它的标签要大,这里笔者就简单地提一下:
- env:这个标签指的是该函数内部会读取进程的某个/某些环境变量,譬如 getenv()函数,前面也给大家介绍过,进程的环境变量其实就是程序的一个全局变量,前面也讲了,对于这类读取(但没更改)了全局变量的可重入函数应该要满足的条件,这里就不再重述了;
- local:local 指的是本地,很容易理解,通常该类函数传入了指针,前面也提到了传入了指针的可
3.线程安全函数
了解了可重入函数之后,再来看看线程安全函数。
一个函数被多个线程(其实也是多个执行流,但是不包括由信号处理函数所产生的执行流)同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数。线程安全函数包括可重入函数,可重入函数是线程安全函数的一个真子集,也就是说可重入函数一定是线程安全函数,但线程安全函数不一定是可重入函数,它们之间的关系如下:
譬如下面这个函数是一个不可重入函数,同样也是一个线程不安全函数(上小节的最后一个例子):
static int glob = 0;
static void func(int loops)
{
int local;
int j;
for (j = 0; j < loops; j++) {
local = glob;
local++;
glob = local;
}
}
如果对该函数进行修改,使用线程同步技术(譬如互斥锁)对共享变量 glob 的访问进行保护,在读写该变量之前先上锁、读写完成之后在解锁。这样,该函数就变成了一个线程安全函数,但是它依然不是可重该变量之前先上锁、读写完成之后在解锁。这样,该函数就变成了一个线程安全函数,但是它依然不是可重入函数,因为该函数更改了外部全局变量的值。
可重入函数只是单纯从语言语法角度分析它的可重入性质,不涉及到一些具体的实现机制,譬如线程同步技术,这是判断可重入函数和线程安全函数的区别,因为你单从概念上去分析的话,其实可以发现可重入函数和线程安全函数好像说的是同一个东西,“一个函数被多个线程同时调用时,它总会一直产生正确的结果,把这样的函数称为线程安全函数”,多个线程指的就是多个执行流(不包括信号处理函数执行流),所以从这里看跟可重入函数的概念是很相似的。
判断一个函数是否为线程安全函数的方法是,该函数被多个线程同时调用是否总能产生正确的结果,如果每次都能产生预期的结果则表示该函数是一个线程安全函数。判读一个函数是否为可重入函数的方法是,从语言语法角度分析,该函数被多个执行流同时调用是否总能产生正确的结果,如果每次都能产生预期的结果则表示该函数是一个可重入函数。
POSIX.1-2001 和 POSIX.1-2008 标准中规定的所有函数都必须是线程安全函数,但以下函数除外:
表 11.10.1 POSIX.1-2001 和 POSIX.1-2008 中列出的线程不安全函数
以上所列举出的这些函数被认为是线程不安全函数,大家也可以通过 man 手册查询到这些函数,"man 7 pthreads",如下所示:
如果想确认某个函数是不是线程安全函数可以
上小节给大家提到过,man 手册可以查看库函数的 ATTRIBUTES 信息,如果函数被标记为 MT-Safe,则表示该函数是一个线程安全函数,如果被标记为 MT-Unsafe,则意味着该函数是一个非线程安全函数,对于非线程安全函数,在多线程编程环境下尤其要注意,如果某函数可能会被多个线程同时调用时,该函数不能是非线程安全函数,一定要是线程安全函数,否则将会出现意想不到的结果、甚至使得整个程序崩溃!
对于一个中大型的多线程应用程序项目来说,能够保证整个程序的安全性,这是非常重要的,程序员必须要正确对待线程安全以及信号处理等这类在多线程环境下敏感的问题,这通常对程序员提出了更高的要求。