当前位置: 首页 > news >正文

笔记:现代操作系统:原理与实现(4)

第五章 进程与线程

现代操作系统需要运行各种各样的程序。为了管理这些程序的运行,操作系统提出了进程(process)的抽象:每个进程都对应于一个运行中的程序。

为了使多个进程能够同时执行,操作系统进一步提出了上下文切换(context switch)机制,通过保存和恢复进程在运行过程中的状态(即上下文),使进程可以暂停、切换和恢复,从而实现了 CPU 资源的共享。

针对进程间数据不易共享、通信开销高等问题,操作系统在进程内部引入了更轻量级的执行单元,也就是线程(thread)。由于上下文切换需要进入内核,开销较大,后续又引入了纤程(fiber)这一抽象,允许上下文直接在用户态切换。

进程

进程的状态

  • 新生状态(new):该状态表示一个进程刚刚被创建出来,还未完成初始化,不能被调度执行。在经过初始化过程之后,进程迁移至预备状态
  • 预备状态(ready):该状态表示进程可以被调度执行,但还未被调度器选择。由于 CPU 数量可能少于进程数量,在某一时刻只有部分进程能被调度到 CPU 上执行。此时,系统中其他的可被调度的进程都处于预备状态。在被调度器选择执行后,进程迁移至运行状态
  • 运行状态(running):该状态表示进程正在 CPU 上运行。当一个进程执行一段时间后,调度器可以选择中断它的执行并重新将其放回调度队列,它就迁移至预备状态。当进程运行结束,它会迁移至终止状态。如果一个进程需要等待某些外部事件,它可以放弃 CPU 并迁移至阻塞状态
  • 阻塞状态(blocked):该状态表示进程需要等待外部事件(如某个 I/O 请求的完成),暂时无法被调度。当进程等待的外部事件完成后,它会迁移至预备状态。
  • 终止状态(terminated):该状态表示进程已经完成了执行,且不会再被调度。

进程在不同状态之间的切换:

在这里插入图片描述

进程的内存空间布局

  • 用户栈:栈保存了进程需要使用的各种临时数据(如临时变量的值),其扩展方向是自顶向下;栈底在高地址上,栈顶在低地址上。当临时数据被压入栈内时,栈顶会向低地址扩展。
  • 代码库:进程的执行有时需要依赖共享的代码库(比如 libc),这些代码库会被映射到用户栈下方的虚拟地址处,并被标记为只读。
  • 用户堆:堆管理的是进程动态分配的内存。与栈相反,堆的扩展方向是自底向上,堆顶在高地址上,当进程需要更多内存时,堆顶会向高地址扩展。
  • 数据与代码段:处于较低地址的是数据段和代码段。它们原本都保存在进程需要执行的二进制文件中,在进程执行前,操作系统会将它们载入虚拟地址空间中。其中,数据段主要保存的是全局变量的值,而代码段保存的是进程执行所需的代码。
  • 内核部分:处于进程地址空间最顶端的是内核内存。每个进程的虚拟地址空间都映射了相同的内核内存。当进程在用户态运行时,内核内存对其不可见;只有当进程进入内核态时,才能访问内核内存。与用户态相似,内核部分也有内核需要的代码和数据段,当进程由于中断或系统调用进入内核后,会使用内核的栈。

典型的进程内存空间布局:

在这里插入图片描述

用户可以通过 cat /proc/PID/maps 来查看某个进程的内存空间布局。由于内核地址空间对用户态的进程不可见,因此 maps 内容没有包含内核部分的映射。其中,vdsos 和 vvar 是与系统调用相关的内存区域。另外,进程也会映射一些匿名的内存区域用于完成缓存、共享内存等工作。

进程控制块和上下文切换

在内核中,每个进程都通过一个数据结构来保存它的相关状态——进程控制块(Process Control Block, PCB)中包含的内容:

  • 进程标识符(Process Identifier, PID)
  • 控制状态
  • 虚拟内存状态
  • 打开的文件等

进程的上下文(context)包括进程运行时的寄存器状态,其能够用于保存和恢复一个进程在处理器上运行的状态。当操作系统需要切换当前执行的进程时,就会使用上下文切换(context switch)机制。该机制会将前一个进程的寄存器状态保存到 PCB 中,然后将下一个进程先前保存的状态写入寄存器,从而切换到该进程执行。

Linux 中的 PCB(task_struct)中的部分重要字段:

struct task_struct {// 进程状态volatile long state;// 虚拟内存状态struct mm_struct *mm;// 进程标识符pid_t pid;// 进程组标识符(详见 fork 部分)pid_t tgid;// 进程间关系(详见 fork 部分)struct task_struct *__rcu *parent;struct list_head children;// 打开的文件struct files_struct *files;// 其他状态(如上下文)...
}

进程上下文切换示意图:

在这里插入图片描述

案例分析:Linux 的进程操作

