本文译自: http://www.linuxprogrammingblog.com/threads-and-fork-think-twice-before-using-them
作者: Damian Pietras
转载请著名作者,译者和出处。
某天,我(原作者:Damian Pietras,下同。下面所有全角括号内的文字,没有说明都为译者所加 ── 译者 周翀)遇到了一个在多线程程序里使用 fork(2) 的 bug。我想这值得我写下这篇关于混合 POSIX 线程和 fork(2) 的文章,因为这种做法容易导致潜在的问题。
当一个多线程程序 fork(2) 之后
fork(2) 程序 创建了当前进程的副本,包括所有的内存页,还有打开文件的句柄等。所有这些工作对于一个 UNIX 程序员来说,都不陌生。子进程和父进程之间一个非常重要的区别是,子进程只有一个线程。 一个程序员也许不希望复制包括所有线程在内的整个进程,而且,这也容易出问题。想想:所有的线程都因为一个系统调用(这里指的是 fork(2))而被暂停(Suspended)。所以,fork(2) 仅仅会复制调用它的那个线程。
那么(当前的实现方式)会遇到什么问题呢?
关键部分,互斥锁(mutex)
这种做法一个潜在的问题是,当 fork(2) 被调用的时候,某些线程可以正在执行关键部分的代码,在互斥锁的保护下对数据进行非原子操作。在子进程里,这些线程消失了,只留下一些修改到一半却没有可能“修正”的数据,不可能去确定 “其他线程正在做什么”和“怎么做可以保持数据一致”。此外,那些(复制过来的互斥锁)的状态是未定义,他们也许不能用(unusable),除非子进程调用 pthread_mutex_init() 去重置他们的状态为一个可用的值。它( pthread_mutex_init() )的实现取决于互斥锁在 fork(2) 执行之后的具体行为。在我的 Linux 机器上,被锁定(locked)的互斥锁的状态(重置之后)在子进程中仍是(locked)。
库函数
上面关于互斥锁和关键代码的问题,又引出了另一个潜在的问题。理论上,写一些在多线程上运行并且在调用 fork(2) 之后不会出错的代码,是可行的。但是,实践中,却有一个问题──库函数。你不能确认你正在用的库函数不会使用到全局数据。即使它(用到的库函数)是线程安全的,它也可能是通过在内部使用互斥锁来达到目的。你永远无法确认。即使系统的线程安全的库函数,也可能使用了互斥锁。一个潜在的例子是,malloc() 函数,至少在我的多线程程序里,内部使用了锁。所以,在其他线程调用 malloc() 的时候调用 fork(2) 是不安全的!一般来说,我们应该怎么做呢?在一个多线程程序调用 fork(2) 之后,你只应该调用异步安全(async-safe)的函数(在signal(7) http://www.kernel.org/doc/man-pages/online/pages/man7/signal.7.html 列出)。这个列表与你在一个消息回调函数(signal hanlder)里面可以调用的函数的列表是相似的,而原因也相似:在两种情况下,在调用一个函数时,线程会被终止(原文为带引号的interrupted,由于该线程在新的子进程里已经不存在,所以翻译为终止)。
这里是几个在我的系统里,使用类内部锁的函数,仅仅是想让你知道,几乎没有东西是安全的:
* malloc()
* stdio的函数,比如printf() - 这是标准要求的
* syslog()
execve() 和文件句柄
似乎使用 execve(2) 来启动一个需要调用fork(2)的多线程程序,是你唯一明智的选择。但即使这样做,也还有一点不足。当调用execve(2) 时,需要注意的是,打开的文件句柄还将维持打开的状态(在新的子进程中 —— 译者Xorcerer),可以继续被读取和写入数据。你在调用 execve(2) 之前打开了一个你不希望在新的子进程里被使用的文件,问题就出现了。这甚至会产生安全方便的问题。对此,有一个解决方案,你必须使用 fcntl(2) 来对每一个打开的文件句柄设施 FD_CLOEXEC 标记,这样,它们会在新的进程中被自动关闭。不幸的是,在多线程程序里,这没那么简单。当我们使用 fcntl(2) 去设置 FD_CLOEXEC 时,会有一个间隙:
- fd = open ("file", O_RDWR | O_CREAT | O_TRUNC,0600);
- if(fd <0){
- perror ("open()");
- return0;
- }
- fcntl (fd, F_SETFD, FD_CLOEXEC);
如果另一个线程正好在当前线程执行 open(2) 之后 fcntl(2) 之前调用 fork(2) 和 execve(2) ,那么得到的新进程将获得这个文件句柄的副本。这不是我们想要的。一个解决方案已经随着新标准(如:POSIX.1-2008)和新的 Linux 内核(2.6.23以及之后的版本)到来了。我们现在可以在 open(2) 使用 O_CLOEXEC 标记,所以,“开打文件与设置 FD_CLOEXEC” 已经成为了一个原子操作。
除了使用 open(2) 之外,还有其他的创建文件句柄的方法:使用 dup(2) 复制它们,使用 socket(2) 创建socket,等。所有这些函数现在都有一个相似的标记如O_CLOEXEC或者其他更新的版本(其中某些函数,如dup2(2)没有一个用于标记位的参数,所以dup3(2)为此产生了)。
值得提到的一点是同样的东西在单线程程序里也可能发生,如果它在同一个消息处理函数(singal handler)中使用 fork(2) 和 execve(2) 。这个操作是完全合法的,因为这两个函数是异步安全并且允许在消息处理函数中被调用,但是问题是这个程序也许会在调用 open(2) 和 fcntl(2) 之间时,被中断。
想知道更多关于设置 FD_CLOEXEC 新API的信息,请参考 《Ulrich Drepper's blog: Secure File Descriptor Handling》。
一个有用的系统函数:pthread_atfork()
其中一个尝试解决多线程程序中使用 fork(2) 的问题的函数是 pthread_atfork()。它拥有如下原型:
- int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
它允许指定在 fork 被调用时的处理函数:
- prepare 新进程产生之前被调用。
- parent 新进程产生之后在父进程被调用。
- child 新进程产生之后,在子进程被调用。
调用的目的是在 fork(2) 被调用时,处理多线程程序的关键部分(本文开始部分提及)。一个常见的场景时在 prepare 处理函数中加锁,在 parent 处理函数解锁和在 child 处理函数重新初始化锁。
总结
在我看来,fork(2) 在多线程程序中有太多的问题,几乎没有办法去正确地执行它。唯一清晰的方式是在调用 fork(2) 之后立即在子进程中调用 execve(2) 。如果你需要做更多的东西,请使用别的方式(而不是这种多线程混搭多进程的方式),真的。从我的经验看来,并不值得去尝试使用 fork(2) ,即使有pthread_atfork() 的情况下。我真的希望你在遇到本文提到的问题之前,读到这篇文章。
引用
- fork() description from The Open Group Single UNIX Specification
- Ulrich Drepper's blog: Secure File Descriptor Handling
- pthread_atfork()
本文译自: http://www.linuxprogrammingblog.com/threads-and-fork-think-twice-before-using-them
作者: Damian Pietras
转载请著名作者,译者和出处。