Linux进程管理
[toc]
# 引言
操作系统中不乏一些优秀的设计理念,这其中进程的创建就涉及非常出色的思考,而本文将从Linux系统角度出发,针对进程的创建及其设计理念进行深入的剖析和讲解,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 进程的基本理念和发展史
# 多道程序设计的理念
计算机发展初期就已经有了多道程序的设计理念,即尽可能在单位时间内执行尽可能多的进程。以一个web服务器为例,当一个HTTP请求到来时,CPU需要发起I/O调度等待完成磁盘数据读取,这是一个相对漫长的过程。所以为了尽可能利用CPU时间片,操作系统会保存当前进程的上下文信息,即将当前的上下文保存在进程的结构体内存空间中,CPU转而去执行其他进程的指令。从用户的使用角度来看,多个程序看起来像是并行执行的:

基于单核计算机时代伪并行的优秀设计理念,使得现代计算机在启动时都会启动各种进程,例如:
- 后台监听电子邮件
- 周期性病毒库更新
- 病毒实时检测
这些程序都会在CPU的时间片中执行几十或者几百毫秒不等,即在保证CPU单位时间内瞬时只能执行一个指令的局限性下,通过利用每个指令阻塞的间隔去尽可能早地执行其他进程指令,通过这种并发实现伪并行,提升CPU单位时间内的吞吐量。
# 进程的基本设计和运行理念
从上文CPU的调度我们可以了解到,单位时间内只有一个进程(线程)可以被执行。而进程(线程)在进行调度切换时,都需要保存上下文信息。从操作系统的角度来看,每一个进程逻辑上都有一个程序计数器,它主要用于保存进程(线程)下一条要执行的指令的内存地址。 对于物理层面,只有一个物理程序计数器,所以在任意时刻计算机只能执行一个进程的指令,即获取被执行进程逻辑计数器中的指令。 当操作系统调度要切换进程时,程序计数器就会切换为另一个进程的逻辑计数器的指令,同时将正在执行的进程的上下文保存到内存中:

同时进程也是操作系统调度的基本单位,操作系统执行进程的时,从磁盘加载程序到内存中,等待CPU调度和执行。所以,从本质的角度来看,进程可以理解为存放在硬盘中的程序+用户输入和运行输出。即:
进程 = 程序 + 数据 + 系统资源
# 进程调度不可控
需要说明的是,尽管CPU可以灵活快速地切换进程,但无法灵活控制进程的切换时机。例如我们编写如下两个进程:
- 进程1:执行1000次的循环
- 进程2:播放音乐
由于CPU调度算法和执行速度,我们不能对调度顺序抱有任何幻想,即CPU执行这两个进程的时候,它无法保证先执行进程1还是进程2,除非循环操作是一个可靠的定时器,才有可能控制进程的休眠和调度时机。
# 详解进程的创建过程
# fork创建子进程
以我们最常用的Linux系统为例,系统的启动都是通过init进程为始,对应该进程我们可以通过ps指令查看到:
sharkchili@xxxxx:/tmp$ ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Sep15 ? 00:02:02 /usr/lib/systemd/systemd --syste
root 2 1 0 Sep15 ? 00:00:00 /init
2
3
4
对应的新进程都是依赖于进程fork调用,这也就意味着我们操作系统的大部分进程都是通过init进程fork创建的。从宏观角度来看,fork调用本质上就是创建一个和父进程(以本例就是init进程)一样的内存映像、环境变量和打开的文件描述符,然后子进程在执行其他调用时修改内存映像从而创建一个新的程序。例如我们在shell中执行sort指令,本质上就是基于当前shell的用户进程fork出一个子进程,复制当前shell的内存映射、环境变量以及打开的文件描述符信息,然后执行子进程的sort指令:
# file中的内容是3行文本,对应c b a
sharkchili@xxx:/tmp$ sort file
a
b
c
2
3
4
5

以更加具体的角度来说,Linux创建fork子进程的详细过程为:
- 父进程调用fork系统调用创建子进程
- 调用切入内核态执行子进程创建
- fork调用为子进程分配进程栈、用户空间以及PID
- 设置子进程以共享父进程的正文段
- 为数据段和栈段复制页表
- 设置共享打开文件
- 为子进程复制父进程的寄存器状态
随后执行sort等exec调用时,对应的步骤为:
- exec代码寻找可执行的程序并验证
- 读取和验证程序头文件
- 为内核复制变量和环境参数
- 释放旧有地址空间并分配新的地址空间
- 为栈复制变量和环境参数
- 信号复位和初始化寄存器
- 执行子进程指令调用,完全脱离父进程
总的来说,一个进程的创建过程大体是fork调用拷贝内存映像、打开文件和环境变量等信息后,基于要执行的指令进行各种复位和初始化,从而构成独立的进程来执行目标指令。

# 写时复制的优秀设计理念
理论上,在进程子进程创建时的复制必须独立且完整地进行分配。设计者考虑到这种复制的耗时和高昂的资源开销,于是提出了一种写时复制的优秀设计理念,即初始化创建子进程时页表都指向父进程的内存页,并标识为只读。当子进程需要修改对应内存页中的数据时,则通过写时复制的方式创建一个全新的副本进行修改操作,从而保证高效且节省RAM资源。
这一理念,对应我们的sort的案例也就是fork操作之后,通过cow(copy on write)创建全新的副本将exec要执行的sort指令写入该副本中,释放旧有的父进程的地址空间和页表:
# 小结
本文详细介绍了操作系统下进程调度的设计理念和进程的基本创建过程,通过一个经典的shell执行sort排序的例子,说明了进程通过fork系统调用的创建和写时复制的方式完成独立的进程创建和指令执行,希望对你有所启发。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 参考
《现代操作系统》