Что происходит с объектами RAII после вилки процесса?

В Unix/Linux, что происходит с моими активными объектами RAII при форкировании? Будут ли двойные удаления? Что такое построение копии и назначение? Как убедиться, что ничего плохого не происходит?

Ответ 1

fork(2) создает полную копию процесса, включая всю его память. Да, деструкторы автоматических объектов будут выполняться дважды - в родительском процессе и в дочернем процессе, в отдельных пространствах виртуальной памяти. Ничего "плохого" не происходит (если, конечно, вы не вычитаете деньги из учетной записи в деструкторе), вам просто нужно знать об этом факте.

Ответ 2

В принципе, не стоит использовать эти функции в С++, но вы должны знать, какие данные используются совместно и как.

Учтите, что при 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.

(Возможные) решения можно найти в терминах "Interprocess Communication" или "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(). Я вообще совет, чтобы не использовать в С++, но при разветвлении он имеет свое место.


Литература:

Ответ 3

В принятом в настоящее время ответе показана проблема синхронизации, которая, откровенно говоря, не имеет никакого отношения к тем, какие проблемы может вызвать 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!)
    ...
}

Избегайте использования сокета в родительском и дочернем

Другая возможность, все еще с сокетом (хотя вы могли бы иметь такой же эффект с трубой или каким-либо другим механизмом для внешней связи), состоит в том, чтобы в конечном итоге передать команду "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" и закрывает соединение. Теперь родитель не может связаться с этим сокетом, так как он закрыт!

Это очень похоже на то, о чем говорит Фреснель с его примером. Только синхронизировать нелегко. Порядок, в котором вы пишете "BYE\n" или другую команду в сокете, не изменит того факта, что в конце сокет будет закрыт с другой стороны (т.е. синхронизация может быть достигнута с использованием межоперационная блокировка, тогда как команда "BYE" похожа на команду 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(), вероятно, является самым безопасным делом. Другими словами, вместо использования 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
    /* NOT REACHED */
}

Это в любом случае намного безопаснее только потому, что вы, вероятно, не хотите, чтобы ребенок возвращался в "родительский код", где могло случиться много других вещей. Не только разворачивание стека. (т.е. продолжение цикла for(), в котором ребенок не должен продолжать...)

Функция exit() не возвращается, поэтому деструкторы объектов, определенных в стеке, не вызываются. "Try/catch" очень важенздесь, поскольку exit() не будет вызываться, если ребенок вызывает исключение, хотя он должен вызывать функцию terminate(), которая также не будет уничтожать все выделенные кучей объекты, она вызывает функцию terminate() после ее развертывания стек и, следовательно, вероятно, вызвали все ваши деструкторы RAII... и снова не то, что вы ожидаете.

Ответ 4

Если вы не знаете, что вы делаете, дочерний процесс должен всегда вызывать _exit() после того, как он сделал свой материал:

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

Подчеркивание важно. Не вызывайте exit() в дочернем процессе, он очищает потоковые буферы на диск (или там, где указывает указатель filedescriptor), и вы получите написанное дважды.