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

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

以下内容是由 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。
程序锅 wechat
欢迎关注微信公众号【一口程序锅】,不定期的技术分享、资源分享。
让我多买本书学习学习
0%