【摘要】NP项目 code review checklist在NPTL多线程分类和信号分类中提出了一些具体的检查点要求,特别对于可重入函数、线程安全、信号安全以及fork安全类型的函数具有特殊的检查要求。本文主要对以上概念进行了详细的阐述,并对在并发应用程序设计过程中容易混淆和忽略的一些问题进行了说明。为了提高读者阅读的兴趣和效率,本文还对部分问题提供了较为典型的情景代码供读者参考。
【关键词】可重入函数 线程安全 信号安全 fork安全 code review
1 引言
1.1 概述
在linux环境下,可以通过不同的方式使应用程序并发执行,产生并发执行序列,提高应用程序的运行效率。这些手段包括:
 ※多线程,本文只讨论linux 2.6以后版本提供的NPTL多线程库的实现
 ※异步信号,linux通过signal和sigaction系统调用提供了用户级的信号处理机制
 ※子进程,从应用程序设计和实现的角度,我们这里只讨论通过使用fork创建子进程实现的进程并发。

在并发应用程序设计与实现层面上存在一些典型的术语和概念,例如在一些场合中我们经常可以看到一些关键词,可重入函数、线程安全、信号安全等。在设计和实现并发执行的应用程序时,我们必须要了解这些关键词所表达的确切含义。那么这些术语的背后究竟隐藏了些什么内容呢,本文将就这一问题展开具体的论述。
1.2 文档内容
本文首先对并发应用程序常见的概念进行了介绍,并对这些概念容易引起混淆和误用的地方进行了详细说明,本文在描述的过程中针对部分内容提供了代码示例。本文所提供的代码片段全部在64位linux开发机上编译通过。
本文未对多线程同步和互斥的实现原理进行说明,关于多线程背后所隐藏的内容,请详见作者的另外一篇文章《NPTL多线程库源代码情景分析》。
2 内容
2.1 可重入函数
我们首先讨论一下可重入函数,维基百科上对可重入函数使用了以下三条规则进行限定:
1) 函数中不能使用任何非const的静态或者全局变量
2) 不能产生任何“副作用”,即不能对所处的环境产生影响。wiki上使用的用语为“Must not modify its own code.”,主要针对特定的实现技术,本文对其进行了扩展。
3) 不能调用其他的不可重入函数

所述的第一点和第三点较容易理解,第二点其实在强调上下文环境在可重入函数实现中的重要性。例如下面的示例代码:
 

并发应用程序code review要点分析_code review

可以看出,上述代码如果在多线程的环境下执行,可能会带来严重的问题。
另外,值得强调的是,代码中不能使用非const的静态变量这一条内容务必是强制性要求,不能忽视,例如一种单例模式(singleton)的实现方法如下所示:
 

并发应用程序code review要点分析_code review_02

此代码的开发人员想通过static对象的方式避免在代码中使用double-check的方式来提供函数的可重用语义功能,供在多线程的场合下使用。这种局部静态对象是一种lazy initialization的方式,其语义为当函数开始执行时完成对象的创建,为了达到C标准规定语义的要求,编译器通常提供了类似下面的实现方式(使用伪代码进行描述):
 

并发应用程序code review要点分析_休闲_03

以粉红色背景显示的伪代码提供了编译器一种可能的实现,我们可以在gcc下进行验证,将上面的代码编译为汇编代码,我们查看gcc如何进行的处理:
 

并发应用程序code review要点分析_休闲_04

