进程分叉后RAII对象会发生什么



在Unix/Linux下,我的活动RAII对象在分叉时会发生什么?会有双重删除吗?复制结构和任务是什么?如何确保没有发生任何不好的事情?

fork(2)创建进程的完整副本,包括其所有内存。是的,自动对象的析构函数将运行两次-在父进程和子进程中,在单独的虚拟内存空间中。没有什么"坏"的事情发生(当然,除非你从析构函数中的账户中扣除钱),你只需要意识到这一事实。

原则上,在C++中使用这些函数是没有问题的,但您必须知道共享哪些数据以及如何共享。

考虑在fork()时,新进程获得父内存的完整副本(使用写时复制)。内存处于状态,因此您有两个独立的进程,必须留下一个干净的状态。

现在,只要你保持在给定的内存范围内,你就应该不会有任何问题:

#include <iostream>
#include <unistd.h>
class Foo {
public:
Foo ()  { std::cout << "Foo():" << this << std::endl; }
~Foo()  { std::cout << "~Foo():" << this << std::endl; }
Foo (Foo const &) {
std::cout << "Foo::Foo():" << this << std::endl;
}
Foo& operator= (Foo const &) {
std::cout << "Foo::operator=():" << this<< std::endl;
return *this;
}
};
int main () {
Foo foo;
int pid = fork();
if (pid > 0) {
// We are parent.
int childExitStatus;
waitpid(pid, &childExitStatus, 0); // wait until child exits
} else if (pid == 0) {
// We are the new process.
} else {
// fork() failed.
}
}

以上程序将大致打印:

Foo():0xbfb8b26f
~Foo():0xbfb8b26f
~Foo():0xbfb8b26f

没有复制构造或复制分配发生,操作系统将按位进行复制。地址是相同的,因为它们不是物理地址,而是指向每个进程的虚拟内存空间的指针。

的两个实例共享信息时,就会变得更加困难,例如,在退出之前必须刷新并关闭打开的文件:

#include <iostream>
#include <fstream>
int main () {
std::ofstream of ("meh");
srand(clock());
int pid = fork();
if (pid > 0) {
// We are parent.
sleep(rand()%3);
of << "parent" << std::endl;
int childExitStatus;
waitpid(pid, &childExitStatus, 0); // wait until child exits
} else if (pid == 0) {
// We are the new process.
sleep(rand()%3);
of << "child" << std::endl;
} else {
// fork() failed.
}
}

这可能会打印

parent

child
parent

或者其他什么。

问题是,这两个实例不足以协调它们对同一文件的访问,并且您不知道std::ofstream的实现细节。

(可能的)解决方案可以在术语"进程间通信"或"IPC"下找到,最接近的解决方案是waitpid():

#include <unistd.h>
#include <sys/wait.h>
int main () {
pid_t pid = fork();
if (pid > 0) {
int childExitStatus;
waitpid(pid, &childExitStatus, 0); // wait until child exits
} else if (pid == 0) {
...
} else {
// fork() failed.
}
}

最简单的解决方案是确保每个进程只使用自己的虚拟内存,而不使用其他内存。

另一个解决方案是特定于Linux的解决方案:确保子进程不进行清理。操作系统将对所有获取的内存进行原始、非RAII清理,并关闭所有打开的文件而不刷新它们。如果您使用fork()exec()来运行另一个进程:,这可能很有用

#include <unistd.h>
#include <sys/wait.h>
int main () {
pid_t pid = fork();
if (pid > 0) {
// We are parent.
int childExitStatus;
waitpid(pid, &childExitStatus, 0);
} else if (pid == 0) {
// We are the new process.
execlp("echo", "echo", "hello, exec", (char*)0);
// only here if exec failed
} else {
// fork() failed.
}
}

另一种在不触发任何析构函数的情况下退出的方法是exit()函数。我通常建议不要在C++中使用,但当分叉时,它有它的位置。


参考文献:

  • http://www.yolinux.com/TUTORIALS/ForkExecProcesses.html
  • 手册页

当前接受的答案显示了一个同步问题,坦率地说,这与RAII真正可能导致的问题无关。也就是说,无论您是否使用RAII,父级和子级之间都会出现同步问题。见鬼,如果你在两个不同的控制台中运行相同的进程,你会遇到完全相同的同步问题(即程序中没有fork(),只有并行运行两次的程序。)

要解决同步问题,可以使用信号量。见sema_open(3)及相关功能。请注意,线程会产生完全相同的同步问题。只有您可以使用互斥来同步多个线程,在大多数情况下,互斥比信号量快得多。。

因此,当你使用RAII来保留我所说的外部资源时,你确实会遇到RAII的问题,尽管所有外部资源都不会受到同样的影响。我在两种情况下都遇到了问题,我将在这里展示这两种情况。

不要关闭()套接字

假设你有自己的套接字类。在析构函数中,你可以关闭。毕竟,一旦你完成了,你还可以向套接字的另一端发送一条消息,说你已经完成了连接:

class my_socket
{
public:
my_socket(char * addr)
{
socket_ = socket(s)
...bind, connect...
}
~my_socket()
{
if(_socket != -1)
{
shutdown(socket_, SHUT_RDWR);
close(socket_);
}
}
private:
int socket_ = -1;
};

使用此RAII类时,shutdown()函数会影响父对象中的套接字和子对象。这意味着父代和子代都不能再对该套接字进行读取或写入。在这里,我假设子级根本不使用套接字(因此我绝对没有同步问题),但当子级死亡时,RAII类会唤醒并调用析构函数。这时它会关闭无法使用的套接字。

