目录

操作系统 | 进程管理之进程【思维导图版】

下面是程序锅在寒假期间根据《现代操作系统原理和实现》做的一个思维导图,完整、详细、清晰的文字版本后头会出,因为自己也想出整一套出来。

https://img.dawnguo.cn/All/进程管理-1.png

以下内容是由 XMind 根据上述思维导图的内容自动转换而来。

进程

进程的控制块

  • 保存进程的相关状态,比如进程标识符(PID)、进程状态、虚拟内存状态、打开的文件。不同操作系统中 PCB 包含的内容可能会有所不同。

进程的状态(五大)

  • 新生状态

    • 该状态表示一个进程刚刚被创建出来,还未完成初始化,不能被调度执行。在经过初始化状态后,迁移至预备状态。
  • 预备状态

    • 该状态表示进程可以被调度执行,但还未被调度器选择。在被调度器选择执行后,迁移至运行状态。
  • 运行状态

    • 该状态表示进程正在 CPU 上运行。当进程执行一段时间后,调度器可以选择中断它的执行并重新将其放回调度队列,即变为了预备状态。当进程运行结束之后,迁移至终止状态。如果进程等待某些外部事件,它可以放弃 CPU 并迁移至阻塞状态。
  • 阻塞状态

    • 该状态表示进程需要等待外部事件(如等待某个 IO 请求的完成),暂时无法被调度。当进程等待的外部事件完成后,会被迁移到预备状态。
  • 终止状态

    • 该状态表示进程已经完成了执行,不会再被调度。

进程的内存空间布局

  • 用户栈

    • 栈保存了进程需要使用的各种临时数据(如临时变量的值)。栈是一种可以伸缩的数据结构,扩展方向是自顶向下。
  • 代码库

    • 进程执行时依赖的代码库(如 libc)就会被映射到用户栈下方的虚拟地址处,并标记为只读。
  • 用户堆

    • 堆管理的是进程动态分配的内存,堆的扩展方向是自底向上。
  • 数据与代码段

    • 这些原本都保存在进程需要的二进制文件中,在进程执行前,操作系统会将它们载入虚拟地址空间中。数据段主要保存的是全局变量的值。
  • 内核部分

    • 每个进程的虚拟地址空间里都映射了相同的内核内存。当进程在用户态运行时,内核内存对其不可见;只有当进程进入内核态时,才能访问内核内存。内核部分也有内核需要的代码和数据段,并且当进程由于中断或者系统调用进入内核之后,使用的是内核的栈。

进程的上下文切换

  • 进程的上下文包括进程运行时的寄存器状态。当 OS 需要切换当前执行的进程时,会使用上下文切换机制,该机制会将前一个进程的寄存器状态保存到 PCB 中,然后将下一个先前保存的状态写入寄存器,从而切换到该进程执行。

[[实例:Linux 进程]]

  • 进程的创建

    • fork

      • 在 Linux 中,进程一般是通过调用 fork 接口,从已有的进程中“分裂”出来的。一般将调用 fork 接口的进程称为父进程,将 fork 创建的新进程称为子进程。

fork 刚刚完成时,两个进程的内存、寄存器、程序计数器等状态都完全一致,但它们是完全独立的两个进程,拥有不同的 PID 与虚拟内存空间。

为了方便应用程序区分父进程和子进程,fork 为两个进程提供了不同的返回值,对于父进程而言 fork 的返回值是子进程的 PID,对于子进程而言,fork 的返回值是 0。

由于子进程在 fork 的过程中获取了完全相同的拷贝,这不仅包括寄存器和内存状态,还包括了打开的文件等 PCB 中包含的内容。所以父子进程使用同一文件描述符操作时,操作的是同一个文件,即父进程对文件的操作会影响到子进程对文件的操作。比如父进程读取了一些内容,导致文件偏移量改变了,那么子进程再读取时是在改变后的文件偏移量上继续读取的。

        - 附:Windows 的进程创建