进程的创建:fork

当一个进程调用 fork 时,操作系统会为该进程创建一个几乎一模一样的新进程。我们一般将调用 fork 的进程称为父进程,将新创建的进程称为子进程。当 fork 刚刚完成时,两个进程的内存、寄存器、程序计数器等状态都完全一致;但它们是完全独立的两个进程,拥有不同的 PID 与虚拟内存空间,在 fork 完成后它们会各自独立地执行,互不干扰。

fork是一个“调用一次,返回两次”的系统调用。对于父进程,fork 的返回值是子进程的 PID;对于子进程,fork 的返回值是 0。

fork 一旦结束,它们就是两个独立的进程,在操作系统调度器的视角里是两个完全独立的个体。因此,父进程和子进程的执行顺序是不确定的,完全取决于调度器的决策。

考虑下面这种情况:

父进程先打开了一个文件,再执行 fork 生成子进程,此后两个进程都使用 read 操作读取文件中的内容。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>char str[11];  // 用于存储读取的内容,预留一个位置给字符串结束符int main() {str[10] = 0;  // 确保字符串正确终止// 打开文件,可读可写模式int fd = open("test.txt", O_RDWR);// 创建子进程if (fork() == 0) {// 子进程:读取文件内容ssize_t cnt = read(fd, str, 10);printf("Child process: %s\n", str);} else {// 父进程:读取文件内容ssize_t cnt = read(fd, str, 10);printf("Parent process: %s\n", str);}return 0;
}

可能得到的结果为

Parent process: abcdefghij
Child process: klmnopqrst

当然,由于调用 fork 之后父子进程执行顺序的不确定性,这里可能会输出不一样的结果。但是无论如何,都无法使父进程和子进程输出完全一样的字符串。这与本章前面介绍的 fork 特性有些许“冲突”:父进程和子进程不是“完全一样的拷贝”吗?

在 fork 过程中,由于 文件描述符(fd,File Descriptor) 表是 PCB 的一部分(对应于 Linux PCB 中的 files 字段),子进程会获得与父进程一模一样的 fd 表,因此会指向相同的文件抽象,与父进程共用同一个偏移量。由于 Linux 在实现 read 操作时会对文件抽象加锁,因此父子进程不可能读到完全一样的字符串。

Windows 的进程创建(CreateProcess)与fork相比:
它会从头创建进程,载入参数 lpApplicationName 指定的二进制文件,并根据其他参数设定的配置直接开始执行指定的二进制代码。由于采取的是从头创建的方法,CreateProcess 需要对进程的运行参数进行大量配置,在提供灵活性的同时也使接口变得异常复杂。可以看出,就算是较为“简单”的 CreateProcessA 接口,与 fork 相比也复杂得多,需要传入十个参数。

进程的执行: exec

execve 实际上是由一系列接口组成的,存在多个变种,其中功能最为全面的是 execve:

#include <unistd.h>int execve(const char *pathname, char *const argv[],char *const envp[]);

execve 共接收三个参数。

  • 第一个参数 pathname 是进程需要载入的可执行文件的路径
  • 第二个参数 argv 则是进程执行所需的参数
  • 第三个参数 envp 是为进程定义的环境变量

一般以键值对字符串的形式(例如:USERNAME=小红)传入。当 execve 被调用时,操作系统至少需要完成以下几个步骤:

  • 根据 pathname 指明的路径,将可执行文件的数据段和代码段载入当前进程的地址空间中。
  • 重新初始化堆和栈。在这里,操作系统可以进行地址空间随机化(Address Space Layout Randomization, ASLR)操作,改变堆和栈的起始地址,增强进程的安全性。
  • 将 PC 寄存器设置到可执行文件代码段定义的入口点,该入口点最终会调用 main 函数。

进程管理

进程间的关系与进程树

在 Linux 中,由于进程都是通过 fork 创建的,操作系统会以 fork 作为线索记录进程之间的关系。每个进程的 task_struct 都会记录自己的父进程和子进程,进程之间因此构成了进程树结构。内核正是通过这种进程树结构来对进程进行管理的。

简化后的Linux进程树:

在这里插入图片描述

处于进程树根部的是 init 进程,它是操作系统创建的第一个进程,之后所有的进程都是由它直接或间接创建出来的。而 kthreadd 进程则是第二个进程,所有由内核创建和管理的进程都是由它 fork 出来的。最后,init 进程会创建出一个 login 进程来要求用户登录,当验证通过后,会从 login 中 fork 出 bash 进程。通过定义进程树结构,内核为进程建立了联系,并在此基础上提供了监控、回收、信号分发等一系列功能。

进程间监控:wait

在 Linux 中,进程可以使用 wait 操作来对其子进程进行监控。与 exec 相似,wait 也有多个变种,这里主要介绍 waitpid:

#include <sys/types.h>
#include <sys/wait.h>pid_t waitpid(pid_t pid, int *wstatus, int options);