从所附的汇编代码可以看出,gcc提供的实现与前述使用C++描述的伪代码的执行逻辑相同。因此,在函数中使用局部静态对象是不可重入的,code review过程中尤其需要关注。
静态对象是一个很微妙的事物,语言本身为其提供了灵活而且强大的功能,但在使用的过程中如果不注意细节也很容易出现一些问题,关于在使用静态对象时需要注意的问题笔者将另行撰文阐述。
2.2 线程安全
线程安全,顾名思义,即表明在多线程的环境下执行是安全的。如前所述,可以得出结论,可重入函数一定为线程安全函数。
为了达到线程安全的实现要求,通常使用一些同步互斥的手段对使用到的全局变量进行保护。
2.2.1 互斥锁
linux携带的glibc提供了POSIX兼容的互斥锁pthread_mutex_t,这是一种推荐采用的方式。具体实现方式本文暂不赘述。
glibc还提供了一种基于“写者优先”的读写锁的同步机制,适合在一定的应用场合下采用。
代码在使用互斥锁进行同步时,常见的问题是使用的锁的“粒度”过大,这应是一种避免的实现方式,进行code review时需要重点关注。
2.2.2 sig_ atomic _t
linux提供了sig_ atomic _t数据类型,该类型定义为int,实际上是一种weak atomic 数据类型,只能执行一些非常受限的原子操作。
sig_ atomic _t类型的变量只保证特定的操作为原子操作,实际上操作的原子性是由底层的硬件平台保障的,即基于比机器字长短的数据类型的操作一般都为原子操作。
我们可以在代码中使用test and set机制实现基于多线程的同步:
 

并发应用程序code review要点分析_应用程序_05

该种处理方法采用了busy-loop的方式,性能较差,通常情况下不建议使用。
2.3 信号安全
本文所指的“信号安全”主要包含两方面的内容:
 ※指定的函数是否允许在信号处理函数中使用
 ※指定的函数在执行过程中是否可能被信号中断,errno返回EINTR类型的错误。
2.3.1 信号安全函数
按用户使用方式进行划分,linux提供了同步信号和异步信号两种不同类型的信号,本文所提及的信号主要指的是异步信号。linux提供了signal和sigaction两个信号初始化函数,相比较而言,sigaction的可移植性更好,另外功能上也有所扩充,例如可以指定信号处理函数执行期间可屏蔽的其他信号。
信号处理函数运行一般运行在主线程即main函数所在线程的上下文当中,我们可以编写一个程序对响应信号的线程进行验证。由于篇幅受限,此程序本文暂不提供,读者有兴趣的话可以自行完成验证。
对于NPTL线程库而言,若主线程存在,发生的信号将在主线程的上下文中响应,否则,运行库将挑选一个线程作为信号处理函数的运行环境。
如前所述,信号与线程同为可并发的执行序列,但在执行方式上具有显著不同,当信号被阻塞时,并不会引起上下文的切换,也就是说不会发生线程的切换,信号安全类的函数相对于线程安全函数来说具有更严格的要求。
例如,glibc提供的malloc、printf等函数都属于线程安全函数,其内部使用互斥锁的方式对使用到的全局数据结构进行保护,因此可以在多线程的环境下使用,但所述函数不属于信号安全的范畴,如果在信号处理函数和线程中同时执行,有可能产生死锁,例如:
 

并发应用程序code review要点分析_休闲_06

