线程注意事项

时间:2022-12-18 21:37:36

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


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

 
还是一边看具体的代码一边解说吧。在下面的代码里至少有三个问题,根据环境的不同很可能引起不正确的动作*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线程注意事项}

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 的值存放到了寄存器里 w hile 循环之前,仅仅是做了一次拷贝变量 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 volatilesig_atomic_t are unspecified, and the value of any object not of volatile sig_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地址。
*3If 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

 

何が起きるか
能引起什么问题呢?

 

実例から見てみましょう。次のコードを実行すると、子プロセスは実行開始直後のdoit() 呼び出し時、高い確率でデッドロックします。
那看看实例吧.一执行下面的代码,在子进程的执行开始处调用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); // 10秒寝る
 8线程注意事项                                                     // 睡10秒
 9线程注意事项
10线程注意事项    pthread_mutex_unlock(&mutex);
11线程注意事项
12线程注意事项    return 0;
13线程注意事项
14线程注意事项}

15线程注意事项
16线程注意事项 
17线程注意事项
18线程注意事项int main(void{
19线程注意事项
20线程注意事项pthread_t t;  
21线程注意事项
22线程注意事项pthread_create(&t, 0, doit, 0); // サブスレッド作成・起動
23线程注意事项
24线程注意事项                                // 做成并启动子线程
25线程注意事项
26线程注意事项    if (fork() == 0) {
27线程注意事项
28线程注意事项        // 子プロセス。
29线程注意事项
30线程注意事项        // 子プロセスが生成される瞬間、親のサブスレッドはnanosleep中の場合が多い。
31线程注意事项
32线程注意事项        //子进程
33线程注意事项
34线程注意事项        //在子进程被创建的瞬间,父的子进程在执行nanosleep的场合比较多
35线程注意事项
36线程注意事项        doit(0); return 0;
37线程注意事项
38线程注意事项    }

39线程注意事项
40线程注意事项pthread_join(t, 0); // サブスレッド完了待ち
41线程注意事项
42线程注意事项                    // 等待子线程结束
43线程注意事项
44线程注意事项}

45线程注意事项

 

以下にデッドロックの理由を説明いたします。
以下是说明死锁的理由.

 

一般に、forkを行うと
一般的,fork做如下事情

  1. 親プロセスの「データ領域」は子プロセスにそのままコピー
  2. 子プロセスは、シングルスレッド状態で生成
  1. 父进程的内存数据会原封不动的拷贝到子进程中
  2. 子进程在单线程状态下被生成

されます。データ領域には、静的記憶域を持つ変数*2が格納されていますが、それらは子プロセスにコピーされます。また、親プロセスにスレッドが複数存在していても、子プロセスにそれらは継承されません。forkに関する上記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
例如,请试着考虑下面那样的执行流程,就明白为什么在上面多线程程序里不经意地使用fork就造成死锁了*3.

1.    fork前の親プロセスでは、スレッド12が動いている

2.    スレッド1doit関数を呼ぶ

3.    doit関数が自身のmutexをロックする

4.    スレッド1nanosleepを実行し、寝る

5.    ここで処理がスレッド2に切り替わる

6.    スレッド2fork関数を呼ぶ

7.    子プロセスが生成される。

8.    この時、子プロセスのdoit関数用mutexは「ロック状態」である。また、ロック状態を解除するスレッドは子プロセス中には存在しない!

9.    子プロセスが処理を開始する。

10.        子プロセスがdoit関数を呼ぶ

11.        子プロセスがロック済みのmutexを再ロックしてしまい、デッドロックする

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*4では、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." とありました。そういうことのようです。 
像这里的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となります。
随便说一下,malloc函数就是一个维持自身固有mutex的典型例子,通常情况下它是fork-unsafe的.依赖于malloc函数的函数有很多,例如printf函数等,也是变成fork-unsafe的.

いままでthread+forkは危険と書いてきましたが、一つだけ特例があります。「fork直後にすぐexecする場合は、特例として問題がない」のです。何故でしょう..exec系関数*6が 呼ばれると、プロセスの「データ領域」は一旦綺麗な状態にリセットされます。したがって、マルチスレッド状態のプロセスであっても、fork後にすぐ、危 険な関数を一切呼ばずにexec関数を呼べば、子プロセスが誤動作することはないのです。ただし、「すぐ」と書いてあることに注意してください。exec前に printf(“I’m child process”); を一発呼ぶだけでもデッドロックの危険があります!
直到目前为止,已经写上了thread+fork是危险的,但是有一个特例需要告诉大家.”fork后马上调用exec的场合,是作为一个特列不会产生问题的”. 什么原因呢..? exec函数*6一被调用,进程的”内存数据”就被临时重置成非常漂亮的状态.因此,即使在多线程状态的进程里,fork后不马上调用一切危险的函数,只是调用exec函数的话,子进程将不会产生任何的误动作.但是,请注意这里使用的”马上”这个词.即使exec前仅仅只是调用一回printf(“I’m child process”),也会有死锁的危险.
译者注:exec函数里指明的命令一被执行,改命令的内存映像就会覆盖父进程的内存空间.所以,父进程里的任何数据将不复存在.

 

災いをどう回避するか
如何规避灾难呢?

 

マルチスレッドのプログラムでのforkを安全に行うための、デッドロック問題回避の方法はあるでしょうか?いくつか考えてみます。
为了在多线程的程序中安全的使用fork,而规避死锁问题的方法有吗?试着考虑几个.

 

回避方法1: forkを行う場合は、それに先立って他スレッドを全て終了させる
规避方法1:做fork的时候,在它之前让其他的线程完全终止.

 

forkに先立って他スレッドを全て終了させておけば、問題はおきません。ただ、それが可能なケースばかりではないでしょう。また、何らかの要因で他スレッドの終了が行われないままforkしてしまった場合、解析困難な不具合して問題が表面化してしまいます。
在fork之前,让其他的线程完全终止的话,则不会引起问题.但这仅仅是可能的情况.还有,因为一些原因而其他线程不能结束就执行了fork的时候,就会是产生出一些解析困难的不具合的问题.

 

回避方法2: fork直後に子プロセスがexecを呼ぶようにする 
规避方法2:fork后在子进程中马上调用exec函数

(2004/9/11 書き忘れていたので追記)
(2004/9/11 追记一些忘了写的东西)

 

回 避方法1が取れない場合は、子プロセスはfork直後に、どんな関数(printfなどを含む)も呼ばずにすぐにexeclなど、execファミリーの関 数を呼ぶようにします。もし、"execしないfork"を一切使わないプログラムであれば、現実的な回避方法でしょう。
不用使用规避方法1的时候,在fork后不调用任何函数(printf等)就马上调用execl等,exec系列的函数.如果在程序里不使用”没有exec就fork”的话,这应该就是实际的规避方法吧.
译者注:笔者的意思可能是把原本子进程应该做的事情写成一个单独的程序,编译成可执行程序后由exec函数来调用.

 

回避方法3: 「他スレッド」ではfork-unsafeな処理を一切行わない
规避方法3:”其他线程”中,不做fork-unsafe的处理

 

forkを呼ぶスレッドを除く全てのスレッドが、fork-unsafeな処理を一切行わない方法です。数値計算の速度向上目的でスレッドを使用している場合*7などは、なんとか可能かもしれませんが、一般のアプリケーションでは現実的ではありません。どの関数がfork-safeなのか把握することだけでも容易ではないからです。fork-safeな関数、要するに非同期シグナルセーフな関数ですが、それは数えるほどしかないからです。この方法では malloc/new, printf すら使えなくなってしまいます。
除了调用fork的线程,其他的所有线程不要做fork-unsafe的处理.为了提高数值计算的速度而使用线程的场合*7,这可能是fork-safe的处理,但是在一般的应用程序里则不是这样的.即使仅仅是把握了那些函数是fork-safe的,做起来还不是很容易的.fork-safe函数,必须是异步信号安全函数,而他们都是能数的过来的.因此,malloc/new,printf这些函数是不能使用的.

 

回避方法4: pthread_atfork関数を用いて、fork前後に自分で用意したコールバック関数を呼んでもらう
规避方法4:使用pthread_atfork函数,在即将fork之前调用事先准备的回调函数.

 

pthread_atfork 関数を用いて、fork前後に自分で用意したコールバック関数を呼んでもらい、コールバック内で、プロセスのデータ領域を掃除する方法です。しかし、OS 提供の関数(: malloc)については、コールバック関数から掃除する方法がありません。mallocの使用するデータ構造は外部からは見えないからです。よって、 pthread_atfork関数はあまり実用的ではありません。
使用pthread_atfork函数,在即将fork之前调用事先准备的回调函数,在这个回调函数内,协商清除进程的内存数据.但是关于OS提供的函数(例:malloc),在回调函数里没有清除它的方法.因为malloc里使用的数据结构在外部是看不见的.因此,pthread_atfork函数几乎是没有什么实用价值的.

 

回避方法5マルチスレッドのプログラムでは、forkを一切使用しない
规避方法5:在多线程程序里,不使用fork

 

forkを一切使用しない方法です。forkするのではなく、素直にpthread_createするようにします。これも、回避策2と同様に現実的な方法であり、推奨できます。
就是不使用fork的方法.即用pthread_create来代替fork.这跟规避策2一样都是比较实际的方法,值得推荐.

 

*1:子プロセスを生成するシステムコール
*1:生成子进程的系统调用

*2:グローバル変数や関数内のstatic変数
*2:全局变量和函数内的静态变量

*3Linuxを使用するのであれば、pthread_atfork関数のman pageを見るとよいです。この種のシナリオについて若干の解説があります
*3:如果使用Linux的话,查看pthread_atfork函数的man手册比较好.关于这些流程都有一些解释.

*4SolarisHP-UXなど
*4:Solaris和HP-UX等

*5forkexecするまでの間
*5:从fork后到exec执行的这段时间

*6≒execveシステムコール
*6:≒execve系统调用

*7:四則演算しか行わないならfork-safe
*7:仅仅做四则演算的话就是fork-safe的



铁则4: 请不要做线程的异步撤消的设计

  • 线程的异步撤销是指: 某个线程的执行立刻被其他线程给强制终止了
  • 请不要单单为了让“设计更简单”或者“看起了更简单”而使用线程的异步撤消

咋一看还是挺简单的。但是搞不好可能会引起各种各样的问题。请不要在不能把握问题的实质就做出使用线程的异步撤消的设计!

在pthread的规格说明中,允许一个线程可以强制中断某个线程的执行。这就是所说的异步撤消。


线程的撤消有下面的两种方式。

  • 方式1: 异步撤消(PTHREAD_CANCEL_ASYNCHRONOUS)
    • 撤销动作是马上进行的
  • 方式2: 延迟撤销(PTHREAD_CANCEL_DEFERRED)
    • 撤消动作,是让线程的处理一直被延迟到撤消点才会去执行

还有,到底是用哪种撤消方式,不是撤消侧,而是被撤销侧能够决定的*1。另外,在被撤销侧也能够选择完全禁止撤消的这种方式 *2


会造成什么问题呢

那么,让我看看乱用线程的异步撤消会引起什么问题呢。看过准则3的人可能会知道,在下面的脚本里,被撤销线程以外的任意一个线程会被死锁。

  1. 线程1中调用malloc函数正在做内存分配的过程中,线程2异步撤消了线程1的处理
  2. 线程1马上被撤销,但是malloc函数中的互斥锁就没有线程去解除了
  3. 后面的任意一个线程如果再次调用malloc函数的话就会马上导致该线程死锁

在这个例子中使用了malloc函数,但是其他的危险函数还有很多。


反之,即使做了异步撤消也没有问题的函数也有少数存在的、我们把它们叫做「async-cancel safe函数」或者「异步撤消安全函数」。在一些商用UNIX*3中、OS提供的api函数的文档说明中有async-cancel safety的记载、但是在Linux(glibc)里就很遗憾,几乎没有相关的说明。


在这儿,参看规格(SUSv3)的话,会发现,、描述异步撤消安全的函数只有3个

  1. pthread_cancel
  2. pthread_setcancelstate
  3. pthread_setcanceltype

而且、里面还有"No other functions are required to be async-cancel-safe"这样的记载。因此,Linux的场合,如果在文档里没有记载成async-cancel safety的函数,我们还是把它假定成不安全的函数为好!


如何避免这些问题呢

在多线程编程中为了安全的使用异步撤消处理、有没有回避死锁的方法呢?我们试着想了几个。他们与准则3里的线程+fork的场合的回避策很相似。


回避方法1: 被撤销线程中,只能使用异步撤消安全函数


首先,被撤销线程中,只能使用异步撤消安全函数。但是这个方法

  • 在规格说明中只有3个异步撤消安全的函数
  • 这些以外的函数是不是异步撤消安全(商用UNIX)、因为没有说明文档我们不清楚(Linux)

中有以上的两点,所以这个回避方法几乎不现实。


回避方法2: 被撤销线程中,在做非异步撤消安全处理的过程中,再把撤消方式设置成「延迟」或者是「禁止」


第二个是,被撤销线程在做非异步撤消安全处理的过程中,把撤消方式再设定成「延迟」或者「禁止」。对于这个方法

  • 就像方法1写的那样、要把我那个函数是异步撤消安全的一时还是挺麻烦的
  • 在任意的场所并不能保证撤消动作会被马上执行
    • 例如,再设定成「延迟」后的一段时间内如果撤消发生时、某个正在阻塞的I/O函数是否能够被解除阻塞还是挺微妙的
    • 如果设定成撤消禁止的话,则撤消会被屏蔽掉

有上面样的问题、会导致「一精心设计撤消方式的替换,从一开始就使用延迟撤消还不够好」这样的结果。所以这几乎是不好的一个回避策。


回避方法3: 使用pthread_cleanup_push函数,登录异步撤消时的线程数据清除的回调函数


第三种则是,用pthread_cleanup_push函数、登录一个在异步撤消发生时的数据清除的回调函数。这和在准则3中介绍的pthread_atfork函数有点儿类似。用这个函数登录的回调函数来清除线程的数据和锁,就可以回避死锁了。


...但是,pthread_cleanup_push函数登录的回调函数,在「延迟撤消」的场合是不能被调用的。因此、这个回避方法对于异步撤消没有什么大的作用。


回避方法4: 不要执行异步撤消处理


最后是、不要执行异步撤消处理。反而代之的是、

  • 设计成不依赖使用异步撤消那样的处理

或者

  • 不得不使用线程撤消的话、不做异步撤消而作延迟撤消的处理

这是比较实际的做法,是我们值得推荐的。

*1:pthread_setcanceltype函数

*2:pthread_setcancelstate函数

*3:Solaris和HP-UX等


准则5: 尽可能避免线程中做延迟撤销的处理

  • 线程的异步撤消是指:一个线程发出中断其他线程的处理的一个动作
  • 延迟撤消因为是规格*度比较高、所以根据OS和C库函数的版本它也有各式各样的动作
    • 要想在不同的环境下都能稳定的动作的话,就必须要详细调查运行环境和,对C库函数进行抽象化,做必要的条件编译
    • 在C++中、「撤消发生时的对象释放」的实现不具有可移植性
  • 线程撤销要慎重使用。在C++里不要使用

说明:


在前面我们已经讲过,线程的撤消分为「异步」「延迟」这两种类型、并且「异步撤消」也是非常容易引起各种复杂问题的元凶。


那么,现在要在程序中除掉「延迟撤消」。延迟撤消虽然不会像异步撤消那样会引起各种各样的问题、但是、注意事项还是有很多的。只有把下面的这些注意事项全部都把握之后才能放心使用。


注意事项1: 要好好把握撤消点


和异步撤消不一样的是、撤消处理一直会被延迟到在代码上明示出来的撤消点之后才会被执行。如果编写了一个具有延迟撤消可能的代码、代码中的那条语句是撤消点、必须要正确的把握。


首先、调用过pthread_testcancel函数的地方就变成撤消点了。当然这个函数是、仅仅为了「变成延迟撤消」的目的而设置出来的函数。除此之外、某些标准库函数被调用后会不会变成撤消点是在规格(SUSv3)中决定的。请参照规格说明、有下面的函数一览。


下面的函数撤消点

accept, aio_suspend, clock_nanosleep, close, connect, creat, fcntl, fdatasync,
fsync, getmsg, getpmsg, lockf, mq_receive, mq_send, mq_timedreceive,
mq_timedsend, msgrcv, msgsnd, msync, nanosleep, open, pause, poll, pread,
pselect, pthread_cond_timedwait, pthread_cond_wait, pthread_join,
pthread_testcancel, putmsg, putpmsg, pwrite, read, readv, recv, recvfrom,
(略)
下面的函数不是撤消点

access, asctime, asctime_r, catclose, catgets, catopen, closedir, closelog,
ctermid, ctime, ctime_r, dbm_close, dbm_delete, dbm_fetch, dbm_nextkey, dbm_open,
dbm_store, dlclose, dlopen, endgrent, endhostent, endnetent, endprotoent,
endpwent, endservent, endutxent, fclose, fcntl, fflush, fgetc, fgetpos, fgets,
fgetwc, fgetws, fmtmsg, fopen, fpathconf, fprintf, fputc, fputs, fputwc, fputws,
(略)

看到这些我想已经明白了、但是在规格中也说明了「能否成为撤消点跟具体的实现相关的函数」也是多数存在的。原因是、为了可移植性、保证「在一定的时间内让线程的延迟撤消完成」是很困难的事情*1。做的不好的话、只要稍微一提升OS的版本就可能让做出来的程序产品不能动作。


即使是这样那还想要使用延迟撤消吗?


注意事项2: 实现要知道cleanup函数的必要性


可能被延迟撤销的线程在运行的过程中,要申请资源的场合,一定要考虑到以下的几点,否则就会编制出含有资源丢失和死锁的软件产品。


例如编写的下面的函数就不能被安全的延迟撤销掉。

void* cancel_unsafe(void*) {
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&mutex); // 此处不是撤消点
struct timespec ts = {3, 0}; nanosleep(&ts, 0); // 经常是撤消点
pthread_mutex_unlock(&mutex); // 此处不是撤消点
return 0;
}
int main(void) {
pthread_t t;
// pthread_create后马发上收到一个有效的延迟撤消的要求
pthread_create(&t, 0, cancel_unsafe, 0);
pthread_cancel(t);
pthread_join(t, 0);
cancel_unsafe(0); // 发生死锁!
return 0;
}