其中,第一个参数表示需要等待的子进程 id,第二个参数用来保存子进程的状态,而最后一个参数则包含一些选项。

在 Linux 中,wait 操作不仅起到监控的作用,还起到回收已经运行结束的子进程和释放资源的作用。如果父进程没有调用 wait 操作,或者还没有来得及调用 wait 操作,就算子进程已经终止了,它所占用的资源也不会完全释放,我们将这种进程称为僵尸进程(zombie)。内核会为僵尸进程保留其进程描述符(PID)和终止时的信息(waitpid 中的 status),以便父进程在调用 wait 时可以监控子进程的状态。由于管理 PID 也需要一定的内存开销,内核会设定最大可用 PID 的限制,如果一个进程创建了大量子进程却从不调用 wait,那么僵尸进程会迅速占据可用的 PID,使得后续的 fork 因为内核资源不足而失败。不过,如果父进程退出了,那么子进程的信息就不再会被父进程使用,也就没有必要继续保留它们了。这时,所有由父进程创建的僵尸进程都会被内核的第一个进程 init 通过调用 wait 的方式回收。

进程组和会话

为了方便应用程序进行进程管理,内核还定义了可以由多个进程组合而成的“小集体”,即进程组和会话。

进程组(process group)是进程的集合,可以由一个或多个进程组成。在默认情况下,父进程和子进程属于同一个进程组。

会话(session)是进程组的集合,可以由一个或多个进程组构成。会话将进程组根据执行状态分为前台进程组(foreground group)后台进程组(background thread group)。**控制终端(controlling terminal)**进程是会话与外界进行交互的“窗口”,它负责接收从用户发来的输入。因此,当小红启动一个终端(Shell)时,这个终端就对应于一个会话。如果小红在她启动的终端里输入 Ctrl-C,终端进程就会收到一个 SIGINT 信号,并将其发送给前台进程组,该信号一般会导致前台进程组的所有进程退出。当fork调用后,子进程也将与父进程属于同一个会话。

讨论:fork过时了吗

fork的优点

  • fork 的设计具有惊人的“简洁之美”:fork 完全不需要任何参数
  • fork 还强调了进程与进程之间的联系:在父进程和子进程之间存在较强的关联性的场景中非常适用

fork的局限性

  • fork 已经变得过于复杂:尽管 fork 的接口依然保持着简洁的风格,但随着操作系统支持的功能越来越多,fork 的实现越发复杂。
  • fork 的性能太差:由于 fork 需要创建出原进程的一份拷贝,原进程的状态越多,fork 的性能就越差。
  • fork 存在潜在安全漏洞:fork 建立的父进程与子进程之间的联系可能会成为攻击者的重要切入点(如BROP 攻击)。
  • 扩展性差、与异质硬件不兼容、线程不安全等

合二为一: posix_spawn

posix_spawn 是 POSIX 提供的另一种创建进程的方式,最初是为不支持 fork 的机器设计的。

#include <spawn.h>int posix_spawn(pid_t *pid, const char *path,const posix_spawn_file_actions_t *file_actions,const posix_spawnattr_t *attrp,char *const argv[], char *const envp[]);

posix_spawn 可以被认为是 fork 和 exec 两者功能的结合:它会使用类似于 fork 的方法(或者直接调用 fork)获得一份进程的拷贝,然后调用 exec 执行。posix_spawn 共接收六个参数:

  • path、argv、envp 分别与 exec 的三个参数对应
  • pid :会在 posix_spawn 返回时被写入新进程的 PID
  • file_actions 、attrp:根据应用程序对这两个参数的配置完成一系列操作

虽然 posix_spawn 完成的任务类似于 fork 和 exec 的组合。posix_spawn 的性能要明显优于 “fork + exec”,且执行时间与原进程的内存无关。因此,当进程创建的性能比较关键时,应用程序可以选择牺牲 “fork + exec” 的灵活性,改用 posix_spawn。

限定场景:vfork

在写时拷贝技术引入之前,fork 的实现是简单地将父进程的内存完整地拷贝一份,因此执行时间较长。为了解决这一问题,BSD 引入了 vfork,作为 fork 在特定场景下的优化:

#include <sys/types.h>
#include <unistd.h>pid_t vfork(void);

vfork 的功能相当于 fork 的裁剪版:它会从父进程中创建出子进程,但是不会为子进程单独创建地址空间,而是让子进程与父进程共享同一地址空间。因此,父子进程中任一进程对内存的修改都会对另一进程产生影响。为了保证正确性,vfork 会在结束后阻塞父进程,直到子进程调用 exec 或者退出为止。vfork 可以提升 fork 的性能表现,但仍无法完全避免 fork 本身带来的安全问题。

