fork安全()

fork安全()

转载 https://liam.page/2017/01/17/fork-safe/
有所补充

本文将探讨一个与多进程、多线程相关的问题:fork()-安全。

抛出异常

首先我们来看这样的代码

//mutex_deadlock.cpp
//g++ mutex_deadlock.cpp -lpthread
#include <pthread.h>
#include <time.h>
#include <unistd.h>

using namespace std;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* doit(void*) {
    pthread_mutex_lock(&mutex);
    struct timespec ts = {20, 0};
    nanosleep(&ts, 0);
    pthread_mutex_unlock(&mutex);
    return 0;
}

int main(void) {
    pthread_t t;
    pthread_create(&t, nullptr, doit, nullptr);

    struct timespec ts = {1, 0};
    nanosleep(&ts, 0);
    if (fork() == 0) {
        doit(nullptr);
        return 0;
    }
    pthread_join(t, 0);

    return 0;
}

在代码里,我们首先用 pthread_create() 创建了一个子线程。在子线程里,doit() 工作函数会持有一把互斥锁,然后睡 20 秒后再释放这把锁。而后,与子线程同时进行的,在主线程中,我们调用 fork() 函数,创建一个子进程。并且,在子进程里,我们也调用 doit() 函数,尝试获取互斥锁。

这里为了提高测试场景出现概率,特意在创建子线程之后睡眠了1秒,然后再fork(),这样的目的是为了让子线程大概率地先获得锁,从而在fork()之后的子进程里面(由于这个子线程消失)只有主线程,而没有拥有锁的子线程,从而发生死锁。

捕获异常

现在我们观察一下,这个程序的运行状态。具体观察方法是在一个终端运行./a.out,新开一个终端运行两次如下shell脚本,注意chmod +x show.sh

ps -ef|grep "a.out"|grep -v grep
sudo strace -p `ps -aux | grep "a.out" | awk 'NR == 1'  | awk {'print $2'}` -f

这里先输出ps以便观察,然后用awk取得第一行第二列的进程号PID,最后用strace观察系统调用。

第一次运行脚本结果

m@ubuntu:~/t$ ./show.sh 
m         87346  86232  0 04:15 pts/0    00:00:00 ./a.out
m         87348  87346  0 04:15 pts/0    00:00:00 ./a.out
strace: Process 87346 attached with 2 threads
[pid 87347] restart_syscall(<... resuming interrupted nanosleep ...> <unfinished ...>
[pid 87346] futex(0x7f1733d959d0, FUTEX_WAIT, 87347, NULL <unfinished ...>
[pid 87347] <... restart_syscall resumed> ) = 0
[pid 87347] madvise(0x7f1733595000, 8368128, MADV_DONTNEED) = 0
[pid 87347] exit(0)                     = ?
[pid 87347] +++ exited with 0 +++
<... futex resumed> )                   = 0
exit_group(0)                           = ?
+++ exited with 0 +++

可以看到,等到我们有机会查看程序的运行状态时,子进程已经被创建出来了。显而易见,87346 是主进程,而 87348 是子进程。我们用 strace 跟踪主进程试试看。

不难发现,strace 提示主进程里有 2 个线程,其中主线程正在等待子线程释放互斥锁(FUTEX_WAIT)。待子线程释放互斥锁并退出后,主线程就获取到锁,而后退出了。这表明,主进程运行正常。

现在看看子进程的状态。再次运行shell脚本,得到如下结果

