更安全的signal handling————使用Reentrant Function

什么时候用,怎么用,才能让你的代码保持bug free

Dipak Jha (dipakjha@in.ibm.com)  Software Engineer IBM 20 Janua


假如你通过多线程或者多进程并发访问函数时,你将会面临不可重入函数的烦恼。通过本文,你将了解到不可重入性将导致代码多么反常,尤其是处理signal的时候。您还将学习到五种推荐的编程实践,同时将讨论编译器前端处理可重入性的编译模型。


早期的编程实践中,因为不会有并发访问函数和中断的现象,所以不可重入性并不是程序员的一个噩耗.。许多古老的C语言实现里,函数的期望工作环境就是单线程。


然而,由于并发编程越来越常见,你需要意识到这些陷阱。本文描述了并发和并行编程下不可重入的潜在危险,尤其是信号(Signal)的产生和处理更添加了复杂性。由于signal本身的异步性,当一个signal handler调用一个不可重入的函数时,很难发觉其中的bug。


本文主要讲述如下东西:

1. 定义可重入性,并且展示POSIX下的可重入函数

2. 展示不可重入性引发的问题

3.为确保函数的重入性提供建议

4.讨论在编译层次提供可重入性。


什么是可重入性?


可重入函数即:多个任务并发访问函数时,不用担心其数据被污染。相反,不可重入函数不能被多个任务共享,除非在临界区使用semaphore或者限制不能中断。可重入函数能改在任意时刻被中断,并且resume的时候不会丢失数据。可重入函数要么使用局部变量,要么在使用全局变量时使用保护机制。

总结,一个可重入函数:

1. 在连续访问时并不持有静态数据

2. 并不返回到静态数据的指针,所有数据均是函数调用者提供的。

3.使用本地数据或者利用本地拷贝一份全局数据,从而保护全局数据

4.禁止调用不可重入函数


请不要弄混淆了不可重入性与线程安全。从编程者的角度,这是两个不同的概念。一个函数可以是可重入的,线程安全的,或者两者都是,两者都不是。不可重入函数不能被多个线程访问。再者,几乎不可能让一个不可重入的函数变得线程安全。

IEEE Std 1003.1 列出了118个可重入的Unix函数,这里就不在赘述了,请看unix.org的资源链接Resource. 剩下的函数不可重入性是因为以下几个原因:


1. 调用了malloc或者free

2.使用了静态数据结构

3.标准I/O库的一部分


信号(Signal)和不可重入函数

信号是软件中断,它让程序员能处理异步事件。发送一个信号给一个进程,内核在进程的表项的signal字段中设置标记,表明相应的signal接受到了。ANSI singal函数的原型为:

void (*signal (int signal,void (*sigHandler)(int)))(int)

或者,可以这样表示:

typedef void sigHandler(int);
sigHandler *signal(int,sigHandler *);

当进程捕获一个信号并处理的时候,进程的正常指令执行序列被signal handler暂时中断。进程继续执行指令,但此时是执行signal handler中的指令。假如signal handler return了,进程继续执行signal捕获时的正常指令序列。

考虑一下,signal被捕获到并执行signal handler时,你不知道进程执行的指令到底在哪。假如进程正在用malloc从堆中分配额外内存进行到一半时,然后你又在signal handler里调用malloc。或者你调用一些函数,而这些函数处理全局数据结构到一半的时候,然后你在signal handler又调用同样的函数。至于malloc这种类型,该进程将遭受很大的破坏,因为通常malloc为它所分配的所有空间维护一个链表,而有可能是正在改变链表的过程中被中断了。

中断甚至能在c的需要多个指令的操作符开始和结束之间发生。在编码层次,指令看上去是原子性的(也就是说不能分割成更小的操作),然而实际上可能需要多余一个指令周期完成该操作。例如,看下面的c代码:

temp += 1;
在x86的处理器中,可能会编译成如下指令:

mov ax,[temp]
inc ax
mov [temp],ax

很显然,这并不是原子性操作。

下面这个例子可以展示当signal handler在改变变量的过程中被调用会发生什么:

List 1: 在改变变量的过程中调用signal handler

  1 #include <signal.h>
  2 #include <stdio.h>
  3
  4 struct two_int { int a,b; } data;
  5
  6 void signal_handler(int signum){
  7     printf("%d, %d\n", data.a, data.b);
  8     alarm(1);
  9 }
 10
 11 int main(int argc,char *argv[])
 12 {
 13     static struct two_int zeros = {0,0}, ones = {1,1};
 14
 15     signal(SIGALRM, signal_handler);
 16
 17     data = zeros;
 18
 19     alarm(1);
 20
 21     while(1){
 22         {data = zeros; data = ones; }
 23     }
 24     return 0;
 25 }

该程序不断用zeros,ones,zeros,ones填充data,无限循环。期间,每隔一秒钟,alarm signal handler打印当前的内容(在这段程序中调用printf是安全的,因为在signal发生时,在handler外面确定没有调用printf)。你期望该程序打印出什么结果?按理说应该打印出0,0或者1,1.可实际上是这样的:


0, 1
1, 1
0, 0
1, 0
0, 0
1, 1
1, 1
1, 1
0, 0
。。。
在大部分机器上,data保存一个新的值需要几个指令。假如signal在这些指令之间发送,handler可能会发现data.a = 1 ,data.b = 0,或者相反。从另外一方面说,假如我们在保存对象数据可以一条指令搞定的机器上编译和运行该段代码,handler将总是打印0,0或者1,1.

信号另外比较复杂的一点是,仅仅跑test case,并不能保证你的代码是signal-bug free,这是因为产生信号本身的异步性导致。


不可重入函数和静态变量

假设signal handler调用了不可重入的gethostbyname,该方法将返回一个静态变量:

struct hostent host; /* result stores here */
然后每次都重用相同的对象,在接下来的例子中,假如信号在main函数调用gethostbyname方法时发生,或者甚至在程序还在使用该值的时候之后发生,该值将会被损坏(it will clobber the value that the program ask for).


List 2  gethostbyname的危险用法

main(){
 struct hostent *hostPtr;
 ...
 signal(SIGALRM, sig_handler);
 ...
 hostPtr = gethostbyname(hostNameOne);
 ...
}
void sig_handler(){
 struct hostent *hostPtr;
 ...
 /* call to gethostbyname may clobber the value stored during the call
 inside the main() */
 hostPtr = gethostbyname(hostNameTwo);
 ...
}
然而,假如没有调用gethostbyname,或者其他返回同一对象信息的函数或者每次调用时总是阻塞信号,那么你就可以安全使用了。

许多库函数总是复用同一对象,返回指定对象的值,这些库函数将会引发同样的问题。假如一个方法使用和改变你提供的对象,那么有可能是不可重入的。假如它们使用相同的对象,两个调用将会互相干预。

相似的例子发生在你使用IO流的时候。假设signal handler调用fprintf打印出一条信息,而signal刚好发生在程序你用同一个io流调用fprintf过程中。signal handler的信息和程序的数据将会被污染,因为两个调用发生在同一个对象上:流。

当你使用第三方库的时候事情会变得更复杂,因为你不知道库的哪些部分是可重入的,哪些部分不是。而至于标准库,有一些库函数总是使用指定的对象,复用相同的对象,从而导致函数的不可重入。

好消息是,这些日子一些厂商考虑提供可重入版本的标准C库。你需要读一下库附带的文档看看原型有没有变,然后再决定使用标准库的函数。


保证可重入性的编程实践

遵循下面的五种最佳实践将会帮助你维护程序的可重入性

实践1

返回一个静态区域的指针将会让函数变得不可重入。例如,strToUpper函数,将一个字符串变得大写,可以这样来实现:


List 3 不可重入版本的strToUpper

char *strToUpper(char *str)
{
    /* Returning pointer to static data makes it non-reentrant */ 
    static char buffer[STRING_SIZE_LIMIT];
    int index;

    for(index = 0; str[index]; index++)
        buffer[index] = toupper(str[index]);
    buffer[index] = '\0';
    return buffer;
}

通过改变该函数的原型,你能实现一个可重入版本,下面的list提供输出字符串的空间:


List 4 strToUpper的可重入版本

char *strToUpper_r(char *in_str, char *out_str)
{
    int index;
    
    for (index = 0; in_str[index] != '\0'; index++)
        out_str[index] = toupper(in_str[index]);
    out_str[index] = '\0';
    
    return out_str;
}

调用者提供输出空间能够保证函数的可重入性。注意:命名可重入函数的标准惯例是在函数名字后面加上"_r"


实践2

回忆一下数据的状态导致函数的不可重入。不同的线程能够相继调用函数并改变数据而不会告诉其他线程它们在使用数据。假如一个函数需要在相继调用函数过程中维持一些数据的状态,例如一个工作缓存或者指针,调用者需要提供这样的数据。

在接下来的例子中,一个函数返回一个字符串相继的小写字符,字符串只是第一次调用的时候提供,同strok子函数一样。当在字符串的末尾时,函数返回\0,该函数的实现代码:


List 5 getLowercaseChar的不可重入版本

char getLowercaseChar(char *str)
{
   static char *buffer;
   static int index;
   char c = '\0';
   /* stores the working string on first call only */
   if (string != NULL){
      buffer = str;
      index = 0;
   }

   /* searches a lowercase character */
   while (c = buffer[index]){
      if (islower(c)){
         index++;
         break;
      }
      index++;
   }
   return c;
}
该函数不是可重入性的,因为它保存变量的状态。为了让它变得可重入,静态数据,index变量,应该由调用者维护。可重入版本的实现如下:


char getLowercaseChar_r(char *str, int *pIndex)
{
   char c = '\0';
   
   /* no initialization - the caller should have done it */
   
   /* searches a lowercase character */
   while (c = buffer[*pIndex]){
      if (islower(c)){
         (*pIndex)++;
         break;
      }
      (*pIndex)++;
   }
   return c;
}

实践3

在大部分系统中,malloc和free并不是可重入的,因为它们使用静态数据结构记录哪些内存块是空闲的。因此,分配和释放内存的库函数都不是可重入的。这包括分配空间保存结果的函数。

在signal handler里面避免分配内存最好的方式是使用提前分配好的空间,避免释放内存最好的方式是标记或者记录被释放的对象,然后程序不时检查一下是否有需要释放的对象。但是这个处理过程必须要小心,因为将一个对象放在链表上并不是原子性的,假如被另外一个处理相同事情的signal handler中断了,那么你可能会“失去”这样一个对象(译注:这里应该是记录对象)。然而,假如你确定当信号发生的时候,程序不可能使用signal handler使用的流,那么你就可以安全使用。假如程序使用其他一些流,也会没有问题。


实践4


要写出bug-free的代码,在使用进程级别的全局变量,比如errno, h_errno.时,要格外小心,考虑以下代码:


if (close(fd) < 0){
   fprintf(stderr, "Error in close, errno: %d", errno);
   exit(1);
}

假设signal在设置close系统调用的errno变量和使用它的返回值这么短的时间内发生了,产生的signal可能会改变errno的值,然后程序的执行就跟期望得不一样了。

在signal handler中保存和回复errno的值可以解决这样的问题

void signalHandler(int signo){
 int errno_saved;
 /* Save the error no. */
 errno_saved = errno;
 /* Let the signal handler complete its job */
 ...
 ...
 /* Restore the errno*/
 errno = errno_saved;
}

实践5


假如函数正在临界区执行中,产生了signal并处理了它,那么这可能会让函数变得不可重入。通过使用signal sets和signal mask,能够保护临界区的代码免受指定信号的干扰。实现如下:

1. 保存当前的signal set

2. 过滤不期望发生的signal

3. 完成临界区的代码

4.最后,重置signal set.

下面是该实践的大体框架:


sigset_t newmask, oldmask, zeromask;
...
/* Register the signal handler */
signal(SIGALRM, sig_handler);
/* Initialize the signal sets */
sigemtyset(&newmask); sigemtyset(&zeromask);
/* Add the signal to the set */
sigaddset(&newmask, SIGALRM);
/* Block SIGALRM and save current signal mask in set variable 'oldmask'
*/
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
/* The protected code goes here
...
...
*/
/* Now allow all signals and pause */
sigsuspend(&zeromask);
/* Resume to the original signal mask */
sigprocmask(SIG_SETMASK, &oldmask, NULL);
/* Continue with other parts of the code */

跳过sigsuspend(&zeromask)会有问题,因为在不阻塞信号和进程的下一条指令执行之间,肯定有时钟周期间隔。在这之间发生的信号将会丢掉。sigsuspend通过一个原子性操作——重置signal mask和让进程休眠可以解决该问题。假如你确定在这期间发生的信号没有副作用,那么你可以跳过sigsuspend函数,然后直接重置signal。


在编译层次处理可重入性


我比较提倡在编译层次提供可重入性。高级语言可以提供一个reentrant 新的关键字,然后标记为reentrant的函数将保证可重入性。就像这样:

reentrant int foo();

这样就可以提示编译器为该函数提供特殊的处理,编译器也可以将该标记保存在符号表中然后在中间代码产生阶段使用它。要实现这样的目标,编译器前端的设计需要做一些改变。重入标记有一下一些指导方针:

1、在相继的调用中不持有静态数据。

2.、本地保存一份全局数据的拷贝从而保护全局数据

3、禁止调用不可重入函数

4、不返回静态数据的引用,所有的数据都是调用者提供


方针1可以通过类型检查确保,假如在函数中有静态声明就抛出一个错误信息。这可以通过编译阶段的语义分析完成。

方针2的保护全局数据,可以通过两种方式确保。最简单的方式是,假如改变全局全局数据就抛出一个错误。一种更复杂的方式是在中间代码的生成阶段,确保不会破坏全局数据。与上述实践4相似的方式,可以通过编译阶段实现。一进入该方法后,编译器使用编译产生的临时名字保存将会更改的全局数据,然后在离开函数之后恢复它。用编译产生的临时名字保存数据是编译器常用的方法。

确保指导方针3的执行,需要调用者指导所有的可重入版本的函数,包括应用程序使用的库。这些函数的额外信息可以保存在符号表中。

最后,方针4可以通过方针2确定,假如函数没有静态数据,很显然就不会返回静态数据的引用。


这种推荐的模型可以让开发者在遵循这些指导方针的前提下让工作变得更容易,而且通过这种模型,可以保护因为无心之失而产生的可重入性bug(and by using this model, code would be protected against the unintentional
reentrancy bug)。








  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值