嵌入式Linux-线程安全


1. 线程安全的概述

当我们编写的程序是一个多线程应用程序时,就不得不考虑到线程安全的问题,确保我们编写的程序是一个线程安全(thread-safe)的多线程应用程序,什么是线程安全以及如何保证线程安全?带着这些问题,本小节将讨论线程安全相关的话题。

2. 线程栈

进程中创建的每个线程都有自己的栈地址空间,这一点我们在线程的属性中有介绍,对于其栈空间,我们称为线程栈。在创建一个新的线程时,可以配置线程栈的大小以及起始地址,当然在大部分情况下,保持默认即可!

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

在这里插入图片描述

3. 可重入函数

要解释可重入(Reentrant)函数为何物,首先需要区分单线程程序和多线程程序。本章开头部分已向各位读者进行了详细介绍,单线程程序只有一条执行流(一个线程就是一条执行流),贯穿程序始终;而对于多线程程序而言,同一进程却存在多条独立、并发的执行流。详细请看线程的开始

进程中执行流的数量除了与线程有关之外,与信号处理也有关联因为信号是异步的,进程可能会在其运行过程中的任何时间点收到信号,进而跳转、执行信号处理函数,从而在一个单线程进程(包含信号处理)中形成了两条(即主程序和信号处理函数)独立的执行流。

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

Tips:上面所说的同时指的是宏观上同时调用,实质上也就是该函数被多个执行流并发/并行调用。

重入指的是同一个函数被不同执行流调用,前一个执行流还没有执行完该函数、另一个执行流又开始调用该函数了,其实就是同一个函数被多个执行流并发/并行调用,在宏观角度上理解指的就是被多个执行流同时调用。

我们使用例程来帮助理解吧:

#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()函数中调用 signal()函数为 SIGINT 信号注册了一个信号处理函数 sig_handler,信号处理函数 sig_handler 会调用 func 函数;main()函数最终会进入到一个循环中,循环调用 func()。

所以,当我们运行该例程的时候,就会理解当main()函数正在执行 func()函数代码,此时进程收到了 SIGINT 信号,便会打断当前正常执行流程、跳转到 sig_handler()函数执行,进而调用 func、执行 func()函数代码;这里就出现了主程序与信号处理函数并发调用 func()的情况,示意图如下所示:
在这里插入图片描述
在信号处理函数中,执行完 func()之后,信号处理函数退出、返回到主程序流程,也就是被信号打断的位置处继续运行。如果每次出现这种情况执行 func()函数都能产生正确的结果,那么 func()函数就是一个可重入函数。

总结一下可重入函数被多个执行流同时调用的两种情况:

  1. 在一个含有信号处理的程序当中,主程序正执行函数 func(),此时进程接收到信号,主程序被打断,跳转到信号处理函数中执行,信号处理函数中也调用了 func()。
  2. 在多线程环境下,多个线程并发调用同一个函数。

所以由此可知,在多线程环境以及信号处理有关应用程序中,需要注意不可重入函数的问题,如果多条执行流同时调用一个不可重入函数则可能会得不到预期的结果、甚至有可能导致程序崩溃!不止是在应用程序中,在一个包含了中断处理的裸机应用程序中亦是如此!所以不可重入函数通常存在着一定的安全隐患。

4. 线程安全函数

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

在这里插入图片描述
譬如下面这个函数是一个不可重入函数,同样也是一个线程不安全函数:

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 7 pthreads

在这里插入图片描述

5. 一次性初始化

**在多线程编程环境下,有些代码段只需要执行一次,譬如一些初始化相关的代码段,通常比较容易想到的就是将其放在 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()时参数 once_control 指向该变量。通常在定义变量时会使用 PTHREAD_ONCE_INIT 宏对其进行初始化
init_routine一个函数指针,参数 init_routine 所指向的函数就是要求只能被执行一次的代码段,pthread_once()函数内部会调用 init_routine(),即使 pthread_once()函数会被多次执行,但它能保证 init_routine()仅被执行一次。

对once_control 初始化:

pthread_once_t once_control = PTHREAD_ONCE_INIT;

通过第一个示例,我们修改一下例程:


#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()函数确实只被执行了一次,也就是被编号为2的线程所执行,其它线程均未执行该函数。**这就是一次性初始化函数的高级部分!

6. 线程特有数据

线程特有数据也称为线程私有数据,简单点说,就是为每个调用线程分别维护一份变量的副本(copy),每个线程通过特有数据键(key)访问时,这个特有数据键都会获取到本线程绑定的变量副本。这样就可以避免变量成为多个线程间的共享数据。

线程特有数据的核心思想其实非常简单,就是为每一个调用线程(调用某函数的线程,该函数就是我们要通过线程特有数据将其实现为线程安全的函数)分配属于该线程的私有数据区,为每个调用线程分别维护一份变量的副本。

线程特有数据主要涉及到 3 个函数:pthread_key_create()、pthread_setspecific()以及 pthread_getspecific(),接下来一一向大家进行介绍。

  1. pthread_key_create()函数
    在为线程分配私有数据区之前,需要调用 pthread_key_create()函数创建一个特有数据键(key),并且只需要在首个调用的线程中创建一次即可。
#include <pthread.h>

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));

函数参数:
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()函数会被自动调用。

  1. 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 指定的特有数据键对应的解构函数来释放这一块动态申请的内存空间。

  1. 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()函数应返回当前调用线程关联到特有数据键的私有数据缓冲区,返回值是一个指针,指向该缓冲区。

  1. 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 指定的特有数据键将不再使用。

7. 线程局部存储

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

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

static __thread char buf[512];

就在普通定义的变量前面,加一个关键词。

但凡带有这种修饰符的变量,每个线程都拥有一份对变量的拷贝,意味着每个线程访问的都是该变量在本线程的副本,从而避免了全局变量成为多个线程的共享数据。

关于线程局部变量需要注意以下几点:

  1. 如果变量声明中使用了关键字 static 或 extern,那么关键字__thread 必须紧随其后。
  2. 与一般的全局或静态变量申明一眼,线程局部变量在申明时可设置一个初始值。
  3. 可以使用 C 语言取值操作符(&)来获取线程局部变量的地址。
#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 绝不是同一个变量,这就是线程局部存储,使得每个线程都拥有一份对变量的拷贝,各个线程操作各自的变量不会影响其它线程。

本文参考正点原子的嵌入式LinuxC应用编程。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

The endeavor

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

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

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

打赏作者

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

抵扣说明:

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

余额充值