可重入函数、线程安全、volatile
一、 POSIX 中对可重入和线程安全这两个概念的定义:
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 既是可重入的,也是线程安全的。也就是说函数如果使用静态变量,通过加锁后可以转成线程安全函数,但仍然有可能不是可重入的。我们所熟知的也是线程安全但不是可
的。
再举个例子,假设函数func()在执行过程中需要访问某个共享资源,因此为了实现线程安全,在使用该资源前加锁,在不需要资源解锁。 假设该函
数在某次执行过程中,在已经获得资源锁之后,有异步信号发生,程序的执行流转交给对应的信号处理函数;再假设在该信号处理函数中也需要调用函
数 func(),那么func()在这次执行中仍会在访问共享资源前试图获得资源锁,然而我们知道前一个func()实例已然获得该锁,因此信号处理函数阻塞;
另一方面,信号处理函数结束前被信号中断的线程是无法恢复执行的,当然也没有释放资源的机会,这样就出现了线程和信号处理函数之间的死锁局
面。 因此,func()尽管通过加锁的方式能保证线程安全,但是由于函数体对共享资源的访问,因此是非可重入。对于这种情况,采用的方法一般是在特
定的区域屏蔽一定的信号。
二、可重入函数
我们知道,当捕捉到信号时,不论进程的主控制流程当前执行到哪儿,都会先跳到信号处理函数中执行,从信号处理函数返回后再继续执行主控制流程
。信号处理函数是一个单独的控制流程,因为它和主控制流程是异步的,二者不存在调用和被调用的关系,并且使用不同的堆栈空间。
C++ Code
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库的很多实现都使用了全局数据结构
三、volatile 限定符
当变量属于以下情况之一的,需要volatile 限定(嵌入式开发居多):
变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都可能不一样;
即使多次向变量的内存单元中写数据,只写不读,也并不是在做无用功,而是有特殊意义的;
什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬件寄存器,例如串口的接收寄存器属于上述第一种情况,
而发送寄存器属于上述第二种情况。
对于多线程的程序,访问冲突的问题是很普遍的,解决的办法是引入锁,获得锁的线程可以完成“读-修改-写”的操作,然后释放锁给其它线程,没有获
得锁的线程只能等待而不能访问共享数据,这样“读-修改-写”三步操作组成一个原子操作,要么都执行,要么都不执行,不会执行到中间被打断,也不
会在其它处理器上并行做这个操作。
C 和 C++ 中的volatile 并不是用来解决多线程竞争问题的(也不能确保不发生 reordering),而是用来修饰一些因为程序不可控因素导致变化的变量,
比如访问底层硬件设备的变量,以提醒编译器不要对该变量的访问擅自进行优化。简单的来说,对访问共享数据的代码块加锁,已经足够保证数据访问
的同步性,再加volatile 完全是多此一举。如果光对共享变量使用volatile 修饰而在可能存在竞争的操作中不加锁或使用原子操作对解决多线程竞争没有
任何作用,因为volatile 并不能保证操作的原子性,在读取、写入变量的过程中仍然可能被其他线程打断导致意外结果发生。
本文对原子操作、锁以及volatile的讨论都比较基础,更深入的探讨请看这篇文章。
参考:
《linux c 编程一站式学习》