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() 函数是不安全的。也因此,必须慎重使用多进程和多线程混搭的模型。