UNIX上的C++程序设计守则( 信号和线程 上)

原文:http://d.hatena.ne.jp/yupo5656/20040712/p1

翻译:http://www.cppblog.com/lymons/archive/2008/06/01/51838.html


Unix 跟Windows 等那些” 对于开发者易于使用” 的OS 比起来,在信号和线程的利用方面有诸多的限制 。但是即使不知道这些知识就做构架设计和实现的情况也随处可见。这个就是那些经常不能再现的bug 的温床吧。

因此,我想分成几回来写一些准则来防止陷入到这些圈套里。

准则 1 :不依赖于信号收发的设计

· 给其他进程以及自己发送异步信号并改变处理流程的设计不要做

  •   异步信号是指用kill 系统调用来创建和发送的信号、例如SIGUSR1,SIGUSR2,SIGINT,SIGTERM 等
  • 简单的使用忽略信号(SIG_IGN) 则没有问题

· 不要把线程和信号一起使用 

  • 这将使程序动作的预测和调试变得很困难

 

说明:

同步信号是指,因为某些特定的操作*1 而 引起向自身进程发送某些特定的信号,例如SIGSEGV,SIGBUS,SIGPIPE,SIGSYS,SIGILL,SIGFPE 。异步信号就是这些以外的信号。在什么时机发送异步信号并不能被预测出来。我们会在程序里追加收到某些信号时做一些特殊处理( 信号处理函数) 的函数。那么根据收到的信号就跳到信号处理函数的程序就叫做” 在任意代码处都能发生跳转” 的程序。这样的程序往往隐藏这下面的那些问题:

  1. 容易引入BUG 。” 任意的代码” 虽然也包含” 执行C/C++ 里面的一条语句的过程中” 的意思,但这很容易跳出程序员的正常思维以及默认的假定条件。编写程序的时候往往需要考虑比C++ 异常分支还要多得多的分支情况。
  2. 使测试项目激增 。即使根据白盒测试达成100% 的分支覆盖, 也不能网罗到因为接受信号而发生的跳转分支处理。也就是说做到100% 的网罗信号跳转分支的测试是不能全部实现的。一般的,加上要考虑” 在实行某个特定代码时因为接受到信号而发生的误操作” 这样的BUG会经常发生*2 的这种情况,测试困难往往就是导致软件的品质低下的诱因。

 

根据经验,” 当检查到子进程结束( 接收到SIGCHLD 信号) 时,要做必要的处理” 像这样的信号处理不管做什么都是有必要的情况会有,但是除此以外的信号处理,例如

  • 把自己的状态用信号告诉其他进程
  • 主线程在输入输出函数里发送信号给被阻塞的子线程,并解除阻塞

等,是应该事先好好考虑过后再去做实际的实现。前者的话,如果不强制在” 普通的” 进程间进行通信的话可能会很好,后者是特意要使用线程,也要应该按照即使阻塞了也不能发生问题那样再设计。

不管怎么样,如果必须要使用信号的话,也要先全部*3 理解这些陷阱以及,和多线程软件设计的场合一样或者说比它更严格的制约. 注意事项都需要铭记在心里。

*1 :例如,引用空指针

*2 :参照 id:yupo5656:20040703 的sigsafe 说明

*3 :暂时先掌握” 准则2” :-)

 

准则 2: 要知道信号处理函数中可以做那些处理

· 在用sigaction 函数登记的信号处理函数中可以做的处理是被严格限定的

· 仅仅允许做下面的三种处理

  •  
    1. 局部变量的相关处理
    2. “volatile sig_atomic_t” 类型的全局变量的相关操作
    3. 调用异步信号安全的相关函数( 可重入函数)

· 以外的其他处理不要做!

说明:

因为在收到信号时要做一些处理,那通常是准备一个信号处理函数并用sigaction 函数把它和信号名进行关联的话就OK 了。但是,在这个信号处理函数里可以做的处理是像上面那样被严格限定的。没有很好掌握这些知识就随便写一些代码的话就会引起下面那样的问题:

