进程一点点的基础知识

355 阅读10分钟

前言

在计算机系统中进程是始终绕不过的话题,那么进程到底是什么呢?

定义

可以先看一下 MacOS 系统下的进程:

从上图可以看出,一个程序对应着一个进程,所以我们可以得到程序和进程是一对的概念,它们分别描述了一个程序的静态形式和动态特征。除此之外,进程还是操作系统进行资源分配的一个基本单位。

还有段很枯燥的进程定义:

  • 狭义定义:进程即正在运行程序的实例
  • 广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动

是不是很枯燥,其实说白了,就是程序是一个没有生命的实体,只有在处理器在执行一个程序时这个执行的过程称为进程。

进程的创建

Unix/Linux 系统中的每一个进程都有原始父进程(init 进程),所有的进程共同组成了一个树状结构。内核启动进程作为进程树的根,负责系统的初始化操作,它是所有进程的祖先,它的父进程就是它自己,这个应该叫原始进程或者祖先进程(哈哈哈个人理解)。

而其他进程呢,则可以通过使用 fork (系统调用函数)创建若干个新的进程,其中前者称为后者的父进程,后者称为前者的子进程。

每个子进程都是源自它的父进程的一个副本,它会获得父进程的数据段、堆和栈的副本,并与父进程共享代码段。每一份副本都是独立的,子进程对属于它的副本的修改对其父进程和兄弟进程(同父进程)都是不可见的,反之亦然。

全盘复制父进程的数据是一种相当低效的做法,Linux 操作系统内核使用写入时复制(Copy On Write,COW)技术来提高进程创建的效率。当然,刚创建的子进程也可以通过系统调用 exec 把一个新的程序加载到自己的内存中,而原先在其内存中的数据段、堆、栈以及代码段会被替换掉。在这之后,子进程执行的就是那个刚刚加载进来的新程序。

fork: 一个系统调用函数,将父进程复制一份出来即子进程

exec: 装载一个新的程序,覆盖当前进程内存空间,从而执行不同的任务

写入时复制(Copy-on-write,COW)是一种计算机程序设计领域的优化策略,核心思想就是当多个调用者同时请求相同的资源,他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这段来自维基百科,COW 在很多服务中起到了重要的作用,不单单是进程,例如还有 Redis 中也会用到。(如果大家有兴趣的话,我可以单独写一篇详细介绍 COW 文章)

进程的标识

在数据库的每张数据表中,我们一般会建一列自增的 ID 来作为一条记录的唯一标识;在现实世界中,我们每个人的身份证号码作为每个人的唯一标识。

为了管理进程,内核必须对每个进程的属性和行为进行详细的记录,包括进程的优先级、状态、虚拟地址范围以及各种访问权限,等等。这些信息都会被记录在每个进程的进程描述符中,进程描述符并不是一个简单的符号,而是一个非常复杂的数据结构。保存在进程描述符中的进程 ID (PID)是进程在操作系统中的唯一标识,其中进程 ID 为 1 的进程就是内核启动进程。

进程 ID 是一个正整型且总是顺序的编号,新创建的进程 ID 总是前一个进程 ID 递增的结果。此外,进程 ID 也可以重复使用,当进程 ID 达到其最大限值时,内核会从头开始查找闲置的进程 ID 并使用最先找到的那一个作为新进程的 ID。另外,进程描述符中还会包含当前进程的父进程 ID(PPID)。

可以通过 Go 的标准库代码包 os 查看当前进程 ID 以及 PPID,可以从结果看,每次进程 ID 总是前一个进程 ID 递增的结果。

PID 对内核以外的程序非常有用,内核可以高效地把 PID 转换成对应的进程描述符。例如,如果我们想要终止某个 PID 所对应的进程,我们可以通过 kill 命令进行终止,这就是发送一个退出的信号,当然还可以发送其他信号。

进程的状态

和我们人一样都是有不同的状态,例如我们会有睡眠状态、工作状态等等,进程也是一样有不同的状态。

  • 可运行状态(TASK_RUNNING)如果进程处于该状态,则表示它可立刻要或者正在CPU上运行,而运行的时机则有调度器来决定
  • 可中断的睡眠状态(TASK_INTERRUPTIBLE)当进程正在等待某个事件到来时,会进入此状态,进程会被放入对应事件的等待队列中,当事件发生时那么这个进程就会被唤醒
  • 不可中断的睡眠状态(TASK_UNINTERRUPTIBLE)这个状态与可中断的睡眠状态状态不同的是,它不会被打断,不会对任何信号作出响应,处于此状态的进程通常是在等待特殊的事件,例如等待同步的I/O操作完成
  • 暂停状态或跟踪状态(TASK_STOPPED或TASK_TRACED)
  • 僵尸状态(TASK_DEAD-EXIT_ZOMBIE)处于此状态的进程即将结束运行,该进程占用的绝对大多数资源也都已经被回收,不过还有一些信息未被删除,之所以保留,是因为考虑到父进程可能需要它们。由于此时的进程主体已经被删除的只剩下一个空壳,所以该状态被称为僵尸状态
  • 退出状态(TASK_DEAD-EXIT_DEAD)在进程退出的过程中,有可能所有信息都不需要被保留,那么这种情况该进程会立即被结束掉,它占用的系统资源也会被操作系统自动回收。

