在 Unix / Linux 下,我的活动 RAII 对象在分叉时会发生什么?会不会出现双删?复制构造和赋值是什么?如何确保没有坏事发生?
4 回答
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()
如果您使用withexec()
运行另一个进程,这可能很有用:
#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++ 中使用,但在分叉时,它有它的位置。
参考:
当前接受的答案显示了一个同步问题,坦率地说,这与 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!)
...
}
避免在 parent 和 child 中使用 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()
调用 a 的可能性。在函数中保护自己的一种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()
or _exit()
,则不会进行清理。因此,如果tmpfile()
子 + 调用_exit()
,该临时文件将不会被自动删除...
除非您知道自己在做什么,否则子进程应始终在完成其工作后调用 _exit():
pid_t pid = fork()
if (pid == 0)
{
do_some_stuff(); // Make sure this doesn't throw anything
_exit(0);
}
下划线很重要。不要在子进程中调用 exit() ,它会将流缓冲区刷新到磁盘(或文件描述符指向的任何地方),您最终会得到两次写入的内容。