vfork 只适合用在进程创建后立即使用 exec 的场景中,由于 exec 本身会创建地址空间,因此,“vfork + exec” 与 “fork + exec” 相比省去了一次地址空间的拷贝,同时避免了 vfork 带来的潜在安全问题。写时拷贝技术的出现一度让 fork 的性能与 vfork 接近,但随着应用程序内存需求的增大,就建立地址空间映射也要消耗大量时间,vfork 在性能上的优势再一次体现出来。因此,在 “fork + exec” 的限定场景中,vfork 可以作为 fork 的优化版本代替 fork。

精密控制:rfork/clone

由于 fork 接口简单,其表达力比较有限。当应用程序希望能选择性地共享父进程和子进程的部分资源时,fork 就“爱莫能助”了。因此在 20 世纪 80 年代,贝尔实验室的操作系统 Plan 9 首次提出了 rfork 接口。能支持父进程和子进程之间的细粒度资源共享。而之后的 Linux 操作系统也借鉴了 rfork,提出了类似的接口 clone。clone 可以认为是 fork 的“精密控制”版:同样通过拷贝的方式创建新进程,clone 允许应用程序通过参数对创建过程进行更多的控制,它在功能方面也有一些扩展。

#include <sched.h>int clone(int (*fn)(void *), void *stack, int flags,void *arg, ...);

clone 支持四个参数,允许应用程序对 fork 的过程进行控制

  • stack:clone 允许应用程序指定子进程栈的位置
  • flag :允许应用程序指定不需要复制的部分
  • fn、arg:进程创建完成后将执行的函数和输入参数

在 Web 服务器的场景中,服务器会使用 fork 创建出新进程以处理请求,可以用 clone 代替这些 vfork,并且通过 clone 提供的参数对进程创建的性能进行调优。由于 clone 的“精密控制”特性,它具有极强的通用性,应用范围更加广泛(用于创建线程,详见下文)。但是,clone 接口本身较为复杂,涉及了操作系统的多个方面,如果使用不慎就会像 vfork 一样造成额外的安全问题。

线程

随着硬件技术的发展,计算机拥有了更多的 CPU 核心,程序的并行度提高,进程这一抽象开始显得过于笨重:

  • 创建进程的开销较大,需要完成创建独立的地址空间、载入数据和代码段、初始化堆等步骤。即使使用 fork 接口创建进程,也需要对父进程的状态进行大量拷贝。
  • 由于进程拥有独立的虚拟地址空间,在进程间进行数据共享和同步比较麻烦,一般只能基于共享虚拟内存(粒度较粗)或者基于进程间通信(开销较高)。

因此,操作系统的设计师们提出在进程内部添加可独立执行的单元,它们共享进程的地址空间,但又各自保存运行时所需的状态(即上下文),这就是线程

多线程的地址空间布局

下图展示了包含三个线程的进程地址空间。多线程的地址空间主要有两个重要特征。

多线程进程的地址空间布局:

在这里插入图片描述

  • 分离的内核栈与用户栈:由于每个线程的执行相对独立,进程为每个线程都准备了不同的栈,供它们存放临时数据。在内核中,每个线程也有对应的内核栈。当线程切换到内核中执行时,它的栈指针就会切换到对应的内核栈。
  • 共享的其他区域:进程除栈以外的其他区域由该进程的所有线程共享,包括堆、数据段、代码段等。当同一个进程的多个线程需要动态分配更多内存时,它们的内存分配操作都是在同一个堆上完成的。因此 malloc 的实现需要使用同步原语,使每个线程能正确地获取到可用的内存空间。

用户态线程与内核态线程

线程分为:

  • 用户态线程(user-level thread)
    • 应用自己创建的,内核不可见,因此也不直接受系统调度器管理
    • 比内核态线程
      • 更加轻量级
      • 创建开销更小
      • 但功能也较为受限
  • 内核态线程(kernel-level thread)
    • 由内核创建,受操作系统调度器直接管理

用户态线程与内核态线程的关系称为多线程模型(multithreading model):

  • 多对一模型
    • 每次只能有一个用户态线程可以进入内核,其他需要内核服务的用户态线程会被阻塞。在多核机器逐步普及的趋势下不再适用,不过,随着近年来应用变得越发复杂,应用内部的调度也开始变得重要,多对一模型又开始得到了应用。
  • 一对一模型
    • 优点:提供了更好的可扩展性,因为每个用户态线程可以使用自己的内核态线程执行与内核相关的逻辑,无须担心阻塞其他用户态线程。
    • 缺点:由于一对一的关系,创建内核态线程的开销会随着用户态线程数量的增加而不断增大。因此需要对用户态线程的总数量进行限制。
    • Linux 和 Windows 系列的操作系统采用的是一对一模型。
  • 多对多模型
    • 将 N 个用户态线程映射到 M 个内核态线程中,其中 N > M
    • 优点:既减轻了多对一模型因为内核态线程过少而导致的阻塞问题,也解决了一对一模型中因为内核态线程过多导致的性能开销过大的问题
    • 缺点:多对多模型也会让内核态线程的管理变得复杂
    • macOS 和 iOS 使用的面向用户体验的调度器 GCD 采用了多对多模型

