9.fork 的应用
系统调用 👉 libc 👉 shell 👉 应用的“软件栈”
能不能用系统调用实现比普通业务逻辑代码更有趣的东西?
如何把操作系统玩出花来?😂
fork 行为的补充解释
要讲 fork 就要讲操作系统。操作系统是个大的状态机,大的状态机里可以分组,比如说一个程序又是一个状态机,当然操作系统内部也有自己的状态,比如说操作系统的对象,程序可以用文件描述符指向对象。
操作系统自己的代码不执行时(一般情况),会直接把一个程序放到CPU上执行,这个程序如果执行 syscall 时,比如 fork 时,那么操作系统会相应的更新操作系统的状态。即 执行 fork 的程序的状态机完整的复制一份,除了 fork 的返回值不同。父进程返回子进程的 pid,子进程返回 0 。
前面都是讲代码,现在反过来想的时候,还有一些有意思的小东西。比如说如果原进程持有一个操作系统的对象,比如 fd=0(stdin)
,那么 fork 以后,子进程同样持有指向同一个操作系统对象的文件描述符,这里面做了一个拷贝,像指针的赋值一样。文件描述符就是个整数 int fd;
操作系统的对象只有一个,两个进程指向了同一个。
我们在 execve 的时候,重置了一个状态机。比如执行 execve("echo");
,这个进程就重置为了 echo
的初始状态。如果在重置前,持有 fd=0
那么时保持的。这个设计,使得在 fork + execve 运行新程序前,可以打开一个管道,这样父子进程都持有指向管道的文件描述符,然后就可以把一部分计算的输出管道给另外一部分计算的输入。
当我们有了文件描述符以后,就有了一点小麻烦。
文件描述符通过这个函数来得到
比如说 open("a.txt"); 这个 a.txt 是操作系统里的一个对象。这时候就会有一个新的文件描述符比如说 fd=3 指向这个 a.txt 对应的对象。
文件描述符是一个指向操作系统内对象的 “指针”,我们只能用操作系统允许的方式来访问
对象只能通过操作系统允许的方式访问
从 0 开始编号 (0, 1, 2 分别是 stdin, stdout, stderr)
可以通过 open 取得;close 释放;dup “复制”
对于数据文件,文件描述符会 “记住” 上次访问文件的位置
write(3, "a", 1); write(3, "b", 1);
对于这个特性,
这段代码,会得到什么呢?父进程写入 world 子进程写入 hello。会覆盖吗?
write 的内在含义就是一直往后写。因此不覆盖才是合理的。从这里,操作系统开始变复杂了。有了偏移量以后操作系统如何管理呢?
复杂性的来源:东西越来越多,设计者还为了保持向前的兼容。好多看起来奇怪的问题都是由历史遗留问题造成的。
前面是对fork的一点补充。
一点点关于 fork 的实现问题。
写时复制
概念上 fork 就是创建了一个进程的副本,但这件事情代价很大,复制一个状态机包括里面的数据。此外类似的情况,操作系统里 malloc(1GB)
这个事情也是允许的。如果进程 fork 了复制了以后又 execve 了,那岂不是前面白复制了,数据也没用上就丢掉了。有没有可能给一个高性能的实现呢?
操作系统如何应对这个问题?
copy-on-write
进程是有地址空间的。pmap看到的就是,操作系统实现虚拟地址的时候用了分页,用了 MMU,实际上进程的每个页面,
进程只是个映射表,内存页面是操作系统的,概念上的状态机,实现上,所有进程的页面属于操作系统,操作系统做了偷梁换柱,有个CR3寄存器,做了个地址翻译,使得进程可以引用操作系统为他分配的页面。
既然所有页面都是操作系统持有的,那么就可以耍一些小花招,在实现 fork 时除了按照里要所有内容都复制一份。还可以让新进程和老进程共享页面,即只复制映射表。新老进程指向同一个可写页面。对于可读可写段,临时禁止写这个行为,这样要写时,触发缺页中断,如果是非法访问,那么是报段错误,如果发现是临时禁止的,那么就拷贝一份再写。
除此之外,操作系统还要维护一个引用计数,计数为1,就可以把rw权限重新还回去。如果是fork后execve基础不会去写内存,这样开销就很小。
这就是 copy-on-write 。好处,一些.so动态库,一些都会引用的东西,整个物理内存里有一份副本就行。
如何写代码证明操作系统有这个机制?
并发,查看内存占用,试探操作系统的行为。
这时候想查看一个进程占用了多少内存是个伪命题。mmap查看的,可以分配特别大的内存,但是不使用。
状态机的复制
最后更新于