在上面的样例代码中、nanosleep执行的过程中经常会触发延迟撤销的最终动作,但是这个时候的mutex锁还处于被锁定的状态。而且、线程一被延迟撤消的话就意味着没有人去释放掉这个互斥锁了*2。因此、在下面的main函数中调用同样的cancel_unsafe函数时就会引起死锁了。


为了回避这个问题、利用pthread_cleanup_push函数在撤消时释放掉互斥锁的话就OK了,也就不会死锁了。

// 新增清除函数
void cleanup(void* mutex) {
pthread_mutex_unlock((pthread_mutex_t*)mutex);
}

// 粗体字部分是新增的语句
void* cancel_unsafe(void*) {
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cleanup_push(cleanup, &mutex);
pthread_mutex_lock(&mutex);
struct timespec ts = {3, 0}; nanosleep(&ts, 0);
pthread_mutex_unlock(&mutex);
pthread_cleanup_pop(0);
return 0;
}

注意事项3: 实现要清楚延迟撤消和C++之间的兼容度


使用C语言的场合,利用上面的pthread_cleanup_push/pop函数就能安全地执行延迟撤消的动作,但是在C++语言的场合就会出现其他的问题。C++与延迟撤消之间的兼容度是非常差的。具体的表现有以下两个问题:


  1. 执行延迟撤消的时候,内存栈上的对象的析构函数会不会被调用跟具体的开发环境有关系
    • GCC3版本就不会调用。
    • Solaris和Tru64 UNIX下的原生编译器的场合,就调用析构函数(好像)
  2. pthread_cleanup_push/pop函数和C++的异常处理机制之间有着怎样的相互影响也能具体环境有关