不同于 Linux,Windows 采用的创建接口为 CreateProcess 系列。与 fork 相比,CreateProcess 的设计更加直观,它会从头创建进程,载入参数指定的二进制文件,并根据其他参数设定的配置直接开始执行指定的二进制代码。由于采取的是从头创建,所以 CreateProcess 需要对进程的运行参数进行大量配置,在提供灵活性的同时也使接口变得异常复杂。

    - 优点

        - fork 的设计具有惊人的“简洁之美”,完全不需要任何参数。在早期的时候,由于 fork 生成的子进程来源于父进程的拷贝,所以它的实现也非常简单。
        - fork 和 exec 的组合可以认为将进程的创建过程进一步解耦:fork 为进程搭建了骨架,而 exec 为进程添加了血肉,两者的分工非常清晰。同时,程序可以在 fork 调用后,在 exec 调用前,对子进程进行各种设定,比如对文件进行重定向。
        - fork 强调了进程与进程之间的联系,因为 fork 具有创建原始进程的拷贝的语义。而这种联系为进程的管理提供了便利。比如,在 shell 中,虽然同一个 shell 创建的进程的功能各不相同,但它们都来自同一个用户,因此可以共享很多状态。在这种父进程和子进程之间存在较强关联的,非常适合使用 fork。

    - 缺点

        - 早在 20 世纪 60 年代,加州大学伯克利分校主导的分时系统研究项目 Genie 就首先使用 fork 作为系统调用,并且 Unix 也沿用了这个设计了。然而 fork 太老了,计算机发生了天翻地覆的变化,很多是 fork 设计者们当初设计时无法想象到的,所以 fork 也遇到了一些挑战。
        - fork 的实现已经变得过于复杂了。虽然 fork 的接口还是那么简洁,但是随着操作系统支持越来越多的功能,而 fork 默认语义又是用于构造与父进程一样的拷贝,所以 fork 在实现过程中需要考虑的特殊情况越来越多。每当操作系统为进程的结构添加功能时,就得考虑 fork 的实现和修改。并且, fork 的实现与进程、内存管理等模块的耦合度过高,不利于内核的代码维护。
        - fork 的性能太差,由于 fork 需要创建出原进程的一份拷贝,所以当原进程的状态越多时,fork 的性能越差。在过去,fork 只需要应付内存规模较小的 shell 程序,但是现在大内存应用已经十分普遍了,尽管写时拷贝技术已经大大减少了内存拷贝,但是对于这类应用来说,就连建立内存映射都需要耗费大量时间。
        - fork 存在潜在安全漏洞,比如 fork 建立的父子进程间的联系就会成为攻击者的切入点。之外,fork 还存在扩展性差,与异质硬件不兼容、线程不安全等。

    - POSIX 标准中列出的调用 fork 时的 25 种特殊情况的处理方法

https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html - 写时拷贝的优化:

早期的 fork 会讲父进程的物理内存完整拷贝一份,并映射到子进程的内存空间中。这种方式在很多情况下是不必要的,一是因为部分虚拟内存是只读的,对它们进行拷贝是一种浪费,而是因为进程往往在 fork 之后会调用 exec 以载入新的可执行文件,重置地址空间,那之前的拷贝将完全失去意义。因此,后头的 fork 使用写时拷贝技术对 fork 的实现进行了优化。这样之后,对于那些本来就是只读的虚拟页来说,父进程和子进程可直接共享,而对于容易发生变化的虚拟页(如堆和栈对应的页),如果出现了写操作,那么将会触发写时拷贝,由操作系统负责处理。

写时拷贝不仅提升了 fork 的性能,还降低了进程占用的内存资源。

- exec

    - fork 完成之后,得到的是一个与父进程几乎完全相同的子进程,但是子进程跟父进程执行的任务往往是不同的。这个时候就需要使用 exec 接口。

exec 接口实际上是由一系列接口组成,存在多个变种。其中功能最全面的是 execve(const char *pathname, char *const argv[], char *const envp[])。

  • 第一个参数 pathname 是进程需要载入的可执行文件的路径。
  • 第二个参数是进程执行所需的参数。
  • 第三个参数 envp 是为进程定义的环境变量,一般使用键值对(USERNAME=张三)传入。

exec 一般完成以下几个步骤:

  • 根据 pathname 指定的路径,将可执行文件的数据段和代码段载入当前进程的地址空间中。

  • 重新初始化堆和栈。在这里操作系统可以使用地址空间随机化(Address Space Layout Randomization,ASLR)操作,改变堆和栈的起始地址,增强安全性。

  • 将 PC 寄存器设置到可执行文件代码段定义的入口点(不是 main 函数),该入口点会最终调用 main 函数。

    • fork/exec 的替代方案

      • fork 和 exec 融合:posix_spawn

        • posix_spawn 可以被认为是 fork 和 exec 两者功能的结合(最初是为不支持 fork 的机器设计的):它会使用类似于 fork 的方法(或者直接调用 fork)获得一份进程的拷贝,然后调用 exec 执行。

相比之前应用程序可以在使用 fork 和 exec 之间对新进程进行一系列设定,现在只能使用 spawn 接口提供的 file_actions 和 attrp 两个参数来进行设定,spawn 会在 exec 执行之前根据这两个参数的配置完成一系列操作,所以后者的灵活程度不如 fork+exec。

虽然 spawn 完成的任务类似于 fork+exec,但它的实现并不是简单地调用 fork 和 exec。spawn 的性能要优于 fork+exec,且执行时间与原进程的内存无关。但是灵活性要差点。

    - 限定场景:vfork

        - vfork 的出现是因为那时候 fork 要将父进程的内存完整地拷贝一份,但那时又没有写时拷贝技术。

所以 vfork 的做法是从父进程创建出子进程,但是又不会为子进程单独创建地址空间,而是让子进程和父进程共享同一地址空间。考虑到父子进程中任一进程的修改都会对另一进程产生影响,vfork 会在结束后阻塞父进程,直至子进程调用 exec 或者退出为止。