三种不同的线程模型:

在这里插入图片描述

线程控制块与线程本地存储

线程也有自己的线程控制块(Thread Control Block, TCB) ,在目前主流的一对一线程模型中,内核态线程和用户态线程会各自保存自己的 TCB。

  • 内核态的 TCB 结构与前面介绍的 PCB 相似,会存储线程的运行状态、内存映射、标识符等信息
  • 用户态 TCB 的结构则主要由线程库决定

在多线程编程中,可以通过 线程本地存储(Thread Local Storage, TLS)实现“一个名字,多份拷贝(与线程数量相同)”的全局变量。这样,当不同的线程在使用该变量时,虽然从代码层面看访问的是同一个变量,但实际上访问的是该变量的不同拷贝。于是可以很方便地实现线程内部(而不是进程)的全局变量。

多线程的TLS结构示意图

在这里插入图片描述

由于 TLS 结构的相似性,对 TLS 中变量寻址的实现方式也较为特殊。具体来说,当一个线程被调度时,pthread 线程库会找到该线程 TLS 的起始地址,并存入段寄存器 FS 中。当线程访问 TLS 中的变量时,会用 FS 中的值加上偏移量的方式获取变量。不同线程的 FS 寄存器中保存的 TLS 起始地址不同,所以不同的线程访问同名的 TLS 变量时,最终其实访问了不同的地址。

线程的基本接口:以 POSIX 线程库为例

线程创建

pthreads 提供的线程创建接口为 pthread_create,该接口会在当前进程中创建一个新线程,并运行第三个参数 start_routine 指定的函数、参数 attr 为线程指定一些属性、arg参数为函数 start_routine 设定参数。pthread_create 成功执行后, thread 会被填入指向新创建的线程的引用。

#include <pthread.h>int pthread_create(pthread_t *thread,const pthread_attr_t *attr,void *(*start_routine)(void *),void *arg);

pthread_create 是通过 clone 实现的

const int clone_flags = (CLONE_VM | CLONE_FILES| CLONE_SIGHAND | CLONE_THREAD| CLONE_SETTLS | ...);ARCH_CLONE(&start_thread, STACK_VARIABLES_ARGS,clone_flags, ...);

在 clone 之前,pthread_create 会传入大量的参数以控制“进程”的创建过程。例如 :

  • CLONE_VM 会共享地址空间
  • CLONE_THREAD 允许新创建的“进程”与 clone 的调用者从属于同一进程
  • STACK_VARIABLES_ARGS 对应于新的用户栈的起始地址

总之,通过这些参数,clone 实际上创建出了一个从属于原进程、与原进程共享大量数据结构、拥有私有栈的实例,而这个实例就对应于一个线程。

线程退出

pthreads 提供了与 pthread_create 相对应的接口 pthread_exit,用于使线程终止并退出。

#include <pthread.h>void pthread_exit(void *retval);

pthread_exit 的调用并不是必要的。当一个线程的主函数(如上面提到的 start_routine)执行结束时,pthread_exit 将会隐式地被调用。pthread_exit 提供了参数 retval 来表示线程的返回值,用户可以通过对 pthread_exit 的调用来设置返回值。如果 pthread_exit 未被显式调用,那么线程的主函数的返回值会被设置为线程的返回值。返回值可以用在多个线程使用 pthread_join 操作进行协作的场景中。

出让资源

除了 pthread_exit,POSIX 还允许线程主动地暂停,让出当前的 CPU,交给其他线程使用。该功能对应的接口是 pthread_yield。

#include <pthread.h>int pthread_yield(void);

pthread_yield 的接口很简单,不接收参数,返回值为该操作执行的结果(0 表示成功)。其实现也非常简单,直接调用 sched_yield 这一系统调用,放弃 CPU 资源。pthread_yield 可以认为是应用程序对操作系统调度的一种帮助。举例来说,当一个线程需要等待外部事件时,此时对它的调度没有实际意义,只会浪费 CPU 资源,这时线程可调用pthread_yield。

合并操作

在多线程协作的场景中,可能会出现线程之间的执行存在相互依赖的情况。因此,线程库会提供合并(join)操作,允许一个线程等待另一个线程的执行,并获取其执行结果。举例来说,当主线程创建了很多工作线程之后,就可以通过调用合并操作获取各工作线程的返回值,判断其执行是否出错,并进行相应的处理。pthreads 提供了 pthread_join 函数来支持合并操作。

#include <pthread.h>int pthread_join(pthread_t thread, void **retval);

pthread_join 要求传入两个参数:一个是需要等待的线程,类型为 pthread_t;另一个则是一块内存,用于接收被等待线程的返回值。该返回值就是调用 pthread_exit 时设定的返回值,pthread_join 会将其从被等待线程的内存拷贝到当前线程指定的地址。