不调用析构函数,或者在抛出异常的时候不能做cleanup处理,经常是发生内存泄漏,资源丢失,程序崩溃,死锁等现象的原因。令人意外的是对于这个深层次的问题,就连Boost C++库都束手无策。

[Q] Why isn't thread cancellation or termination provided?

[A] There's a valid need for thread termination, so at some point Boost.Threads probably will include it, but only after we can find a truly safe (and portable) mechanism for this concept.

先必须确保对象的*存储,而后全都让cleanup函数去释放对象的方法也有,但是这次是牺牲了异常安全性。
(原文没有看明白:オブジェクトを必ずフリーストア上に確保し、解体を全て、クリーンナップハンドラに行わせる手もありますが、今度は例外安全性が犠牲になるでしょう。)


应该说的是,在使用C++的工程里不对线程进行延迟撤消处理还是比较实际的。

*1:好的问题是 gethostbyname()函数

*2:异步撤消跟malloc函数的例子很相似


准则6: 遵守多线程编程的常识


  1. 要准确把握在POSIX标准的函数中,那些函数是非线程安全的,一定不要使用
  2. 要让自己编写的函数符合线程安全
    • 在访问共享数据/变量之前一定要先锁定
    • 如果使用C++的话,一定要注意函数的同步方法

说明: (1) 要准确把握那些非线程安全的函数,一定不要使用


