Unix下fork与pthread混用的问题

(来自云风的Blog

    在POSIX标准中,fork 的行为是这样的:复制整个用户空间的数据(通常使用copy-on-write的策略,所以可以实现的速度很快)以及所有系统对象,然后仅复制当前线程到子进程。这里:所有父进程中别的线程,到了子进程中都是突然蒸发掉的。

    其它线程的突然消失,是一切问题的根源。

    我之前从未写过多进程多线程程序,不过公司里有David Xu同学(他实现维护着 FreeBSD 的线程库)是这方面的专家,今天跟徐同学讨论了一下午,终于觉得自己搞明白了其中的纠结。嗯,写点东西整理一下思路。

    可能产生的最严重的问题是锁的问题。

    因为为了性能,大部分系统的锁是实现在用户空间的。所以锁对象会因为 fork 复制到子进程中。

    对于锁来说,从OS看,每个锁有一个所有者,即最后一次lock它的线程。

    假设这么一个环境,在fork之前,有一个子线程lock了某个锁,获得了对锁的所有权。fork以后,在子进程中,所有的额外线程都人间蒸发了。而锁却被正常复制了,在子进程看来,这个锁没有主人,所以没有任何人可以对它解锁。

    当子进程想lock这个锁时,不再有任何手段可以解开了。程序发生死锁。

    为何POSIX指定标准时,会定下这么一个显然不靠谱的规则?允许复制一个完全死掉的锁?答案是历史和性能。因为历史上,把锁实现在用户态是最方便的(今天依旧如此)。背后可能只需要一条原子操作指令即可。大多数CPU都支持的。fork只管用户空间的复制,不会涉及其中的对象细节。

    一般的惯例,多线程程序fork前,应该由发起fork的线程lock所有子进程可能用到的锁,fork后,把它们一一unlock。当然,这样的做法就隐含了锁的次序。如果次序和平时不同,那么就会死锁。

    不光是显式的使用锁,许多CRT函数也会间接的使用。比如fprintf这些文件操作。因为对FILE *的操作是依靠锁来达到线程安全的。最常见的问题是在子线程里调用fprintf写log。

    除此之外,就是要小心一些不依赖锁的数据一致性问题了。比如若在父进程里另一个线程中操作一个链表,fork发生时,因为其它线程的突然消失,这个链表就可能会因为只操作了一半而是不完整的数据。不过这一般不会是问题,或者可以归咎于对锁的处理。(多个线程,访问同一块数据。比如一条链表。就是需要加锁的)

    最后引用讨论中,David Xu的话“POSIX这个问题一直是讨论的热门话题。而且双方立场很清楚,一方是使用者,另外一方是实现者,双方互相指责”

(然后来自另一篇BlogUNIX上C++程序设计守则3


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

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

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

void* doit(void*) {
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    pthread_mutex_lock(&mutex);
    struct timespec ts = {10, 0}; nanosleep(&ts, 0); // 睡10秒
    pthread_mutex_unlock(&mutex);
    return 0;
}

int main(void) {
        pthread_t t;

        pthread_create(&t, 0, doit, 0); // 做成并启动子线程
        if (fork() == 0) {
              //子进程
             //在子进程被创建的瞬间,父的子进程在执行nanosleep的场合比较多
              doit(0);

              return 0;
        }
        pthread_join(t, 0); //
         // 等待子线程结束
}

以下是说明死锁的理由。


一般的,fork做如下事情
   1. 父进程的内存数据会原封不动的拷贝到子进程中
   2. 子进程在单线程状态下被生成

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

像这里的doit函数那样的,在多线程里因为fork而引起问题的函数,我们把它叫做”fork-unsafe函数”。反之,不能引起问题的函数叫做”fork-safe函数”。malloc函数就是一个维持自身固有mutex的典型例子,通常情况下它是fork-unsafe的。

如何规避灾难呢?

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

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

 

    规避方法2:fork后在子进程中马上调用exec函数

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

 

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

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


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


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

    就是不使用fork的方法。即用pthread_create来代替fork。这跟规避策2一样都是比较实际的方法,值得推荐。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值