挂起与唤醒

当线程发现自己的执行需要等待某个外部事件时,它可以进入阻塞状态,让出计算资源给其他线程执行。使线程进入阻塞状态的机制称为挂起。线程的挂起一般有两种方式:一是等待固定的时间,二是等待具体的事件。操作系统也为这两种方式提供了相应的唤醒机制。

  • 第一种方式是等待固定的时间。pthreads 没有提供等待固定时间的接口,线程可以使用 POSIX 中的 sleep 接口:
#include <unistd.h>unsigned int sleep(unsigned int seconds);

如果想要更加细粒度的控制,则可以使用 nanosleep。当 sleep 指定的时间走完后,内核会将该线程唤醒,重新将其状态改为预备状态。由于内核不能保证线程被唤醒后立即被调度,因此一般来说,线程从挂起到再次执行的时间会长于其指定的需要挂起的时间。

  • 第二种方式是等待具体的事件。线程也可以在进入挂起状态之前指明等待的具体事件。在 pthreads 中,对应的接口是 pthread_cond_wait。
#include <pthread.h>int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

pthread_cond_wait 使用条件变量作为同步的方式。pthread_cond_wait 会使线程等待在第一个参数 cond 上,内核会使该线程挂起。当其他线程使用 pthread_cond_signal 操作同一个条件变量 cond 时,内核会协助将挂起的线程唤醒,使其重新变为预备状态。

POSIX 还提供了一个类似的等待接口 pthread_cond_timedwait,它相当于“等待具体事件”和“等待固定时间”的结合。pthread_cond_timedwait 还接收一个额外的时间参数 abstime,如果等待时间超过 abstime 或 cond 被其他线程使用 pthread_cond_signal 操作,内核都会将其唤醒。

纤程

由于主流的操作系统都采取一对一的线程模型,用户态线程和内核态线程具有一对一关系,可以认为用户态线程的执行几乎完全受到操作系统调度器的管理。可是,随着计算机的发展,应用程序也变得越来越复杂,线程也就越来越复杂。而应用程序本身比操作系统更加熟悉线程的语义和执行状态。因此,操作系统开始提供更多对用户态线程,即纤程(fiber)的支持,用户态线程和内核态线程的关系也由一对一向多对一扩展。

对纤程的需求:一个简单的例子

现在,假设一个进程拥有两个线程,一个线程是生产者,另一个线程是消费者。由于两个线程共享同一地址空间,生产者可以在共享的内存里直接写入数据,供消费者使用。假设该计算机只有一个处理器,那么从生产者生成数据到消费者处理数据至少需要经历一次上下文切换(即生产者切换到消费者),才能完成数据处理。而在实际情况中,由于该计算机上可能运行了很多程序,而调度器并不知道生产者与消费者之间的关系,因此未必会优先选择消费者进行调度。尽管生产者早已完成了数据的生成,但由于上下文切换的开销和调度器的选择,消费者可能需要经历较长的延迟才能开始处理数据。为了消除这部分延迟,应用程序可以使用纤程。

POSIX 的纤程支持:ucontext

POSIX 用来支持纤程的是 ucontext.h 中的接口:

#include <ucontext.h>int getcontext(ucontext_t *ucp);
int setcontext(const ucontext_t *ucp);
void makecontext(ucontext_t *ucp, void (*func)(), int arg, ...);
  • getcontext 可以用来保存当前的上下文
  • setcontext 则可以用来切换到另一个上下文
  • makecontext 可以创建一个全新的上下文,并将其保存在第一个参数 ucp 中。

由于这些上下文属于纤程,因此并不会保存到内核中的 TCB,而是完全保存在用户态。

代码片段:通过 POSIX 的 ucontext 系列接口实现的生产者消费者模型

#include <signal.h>
#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>ucontext_t ucontext1, ucontext2;int current = 0;void produce()
{current++;setcontext(&ucontext2);
}void consume()
{printf("current value: %d\n", current);setcontext(&ucontext1);
}int main(int argc, const char *argv[])
{char iterator_stack1[SIGSTKSZ];char iterator_stack2[SIGSTKSZ];getcontext(&ucontext1);ucontext1.uc_link = NULL;ucontext1.uc_stack.ss_sp = iterator_stack1;ucontext1.uc_stack.ss_size = sizeof(iterator_stack1);makecontext(&ucontext1, (void (*)(void))produce, 0);getcontext(&ucontext2);ucontext2.uc_link = NULL;ucontext2.uc_stack.ss_sp = iterator_stack2;ucontext2.uc_stack.ss_size = sizeof(iterator_stack2);makecontext(&ucontext2, (void (*)(void))consume, 0);setcontext(&ucontext1);return 0;
}