进程在其生命周期内可能产生一系列的状态变化,简单的说,进程的状态只会在可运行状态和非可运行状态之间转换。下图展示了一个进程的生命周期以及进程状态的转换:

进程的空间

用户进程总会生存在用户空间中,它们可以做很多事情,却不能与其所在计算机的硬件进行交互。内核可以与硬件交互,但是它却生存在内核空间中。用户进程无法直接访问内核空间,用户空间和内核空间都是操作系统在内存上划分出的一个范围,它们共同瓜分了操作系统能够支配的内存区域,并体现了 Linux 系统对物理内存的划分。

内存区域中的每一个单元都是有地址的,这些地址由指针来标识和定位,通过指针来进行内存寻址。这里的地址并非为物理内存的地址,而是虚拟你地址,虚拟地址标识的内存区域又称为虚拟地址空间,即虚拟内存。虚拟内存的最大容量与实际可用的物理内存大小无关,内核和CPU会负责维护虚拟内存和物理内存之间的映射关系。

内核会为每个用户进程分配的是虚拟内存而不是物理内存,每个用户进程分配到的虚拟内存总是在内存空间中,而内核空间则留给内核专用。另外,每个用户进程都认为分配给它的虚拟内存就是整个用户空间。一个用户进程不可能操纵另一个用户进程的虚拟内存,因为后者的虚拟内存对于前者来说是不可见的,也就是说这些进程的虚拟内存几乎是独立互不干扰的,这是由于它们基本上被映射到了不同的物理内存之上。

进程的系统调用

用户进程生存在用户空间中且无法直接操纵计算机硬件,但是内核空间中的内核可以做到,用户进程无法直接访问内核空间,也无法直接指使内核,为了使用户进程能够使用操作系统更底层的功能,内核会暴露出一些接口供它们使用,这些接口是用户进程使用内核功能的唯一手段,也是用户空间和内核空间的唯一通道。用户进程使用这些接口的行为即成为系统调用。

用户态和内核态

为了保证操作系统的稳定和安全,内核由 CPU 提供的可以让进程驻留的特权级别简历了两个特权状态:用户态和内核态。

  • 内核态:CPU 在用户态下运行的用户进程不能与内核接触的,当用户进程发出系统调用时,CPU会从用户态切换到内核态,来执行对应的内核函数
  • 用户态:当内核函数执行完毕后,内核会把CPU从内核态转换成用户态,并把执行结果返回给用户进程

进程的切换和调度

与其他分时操作系统相同,Linux 操作系统也可以凭借CPU快速在多个进程之间进行切换,即进程间的上下文切换。无论切换速度如何,在同一时刻正在运行的进程仅会有一个。

切换 CPU 正在运行的进程是需要代价的,例如,内核此刻要换下正在 CPU 上运行的进程A,并让 CPU 开始运行进程B,在换下进程A之前,内核必须要及时保存进程A的运行状态,如果进程B不是第一次执行,那么内核必须将进程B上次运行的状态以及保存的信息,这种在进程换出换入期间必须要做的任务即为进程切换。

除了进程切换,为了使各个生存着的进程都有运行的机会,内核还要考虑下次切换时运行哪个进程、何时切换进程、被换下的进程何时再换上等等,那么这种的任务即为进程调度。

总结

这篇文章主要介绍了什么叫进程,进程的创建过程,进程生存的空间以及切换调度等等,在计算机系统中算是基础知识吧,所以把进程弄清楚还是很有必要的。

在本篇文章还有提到一个概念 COW,这个在进程以及其他服务中都很重要,而且它的思想也很重要,强烈推荐了解一下。

后续还有一篇关于进程通信的文章,会详细介绍进程之间是如何通信。

参考

《深入理解计算机系统》

《Linux 高性能服务器编程》

《Go 并发编程实战》

推荐阅读

为什么 Linux 需要虚拟内存?draveness.me/whys-the-de…

为什么 Linux 需要 swapping draveness.me/whys-the-de…