vfork 只适合用在进程创建后立即使用 exec 的场景中,由于 exec 本身会创建地址空间,因此 vfork + exec 相比 fork + exec 省去了一次地址空间的拷贝。虽然写时拷贝技术的出现一度让 fork 的性能和 vfork 接近,但随着应用程序内在需求的增大,就连建立地址空间映射也要消耗大量时间,因此 vfork 的性能优势再次显示出来了。

    - 精密控制:rfork/clone

        - 由于 fork 接口简单,表达力有限。当应用程序希望父子进程可以选择性共享部分资源时,fork 就不行了。

20 世纪 80 年代,贝尔实验室的 Plan9 首次提出的 rfork 接口,能支持父子进程间的细粒度资源共享。Linux 之后也借鉴了 rfork 提供了类似的接口 clone。clone 相当于 fork 的精密控制版,它也使用拷贝的方式创建新进程,但是 clone 允许应用程序通过参数来对创建过程做更多的控制。

int clone(int (fn)(void), void *stack, int flags, void * arg, …) 支持四个参数,第三个参数允许应用程序指定不需要复制的部分。比如,当第三个参数设置了 CLONE_VM 之后就不会复制内存了,允许子进程与父进程使用相同的地址空间了。clone 还允许指定子进程栈的位置(stack),解决了父进程与子进程共享地址空间时栈冲突的问题,同时也一定程度上缓解了 fork 造成的地址空间相同的安全风险。fn 和 arg 两个参数则是进程创建完成后将执行的函数和输入参数。在函数执行完成之后,子进程将终止并返回。

clone 既可以用在 fork+exec 的场景中也可以用在单纯使用 fork 的场景中。clone 精密控制的特性使得它具有较强的通用性,应用范围也更加广泛。但是,clone 接口本身较为复杂,涉及到操作系统的多个方面,如果使用不慎就会造成安全问题。

  • 进程管理

    • 进程间的关系与进程树

      • 每个进程的 task_struct 都会记录自己的父进程和子进程,进程之间构成了进程树结构。通过定义进程树结构,内核为进程建立了联系并在此基础之上提供了监控、回收、信号分发等一系列功能。
      • 处于进程树根部的是 init 进程,它是操作系统创建的第一个进程,之后所有的进程都是由它直接创建或间接创建出来的
      • kthread 进程是第二个进程,所有由内核创建和管理的进程都是由它 fork 出来的。
    • 进程间监控:wait()

      • Linux 中,进程可以使用 wait 操作来对其子进程进行监控,就是调用 wait 函数对子进程进行监控,如 pid_t waitpid(pid_t pid, int *wstatus, int options)
      • wait 操作不仅起到监控的作用,还起到回收已经运行结束的子进程和释放资源的作用。如果父进程没有调用 wait 操作,或者还没有来得及调用 wait 操作,就算子进程已经终止了,它所占用的资源也不会完全释放,这个时候的进程被叫做僵尸(zombie)进程。内核会为进程保留其进程描述符(PID)和终止时的信息(waitpid 中的 status),以便父进程在调用 wait 时可以监控子进程的状态。

由于管理 PID 也需要一定的内存开销,内核会设定最大可用 PID 的限制,如果一个进程创建了大量子进程却不调用 wait,那么僵尸进程会占用 PID,从而使得后续的 fork 因为内核资源不足而失败。 - 如果父进程退出了,并且没有调用 wait 来回收子进程资源等。那么,这个时候子进程的信息就没有必要保存了,因为父进程压根不可能再使用这些信息了。这个时候,所有父进程创建的僵尸进程都会被内核的第一个进程 init 通过调用 wait 的方式回收。

- 进程组和会话

    - 线程组/进程组

        - task_struct 记录的 tgid(thread group id)是线程对应的线程组标识符,其实只要 tgid 是一样的,那么就是一个组的。

这里既可叫做线程组,也可以叫做进程组,个人觉得是因为 Linux 内核中,进程和线程使用的都是 task_struct 这个结构体。所以 tgid 相同的情况下,一个 task_struct 可能是线程也可能是进程,所以可以叫进程组也可以叫线程组。但是,我个人偏向于叫线程组。

应用程序可以调用 killpg 向一个线程组发送信号,这个信号会被发送到这个线程组的每个线程,从而结束这个进程的所有。

    - 会话

        - 会话相当于进程的集合(或者说是线程/进程组的集合),由多个进程组成。会话会将进程根据执行状态分为前台进程和后台进程。
        - 控制终端进程是会话与外界进行交互的“窗口”,当启动一个终端之后,这个终端就对应于一个会话。如果在启动的终端里输入 ctrl-c,终端进程就会收到一个 SIGINT 信号,并将其发送给前台进程,该信号一般会导致前台进程退出。后台进程不会受到该信号的影响,可以继续执行。
        - fork 调用后,子进程将与父进程属于同一个会话。
        - 会话和进程组主要用于 shell 环境中的进程管理。
        - 由内核管理的进程,并不存在与用户进行交互的需求,因此不存在会话这一东西,同时也不存在线程组 id。