在主函数中,该程序使用 makecontext 创建了两个上下文 ucontext1 和 ucontext2,分别用于调用 produce 和 consume 函数。需要注意的是,这两个上下文的栈需要由应用程序准备。之后,应用程序调用 setcontext,进入 ucontext1 对应的 produce 函数执行。此后,该应用程序通过 setcontext 在 produce 和 consume 之间跳转,不断重复“生产”和“消费”的过程。

上述代码片段中控制流变化示意图:

在这里插入图片描述

通过利用纤程,可以对生产者消费者这类需要多个模块协作的场景进行有效支持。当生产者完成任务后,可以立即切换到消费者继续执行。由于该切换是通过用户态程序完成的,不需要操作系统的参与,也不受调度器的控制,因此可以达到很好的性能。

纤程的上下文切换

纤程的上下文切换的触发机制与内核态存在较大不同。操作系统可以通过中断的方式抢占当前的 CPU 并进行上下文切换,这种切换是强制的,因此称为抢占式多任务处理(preemptive multitasking)模式。而纤程并不具备使用中断抢占其他线程的权限,因此无法使用这种模式。所以,纤程库一般会提供 yield 接口,该接口与一对一模型的 yield 接口相似,都会暂时放弃 CPU,允许其他纤程的调度。这种调度方式被称为合作式多任务处理(cooperative multitasking),需要其他的纤程进行协作以完成调度。在 ucontext 中,setcontext 提供了与 yield 类似的功能。

对纤程的其他支持
windows提供了类似于 TLS 的纤程本地存储(Fiber Local Storage, FLS),用来为每个纤程保存同一变量的私有拷贝。
Lua、Go、Ruby、C++ 等程序语言都提供了协程特性,为纤程提供支持。

思考题

为什么不用类似于 fork 的方法创建线程呢?

fork函数的特性不适用于线程:fork() 的目标是资源隔离、fork() 的开销很大、fork() 创建的进程之间通信复杂。

在 Linux 中,一个多线程进程使用 fork 生成了一个新进程,新进程中会存在几个线程?分析一下 Linux 为什么要这么设计?

新进程(子进程)初始时仅包含一个线程,即调用 fork的线程的副本。如此设计的原因:技术实现复杂、避免死锁、性能考量。

编写至少两种使用 fork 的程序,使 fork 执行失败,并解释导致 fork 失败的原因?

情况一:耗尽PID,系统对全局 PID 数量有硬性限制,通常存储 /proc/sys/kernel/pid_max 中。

情况二:达到用户进程数限制
参考 vfork、clone、posix_spawn 的设计,为 fork 提出针对大内存应用的优化策略。要求:修改后的 fork 必须同时支持 “fork + exec” 和 fork 后直接使用这两种场景?

基于“缺页异常”的惰性映射(Lazy Mapping on Page Fault):

fork() 系统调用发生时:内核为子进程创建新的进程描述符(task_struct)、新的内核栈等。关键步骤: 不为子进程复制父进程的页表。相反,子进程的页表初始化为空(或仅包含内核空间映射)。记录子进程的地址空间是父进程的一个“惰性副本”(设置一个标志 lazy_cow = 1,并指向父进程的 mm_struct)。fork() 调用迅速返回。此时,子进程的虚拟内存几乎没有任何映射,创建成本极低。子进程开始执行:场景 A:子进程立即调用 exec()exec() 系统调用会加载新的程序映像,并构建全新的地址空间。当 exec() 检测到子进程处于 lazy_cow 状态时,它知道当前的“空”地址空间没有任何有价值的内容,可以直接丢弃。随后,exec() 像正常一样为新程序设置全新的页表。优化达成: 完全避免了为即将被丢弃的父进程地址空间建立 COW 映射的开销。场景 B:子进程访问内存(在调用 exec() 之前):一旦子进程尝试读取或写入任何内存(包括执行代码),都会触发缺页异常(Page Fault)。缺页异常处理程序检查到该进程具有 lazy_cow 标志。对于引发异常的地址,处理程序会:a. 找到父进程中对应的物理页。b. 为子进程的对应虚拟地址按需建立 COW 映射(即创建页表项,指向父进程的物理页,并标记为只读/COW)。c. 恢复子进程的执行。此后,对该页的写入将触发标准的 COW 机制。场景判断与切换:如果子进程在触发任何缺页异常之前就调用了 exec(),则享受最大化的性能收益(纯“fork + exec”场景)。如果子进程在 exec() 之前访问了 N 页内存(N 很小),则只为这 N 页建立了 COW 映射,开销远小于建立整个地址空间的映射(部分“fork-only”场景)。如果子进程访问了大量内存,最终会逐步建立起完整的 COW 映射,其行为最终与传统 fork() 一致,保证了正确性。