如果在POSIX平台上进行多线程编程时,有几个最基本的知识,也就是所说的“常识”,希望大家一定要严格遵守。


...首先、我们要理解“线程安全”的意思。线程安全的函数就是指,“一个能被在多个线程同时调用也不会发生问题的函数”。这样的函数通常要满足以下几个的特质。

  1. 不要操作局部的静态变量(函数内的static变量)和全局静态数据(全局变量,函数外的静态变量)。而且,也不要调用其他的非线程安全的函数
  2. 如果要操作这样的变量的话,事先必须使用互斥锁mutex进行同步,否则一定要限制多个线程同时对它的访问

那么、在POSIX标准的函数里面,也有不满足上述条件的。由于历史遗留问题,一些函数的识别标识(signature)的定义没有考虑到线程安全的问题,所以不管怎么做都不能满足上述的条件。例如,看看 localtime函数吧。它的定义的识别标识(signature)如下:

struct tm *localtime(const time_t *timer);

localtime 函数是,把一个用整数形式表示的时刻(从1970/1/1到现在为止的秒数)、转换成一个能让人容易明白的年月日形式表示出来的tm结构体并返回给调用者的函数。根据规格说明、返回出来的tm结构体是不需要free()掉,也不能释放的。这个函数典型的实现就像下面的代码那样:

struct tm *localtime(const time_t *timer) {
static struct tm t;

/* ... 从timer参数里算出年月日等数值 ... */

t.tm_year = XXX;
/* ...把它们填入到结构体内... */
t.tm_hour = XXX;
t.tm_min = XXX;
t.tm_sec = XXX;

return &t;
}

这个函数如果被像下面那样使用的话,就会有漏洞:

  1. 在线程A里执行 ta = localtime(x); 
  2. 在线程B里执行 tb = localtime(y); 
  3. 线程A参照ta结构体里的数据 → 就发现这些数据是一些奇怪的值!

...在函数的说明手册里对这个问题也没有做过详细的说明。关于这个漏洞,在localtime函数即使使用了mutex锁也不能被回避掉。所以,这个函数定义的识别标识是不行滴。
[译者lymons注:在多个线程里调用localtime函数之所以有问题的原因是,localtime函数里返回的tm构造体是一个静态的结构体,所以在线程A里调用localtime函数时,该结构体被赋予正确的值;而在线程A参照这个结构体之前,线程B又调用localtime的话,这个静态的结构体又被赋予新的一个值。因此在线程A对这个结构体的访问都是基于一个错误的值进行的]


正因为如此,就像上面说过的POSIX规格(SUSv3)里整齐的定义了一些“非线程安全的函数”。在"§2.9.1 Thread-Safety" 这里登载了的非线程安全的函数有如下所示。

