多进程的死锁问题 我曾经在编码开发时遇到过这样一个问题,代码中产生了死锁,可是我 review 了多次加锁、解锁的地方,却暂时没有发现有什么问题。最终发现是因为在代码中使用了 popen 系统调用,我把遇到的这个问题浓缩成如下的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 pthread_mutex_t mtx;void my_fork () { int pid; if ((pid = fork()) < 0 ) { printf ("fork failed\n" ); } else if (pid == 0 ) { printf ("id %d (child)\n" , getpid ()); for (;;) { printf ("child start lock\n" ); pthread_mutex_lock (&mtx); sleep (1 ); printf ("child run business magic\n" ); pthread_mutex_unlock (&mtx); printf ("child end lock\n" ); } } } int main (void ) { pthread_mutex_init (&mtx, nullptr ); pthread_mutex_lock (&mtx); my_fork (); for (;;) { printf ("id %d (parent)\n" , getpid ()); pthread_mutex_unlock (&mtx); printf ("parent end lock\n" ); sleep (1 ); printf ("parent start lock\n" ); pthread_mutex_lock (&mtx); } return 0 ; }
代码很简单,正常的业务加锁、解锁逻辑中,出现过一个 my_fork()
函数,而这个函数中会调用 fork,并且也同样使用了锁。此时就导致了死锁。如下会输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 id 18923 (parent) parent end lock id 18924 (child) child start lock parent start lock id 18923 (parent) parent end lock parent start lock id 18923 (parent) parent end lock parent start lock id 18923 (parent) parent end lock ...
可以看出,父进程可以正常的加锁、解锁,执行业务逻辑。而子进程输出完 child start lock
之后,就阻塞在 pthread_mutex_lock(&mtx)
这句代码了,也就是加锁一直未成功。我们可以通过 strace 看出这个子进程是卡在加锁上。
1 2 3 # sudo strace -p 19870 strace: Process 19870 attached futex(0x562529d05040, FUTEX_WAIT_PRIVATE, 2, NULL
当然如上的代码逻辑可能很简单,大家一眼就可以看出问题所在,但是在复杂的项目中,可能不容易看出问题。下面我就着问题来说下出现的原因,以及如何解决。
一、缘由 子进程通过继承整个地址空间的副本,还从父进程哪儿继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上的线程,子进程在 fork 返回以后,如果紧接着不是马上调用 exec 的话,就需要清理锁状态。在子进程内部,只存在一个线程,他是由父进程中调用 fork 的线程的副本构成的。如果父进程中的线程占有锁,子进程将同样占有这些锁。
问题来了,子进程并不包含占有锁的线程的副本,所以子进程没有办法知道他占有了那些锁、需要释放那些锁。
POSIX.1 声明中,在 fork 返回和子进程调用 exec 函数之间,子进程只能调用异步信号安全的函数。
所以,终究的原因是,子进程继承了父进程的每个互斥量、读写锁和条件变量的状态。在继承时,如果互斥量是加锁状态,并且子进程继续去加锁,就会死锁。
此时,我们可能会想到,那我们在 fork 前获取锁,在 fork 后释放锁就可以了呀。当然,我们自己创建的锁,我们知道并且能够做出反应。但是我们调用的系统调用或者 C 库函数,如果他们底层有锁,但我们不知道,就比较惨了。比如常见的 printf,其内部实现是有锁的,因此 fork 出来的子进程执行 exec 之前,不能调用 printf 函数的。
二、解决 即然已经知道了问题所在,那么接下来我们来分享下如何解决问题。
为了防止死锁,请确保在 fork 的时候父进程没有持有锁,或者说在 fork 的前后,锁的状态是安全的。我们介绍一种方法:
1 int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
此系统调用可以确保 fork 调用后父进程和子进程都拥有一个清楚的锁状态。
prepare:将在 fork 调用创建出子进程之前被执行,他可以用来锁住所有父进程的互斥锁
parent:fork 调用创建出子进程之后,而 fork 返回之前,在父进程中被执行。他的作用是释放所有在 prepare 中被锁住的互斥锁
child:fork 调用返回之前,在子进程中被执行。用于释放所有在 prepare 中被锁住的互斥锁
函数成功返回 0,错误时返回错误码。
三、示例 我们知道了解法,我们接下来改造一下如上的有问题的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 pthread_mutex_t mtx;void my_fork () { int pid; if ((pid = fork()) < 0 ) { printf ("fork failed\n" ); } else if (pid == 0 ) { printf ("id %d (child)\n" , getpid ()); for (;;) { printf ("child start lock\n" ); pthread_mutex_lock (&mtx); sleep (1 ); printf ("child run business magic\n" ); pthread_mutex_unlock (&mtx); printf ("child end lock\n" ); } } } int main (void ) { pthread_mutex_init (&mtx, nullptr ); pthread_atfork ( []() { int res = pthread_mutex_trylock (&mtx); if (res == 0 ) { return ; } else if (res == EBUSY) { return ; } else { printf ("pthread_mutex_trylock failed, err: %s\n" , strerror (errno)); pthread_mutex_lock (&mtx); }}, []() { pthread_mutex_unlock (&mtx); }, []() { pthread_mutex_unlock (&mtx); }); pthread_mutex_lock (&mtx); my_fork (); for (;;) { printf ("id %d (parent)\n" , getpid ()); pthread_mutex_unlock (&mtx); printf ("parent end lock\n" ); sleep (1 ); printf ("parent start lock\n" ); pthread_mutex_lock (&mtx); } return 0 ; }
我们在 pthread_atfork 中,prepare 尝试去加锁,加锁成功或者已经加锁的情况不用管,未加锁的情况我们主动进行加锁。然后在 fork 调用返回前,分别在父进程和子进程的空间中执行解锁操作,这样就可以确保父子进程都很清楚锁的状态,好做下一步操作。
参考:https://docs.oracle.com/cd/E19455-01/806-5257/gen-92888/index.html