m@ubuntu:~/t$ ./show.sh 
m         87348   1508  0 04:15 pts/0    00:00:00 ./a.out
strace: Process 87348 attached
futex(0x559b09228040, FUTEX_WAIT_PRIVATE, 2, NULL

注意,这里光标会一直闪烁在NULL后面。

这里有几处值得注意的地方

  • 再次执行 ps -ef 的时候,主进程已经退出了,但是子进程依然存活。这时候,理论上子进程变为孤儿,过继给 1 号进程 init。但在具体实现上是托管给了1508进程。
  • 执行 strace 发现,子进程只有一个线程(而不是 2 个线程,因为没有attached with 2 threads字样)。
  • 并且,子进程的线程,在不断尝试获取互斥锁而不得,陷入了死锁状态。一直卡在那里。必须调用kill -9 87348才会杀死子进程。

异常分析

子进程陷入死锁,因而等主进程退出后就变成孤儿进程。这件事情符合逻辑,不需要做额外的探讨。但是不符合逻辑的地方有两处:

  • 主进程显而易见有两个线程,为什么经由其 fork() 得到的子进程却只有 1 个线程?
  • 既然子进程只有 1 个线程,为什么会陷入死锁?

为了解答这两个疑惑,我们需要更加深入地了解一下 fork() 函数的行为。阅读 fork() 函数的说明,我们可以发现有这样一段话:

The child process is created with a single thread — the one that called fork(). The entire virtual address space of the parent is replicated in the child, including the states of mutexes, condition variables, and other pthreads objects; the use of pthread_atfork() may be helpful for dealing with problems that this can cause.

翻译过来就是:经由 fork() 创建的子进程,其中只有一个线程。子进程里仅存的线程,对应着主进程里调用 fork() 函数的线程。此外,主进程的整个虚存空间都被复制到了子进程。因而,包括互斥锁、条件变量、其余线程的内部对象等,都保持着原样。由此引发的问题,可以考虑用 pthread_atfork() 函数解决。

打住!我们似乎发现了什么……

回过头来看代码。在 fork() 执行时,子线程还持有着 mutex 互斥锁。而当 fork() 执行之后,子进程里的子线程就蒸发掉了,但是 mutex 互斥锁依然保持着被持有的状态。而子进程里仅存的线程,马上就进入 doit() 函数,尝试获取锁——它在尝试获取一个永远不会被释放的锁,形成死锁。

这是一个刻意构造的例子,说明当子线程持有锁的时候,由主线程进行 fork() 操作是不安全的。在生产实际中,这种现象不总是发生,但是在概率的意义上是必然发生的。因此,我们有必要考虑怎样解决这个问题。好在,fork() 的文档中给出了提示:使用 pthread_atfork() 函数。

pthread_atfork() 函数

pthread_atfork() 和 phread_create() 函数一样,由 pthread 库提供。它的原型是

int pthread_atfork (void (*prepare) (void), void (*parent) (void), void (*child) (void));
它接收三个参数,分别是
  • prepare: 将在 fork() 之前执行;
  • parent: 将在父进程 fork() 即将 return 的地方执行;
  • child: 将在子进程 fork() 即将 return 的地方执行。

这个函数实际上是一个注册机,它可以被执行多次,而后将诸多 prepare 函数压入堆栈中,在 fork() 之前依次弹栈执行(执行顺序与注册的顺序相反);将诸多 parent 和 child 函数分别填入队列中,在 fork() 函数即将 return 的地方依次执行(执行顺序与注册顺序相同)。按照设计的意图,程序员可以在 fork() 之前,做好清理工作,以便 fork() 能够安全地调用;并且在 fork() 返回之前,对函数做初始化,以便后续代码能够顺利执行。

据此,对上面的代码,我们可以有这样的修改

//mutex_deadlock_fix.cpp
#include <pthread.h>
#include <time.h>
#include <unistd.h>

using namespace std;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* doit(void*) {
    pthread_mutex_lock(&mutex);
    struct timespec ts = {20, 0};
    nanosleep(&ts, 0);
    pthread_mutex_unlock(&mutex);
    return 0;
}

void prepare(void) {
    pthread_mutex_lock(&mutex);
}

void parent(void) {
    pthread_mutex_unlock(&mutex);
}

void child(void) {
    pthread_mutex_unlock(&mutex);
}

int main(void) {
    pthread_atfork(prepare, parent, child);

    pthread_t t;
    pthread_create(&t, nullptr, doit, nullptr);

    if (fork() == 0) {
        doit(nullptr);
        return 0;
    }
    pthread_join(t, 0);

    return 0;
}

不难验证,死锁的问题已经解决。给出一个输出结果样例

m@ubuntu:~/t$ ./show.sh 
m         87484  86232  0 04:32 pts/0    00:00:00 ./a.out
m         87486  87484  0 04:32 pts/0    00:00:00 ./a.out
strace: Process 87484 attached with 2 threads
[pid 87485] restart_syscall(<... resuming interrupted nanosleep ...> <unfinished ...>
[pid 87484] futex(0x7f5733dfd9d0, FUTEX_WAIT, 87485, NULL <unfinished ...>
[pid 87485] <... restart_syscall resumed> ) = 0
[pid 87485] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=87486, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
[pid 87485] madvise(0x7f57335fd000, 8368128, MADV_DONTNEED) = 0
[pid 87485] exit(0)                     = ?
[pid 87485] +++ exited with 0 +++
<... futex resumed> )                   = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
futex(0x7f5733dfd9d0, FUTEX_WAIT, 87485, NULL) = -1 EAGAIN (Resource temporarily unavailable)
exit_group(0)                           = ?
+++ exited with 0 +++
m@ubuntu:~/t$ ./show.sh 
strace: attach: ptrace(PTRACE_SEIZE, 87507): No such process
m@ubuntu:~/t$ 

没有银弹

不幸的是,pthread_atfork() 函数并不是解决此类问题的银弹。事实上,pthread_atfork() 本身就可能造成死锁的问题。

实际上,因为库作者不可能知道其它第三方库对锁的使用,因此每个库必须自己调用 pthread_atfork() 来处理自己的环境。然而,在实际环境中,各个 pthread_atfork() 函数调用的时机是不确定的;也因此,各个 prepare 函数的调用顺序是不确定的。这有可能会造成问题,例如可能有下面的情况发生

  • Thread 1 调用 fork() 函数。
  • Thread 1 执行 libc 中注册的 prepare 函数,获取 libc 中的 mutex。
  • Thread 2 中,第三方库 A 获取了它自己的互斥锁 AM;接下来 Thread 2 尝试获取 libc 的 mutex 以便继续清理环境;而此时 mutex 已经在 Thread 1 中被持有,因此 Thread 2 进入等待状态。
  • Thread 1 现在尝试清理第三方库 A 的环境,于是它要去获取 AM;然而 AM 在 Thread 2 手里,于是 Thread 1 进入等待状态。
  • 产生死锁。

因为这里必须首先获得所有的锁,然后再统一释放,而永远无法预知第三方库获得自身锁的时机,所以由于加锁顺序的交叉,就产生了死锁。

这件事情的不可解之处在于,死锁的产生和程序员自身的编码没有任何关系:使用任何第三方库,在多线程的环境下执行 fork(),都可能死锁。由此,我们得出结论:在多线程环境下,执行 fork() 函数是不安全的。也因此,必须慎重使用多进程和多线程混搭的模型。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值