彻底理解 fork 之写时复制 《一》
一直以来都对操作系统都比较感兴趣,这篇文章呢就主要研究一下当我们调用fork系统掉用所用到的写时复制技术(copy-on-write)。
下图是fork系列函数的调用过程
<摘自网络 侵删>
写时复制,其实在很多地方都会用到,我们先来看看关于字符串使用写时复制的例子吧。
写时拷贝故名思意:是在写的时候(即改变字符串的时候)才会真正的开辟空间拷贝(深拷贝),如果只是对数据的读时,只会对数据进行浅拷贝。
写时拷贝:引用计数器的浅拷贝,又称延时拷贝
:写时拷贝技术是通过"引用计数"实现的,在分配空间的时候多分配4个字节,用来记录有多少个指针指向块空间,当有新的指针指向这块空间时,引用计数加一,当要释放这块空间时,引用计数减一(假装释放),直到引用计数减为0时才真的释放掉这块空间。当有的指针要改变这块空间的值时,再为这个指针分配自己的空间(注意这时引用计数的变化,旧的空间的引用计数减一,新分配的空间引用计数加一)。
基于此 我们来再看看调用 fork 时需要使用的写时复制技术吧!
其实这块比较有意思,系统需要处理的事情太多,处理任务一般都采用最懒惰的策略,在网上也看了几个证明写时复制的例子,但感觉并不严谨,并不能来证明。比如这个高票回答
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
void main()
{
char str[6]="hello";
pid_t pid=fork();
if(pid==0)
{
str[0]='b';
printf("子进程中str=%s\n",str);
printf("子进程中str指向的首地址:%x\n",(unsigned int)str);
}
else
{
sleep(1);
printf("父进程中str=%s\n",str);
printf("父进程中str指向的首地址:%x\n",(unsigned int)str);
}
}
/**结果**/
子进程中str=bello
子进程中str指向的首地址:bfdbfc06
父进程中str=hello
父进程中str指向的首地址:bfdbfc06
/******/
/先解释一下程序的两个比较容易让人困惑的点/
-
通常情况下,进程都会有独立的地址空间,为了提高系统的资源利用率,我们所使用的地址都是虚拟地址,(具体内容不在此处讨论),控制台打印出来的地址是虚拟地址(我们用户能看到的),真正的物理地址是给cpu看的,虚拟地址与真实物理地址之间是有一个对应关系的。
每个进程都有自己的虚拟地址空间,不同进程的相同的虚拟地址显然可以对应不同的物理地址。因此地址相同(虚拟地址)而值不同没什么奇怪。 -
为什么会打印两次
这明明是一个互斥的选择语句,哦天哪,难道这个fork返回了两个值,这不科学啊,这颠覆了函数只有一个返回值的真理啊,哈哈哈,不用担心,原因是fork之后产生了两个进程,屏幕上的两句话,是fork在两个进程分别返回,所以打印两次。
首先明确一点,因为str的数据改变了,str所在的页面操作系统会给子进程重新分配,为什么打印出来的地址是一模一样的,请记住,fork时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。 这也就是我所说的开头的代码是无法证明写时复制的,别急,往下看!
为什么需要写时复制呢?
直接给进程分配独立的地址空间不就更省事了嘛,效率也高,但是请记住一点,商业的东西都是需要成本控制的,内存在电脑体系中是非常宝贵的资源(手动狗头三星),所以Linux等人,要想方设法节省资源,提高资源利用率,好明白了这一点,重点就来了,假如父子进程对原有的所有页面是无任何改变,也就是说对数据是只读的,没有写过,那么懒懒的操作系统是不是根本没有必要为了子进程再开辟一块物理空间(页面),所以说子进程里的页表是和父进程的页表是一模一样的即其中的逻辑地址对应的物理地址是一模一样的,也就是所谓的页面共享。这时我们如果改变策略,改变了某一个共享页面的某一项数据,那么此时这个页面已经无法被共享了,会发生什么呢?
具体过程是这样的:
fork子进程完全复制父进程的栈空间,也复制了它的内存分配页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似mmap的private的方式),如果父子进程一直对这个页面是同一个页面,直到其中任意一个进程要对共享的页面进行“写操作”,这时内核会 分配+复制 一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。画重点,操作性同在内存分配这块非常懒,仅仅只分配"新的页面" 并在页表里边改变相关属性
这就是所谓的“写时复制”。正因为fork采用了这种写时复制的机制,所以fork出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行exec,会清空栈、堆。。这些和父进程共享的空间,加载新的代码段。这就避免了“写时复制”拷贝共享页面的机会。如果父进程先调度很可能写共享页面,会产生“写时复制”的无用功。所以在这种情况下,一般是子进程先调度滴(欢迎指正),这个关于父子进程谁先执行是一个比较复杂的问题不是本篇重点,需要考虑很多因素,这个我们之后再细细研究。
所以我们如何来证明写是复制呢,没错,寻找进程页面对应的物理地址,如果在更改字符串之后,那么会有一个新的物理页面产生,所以我们来搞一搞!!!
关于这个问题有什么方法呢,先抛出一个引子,进程在磁盘里的实体就在/proc/pid.
限于篇幅,这个实验请关注
彻底理解fork之写时复制<二>