asctime, basename, catgets, crypt, ctime, dbm_clearerr, dbm_close, dbm_delete, dbm_error, dbm_fetch, dbm_firstkey, dbm_nextkey, dbm_open, dbm_store, dirname, dlerror, drand48, ecvt, encrypt, endgrent, endpwent, endutxent, fcvt, ftw, gcvt, getc_unlocked, getchar_unlocked, getdate, getenv, getgrent, getgrgid, getgrnam,

(省略)

对于在规格中被定义为非线程安全的函数,应该制定一个避免使用它们的规则出来,并且制作一个能够自动检查出是否使用了这些函数的开发环境,应该是比较好的。


反之,在这里没有被登载的POSIX标准函数都被假定为 "shall be thread-safe" 的、所以在实际的使用中可以认为在多线程环境里是没有问题的(而且在使用的平台上没有特别地说明它是非线程安全的话)。


另外,有几个非线程安全的函数,都准备了一个备用的线程安全版本的函数(仅仅是变更了函数的识别标识)。像这些函数为了与原版进行区别都在其函数名后面添加了 _r 这个后缀*1。例如,asctime函数就有线程安全版本的函数asctime_r。在规格说明中是否定义了备用函数,可以试着点击刚才的那个网页里面的函数名就可以看到。点击 rand函数就可以看到,