当子进程终止时,由于父进程可能正忙于自己的工作,没能及时调用 wait,因此子进程将变成僵尸进程。不过,由于操作系统记录了父进程与子进程之间的关系,在子进程退出时,它会发送 SIGCHLD 信号给父进程。请基于该机制实现程序,使得父进程能及时处理子进程的退出,尽可能避免子进程成为僵尸进程?

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>// SIGCHLD信号处理函数
void sigchld_handler(int sig) {int status;pid_t pid;// 使用WNOHANG非阻塞方式等待所有已终止的子进程while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {if (WIFEXITED(status)) {printf("子进程 %d 正常退出,退出状态: %d\n", pid, WEXITSTATUS(status));} else if (WIFSIGNALED(status)) {printf("子进程 %d 被信号终止,信号编号: %d\n", pid, WTERMSIG(status));}}
}int main() {pid_t pid;// 设置SIGCHLD信号处理函数struct sigaction sa;sa.sa_handler = sigchld_handler;  //注册SIGCHLD信号的处理函数sigchld_handlersigemptyset(&sa.sa_mask);sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;if (sigaction(SIGCHLD, &sa, NULL) == -1) {perror("sigaction");exit(1);}// 创建子进程pid = fork();if (pid == 0) {// 子进程printf("子进程开始执行,PID: %d\n", getpid());sleep(3);  // 模拟工作printf("子进程结束\n");exit(42);  // 退出状态为42} else if (pid > 0) {// 父进程printf("父进程 PID: %d,子进程 PID: %d\n", getpid(), pid);// 父进程忙于自己的工作for (int i = 0; i < 10; i++) {printf("父进程工作中... %d/10\n", i + 1);sleep(1);}printf("父进程结束\n");} else {perror("fork");exit(1);}return 0;
}

为什么 ChCore 能保证内核态线程的栈在进入 / 退出内核时一定是空的?

这部分还没做。。

在上下文切换过程中,如果两个线程属于同一个进程,那么上下文切换过程会有什么不同?对上下文切换的性能会有怎样的影响?

同一个进程的线程进行上下文切换避免了地址空间切换和随之而来的TLB刷新,具有非常轻量级的性能开销,

Windows 的 CreateProcess 和 Linux 的 fork 都具有创建进程的功能,但语义有所不同。请分析以下三种场景,说明 fork 和 CreateProcess 哪个更合适,并解释原因。

  • Web 服务器接收到请求,并创建一个新进程处理该请求。(fork,共享web服务的资源和配置)
  • Shell 接收到用户输入的 ls 命令,并创建一个新进程来执行该命令。(虽然 CreateProcess 能实现,但 fork()/exec() 模型因其灵活性和与 Shell 职责的完美匹配,成为类 Unix 系统上更自然的选择)
  • 父进程创建一个子进程,并希望设置共享内存进行进程之间的通信。(CreateProcess ,对于此场景来说,fork将共享了太多不该共享的东西,这种紧密的耦合可能带来复杂性和风险)

由于用户态线程不具备抢占能力,因此一般采用合作式多任务处理的模式执行。如果让你为用户态线程的实现添加抢占式的调度支持,你会如何实现呢?

利用信号或定时器中断来强制触发调度。

http://www.dtcms.com/a/412516.html

相关文章:

  • 基于OFDM+QPSK调制解调的通信链路matlab性能仿真,包含同步模块,信道估计和编译码
  • 宁波网站推广方式西樵网站制作公司
  • apache建设网站网站设计公司官网
  • 桂林北站是高铁站吗以公司做网站
  • 【anaconda】anaconda安装配置,git安装配置以及pytorch安装
  • 仓颉编程语言青少年基础教程系列汇总
  • C++中STL---map
  • Java基础 9.26
  • 优秀响应式网站南宁百度seo优化
  • 营销型网站建设需要注意什么龙岗建设局网站
  • 用js做简单的网站页面软件外包公司为什么不好
  • 《2025年AI产业发展十大趋势报告》七十二
  • 模电基础:场效应管的放大电路
  • 黑色网站后台网页版传奇如何作弊?
  • 新手学易语言多久可以做网站广州网站建设推广公司有哪些
  • Python从入门到实战 (14):工具落地:用 PyInstaller 打包 Python 脚本为可执行文件
  • 如何优化网站导航阿里云服务器做电影网站
  • 企业代运营公司seo广告优化
  • 快手做任务网站济南手机网站建设公司排名
  • 重庆建网站推广网站安全建设方案需求分析
  • 湖北省建设教育协会网站首页网站开发的几个主要阶段
  • 嵌入式学习ARM架构12——SPI
  • 网站建设与维护期末试卷网络推广托管公司深圳
  • 购物网站建设的意义与目的个人简介网页制作
  • 永康网站设计饮料企业哪个网站做的比较好
  • 数据结构——基本排序算法
  • AI编码工具为何加速开发却难保质量?
  • 如何与知名网站做友情链接wordpress页面和文章
  • 有没有专门做设计的网站首页有动效的网站
  • 深圳服饰网站建设太原seo公司网站