Reentrant Function: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 Function:A function that may be safely invoked concurrently by multiple threads.
Async-Signal-Safe Function: A function that may be invoked, without restriction from signal-catching functions. No function is async-signal -safe unless explicitly described as such.
以上三者的关系为:可重入函数 必然 是 线程安全函数 和 异步信号安全函数; 线程安全函数不一定是可重入函数。
可重入与线程安全的区别体现在能否在signal处理函数中被调用的问题上,可重入函数在signal处理函数中可以被安全调用,
因此同时也是Async-Signal-Safe Function;而线程安全函数不保证可以在signal处理函数中被安全调用,如果通过设置信号
阻塞集合等方法保证一个非可重入函数不被信号中断,那么它也是Async-Signal-Safe Function。
举个例子,strtok是既不可重入的,也不是线程安全的;加锁的strtok不是可重入的,但线程安全;而strtok_r既是可重入的,
也是线程安全的。也就是说函数如果使用静态变量,通过加锁后可以转成线程安全函数,但仍然有可能不是可重入的。我们所熟知的
malloc也是线程安全但不是可重入的。
再举个例子,假设函数func()在执行过程中需要访问某个共享资源,因此为了实现线程安全,在使用该资源前加锁,
在不需要资源解锁。 假设该函数在某次执行过程中,在已经获得资源锁之后,有异步信号发生,程序的执行流转交给
对应的信号处理函数;再假设在该信号处理函数中也需要调用函数 func(),那么func()在这次执行中仍会在访问共享资源
前试图获得资源锁,然而我们知道前一个func()实例已然获得该锁,因此信号处理函数阻塞;另一方面,信号处理函数
结束前被信号中断的线程是无法恢复执行的,当然也没有释放资源的机会,这样就出现了线程和信号处理函数
之间的死锁局面。
因此,func()尽管通过加锁的方式能保证线程安全,但是由于函数体对共享资源的访问,因此是非可重入。
对于这种情况,采用的方法一般是在特定的区域屏蔽一定的信号。
二、可重入函数
我们知道,当捕捉到信号时,不论进程的主控制流程当前执行到哪儿,都会先跳到信号处理函数中执行,从信号处理函数
返回后再继续执行主控制流程。信号处理函数是一个单独的控制流程,因为它和主控制流程是异步的,二者不存在调用和
被调用的关系,并且使用不同的堆栈空间。
1
2 |
(By
default, the signal handler is invoked on the normal process stack. It is possible to arrange that the signal handler
uses an alternate stack; see sigaltstack( 2) for a discussion of how to do this and when it might be useful.) |
引入了信号处理函数使得一个进程具有多个控制流程,如果这些控制流程访问
相同的全局资源(全局变量、硬件资源等),就有可能出现冲突,如下面的例子所示。
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,
再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,
插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步
之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个
全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为
可重入(Reentrant)函数。
不可重入函数的原因在于:
1> 已知它们使用静态数据结构
2> 它们调用malloc和free.
因为malloc通常会为所分配的存储区维护一个链接表,而插入执行信号处理函数的时候,进程可能正在修改此链接表。
3> 它们是标准IO函数.
因为标准IO库的很多实现都使用了全局数据结构
3、sig_atomic_t类型与volatile限定符
在上面的图示例子中,main和sighandler都调用insert函数则有可能出现链表的错乱,其根本原因在于,对全局链表的
插入操作要分两步完成,不是一个原子操作,假如这两步操作必定会一起做完,中间不可能被打断,就不会出现错乱了。
关于原子操作最原始的说法是一条汇编指令能够完成(对于多线程程序来说原子操作可以指加锁后的几个步骤集合),
即使是一条C语句也不一定是一个原子操作,比如 a = 5; 如果a是32位的int变量,在32位机上赋值是原子操作(需要4字节对齐),在16位机上就不是(注:一个字节的读写如bool类型,都是原子性的)。如果在程序中需要使用一个变量,要保证对它的读写都是原子操作,应该采用什么类型呢?
为了解决这些平台相关的问题,C标准定义了一个类型sig_atomic_t,在不同平台的C语言库中取不同的类型,例如在32位机上定义sig_atomic_t为int类型。
在使用sig_atomic_t类型的变量时,还需要注意另一个问题。看如下的例子:
sig_atomic_t a=0;
int main(void)
{
/* register a sighandler */
while(!a); /* wait until a changes in sighandler */
/* do something after signal arrives *
/ return 0;
}
为了简洁,这里只写了一个代码框架来说明问题。在main函数中首先要注册某个信号的处理函数sighandler,然后在一个while死循环中
等待信号发生,如果有信号递达则执行sighandler,在sighandler中将a改为1,这样再次回到main函数时就可以退出while循环,
执行后续处理。如果在编译时加了优化选项,则如果第一次比较a是否为0,如果相等则成了死循环,因为不会再次从内存读取变量a的值。
是编译器优化得有错误吗?不是的。设想一下,如果程序只有单一的执行流程,只要当前执行流程没有改变a的值,a的值就没有理由会变,
不需要反复从内存读取,因此上面的两条指令和while(!a);循环是等价的,并且优化之后省去了每次循环读内存的操作,效率非常高。
所以不能说编译器做错了,只能说编译器无法识别程序中存在多个执行流程。之所以程序中存在多个执行流程,是因为调用了特定平台上的
特定库函数,比如sigaction、pthread_create,这些不是C语言本身的规范,不归编译器管,程序员应该自己处理这些问题。
C语言提供了volatile限定符,如果将上述变量定义为volatile sig_atomic_t a=0;那么即使指定了优化选项,编译器也不会优化掉对
变量a内存单元的读写。
对于程序中存在多个执行流程访问同一全局变量的情况,volatile限定符是必要的,此外,虽然程序只有单一的执行流程,
但是变量属于以下情况之一的,也需要volatile限定:
变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都可能不一样;
即使多次向变量的内存单元中写数据,只写不读,也并不是在做无用功,而是有特殊意义的;
什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬件寄存器,例如串口的
接收寄存器属于上述第一种情况,而发送寄存器属于上述第二种情况。
sig_atomic_t类型的变量应该总是加上volatile限定符,因为要使用sig_atomic_t类型的理由也正是
要加volatile限定符的理由。
对于多线程的程序,访问冲突的问题是很普遍的,解决的办法是引入锁,获得锁的线程可以完成“读-修改-写”的操作,
然后释放锁给其它线程,没有获得锁的线程只能等待而不能访问共享数据,这样“读-修改-写”三步操作组成一个原子操作,
要么都执行,要么都不执行,不会执行到中间被打断,也不会在其它处理器上并行做这个操作。
参考:
《linux c 编程一站式学习》
http://blog.csdn.net/lmh12506/article/details/7169358