现在的位置: 首页 > 综合 > 正文

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

2018年04月27日 ⁄ 综合 ⁄ 共 9377字 ⁄ 字号 评论关闭

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

2010-12-22 12:00 by zhenjing, 2106 阅读, 2 评论, 收藏编辑

摘自桃源谷的blog: http://www.cppblog.com/lymons

     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、按照次序来说明里面的错误。

     1int gSignaled;
     2void sig_handler(int signo) {
     3    std::printf("signal %d received!\n", signo);
     4    gSignaled = 1;
     5}
     6int 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}
    16

    错误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 theprocessing of the abstract machine is interrupted by receipt of a signal, thevalue of objects with type other than volatile sig_atomic_t are unspecified, and the value of any object not of volatile sig_atomic_tthat
    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信号!
            callsig_handler
              call printf // 信号处理函数中的printf函数
               call malloc
                 call pthread_mutex_lock(锁定malloc函数内的静态mutex) 
                 // 相同的mutex一被再度锁定,就死锁啦!!

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

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

    1. 虽然在SUSv3里有异步信号安全(async-signal-safe)函数的一览,但根据不同的操作系统,某些函数是没有被实现的。所以一定要参考操作系统的手册
    2. 第三者做成的函数,如果没有特别说明的场合,首先要假定这个函数是不可重入函数,不能随便在信号处理函数中使用。
    3. 调用不可重入函数的那些函数就会变成不可重入函数了

          最后,为了明确起见,想说明一下什么是” 异步信号安全(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 functionin the standard library other than one of the functions listed in the tableabove or refers to any object with static storage duration other
    than byassigning a value to a static storage duration variable of type volatilesig_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 longerthan int are atomic. ”这部分是不正确的。请参照Alpha的例子
    *5:The following table defines a set
    of functions that shall be eitherreentrant 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. 子进程在单线程状态下被生成 

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

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

    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 libraryroutines 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的

    抱歉!评论已关闭.