可重入、线程安全辨析与场景举例
定义
可重入(reentrant)的定义1:
在单个线程中先后执行一段代码是安全的,所谓安全,即一段代码执行的时候,其不会因为进程的signal打断而产生不一致的结果(以及产生的副作用,如更改的全局变量)。signal中断如下:
可重入(reentrant)的定义2:但是,如果参考POSIX的定义,a "reentrant function" is defined as a "function whose effect, when called by two or more threads, is guaranteed to be as if the threads each executed the function one after another in an undefined order, even if the actual execution is interleaved". 即多个线程可以同时调用函数而保证安全,那么可重入的意义其实就是线程安全了。
线程安全(thread-safe)的定义:
一个线程内运行的程序不会因为多个线程的运行而产生跟单个线程运行的时候产生不一致的结果或副作用。
举例:纯函数,即不会影响全局相关变量的函数;或者是获取static变量,线程共享变量的时候有加锁的线程;这些情况下的代码是线程安全的。
0.可重入跟线程安全的关系
可重入程序中可以实现线程安全,但可重入不一定线程安全。反之,线程安全代码也不一定是可重入的。
一般可重入程序的概念只在signal中断的情况下有讨论意义,且单线程(进程)程序中的中断便可以用于讨论可重入。
下面的示例分别展示了可重入/不可重入、线程安全/不安全的各种情况;
1.不可重入/非线程安全
int tmp; void swap(int* x, int* y) { tmp = *x; *x = *y; /* Hardware interrupt might invoke isr() here. */ *y = tmp; } void isr() { int x = 1, y = 2; swap(&x, &y); }
其中swap函数不是thread-safe的,因为全局变量tmp可能会因为多线程调用导致一个线程的执行中tmp发生变化,导致*y的值发生竞态条件,其运行结果会有变化;
也不是reentrant 可重入的,在该代码中的注释位置,在中断的时候可以调用isr,在新的isr中如果tmp被赋值,完成调用后回到原线程,则*y的值会不一致;
2.可重入/非线程安全(按照旧定义)
可重入的函数一般也是线程安全的,但是有很多反例,如下:
int tmp; int add10(int a) { tmp = a; return a + 10; }
该代码仍然有可能在任意地方被signal打断,但是由于在单线程中返回的值与全局共享的tmp无关,所以是可重入的。但是这个不是thread-safe的,因为tmp在该调用过程中可能被其他线程修改。
在这个例子中,对于add10内的变量来说,可重入意味着修改操作对函数执行结果无影响。
反例2:
int t;
void swap(int *x, int *y) {
int s;
s = t;
t = *x;
*x = *y;
y = t;
t = s;
}
交换两个数,其中会临时修改全局变量,但是t最后会被恢复;该函数按照定义1是可重入的,定义2则不然,因为它并不线程安全。考虑单线程情况下,即使A被B打断了,仍然可以重入;但是多个线程执行的时候A从t=*x之后,另一个进程B覆盖掉t的值,并在切回线程A的时候覆盖掉y的值,直接导致线程不安全。
3.不可重入/线程安全
thread_local int tmp; int add10(int a) { tmp = a; return tmp + 10; }
该函数是thread-safe的,因为tmp是thread_local变量,各个线程之间的修改不会影响函数结果;
但是它不是可重入的,因为tmp可能在tmp+10处因为signal中断导致返回的结果发生变化(在同一个线程内)。
该例子是对全局没有影响,但是对局部有了影响。
4.可重入/线程安全
int add10(int a) { return a + 10; }
该代码是thread-safe的:因为它不涉及多线程内变量的干扰;
是可重入的:因为它的变量即使被打断了,a的值也不变,返回的结果仍然是a+10。
实际场景
1.signal中断的可重入
实际代码中signal中断如果是自己写的,那么要避免产生关联函数内互相干扰状态的问题;如果是跟别人的代码一起工作的,那也要注意避免写到全局变量;
2. malloc
malloc函数因为是对全局内存进行分配的,所以是不可重入的;但是一般对malloc的实现默认是线程安全的。
3. 标准I/O库的不可重入
标准I/O函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。例如,书上或网上一些例子,信号处理函数中调用了printf,仅仅是为了直观说明程序的运行,实际生产代码中printf不能在信号处理函数中调用。
4.如何保证线程安全性
避免访问并修改全局变量,static变量,如果要访问,使用mutex保证线程安全;
5. 有一些函数虽然不要求thread-safe,但是也有thread-safe的实现,一般只把它们当作not thread-safe的;
clib中的有些函数是not reentrant的,但是也有reentrant的版本,如rand_r, srand_r,如下:
总结
1.signal可重入和递归调用的区别:
signal可重入是在任意的一个位置因为信号而被切换出去的,在堆栈上没有直接关联;而递归调用是在确定的位置调用代码的,堆栈有父子关系。
2.
可重入不一定线程安全,线程安全不一定可重入;
但是实际遇到的情况:
程序可重入->大概率线程安全;
线程安全->跟可重入没有太大关系,因为可重入是只针对单个线程内的信号的,跟线程的干扰不一样;而且实际编码中signal内的处理函数是自己写的,只要不写出干扰了全局状态的代码,或者加了自身的非reentrant锁,一般也不会出现问题。
References
[1]Reentrancy, Wikipedia https://en.wikipedia.org/wiki/Reentrancy_(computing)
[2]Is Malloc Thread-Safe? https://stackoverflow.com/questions/855763/is-malloc-thread-safe
[3]日常开发笔记总结(六) https://www.52coder.net/post/weeknote-6
[4]可重入與執行緒安全 (reentrant vs thread-safe) Part1,https://magicjackting.pixnet.net/blog/post/113860339
[5]C-Language Use and Implementation of Interfaces https://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_09_01