fork与多进程

通过fork创建的一个子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝,包括文本、数据和bss段、堆以及用户栈等。子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着子进程可以读写父进程中任何打开的文件,父进程和子进程之间最大的区别在于它们有着不同的pid。

但要注意:在linux中,fork的时候只复制当前线程到子进程,也就是说除了调用fork的线程外, 其他线程在子进程中“蒸发”了。

这就是多线程中fork所带来的一切问题的根源所在。
互斥锁就是多线程fork大部分问题的关键部分。
在大多数操作系统上,为了性能的因素,锁基本上都是实现在用户态的而非内核态,所以调用fork的时候,会复制父进程的所有锁到子进程中。

问题就出在这了。从操作系统的角度上看,对于每一个锁都有它的持有者,即对它进行lock操作的线程。假设在fork之前,一个线程对某个锁进行了lock操作,即持有了该锁,然后另外一个线程调用了fork创建子进程。可是在子进程中持有那个锁的线程却”消失”了(因为fork只复制当前线程到子进程中,而那个拥有锁的线程没有被复制到该进程中),从子进程的角度来看,这个锁被“永久”的上锁了,因为它的持有者“蒸发”了。那么如果子进程中的任何一个线程对这个已经被持有的锁进行lock操作话,就会发生死锁。

这种情况,除了发生在用户自己使用了锁,还可能会发生在调用了一些库函数时。因为你不能确定你所用到的所有库函数都不会使用共享数据,即他们都是完全线程安全的。有相当一部分线程安全的库函数都是在内部通过持有互斥锁的方式来实现的,比如几乎所有程序都会用到的c/c++标准库函数mallocprintf等等。

比如一个多线程程序在fork之前难免会分配动态内存,这就必然会用到malloc函数;而在fork之后的子进程中也难免要分配动态内存,这也同样要用到malloc,可这却是不安全的,因为有可能malloc内部的锁已经在fork之前被某一个线程所持有了,而那个线程却在子进程中消失了。

所以,按照上文的分析,似乎多线程中在fork出的子进程中立刻调用exec函数是唯一明智的选择了,其实即使这样做还是有一点不足。因为子进程会继承父进程中所有已打开的文件描述符,所以在执行exec之前子进程仍然可以读写父进程中的文件,但如果你不希望子进程能读写父进程里的某个已打开的文件该怎么办?

或许fcntl设置文件属性是一种方法:

int fd = open("file", O_RDWR | O_CREAT);
if (fd < 0)
{
    perror("open");
}
fcntl(fd, F_SETFD, FD_CLOEXEC);

但是如果在open打开file文件之后,调用fcntl设置CLOEXEC属性之前有其他线程fork出了子进程了的话,这个子进程仍然是可以读写file文件。如果用锁的话,就又回到了上文所讨论的情况了。

从linux 2.6.23版本的内核开始,我们可以在open中设置O_CLOEXEC标志了,相当于“打开文件再设置CLOEXEC”成为了一个原子操作。这样在fork出的子进程执行exec之前就不能读写父进程中已打开的文件了。

总结:
在多线程程序中最好只用fork来执行exec函数,不要对fork出的子进程进行其他任何操作。
如果确定要在多线程中通过fork出的子进程执行exec函数,那么在fork之前打开文件描述符时需要加上CLOEXEC标志。