【问题标题】:What happens to RAII objects after a process forks?进程分叉后 RAII 对象会发生什么?
【发布时间】:2012-09-18 02:25:08
【问题描述】:

在 Unix / Linux 下,我的活动 RAII 对象在分叉时会发生什么?会不会出现双删? 复制构造和赋值是什么?如何确保没有坏事发生?

【问题讨论】:

  • 为什么要使用 fork,C++ 使用线程要好得多。
  • fork(2) 是一个基本的 Unix 系统调用。你会使用它......每次你需要一个新的过程。
  • @OliverStutz:例如创建守护进程或运行进程并捕获它们的输入、输出和错误通道
  • @OliverStutz,以避免线程带来的所有问题(以及许多崩溃)。确保你的程序使用线程比使用“简单”fork() 更困难,除非你有管理外部资源的 RAII 对象......
  • @AlexisWilke 你怎么知道一个复杂的程序可以用 fork 很好地工作?

标签: c++ linux unix fork


【解决方案1】:

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

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

因此,当您使用 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!)
    ...
}

避免在父子中使用socket

另一种可能性,仍然使用套接字(尽管您可以使用管道或其他用于外部通信的机制具有相同的效果)是最终发送两次“BYE”命令。不过,这实际上非常接近于同步问题,但在这种情况下,同步会在 RAII 对象被销毁时发生。

例如,您创建一个套接字并在一个对象中管理它。每当对象被破坏时,你想通过发送“BYE”命令告诉对方:

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;
};

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

这与 phresnel 在他的 ofstream 示例中所说的非常相似。只是,修复同步并不容易。您向套接字写入“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_, "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 */
}

这无论如何都要安全得多,因为您可能不希望孩子返回“父母的代码”,因为那里可能会发生许多其他事情。不只是堆栈展开。 (即继续一个孩子不应该继续的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(),则不会进行清理。因此,如果在子进程中调用tmpfile() + _exit(),该临时文件将不会被自动删除...

【讨论】:

  • 同步问题出在C++保证析构函数执行的地方,除了将实例包装为动态分配的对象外,没有其他办法解决。当问题出在不想让线程正确处理的应用程序代码之外时,我真的不喜欢过度设计所有资源充足的对象以使用非标准函数。不仅有套接字会带来问题。它也可以是一个精美的 HTML 打印库,或者是 Windows 上 DirectPhysics 包装器的智能句柄。
  • 我确实回答了 RAII 问题:“会有双重删除吗?”这也是帖子的标题。我提到了所有内存和描述符都被重复的事实(第二个代码示例),这应该足以知道孩子的一切看起来都是一样的,至少在开始时......至于在堆上分配的对象,他们应该使用std::shared_ptr<>()。这意味着他们一定会在某个时候被释放。所以问题是要避免堆栈展开,exit() 这样做...
  • 在子进程中调用exit 是不正确的,并导致了具有严重安全后果的错误。通常,您无法知道 atexit 处理程序库可能注册了哪些内容,以及如果由子级调用然后由父级调用它们可能会做什么。
  • @AlexisWilke 如果您不打算调用 exec 函数(或该函数失败),建议终止子进程的方法是 _exit
  • @AlexisWilke 不幸的是,孩子可以做什么总是会受到限制,因为您从库等继承了未知的上下文。孩子不能依赖出口处理程序,因为它无法知道他们将要做什么。有很多方法可以解决这个问题,具体取决于您要执行的操作。
【解决方案2】:

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

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

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

【讨论】:

  • 寿 man 2 _exit: [...] Whether it flushes standard I/O buffers and removes temporary files created with tmp‐ file(3) is implementation-dependent.[...]
  • @phresnel,不仅仅是文件。实际上关闭子文件描述符根本不是问题。但是,某些其他问题(例如关闭套接字)通常会产生不良后果。
  • 请注意,Make sure it does not throw 可以更改为 try { do_some_stuff(); } catch(...) {} _exit(0);。你可能会也可能不会捕捉到像 SEGV 这样的东西,但总的来说,它应该仍然很安全。
  • @AlexisWilke:SEGV 也不例外,您可以在 C++ 中使用catch。不过,有些图书馆可以产生这种效果。
【解决方案3】:

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


参考资料:

【讨论】:

  • 我认为如果您的 waitpid 示例仍然显示正在写入的文件,但现在以可预测的顺序显示,它会更好。我不确定执行代码显示的是什么;是否应该有其他一些对象可以清理或不清理?
  • 您在这里展示的只是一个非常简单的同步问题。这可以很容易地通过锁来解决(线程中的互斥锁,因为您会在线程中遇到类似的问题......并且可能是父级和分叉子级之间的信号量。)任何一种方式都与 RAII 无关每说。也就是说,RAII 的重点是类的析构函数,并且在父子进程运行时不使用文件等各种东西。
  • @AlexisWilke:你是不是建议保持一个锁围绕一个资源满的对象,不管资源满的对象存活多久?或者,您是否建议让所有资源完整的类都具有线程意识,以防 someone 可能 somewhen 在线程上下文中使用实例?我不明白,作为一个通用解决方案,这不是过早悲观。
  • 如果您想以一种有序的方式写入文件,并且多个进程可以访问该文件,则需要同步。在这种特定情况下,您可以锁定文件。这就是好的日志库所做的。您可以让任意数量的进程同时发送日志,并且要获得看起来正确的日志,您 (1) 锁定文件,(2) 写入日志行,(3) 解锁文件,这并不特定于 RAII。
  • @AlexisWilke:不,这不是 RAII 特有的。但是排序问题和双重效果(例如在不允许的情况下第二次访问或修改某些内容)可以从 RAII 引出,这就是问题的重点......不知道你在做什么.
【解决方案4】:

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

【讨论】:

  • 对于仅内存对象,甚至是持有简单文件描述符的对象,是的。事情很简单。内存使用写时复制和描述符使用dup() 进行复制。但是,析构函数可能会做更具破坏性的事情,例如在套接字上执行shutdown()。即使在父母/孩子之间也不安全。
猜你喜欢
  • 1970-01-01
  • 2011-01-24
  • 2017-02-14
  • 1970-01-01
  • 1970-01-01
  • 2011-05-10
  • 2021-04-08
  • 1970-01-01
  • 2016-01-04
相关资源
最近更新 更多