·  问题1: 有程序死锁的危险 
   o  这是那些依赖于某一时刻,而且错误再现比较困难的BUG 产生的真正原因 
   o  死锁是一个比较典型的例子,除此之外还能引起函数返回值不正确,以及在某一函数内执行时突然收到SEGV 信号等的误操作。
   ◆ 译者注1 :SEGV 通常发生在进程试图访问无效内存区域时(可能是个NULL 指针,或超出进程空间之外的内存地址)。当bug 原因和SEGV 影响在不同时间呈现时,它们特别难于捕获到。

·  问题2: 由于编译器无意识的优化操作,有导致程序紊乱的危险 
   o  这是跟编译器以及编译器优化级别有关系的bug 。它也是“ 编译器做了优化处理而不能正常动作” ,“ 因为inline 化了程序不能动作了” ,“ 变换了OS 了程序也不能动作” 等这些解析困难bug 产生的原因。

还是一边看具体的代码一边解说吧。在下面的代码里至少有三个问题,根据环境的不同很可能引起不正确的动作 *1 、按照次序来说明里面的错误。

1 int gSignaled;

  2 void sig_handler(int signo) {

  3     std::printf("signal %d received!/n", signo);

  4     gSignaled = 1;

  5 }

  6 int main(void) {

  7     struct sigaction sa;

  8   // ( 省略)

  9   sigaction(SIGINT, &sa, 0);

10     gSignaled = 0;

11     while(!gSignaled) {

12   //std::printf("waiting /n");

13      struct timespec t = { 1, 0 }; nanosleep(&t, 0);

14     }

15 }

 

错误1: 竞争条件 
    在上面的代码里有竞争条件。在sigaction 函数被调用后、在gSignaled 还未被赋值成0 值之前,如果接受到SIGINT 信号了那会变得怎么样呢? 在信号处理函数中被覆写成1 后的gSignaled会在信号处理函数返回后被初始化成0 、在后面的while 循环里可能会变成死循环。

错误2: 全局变量gSignaled 声明的类型不正确 
     在信号处理函数里使用的全局变数gSignaled 的类型没有声明成volatile sig_atomic_t 。这样的话、在执行while 循环里的代码的时候接收到了了SIGINT 信号时、有可能引起while 的死循环。那为什么能引起这样的情况呢:
    ·  信号处理函数里,把内存上gSignaled 的值变更成1 ,它的汇编代码如下:

          movl    $1, gSignaled

    ·  但是,就像下面的代码描述的那样,main 函数是把gSignaled 的值存放到了寄存器里。在while 循环之前,仅仅是做了一次拷贝变量gSignaled 内存上的值到寄存器里、而在while 循环里只是参照这个寄存器里的值。

          movl     gSignaled, %ebx
       .L8:
                       testl    %ebx, %ebx
                       jne      .L8

     在不执行优化的情况下编译后编译器有可能不会生成上面那样的伪代码。但Gcc 当使用-O2选项做优化编译时,生成的实际那样的汇编代码产生的危害并不仅仅是像上面说的威胁那样简单。这方面的问题,是设备驱动的开发者所要知道的常识,但现实情况是对于应用程序的设计者. 开发者几乎都不知道这些知识。
为了解决上面的问题,全局变量gSignaled 的类型要像下面那样声明。

     volatile sig_atomic_t gSignaled;

     volatile 则是提示编译器不要像上面那样做优化处理,变成每次循环都要参照该变量内存里的值那样进行编译。所以在信号处理函数里把该变量的值修改后也能真实反映到main 函数的while循环里。

sig_atomic_t 是根据CPU 类型使用typedef 来适当定义的整数值 ,例如x86 平台是int 类型。就是指” 用一条机器指令来更新内存里的最大 数据 *2 “ 。在信号处理函数里要被引用的变量必须要定义成sig_atomic_t 类型。 那么不是sig_atomic_t 类型的变量( 比如x86 平台上的64位整数) 、就得使用两条机器指令来完成更新动作。如果在执行一条机器指令的时候突然收到一个信号而程序执行被中断,而且在信号处理函数中一引用这个变量的话,就只能看到这个变量的部分的值。另外,由于字节对齐的问题不能由一条机器指令来完成的情况也会存在。把该变量的类型变成sig_atomic_t 的话,这个变量被更新时就只需要一条机器指令就可以完成了。所以在信号处理函数里即使使用了该变量也不会出现任何问题。

     2006/1/16 补充: 有一点东西忘记写了。关于sig_atomic_t 详细的东西,请参考C99 规范的§7.14.1.1/5 小节。在信号处理函数里对volatile sig_atomic_t 以外的变量进行修改,其结果都是"unspecified" 的( 参照译者注2) 。另外, sig_atomic_t 类型的变量的取值范围是在SIG_ATOMIC_MIN/MAX 之间 ( 参见§7.18.3/2) 。有无符号是跟具体的实现有关。考虑到移植性取值在0 ~127 之间是比较合适的。C99 也支持这个取值范围。C++ 规范(14882:2003) 里也有同样的描述、确切的位置是§1.9/9 这里。在SUSv3 的相关描述请参考sigaction 这里*3 。此外、虽然在GCC 的参考手册里也说了 把指针类型更新成原子操作,但在标准C/C++ 却没有记载 *4 
