父进程执行fork()会创建子进程,父进程fork()返回值为子进程pid,子进程通过父进程fork()返回值为0;然后父子进程分别执行各自的代码或共有的代码。
写时拷贝:可以推迟甚至避免拷贝的技术,Linuxfork()就是使用这种技术,内核并不复制地址空间,而是让父子进程共享这个地址空间,这里的地址空间既可以是虚拟的,也可以是物理的,当然这里指的是用户区的数据,不是内核区的数据(内核区包含进程pid等),而当父进程或子进程写入的时候才会复制开辟一个新的物理的地址空间,注意这里无论是父进程还是子进程都是一样的,都要复制一个新的物理地址空间,因为父进程可能会有不同的子进程,只有当所有的子进程都修改了(也就是“引用计数”减为0,这个原先的物理地址才会释放(引用计数下面我会提到),并且这样能避免复杂的条件判断(内部逻辑)。
这里的写时拷贝是对父子进程的用户区来说的,只有改变用户区的数据才会进行写时拷贝,而对系统文件修改并不会进行写时拷贝,比如说管道通信,父子进程通过读入共享的内核区的文件描述符指向的文件描述表中的文件的位置,通过这个位置对文件进行修改,并不会写时拷贝,因为并没有写入内存产生缺页中断。
一个问题是在调用fork()时父子进程就已经产生不同的内容比如返回值pid(不是内核区本身进程的pid),这个pid存储在栈中,是不是就要拷贝呢?对,但是只拷贝pid这一小部分栈空间,fork()后,父子进程的虚拟地址空间相同,但每个进程的页表不同,即映射后的物理地址空间不同,写数据的时候,产生冲突,产生缺页中断,页表中添加新的页表项,开辟新的物理地址。
另一个问题就是当子进程修改其中一部分的时候,是全部拷贝吗?答案不是的,假如共享页面分成ABC三个页面,当修改A页面时只会拷贝一个A页面的副本。
“引用计数” 的概念:在开辟的空间中多维护四个字节来存储引用计数。
有两种方法:
①:多开辟四个字节(pCount)的空间,用来记录有多少个指针指向这片空间。
②:在开辟空间的头部预留四个字节的空间来记录有多少个指针指向这片空间。源码中也是这种写法
当我们多开辟一份空间时,让引用计数+1,如果有释放空间,那就让计数-1,但是此时不是真正的释放,是假释放,等到引用计数变为 0 时,才会真正的释放空间。如果有修改或写的操作,那么也让原空间的引用计数-1,并且真正开辟新的空间。效率能高一些,如果放在尾部维护的话,每次开辟新的空间都要讲这四个字节也向后挪动相应的位置,所以放在前面效率高点。
class String
{
public:
//构造
String(const char* str)
:_str(new char[strlen(str) + 4 + 1])
{
_str += 4; //前四个字节放引用计数
strcpy(_str, str);
GetRefCount() = 1;
}
//拷贝构造
String(const String& s)
:_str(s._str)
{
GetRefCount()++;
}
//赋值运算符重载
String& operator=(const String& s)
{
if(_str != s._str)
{
if(--GetRefCount() == 0)
{
delete[] (_str - 4);
}
_str = s._str;
GetRefCount()++;
}
return *this;
}
~String()
{
if(--GetRefCount() == 0)
{
delete[] (_str - 4);
_str = nullptr;
}
}
char& operator[](size_t pos)
{
if(GetRefCount() > 1)
{
--GetRefCount();
char* newstr = new char[strlen(_str) + 4 + 1];
newstr += 4;
strcpy(newstr, _str);
_str = newstr;
GetRefCount() = 1;
}
return _str[pos];
}
int& GetRefCount()
{
return *((int*)(_str - 4)); //前四个字节为引用计数
}
private:
char* _str;
};
写时拷贝不仅能避免子进程不修改数据(只读数据)带来的内存资源开销,还能在fork()之后进行子进程的exec()调用节约开销,避免二次复制拷贝。
参考文章:https://blog.csdn.net/bandaoyu/article/details/116793991