前面对 fork 函数牛刀小试,相信你已基本掌握了简单的“影分身术”了,不过在篇末,却为各位留下了一些坑位。为了能够说明白一些问题,本篇将讨论有关进程的一些必备知识,以及 fork 函数的底层实现。如此一来,也方便加深对其它有关问题的理解。当然,如果你对此完全不感兴趣,大可跳过。
本文所讨论的范围,限制在 32 位的 linux 操作系统。
1. 进程空间
这里的进程空间,说的就是进程虚拟地址空间。
稍微懂点编程和操作系统的同学都应该知道,每个进程都有自己的 4GB 虚拟地址空间,这具有深远的意义。有个经典的 C 语言的例子,就是进程 A 中往地址 0x50000000
写入一个 int 类型的整数 100,然后在进程 B 的地址0x50000000
写入另一个 int 类型的整数 1000,最后两个进程再各自通过 printf 函数打印这两个地址保存的值,发现进程 A 仍然可以正常打印 100,而进程 B 正常打印出 1000.
这段代码,可以在windows 系统的 visual studio 模拟(没有使用 linux 是因为 linux 不直接提供在指定地址上分配内存的函数,另外在概念上,windows 的进程空间和 linux 是互通的,不会有太大区别)。
- 进程 A 的代码
#include <windows.h>
int main()
{
int *buf = (int*)0x50000000;
LPVOID res = VirtualAlloc((LPVOID)buf, sizeof(int), MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (res != buf) {
printf("ERROR!\n");
return 1;
}
*buf = 100;
printf("A = %d\n", *buf);
Sleep(3000);
return 0;
}
- 进程 B 的代码
#include <windows.h>
int main()
{
int *buf = (int*)0x50000000;
LPVOID res = VirtualAlloc((LPVOID)buf, sizeof(int), MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (res != buf) {
printf("ERROR!\n");
return 1;
}
*buf = 1000;
printf("B = %d\n", *buf);
Sleep(3000);
return 0;
}
然后开启了两个控制台窗口,同时(只要 A 进程没结束的情况下启动 B 程序就行了)运行这两个程序,结果如下。
图 1
形成图 1 中现象的原因在于进程 A 和进程 B 的地址空间是隔离的,尽管它们都在地址 0x50000000 这个位置写了不同的值,但是各自都互不影响。这就好像我在我家菜地的5号地种的土豆,你在你家菜地的5号地种的黄瓜,根本就是谁也挨不着谁。
图 2
2 虚拟地址到物理地址的映射
对于进程来说,其实属于进程那一块菜地,是虚拟的,是假的。这中间有一步到物理地址的映射过程。就好像上面的菜地,你看到的是菜地也是假的。要怎么理解这件事情,难道你看到的不是菜地吗?
2.1 种菜的故事
不妨假设一下,亲自下地种菜的人不是你本人,而是由另一个管家代劳。而你,只负责下达命令就行了。当你来到这个世上,你老爸就告诉你,你有一块的菜地,被分成了16个小块,你可以在任意一块地上随便种,你想种什么,种在哪块地上,和管家说一声就行了,你想从地上摘什么菜,也和管家说就行了。你爸把菜地大概的样子画给你看,就像图 2 中的那样。
除此之外,你还有一个弟弟,你老爸也和你弟说了相同的话,你弟弟也有和你一块一样大小的地,而且你弟弟要种菜,也只要给你家的管家下达命令就行。
就这样,你和你弟弟就在图 2 那样的菜地上,各自快乐的种着菜,生活了几年;可是你并不知道你家菜地真实情况是长啥样,你弟弟也同样不知道。你和你弟弟知道的菜地的样子,仅仅就是你老爸当初给你们画出来的图 2 的样子。
其实对你来说,菜地长啥样都无所谓了。你的菜地上有什么,统统问管家,你想在哪块地上种什么,也由管家代劳。
2.2 菜地长什么样
就这样,5年过去了。种了 5 年菜的你已经不耐烦了,突然有一天,你想亲自看看你的菜地。你不顾一切管家的阻挠,带着你弟弟,一起来到了所谓的菜地,这时候,你们慌了。你们看到的菜地,实际是图 3 这样的。根本就没有你和你弟弟的菜地,你们种的菜,全都只是在图 3 这块地上。
图 3
管家气喘吁吁的正好赶来了,你和你弟弟刚好和他对簿公堂。只有一块地,却骗了你们 5 年。管家是如何做到的?再三追问下,管家拿出了他的两本笔记本,说秘密都在这里了。其中一本记录了你的菜地使用情况,另一本记录了你弟弟的菜地使用情况。
管家继续说到,如果你说要去你的 5 号地取土豆,他就会翻翻笔记本,看看你的 5 号地对应实际哪块地,比如现在就对应着实际的 3 号地,管家就会去 3 号地把东西取给你。对你弟弟也是一样,只要拿出相应对应的笔记本就可以很快查到。这就好像是下面这个样子。
图 4
2.3 进程的那块地
看完前面的故事,我希望你能够触类旁通,每个进程生来认为自己有 4GB 的虚拟地址,可却被“管家”(操作系统)欺骗了一生,它没有你和你弟那么幸运,最终破解这个分地谜题。进程在自己的土地上心情的挥霍,它意识不到真正的菜地到底有多大(你的物理内存),只要“管家”能够满足它的需求就行了(管家甚至做了内存交换这种事,因为实际的土地只有 0 到 20 号,一共 21 块地,而你和你弟弟的地加起来有 32 块,万一土地不够了,管家就会偷偷的把你们不经常用的土地里的东西取到硬盘上)。
值得一说的是管家的笔记本,对应到进程这里就是页表的概念,有关这一块的知识,不详细展开,对细节感兴趣的同学,请参考我的另一个笔记《OS学习笔记》。
3. fork 干了什么
有了进程空间的概念,就很容易知道 fork 做了什么事。以前面的菜地为例,再假设你没有弟弟。fork 的含义有点类似下面这样:
- 你告诉你老爸,给我生个弟弟出来。
- 你告诉你的管家,给我弟弟种一片和我的菜地一模一样的菜地出来。
这一切完成后,菜地应该是这样的。
图 5 你弟弟复制了一份你的菜地
那么再此之后,你种什么,你弟弟种什么就各不相干了。
相对于进程来说,这两个进程的进程空间是一模一样。
4. 上一篇的遗留问题
如果你完成上一篇最后一节总结里的实验,你会惊讶的发现,tmp 文件内容和直接运行 ./myfork
打印在屏幕上的结果是完全不一样的。
tmp 文件里的内容,应该像下面这样:
Hello, I'm father
before fork
I'm father 3542; my child is 3543
before fork
I'm child 3543; my father is 3542
执行 ./myfork
打印到屏幕上的内容,却是这样:
Hello, I'm father
before fork
I'm father 4382; my child is 4383
I'm child 4383; my father is 4382
你比较感兴趣的地方应该在于 tmp 文件里的 before fork 竟然出现了两次!这个谜题在这一篇相信你可以搞清楚,原因在于 printf 这个函数,它是带缓冲区的!!!
当 printf 接收到字符串后,第一件事情是把字符串复制到一个 char 数组(缓冲区)里,当这个数组遇到了特定的字符,比如 ‘\n’ 字符,回车或者装满等等,就会立即把字符写到屏幕终端上。
而当我们把 printf 重定向到文件的时候,如果 printf 函数遇到 ‘\n’ 字符,并不会立即把字符写到文件里,这是 printf 函数将字符定向到屏幕和文件的重要区别。
所以当 ./myfork > tmp
这个进程执行到 fork 的时候,printf 里的缓冲区数据还没来得及被刷新到 tmp 文件里,就被 fork 函数复制了,同时,printf 的缓冲区也被复制了一模一样的一份出来。这也就是为什么子进程里为什么子进程也会输出 before fork 的原因。
5. 关于 fork 函数的返回值
前一节讲了 fork 就是将进程的地址空间完完全全的复制了一份(不是100%复制,除少数几个地方外),实际在复制完后,操作系统会悄悄的修改复制出来的进程空间里的 fork 函数的返回值,把它改成 0(准确的说不是改,而是根本就没有复制原来的值,直接把它赋值为 0)。
这也就是子进程 fork 函数返回了 0 的原因。
6. 写时复制技术
到此 fork 并没结束。早在 linux 0.11 里,fork 函数就实现了 Copy On Write(COW) 机制,也就是写时复制技术。什么意思呢?
你告诉管家给你弟弟种一份一模一样的菜地时,管家并没有照做,他干了下面这样的事情:
图6 读时共享
不得不说,这管家已经懒到家了……但是也不得不说,管家很聪明。
当你弟弟只是问问管家,我的 5 号地种了什么?管家什么也不用管,直接查查笔记就告诉你弟弟,这是土豆就行了。
当你和你弟弟任何一方想挖掉在 5 号地上的土豆了怎么办?这岂不是很糟糕?不会的。聪明的管家早就意识到了这个问题。如果你弟弟需要挖掉他 5 号地,管家这时候才会真正的把你的 5 号地再复制一份出来,然后从真正的土地做一个映射,分配到你弟弟的 5 号地上,你弟弟爱怎么挖怎么挖,都不会影响到你。
图 7 写时复制
7. 总结
- 理解进程空间的概念,知道进程的地址是虚拟的
- 知道进程地址空间之间是隔离的
- 理解进程虚拟地址和物理地址之间的映射关系
- 理解 fork 函数的工作原理
- 理解为什么子进程的 fork 函数返回 0
- 理解写时复制技术