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

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

准则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 functionsare 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函数有点儿类似。用这个函数登录的回调函数来清除线程的数据和锁,就可以回避死锁了。

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

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

  • 设计成不依赖使用异步撤消那样的处理
  • 不得不使用线程撤消的话,不做异步撤消而作延迟撤消的处理

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

*1:pthread_setcanceltype函数

*2:pthread_setcancelstate函数

*3:Solaris和HP-UX等

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

  • 线程的异步撤消是指:一个线程发出中断其他线程的处理的一个动作
  • 延迟撤消因为是规格自由度比较高,所以根据OS和C库函数的版本它也有各式各样的动作
  1. 要想在不同的环境下都能稳定的动作的话,就必须要详细调查运行环境和对C库函数进行抽象化,做必要的条件编译
  2. 在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.1Thread-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

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

The freeaddrinfo() and getaddrinfo() functions shall bethread-safe.

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

后续

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

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

(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"。

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

posted @ 2011-09-28 20:20  夏大王  阅读(236)  评论(0编辑  收藏  举报