12 [虚拟化] 进程抽象;fork,execve,exit
南京大学操作系统课蒋炎岩老师网络课程笔记。
视频:https://www.bilibili.com/video/BV1N741177F5?p=12
讲义:http://jyywiki.cn/OS/2021/slides/8.slides#/
本讲概述
回到“操作系统是管理程序运行的软件”
- 操作系统中的进程
- 程序 = 状态机 (M,R)
- 操作系统 = 多个状态机
- 进程管理API
- fork:状态机的复制
- execve:状态机的重置
- exit
再次强调,一定要深入理解:程序(进程)就是一个状态机。
操作系统中的进程
复习:应用程序
应用程序 = 代码 + 数据(文件) = 状态机
- a.out, bash, ls ,grep
- gcc(cc1, as, collect2, ld)
- xedit, vscode
复习:操作系统
操作系统是管理多个应用程序执行的软件。
- 应用视角:操作系统就是一组系统调用
- 硬件视角:操作系统就是个状态机(C程序)
理解“最小”操作系统:
如果硬件提供一些机制(如虚拟存储来虚拟化内存M和寄存器R,即(M,R))使得各个“线程”不能访问其他“线程”、操作系统的内存,就得到了虚拟化的“进程”,仿佛独占CPU运行。注意:这里是将运行在操作系统上的各个程序(进程)看做了是运行在操作系统这个大程序(进程)上的一些”线程“。
操作系统:状态机的虚拟化
操作系统“模拟”了其中所有程序的状态机
- 这就是“虚拟化”
- 程序仿佛自己独占CPU运行,但它独占的只是CPU的一部分,其他部分它“看不见”。
进程:运行的程序。任意时刻,进程都可以看做是状态机的状态。
操作系统在终端以后,可以选择将进程(状态机)调度到CPU上运行。而进程执行系统调用,会使用指令(syscall)等回到操作系统。
“操作系统是一个中断处理程序”
- 被动的中断:硬件(时钟、I/O设备、NMI,)
- 主动的中断:系统调用
操作系统运行的两种模式
- 用户态(ring 3):应用程序运行在用户态
- 内核态(ring 0):操作系统运行在内核态
二者的切换如上面所述:
- 中断、系统调用:用户态 -> 内核态
- 操作系统调度:内核态 -> 用户态
就是这样的切换,使得我们的应用程序(在用户态)实现了虚拟化,同时操作系统仿佛就是一个中断处理程序。
操作系统课的三种调用
-
进程(状态机)管理
fork, execve, exit:进程(状态机)的创建、改变和删除
-
存储(地址空间)管理
mmap:对进程虚拟地址空间的一部分进行映射
brk:虚拟地址空间管理
-
文件(数据对象)管理
open, close:文件访问管理
read, write:数据管理
mkdir, link, unling:目录管理
fork() 状态机管理:创建状态机
如果需要创建状态机,我们需要什么样的API?
UNIX的答案:fork()
- 做一份状态机的完整的复制(内存M,寄存器现场R)
- 父进程返回子进程的PID,子进程返回0
fork bomb
fork bomb代码解析:
:(){:|:&};: # 一行版本的fork bomb:(){ # 格式化一下: | : &
};:fork(){ # 这其实在bash中定义了一个函数,bash允许以冒号作为标识符fork | fork &
}; fork
父子进程、进程树
因为状态机是复制的,因此总能找到”父子关系“。
因此有了进程树(pstree),如下:
systemd─┬─ModemManager───2*[{ModemManager}]├─NetworkManager─┬─dhclient│ └─2*[{NetworkManager}]├─accounts-daemon───2*[{accounts-daemon}]├─acpid├─avahi-daemon───avahi-daemon├─boltd───2*[{boltd}]├─colord───2*[{colord}]├─cron├─cups-browsed───2*[{cups-browsed}]├─cupsd───dbus├─dbus-daemon├─gdm3─┬─gdm-session-wor─┬─gdm-x-session─┬─Xorg───{Xorg}│ │ │ ├─gnome-session-b─┬─gnome-shell─┬─ibus-daemon─┬─ibus-dconf───3*[{ibus-dconf}]│ │ │ │ │ │ ├─ibus-engine-sim───2*[{ibus-engine-
...
进程树的存在,是我们用fork()复制创造子进程,所得到的一个很自然的结果。
例程1
猜猜会打印出什么呢?(提示:可以试着画一下状态机,线程树)
#include <unistd.h>
#include <stdio.h>int main(){pid_t pid1 = fork();pid_t pid2 = fork();pid_t pid3 = fork();printf("Hello World from (%d, %d, %d)\n", pid1, pid2, pid3);
}
例程2
猜猜会打印出什么呢?
#include <unistd.h>
#include <stdio.h>#define N 2
int main(){for (int i=0; i<N; i++){fork();printf("Hello\n");}
}
还是可以逐步画一下程序的状态机,
gcc test.c
./a.out
输出结果应该是6。
有趣的是,如果我们将输出重定向到管道,再通过wc -l
命令打印出行数,这时会输出8,这不禁令我们大为惊奇。
为什么会这样呢?这其实是因为printf函数将内容直接输出到标准输出stdout时是直接输出的,会按照我们的理解打印出6个Hello。但是如果要重定向到管道(或文本文件等),printf则会将要输出的内容先放置到缓冲区,到最后一起打印,在本例中,由于我们fork()创建进程的时候,会将全部的内存M和寄存器现场R复制一份,导致每次fork()时,缓冲区也被完整地复制,最后我们每个进程的缓冲区有2个Hello,最后共有四个进程,故会有8个Hello。(也可以重定向到文本文件中试一下,确实是有8个Hello)。这恰好进一步验证了我们所说的fork()是对整个(M,R)的完整复制。
可以尝试理解一下N=3时正常打印和重定向打印会有多少个Hello。笔者认为分别是 ∑i=1N2i\sum_{i=1}^N2^i∑i=1N2i 和 2N×N2^N\times N2N×N。
机器永远是对的,计算机系统的世界没有魔法,一切都是按部就班地进行的
execve() 状态机管理:替换状态机(执行)
只有fork新建还不够,我们还需要能够执行别的程序。
UNIX的答案:execve
execve(filename, argv, enpv)
- 执行名为filename的程序
- 分别传入参数argv(v)和环境变量enpv(e)
这刚好对应了main函数的参数:
int main(int argc, char** argv, char** enpv){// ...
}
关于main函数的参数:Linux中 C++ main函数参数argc和argv含义及用法
execve可以看作状态机的重置。
环境变量
环境变量即应用程序执行的环境。
- 使用env命令来查看
- PATH:可执行文件的搜索路径
- PWD:当前路径
- HOME:home目录
- DISPLAY:图形输出
- PS1:shell的提示符
- export:告诉shell在创建子进程时设置环境变量
PATH环境变量
PATH环境变量是可执行文件的搜索路径
还记得gcc的strace结果吗
[pid 28369] execve("/usr/local/sbin/as", [“as”, “–64”, …
[pid 28369] execve("/usr/local/bin/as", [“as”, “–64”, …
[pid 28369] execve("/usr/sbin/as", [“as”, “–64”, …
[pid 28369] execve("/usr/bin/as", [“as”, “–64”, …
这个搜索顺序恰好是PATH环境变量中指定的顺序:
$ PATH="" /usr/bin/gcc fork-demo.c
gcc: error trying to exec ‘as’: execvp: No such file or directory
$ PATH="/usr/bin/" gcc fork-demo.c
在gcc被execve时,将环境变量PATH传给gcc,它就会按照其顺序来搜索可执行文件的路径。
计算机系统里没有魔法,机器永远是对的
exit() 状态机管理:终止状态机
有了fork,execve,我们可以自由地创建、执行程序(状态机)了,还缺一个销毁状态机的函数
UNIX的答案:exit
- 销毁当前状态机,并允许有一个返回值
- 子进程终止会通知父进程(之后会讲)
问题是一个进程(状态机)中有多个线程啊。
结束程序执行的三种方法
exit的几种写法,它们是不同的:
- exit(0) - stdlib.h中声明的libc函数
- 它会调用atexit
- _exit(0) - glibc中的syscall wrapper
- 执行exit_group系统调用,终止整个进程(所有线程)
- 不会调用atexit
- syscall(SYS_exit, 0)
- 执行exit系统调用终止当前线程
- 不会调用atexit
最起码要区分好库函数(应用程序的一部分)和系统调用。
可以用strace观察各种结束方式的执行。
Fork-Exec vs. Spawn
我们既然fork创建了一个子进程,那我们绝大多情况下肯定是要execve执行这个进程的,也就是说fork后面几乎一定会跟着execve,那为什么不直接把它们合成一个系统调用 spawn(path, argv, enpv) 呢?即spawn = fork + execve
实际上,fork + execve是一个非常优雅的实现,因为要考虑到进程可以持有操作系统中的对象,这使fork、execve、exit还要涉及到操作系统的对象的管理。
例如,在上面用到过的管道技术中:
./a.out | wc -l
其中./a.out
持有了操作系统中的对象——管道的写口,而wc -l
则持有了管道的读口,从而能够将前者的输出作为后者的输入。而如果./a.out
这个进程(状态机)需要fork一个子进程,那么这个子进程就可以自然地复制拿到父进程的全部(M,R)。这样,对于操作系统中的对象——管道,子进程就持有了其写口。
Take aways and Wrap-up
虚拟化
- 程序 = 状态机
- 操作系统 = 状态机的管理者
- 用硬件(物理状态机)实现多个并发执行的虚拟状态机
- API:fork,execve,exit
Ref:
http://jyywiki.cn/OS/2021/slides/8.slides#/
https://www.bilibili.com/video/BV1N741177F5?p=12