◆ 译者注2 : 
           When the processing of the abstract machine is interrupted by receipt of a signal, the value of objects with type other than volatile sig_atomic_t are unspecified , and the value of any object not of volatilesig_atomic_t that is modified by the handler becomes undefined. 
                       ------ ISO/IEC FDIS 14882:1998(E) 的 1.9 小节

 

错误3: 在信号处理函数里调用了不可重入的函数

上述的样例代码中调用了printf 函数,但是这个函数是一个不可重入函数,所以在信号处理函数里调用的话可能会引起问题。具体的是,在信号处理函数里调用printf 函数的瞬间,引起程序死锁的可能性还是有的。但是,这个问题跟具体的时机有关系,所以再现起来很困难,也就成了一个很难解决的bug 了。

下面讲一下bug 发生的过程。首先、讲解一下printf 函数的内部实现。
    · printf 函数内部调用malloc 函数 
    · malloc 函数会在内部维护一个静态区域来保存mutex 锁、是为了在多线程调用malloc 函数的时候起到互斥的作用 
    ·  总之、malloc 函数里有“mutex 锁定,分配内存,mutex 解锁” 这样“ 连续的不能被中断” 的处理

 

main 関数 :
  call printf  // while 循环中的 printf 函数 
    call malloc
      call pthread_mutex_lock( 锁定 malloc 函数内的静态 mutex)
      // 在 malloc 处理时 ..
☆ 收到 SIGINT 信号! 
        call sig_handler
          call printf // 信号处理函数中的 printf 函数 
            call malloc
              call pthread_mutex_lock( 锁定 malloc 函数内的静态 mutex) 
              // 相同的 mutex 一被再度锁定,就死锁啦 !!

 

知道上面的流程的话、像这样的由于信号中断引起的死锁就能被理解了吧。为了修正这个bug,在信号处理函数里就必须调用可重入函数。可重入函数的一览表在UNIX 规范 (SUSv3) 有详细记载 *5 。你一定会惊讶于这个表里的函数少吧。

另外,一定不要忘记以下的几点:

·  虽然在SUSv3 里有异步信号安全(async-signal-safe) 函数的一览,但根据不同的操作系统,某些函数是没有被实现的。所以一定要参考操作系统的手册

·  第三者做成的函数,如果没有特别说明的场合,首先要假定这个函数是不可重入函数,不能随便在信号处理函数中使用。

·  调用不可重入函数的那些函数就会变成不可重入函数了

 

最后,为了明确起见,想说明一下什么是” 异步信号安全(async-signal-safe)” 函数。异步信号安全函数是指” 在该函数内部即使因为信号而正在被中断,在其他的地方该函数再被调用了也没有任何问题” 。如果函数中存在更新静态区域里的数据的情况( 例如,malloc) ,一般情况下都是不全的异步信号函数。但是,即使使用静态数据,如果在这里这个数据时候把信号屏蔽了的话,它就会变成异步信号安全函数了。

◆ 译者注3 : 不可重入函数就不是异步信号安全函数

