在多线程程序中使用 fork,可能会导致一些意外:
- 子进程中只剩下一个线程,它是父进程中调用 fork 的线程的副本构成。这意味着在多线程环境中,会导致“线程蒸发”,莫名奇妙的失踪!
- 因为线程蒸发,它们所持有的锁也可能未释放,这将导致子进程在获取锁时进入死锁。
本文将验证这两个问题,并给出一个可行的解决方案。
1. 线程蒸发
如果在多线程环境中执行 fork,派生的子进程是单线程,子进程中的线程是由父进程中调用 fork 的那个线程的副本构成,而子进程中所有其它的线程会消失。
程序 th_fork 的功能是在父进程中创建一个线程,不断打印自己的 pid 和 ppid。创建完线程后,父进程在主线程中执行 fork,子进程每 2 秒打印一个点。
1.1 代码
// th_fork.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <errno.h>
#define PERR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0)
void* fun(void* arg) {
while(1) {
printf("I'm %d, my father is %d\n", getpid(), getppid());
sleep(1);
}
return NULL;
}
int main() {
int err;
pid_t pid;
pthread_t tid;
pthread_create(&tid, NULL, fun, NULL);
puts("parent about to fork ...");
pid = fork();
if (pid < 0) PERR(errno, "fork");
else if (pid == 0) {
// child
int status;
err = pthread_join(tid, (void**)&status);
if (err != 0) PERR(err, "pthread_join");
while(1) {
puts(".");
sleep(2);
}
exit(0);
}
pthread_join(tid, NULL);
}
1.2 编译和运行
$ gcc th_fork.c -o th_fork -lpthread
$ ./th_fork
图1 线程蒸发
从图 1 中可以看到,子进程中只有一个线程在打点,子进程的 fun 函数线程已经“蒸发”掉了。
另外,在子进程中调用了 pthread_join 函数并没有报错,看起来就好像是子进程中的 fun 函数线程已经正常结束了。
2. fork 死锁
如果在 fork 的时候,线程未释放持有的锁,将导致死锁。
程序 fork_lock 演示了这种情况。
2.1 代码
// fork_lock.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <errno.h>
#define PERR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0)
int total = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void* fun(void* arg) {
while(1) {
pthread_mutex_lock(&lock);
total++;
puts("fun: total++");
// 时间稍稍长一点,在这个时候父进程发起 fork 就导致持锁线程蒸发
sleep(5);
pthread_mutex_unlock(&lock);
sleep(1);
}
return NULL;
}
int main() {
int err;
pid_t pid;
pthread_t tid;
pthread_create(&tid, NULL, fun, NULL);
// 推迟 1 秒,让线程进入临界区。
sleep(1);
puts("parent about to fork ...");
pid = fork();
if (pid < 0) PERR(errno, "fork");
else if (pid == 0) {
// child
int status;
while(1) {
// 由于 fun 线程蒸发,子进程获取锁就会死锁
puts("child require lock...");
pthread_mutex_lock(&lock);
total++;
puts("child: total++");
sleep(2);
pthread_mutex_unlock(&lock);
sleep(1);
}
exit(0);
}
pthread_join(tid, NULL);
}
2.2 编译和运行
$ gcc fork_lock.c -o fork_lock -lpthread
$ ./fork_lock
图2 fork 死锁
图 2 中,子进程在请求锁后,再也没有回应,进入死锁。
2.3 解决方案
初始的解决方案是在 fork 前先请求所有的锁,然后再 fork,另外我们希望 fork 完成后,再对所有锁进行解锁。看起来像下面这样(假设程序中只用了三个互斥量):
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock3);
pid = fork();
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock3);
if (pid < 0) {
perror("fork");
}
else if (pid > 0) {
...
}
else if (pid == 0) {
...
}
因此,我们将 pid = fork() 那一行改为下面三行:
pthread_mutex_lock(&lock);
pid = fork();
pthread_mutex_unlock(&lock);
其它的地方不变,重新编译运行,程序正常。
图2 将 fork 放入临界区,程序正常
实际上,linux 为我们提供一更加方便的机制,让我们不用每次 fork 的时候都自己加锁——atfork 函数。
关于 atfork 函数,在下一篇文章讨论,其实它所做的事,和我们的解决方案是差不多的。
3. 总结
- 理解 fork 导致的线程蒸发
- 理解 fork 死锁问题何时出现