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

时间:2012-09-26 12:48:58

标签: c++ linux unix fork

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

4 个答案:

答案 0 :(得分:13)

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

答案 1 :(得分:5)

原则上,在C ++中使用这些函数没有问题,但您必须了解共享的数据和方式。

考虑到fork(),新进程获取父进程内存的完整副本(使用copy-on-write)。因此,内存是状态 你有两个独立的过程,必须留下一个干净的状态。

现在,只要你保持在给予你的记忆范围内,你就不应该有任何问题:

#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 ++中使用,但在分叉时,它就有它的位置。


参考文献:

答案 2 :(得分:2)

除非你知道自己在做什么,否则子进程应该在完成它之后调用_exit():

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

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

答案 3 :(得分:2)

目前接受的答案显示同步问题,坦率地说与RAII真正导致的问题无关。也就是说,无论您是否使用RAII,您父母之间都会遇到同步问题。哎呀,如果你在两个不同的控制台上运行相同的过程,你就会遇到完全相同的同步问题! (即没有fork()参与您的程序,只是您的程序并行运行两次。)

要解决同步问题,您可以使用信号量。请参阅sema_open(3)及相关功能。请注意,线程会生成完全相同的同步问题。只有您可以使用互斥锁来同步多个线程。

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

不要关闭()套接字

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

class my_socket
{
public:
    my_socket(char * addr)
    {
        socket_ = socket(s)
        ...bind, connect...
    }

    ~my_socket()
    {
        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!)
    ...
}

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

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

例如,假设您创建了一个套接字并在对象中对其进行管理。每当对象被破坏时,你想通过发送一个&#34; BYE&#34;来告诉对方。命令:

class communicator
{
public:
    communicator()
    {
        socket_ = socket();
        ...bind, connect...
    }

    ~communicator()
    {
        write(socket_, "BYE\n", 4);
        // shutdown(socket_); -- now we know not to do that!
        close(socket_);
    }

private
    int socket_ = -1;
};

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

这与phresnel谈论他的ofstream示例非常相似。只是,修复同步并不容易。您编写&#34; BYE \ n&#34;的顺序或者套接字的另一个命令不会改变套接字从另一端关闭的事实(即同步可以使用进程间锁来实现,而那个& #34; BYE&#34;命令类似于shutdown()命令,它会停止其轨道中的通信!)

解决方案

对于shutdown()这很容易,我们只是不调用该函数。话虽这么说,也许你仍然想让父母发生shutdown(),而不是孩子。

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

class communicator
{
    communicator()
        : pid_(getpid())
    {
        socket_ = socket();
        ...bind, connect...
    }

    ~communicator()
    {
        if(pid_ == getpid())
        {
            write(socket_, "BYE\n", 4);
            shutdown(socket_, SHUT_RDWR);
        }
        close(socket_);
    }

 private:
     pid_t pid_;
     int   socket_;
 };

只有当我们在父母身边时,才会执行write()shutdown()

请注意,孩子可以(并且应该)在套接字描述符上执行close(),因为fork()在所有描述符上都调用dup(),因此孩子有一个不同的文件描述符到它所拥有的每个文件。

另一名保安员

现在可能有更复杂的情况,其中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 */
}

这是更安全的,因为你可能不希望孩子在父母的代码中返回&#34;还有很多其他事情可能发生。不只是堆叠展开。 (即继续一个孩子不应继续的for()循环......)

_exit()函数不返回,因此不会调用堆栈上定义的对象的析构函数。 try / catch在这里非常重要,因为如果孩子引发异常,则不会调用_exit(),尽管它应该调用terminate()函数,该函数也赢了&# 39; t销毁所有堆分配的对象,它在展开堆栈后调用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(),则不会进行清理。因此,如果孩子的tmpfile()来电+ _exit(),则该临时文件不会自动删除...