*1 : sigaction 函数被调用前,一接收到 SIGINT 信号就终止程序,暂且除外吧 
*2 : “ 最大 ” 是不完全正确的。例如, Alpha 平台上 32/64bit 的变量用一条命令也能被更新,但是好像把 8/16bit 的数据更新编程了多条命令了。http://lists.sourceforge.jp/mailman/archives/anthy-dev/2005-September/002336.html 请参考这个 URL 地址。 
*3 : If the signal occurs other than as the result of calling abort(), kill(), or raise(), the behavior is undefined if the signal handler calls any function in the standard library other than one of the functions listed in the table above or refers to any object with static storage duration other than by assigning a value to a static storage duration variable of type volatile sig_atomic_t. Furthermore, if such a call fails, the value of errno is unspecified.
*4 :在这个手册里 “ In practice, you can assume that int and other integer types no longer than int are atomic. ” 这部分是不正确的。请参照 Alpha 的例子 
*5 : The following table defines a set of functions that shall be either reentrant or non-interruptible by signals and shall be async-signal-safe. 后面有异步信号安全函数一览

 

准则 3 :多线程程序里不准使用 fork

在多线程程序里, 在”自身以外的线程存在的状态”下一使用fork 的话, 就可能引起各种各样的问题. 比较典型的例子就是,fork 出来的子进程可能会死锁. 请不要, 在不能把握问题的原委的情况下就在多线程程序里fork 子进程.

能引起什么问题呢?

那看看实例吧. 一执行下面的代码, 在子进程的执行开始处调用doit() 时, 发生死锁的机率会很高.

  1. void* doit(void*) {
  2.  
  3.    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  4.  
  5.    pthread_mutex_lock(&mutex);
  6.  
  7.    struct timespec ts = {10, 0}; nanosleep(&ts, 0); 
  8.                                                     //  睡10 秒
  9.  
  10.    pthread_mutex_unlock(&mutex);
  11.  
  12.    return 0;
  13.  
  14. }
  15.  
  16. int main(void) {
  17.  
  18. pthread_t t;  
  19.  
  20. pthread_create(&t, 0, doit, 0); 
  21.  
  22.   if (fork() == 0) {
  23.  
  24.  
  25.        // 子进程
  26.  
  27.       // 在子进程被创建的瞬间, 父的子进程在执行nanosleep 的场合比较多
  28.  
  29.        doit(0); return 0;
  30.  
  31.    }
  32.  
  33. pthread_join(t, 0); 
  34.  
  35. }
  36.  

以下是说明死锁的理由.

一般的,fork 做如下事情

  1. 父进程的内存数据会原封不动的拷贝到子进程 中
  2. 子进程在单线程状态下被生成 

在内存区域里, 静态变量*2 mutex 的内存会被拷贝到子进程里. 而且, 父进程里即使存在多个线程, 但它们也不会被继承到子进程里. fork 的这两个特征就是造成死锁的原因.

译者注: 死锁原因的详细解释 ---

  1.  
    1. 线程里的doit() 先执行.
    2. doit 执行的时候会给互斥体变量mutex 加锁.
    3. mutex 变量的内容会原样拷贝到fork 出来的子进程中( 在此之前,mutex 变量的内容已经被线程改写成锁定状态).
    4. 子进程再次调用doit 的时候, 在锁定互斥体mutex 的时候会发现它已经被加锁, 所以就一直等待, 直到拥有该互斥体的进程释放它( 实际上没有人拥有这个mutex 锁).
    5. 线程的doit 执行完成之前会把自己的mutex 释放, 但这是的mutex 和子进程里的mutex已经是两份内存. 所以即使释放了mutex 锁也不会对子进程里的mutex 造成什么影响.

 

例如, 请试着考虑下面那样的执行流程, 就明白为什么在上面多线程程序里不经意地使用fork就造成死锁了*3 .

 

1.     在fork 前的父进程中, 启动了线程1 和2

2.     线程1 调用doit 函数

3.     doit 函数锁定自己的mutex

4.     线程1 执行nanosleep 函数睡10 秒

5.     在这儿程序处理切换到线程2

6.     线程2 调用fork 函数

7.     生成子进程

8.     这时, 子进程的doit 函数用的mutex 处于”锁定状态”, 而且, 解除锁定的线程在子进程里不存在

9.     子进程的处理开始

10. 子进程调用doit 函数

11. 子进程再次锁定已经是被锁定状态的mutex, 然后就造成死锁

 