{
my_socket soc("127.0.0.1:1234");
// do something with soc in parent
...
pid_t const pid(fork());
if(pid == 0)
{
int status(0);
waitpid(pid, &status, 0);
}
else if(pid > 0)
{
// the fork() "duplicated" all memory (with copy-on-write for most)
// and duplicated all descriptors (see dup(2)) which is why
// calling 'close(s)' is perfectly safe in the child process.
// child does some work
...
// here 'soc' calls my_socket::~my_socket()
return;
}
else
{
// fork did not work
...
}
// here my_socket::~my_socket() was called in child and
// the socket was shutdown -- therefore it cannot be used
// anymore!
// do more work in parent, but cannot use 'soc'
// (which is probably not the wanted behavior!)
...
}

避免在父级和子级中使用套接字

另一种可能性,仍然是使用套接字(尽管你可以使用管道或其他用于外部通信的机制来获得相同的效果),是最终发送一个";BYE";命令两次。不过,这实际上非常接近于同步问题,但在这种情况下,当RAII对象被破坏时,同步会发生在RAII对象中。

例如,假设您创建了一个套接字并在对象中管理它。每当对象被破坏时,你都想通过发送一个";BYE";命令:

class communicator
{
public:
communicator()
{
socket_ = socket();
...bind, connect...
}
~communicator()
{
write(socket_, "BYEn", 4);
// shutdown(socket_); -- now we know not to do that!
close(socket_);
}
private
int socket_ = -1;
};

在这种情况下;BYE";命令并关闭连接。现在,父级无法使用该套接字进行通信,因为它被另一端关闭了!

这和phrasnel在他的流例子中所说的非常相似。只是,修复同步并不容易。你写";BYE\n";或者对套接字的另一个命令不会改变套接字最终从另一侧关闭的事实(即同步可以使用进程间锁定来实现,而"BYE"命令类似于shutdown()命令,它会停止其轨道上的通信!)

解决方案

对于shutdown()来说,这很简单,我们只是不调用函数。话虽如此,也许你仍然希望shutdown()发生在父母身上,只是不想发生在孩子身上。

有几种方法可以解决这个问题,其中之一是记住pid,并使用它来知道是否应该调用这些破坏性的函数调用。有一个可能的解决方案:

class communicator
{
communicator()
: pid_(getpid())
{
socket_ = socket();
...bind, connect...
}
~communicator()
{
if(socket_ != -1)
{
if(pid_ == getpid())
{
write(socket_, "BYEn", 4);
shutdown(socket_, SHUT_RDWR);
}
close(socket_);
}
}
private:
pid_t pid_;
int   socket_;
};

在这里,只有当我们在父对象中时,我们才执行write()shutdown()

请注意,由于fork()在所有描述符上调用dup(),因此子级可以(并且应该)在套接字描述符上执行close(),因此该子级对其持有的每个文件都有一个不同的文件描述符。

另一名保安

现在可能有更复杂的情况,其中RAII对象是在父对象中创建的,而子对象无论如何都会调用该RAII对象的析构函数。正如roemcke所提到的,调用_exit()可能是最安全的做法(exit()在大多数情况下都有效,但它可能会对父级产生不必要的副作用,同时,可能需要exit()才能让子级干净地结束,即删除它创建的tmpfile()!)。换句话说,不使用return,而是调用_exit()

pid_t r(fork());
if(r == 0)
{
try
{
...child do work here...
}
catch(...)
{
// you probably want to log a message here...
}
_exit(0); // prevent stack unfolding and calls to atexit() functions
/* NOT REACHED */
}

这无论如何都要安全得多,因为你可能不希望孩子在";父母的代码";在那里可能会发生许多其他事情。不仅仅是堆叠展开。(即继续for()循环,子循环不应该继续…)

_exit()函数不返回,因此不会调用堆栈上定义的对象的析构函数。try/catch在这里非常重要,因为如果子级引发异常,就不会调用_exit(),尽管它应该调用terminate()函数,该函数也不会破坏所有堆分配的对象,但它在展开堆栈后调用terminate()函数,因此可能会调用所有RAII析构函数。。。同样也不是你所期望的。

exit()_exit()的区别在于前者调用atexit()函数。你很少需要在孩子或父母身上这样做。至少,我从来没有任何奇怪的副作用。然而,有些库确实使用了atexit(),而没有考虑调用fork()的可能性。在atexit()函数中保护自己的一种方法是记录需要atexit()函数的进程的PID。如果函数被调用时PID不匹配,那么您只需返回并不执行其他操作。

pid_t cleanup_pid = -1;
void cleanup()
{
if(cleanup_pid != getpid())
{
return;
}
... do your clean up here ...
}
void some_function_requiring_cleanup()
{
if(cleanup_pid != getpid())
{
cleanup_pid = getpid();
atexit(cleanup);
}
... do work requiring cleanup ...
}

显然,使用atexit()并且做得很好的库的数量可能非常接近0。所以…你应该避开这样的图书馆。

请记住,如果调用execve()_exit(),则不会进行清理。因此,如果子级+_exit()中有tmpfile()调用,则该临时文件不会自动删除。。。

除非您知道自己在做什么,否则子进程在完成任务后应该始终调用_exit():

pid_t pid = fork()
if (pid == 0)
{
do_some_stuff(); // Make sure this doesn't throw anything
_exit(0);
}

下划线很重要。不要在子进程中调用exit(),它会将流缓冲区刷新到磁盘(或文件描述符指向的任何位置),您最终会写两次。

最新更新