因此一个常见的设计约束为在信号处理函数中不能使用任何有可能导致发生阻塞的库函数,这也是嵌入式实时操作系统用户使用手册上常见的使用限制。
我们可以通过在函数中屏蔽指定的信号来达到信号安全的目的,linux提供的sigaction系统调用可以完成这一功能,读者有兴趣的话可以参考相关的资料。
2.3.2 EINTR错误
POSIX规定,当系统调用(system call)在执行的过程中被信号中断时,应返回错误值,并将指示错误状态的全局变量errno设置为EINTR。
google维护的开源浏览器项目chrome的开发者邮件列表中对可能返回EINTR错误类型的函数进行了整理:
* read, readv, write, writev, ioctl
* open() when dealing with a fifo
* wait*
* Anything socket based (send*, recv*, connect, accept etc)
* flock and lock control with fcntl
* mq_ functions which can block
* futex
* sem_wait (and timed wait)
* pause, sigsuspend, sigtimedwait, sigwaitinfo
* poll, epoll_wait, select and 'p' versions of the same
* msgrcv, msgsnd, semop, semtimedop
* close (although, on Linux, EINTR won't happen here)
* any sleep functions (careful, you need to handle this are restart with
different arguments)
因此,对于以上函数,根据程序所完成功能的需要,开发人员应正确进行处理,例如可以采取下面的宏简化处理方式:
 

并发应用程序code review要点分析_code review_07

该宏使用了gcc的扩展关键字typeof用来获得指定函数的类型,使用时可以采用如下调用方式:
 

并发应用程序code review要点分析_code review_08

linux提供的系统调用sigaction可以改变针对特定信号中断时系统调用的行为为BSD风格的restart,即若产生信号中断事件,系统调用将被重置。
 

并发应用程序code review要点分析_应用程序_09

值得注意的是,部分与时间相关的系统调用并不在设置SA_RESTART标志位影响的范围之内,这一类系统调用包括select、connect以及nanosleep函数等。
2.4 虚假唤醒
当线程通过等待函数进行等待时,可能因为发生信号导致等待函数返回。不同发行版本的类unix平台提供的处理方式因实现不同而各不相同。在linux下,查看pthread_cond_wait函数的man手册,内容中具有明确描述:
 

并发应用程序code review要点分析_要点分析_10

可以看出,linux提供的信号等待同步函数不会返回EINTR类型的错误。
“虚假唤醒”还包括另外一个方面的内容,主要指条件变量wait和signal操作之间的不匹配,解决的方法通常是采用如下的编码风格:
 

并发应用程序code review要点分析_要点分析_11

这种编程方式主要解决了先释放信号再等待信号这种不同步可能导致应用程序陷入死锁的问题。如果只有一个signal条件变量的线程,等待代码中的while循环可以调整为if语句。
2.5 fork安全
linux提供的系统调用fork完成子进程创建的任务,创建后的子进程完全继承父进程的内存布局,但并不会继承创建子进程是父进程所处的多线程的运行环境。换一种思路理解起来更为容易,fork api为为操作系统调用,而多线程是以运行库的方式提供的,因此fork创建的子进程并不会继承父进程的线程情况,换言之不论父进程是否使用了多线程,创建的子进程都将采用单线程的执行方式。
由于系统调用fork实现方式的原因,子进程的代码与父进程的代码将重用相同的源文件,如果父进程采用了多线程的实现方式,那么子进程不应依赖于父进程所采用的多线程控制结构,否则容易出现问题,例如如下所示的代码片段:
 

并发应用程序code review要点分析_要点分析_12

从上面的代码可以看出,fork系统调用将在创建的线程之后运行,若线程执行到加锁时切换到主线程,主线程将开始执行fork创建子进程,根据前面内容的描述,fork将复制父进程的内存到子进程当中,此时已加了锁的pthread_mutex_t类型变量将被完整的复制到子进程,当子进程执行上面标记为红色的代码重新开始获取锁时,因获取不到所以将被无限期的挂起。
以上代码给出的是直接使用mutex的方式,glibc提供的很多库函数(例如printf)为了确保线程安全的特性大都在内部使用了互斥锁,对于这一类函数在fork创建的子进程中使用会出现相同的问题。
因此在执行的code review过程中,我们应认真检查所述的类似代码,特别是fork出来的子进程代码所使用到的各类函数,防止误用而导致出现各类问题。
了解了问题产生的本质,相应的解决方法也较为容易发现,本文暂且卖一个关子,留给读者来完成这个解决方案的具体实现。
3 总结
通过以上内容的描述,可以得出以下结论:
1) 可重入函数一定是线程安全函数,也一定是信号安全函数。
2) 不可重入函数可以通过在函数内增加互斥机制成为线程安全函数。
3) 满足线程安全不一定能够满足信号安全。例如:
 ※errno是线程安全的全局变量,其实现原理为通过NPTL提供的线程局部存储功能完成,当发生上下文切换时,被切换线程与切换线程的errno被保存和恢复。
  ※内部使用了同步互斥机制的函数是线程安全的,但一定不是信号安全的,如果在信号处理函数中使用可能会造成死锁
4) 信号安全的函数也不一定是线程安全函数
5) fork安全与信号安全在问题产生的机理方面具有一定的相似程度。

(作者:zhouwei)