像这里的doit 函数那样的, 在多线程里因为fork 而引起问题的函数, 我们把它叫做”fork-unsafe 函数”. 反之, 不能引起问题的函数叫做”fork-safe 函数”. 虽然在一些商用的UNIX里, 源于OS 提供的函数( 系统调用), 在文档里有fork-safety 的记载, 但是在Linux(glibc) 里当然! 不会被记载. 即使在POSIX 里也没有特别的规定, 所以那些函数是fork-safe 的, 几乎不能判别. 不明白的话, 作为unsafe 考虑的话会比较好一点吧. (2004/9/12 追记)Wolfram Gloger 说过, 调用异步信号安全函数是规格标准, 所以试着调查了一下, 在pthread_atforkの 这个地方里有” In the meantime*5 , only a short list of async-signal-safe library routines are promised to be available. ”这样的话. 好像就是这样.

随便说一下,malloc 函数就是一个维持自身固有mutex 的典型例子, 通常情况下它是fork-unsafe 的. 依赖于malloc 函数的函数有很多, 例如printf 函数等, 也是变成fork-unsafe 的.

 

直到目前为止, 已经写上了thread+fork 是危险的, 但是有一个特例需要告诉大家. ”fork 后马上调用exec 的场合, 是作为一个特列不会产生问题的”. 什么原因呢..? exec 函数*6 一被调用, 进程的”内存数据”就被临时重置成非常漂亮的状态. 因此, 即使在多线程状态的进程里,fork 后不马上调用一切危险的函数, 只是调用exec 函数的话, 子进程将不会产生任何的误动作. 但是, 请注意这里使用的”马上”这个词. 即使exec 前仅仅只是调用一回printf( “I’m child process ”), 也会有死锁的危险.

译者注:exec 函数里指明的命令一被执行, 改命令的内存映像就会覆盖父进程的内存空间. 所以, 父进程里的任何数据将不复存在.

 

如何规避灾难呢?

为了在多线程的程序中安全的使用fork, 而规避死锁问题的方法有吗? 试着考虑几个.

 

规避方法1: 做fork 的时候, 在它之前让其他的线程完全终止.

在fork 之前, 让其他的线程完全终止的话, 则不会引起问题. 但这仅仅是可能的情况. 还有,因为一些原因而其他线程不能结束就执行了fork 的时候, 就会是产生出一些解析困难的不具合的问题.

 

规避方法2:fork 后在子进程中马上调用exec 函数 
(2004/9/11 追记一些忘了写的东西)

不用使用规避方法1 的时候, 在fork 后不调用任何函数(printf 等) 就马上调用execl等,exec 系列的函数. 如果在程序里不使用”没有exec 就fork ”的话, 这应该就是实际的规避方法吧.

译者注: 笔者的意思可能是把原本子进程应该做的事情写成一个单独的程序, 编译成可执行程序后由exec 函数来调用.

 

规避方法3: ”其他线程”中, 不做fork-unsafe 的处理

除了调用fork 的线程, 其他的所有线程不要做fork-unsafe 的处理. 为了提高数值计算的速度而使用线程的场合*7 , 这可能是fork-safe 的处理, 但是在一般的应用程序里则不是这样的. 即使仅仅是把握了那些函数是fork-safe 的, 做起来还不是很容易的. fork-safe 函数, 必须是异步信号安全函数 ( 可重入函数), 而他们都是能数的过来的. 因此,malloc/new,printf 这些函数是不能使用的.

 

规避方法 4: 使用 pthread_atfork 函数 , 在 即将fork 之前调用事先准备的回调函数.

使用pthread_atfork 函数, 在即将fork 之前调用事先准备的回调函数, 在这个回调函数内,协商清除进程的内存数据. 但是关于OS 提供的函数( 例:malloc), 在回调函数里没有清除它的方法. 因为malloc 里使用的数据结构在外部是看不见的. 因此,pthread_atfork 函数几乎是没有什么实用价值的.

 

规避方法5: 在多线程程序里, 不使用fork

就是不使用fork 的方法. 即用pthread_create 来代替fork. 这跟规避策2 一样都是比较实际的方法, 值得推荐.

 

*1 :生成子进程的系统调用 
*2 :全局变量和函数内的静态变量 
*3 :如果使用Linux 的话, 查看pthread_atfork 函数的man 手册比较好. 关于这些流程都有一些解释. 
*4 : Solaris 和HP-UX 等 
*5 :从 fork 后到 exec 执行的这段时间 
*6 : ≒execve 系统调用

*7 : 仅仅做四则演算的话就是fork-safe 的



  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值