[TSF] int rand_r(unsigned *seed);

用[TSF]这样的文字标记出来的函数吧。这就是备用函数。在一览中没有记载出来的函数(备注: 稍微有点儿出入。请参照这里)、据我所知还有下面的备用函数。

asctime_r, ctime_r, getgrgid_r, getgrnam_r, getpwnam_r, getpwuid_r, gmtime_r, localtime_r, rand_r, readdir_r, strerror_r, strtok_r

还有,在规格以外,还准备了很多的下面那样的函数。

gethostbyname_r, gethostbyname2_r

在最近的操作系统中、也使用 getaddrinfo API函数来解决IPv6名字对应的问题。gethostbyname系列的API都是比较陈旧的函数了、所以使用前面的函数还是比较好吧*2。根据规格SUSv3,getaddrinfo也是线程安全的:

The freeaddrinfo() and getaddrinfo() functions shall be thread-safe.

在多线程编程中,不要使用非线程安全的函数,而他们的备用函数可以放心地积极的去使用。


后续

*1:在C言語里函数不能重载,所以只能添加一个新的函数

*2:跟网络有关的API哪些是新的哪些是旧的,可以参考 IPv6网络编程 (network technology series) 这本好书

准则6: 遵守多线程编程的常识


  1. 要准确把握在POSIX标准的函数中,那些函数是非线程安全的,一定不要使用
  2. 要让自己编写的函数符合线程安全
    • 在访问共享数据/变量之前一定要先锁定
    • 如果使用C++的话,一定要注意函数的同步方法

说明: (2) 要让自己编写的函数符合线程安全


在写多线程的应用程序时,在多个线程里共享的变量要先锁定然后在更新它.。那么在多线程里共享的变量主要有全局变量和函数内的静态变量。而且,即使是short型和int型的共享变量也要先锁定后更新才能保证其安全。


※ 详细的是参考 id:yupo5656:20040618 "[C++] 多线程和共享变量"


还有,在使用C++编程的场合要注意函数的方步方法。一般的说来下面的写法是错误的。Mutex在函数内被声明成静态变量是不允许的

int incr_counter(void) {
static Mutex m; // 这么写不行
m.Lock();

static int counter = 0;
int ret = ++counter;

m.Unlock();
return ret;
}

应该用下面的方式来代替,

Mutex m;

int incr_counter(void) {
m.Lock();
// ...

把Mutex声明成全局变量的话比较好(稍微比上一个好)。


※ 详细是参考 id:yupo5656:20040713 "[C++] C++中写出synchronized method比较难" 。


UNIX上C++程序设计守则(6)-- 补记线程注意事项

线程安全函数是像下面那样

  1. 不要操作局部的静态变量(函数内的static型的变量)和非局部的静态数据(全局变量)。并且,其它的非线程安全函数不要调用
  2. 要操作这样的变量的话, 就要使用mutex进行同步处理,来限制多个线程同时对它进行操作

被定义的,但是

  • 特别是前者, 和被叫做可重入的(reentrant)函数有区别
  • 反之, 后者特别是和叫做"Serializable"(不单单是MT-Safe)"Safe"的函数有区别

也有以上的情况。在Solaris的man手册里, 用后者的方式进行区别. 从多线程程序里安全调用的话,就叫做"Safe", 而且, 在多线程中能够并发(concurrency)地执行这个函数的处理的话,好像就叫做"MT-Safe"。

 

嗯, 因为比较详细的, 如果不是在对于执行速度要求比较苛刻的环境中编写代码的话, 单单地意识到「是否线程安全」就足够了,不是吗。