第六章 进程与线程

第六章 进程与线程

操作系统对处理器资源提供了抽象,即进程(Process)和线程(Thread)

进程就是运行中的程序,它为程序提供了“独享”的处理器资源,从而简化了程序的编写。

针对进程间数据不易共享、通信开销高等问题,操作系统又在进程内部引入了更加轻量级的执行单眼,也就是线程。

6.1 进程的内部表示和管理接口

  1. 进程在内核中是如何表示的?
  2. 进程的基本管理接口是如何实现的?
  3. 进程的执行状态是什么,有什么用?

6.1.1 进程的内部表示——PCB

操作系统为每个程序对应的进程为何了结构体 process,保存了进程包含的处理器上下文信息。在引入虚拟内存之后,process 结构体被进一步扩展,包含虚拟地址空间相关的内容。该结构体就称为进程控制块(Process Control Block, PCB)

进程在初始化时已经分配了用于在用户态执行的栈结构,但当它切换至内核态后,操作系统也需要栈结构来存储临时变量,且栈内保存的内存不能被进程随意访问和修改。操作系统为每个进程都提供了内核栈,与用户态使用的用户栈进行区分。

目前为止的结构体(第一版)

struct {
    struct context *ctx;           // 处理器上下文
    struct vmspace *vmspace;       // 虚拟空间
   	void *stack;                   // 内核栈
}

6.1.2 进程创建的实现

在第三章中以mysh这个简单的 shell 为例子介绍了进程创建、退出、等待接口的用法。

从用户角度来看,进程创建只需要简单的一个系统调用或者一条命令即可完成。从实现的角度看,进程的创建要复杂的多,涉及到了 PCB 创建、虚拟内存初始化、可执行文件加载等多个步骤。

int process_create(char *path, char *argv[], char *envp[]) {
    // 创建一个新的 PCB ,用于管理新的进程
    struct process *new_proc = alloc_process();
    // 虚拟内存初始化:初始化虚拟地址空间及页表基地址
    init_vmspace(new_proc->vmspace);
    // 内核栈初始化
    init_kern_stack(new_proc->stack);
    // 加载可执行文件并映射到虚拟地址空间
    struct file *file = load_elf_file(path);
    for (struct seg loadable_seg : file->segs) {
        vmspace_map(new_proc->vmspace, loadable_seg);
    }
    // 准备运行环境:创建映射用户栈
    void *stack = alloc_stack(STACKSIZE);
    vmspace_map(new_proc->vmspace, stack);
    // 准备运行环境:将参数和环境变量放到栈上
    preparee_env(stack, argv, envp);
    // 上下文初始化
    init_process_ctx(new_proc->ctx);
}
  1. 创建进程控制块:当 process_create 被调用时,内核首先创建一个新的 PCB ,用于维护子进程的相关信息。

  2. 虚拟内存初始化:独立的虚拟地址空间是进程必不可少的组成部分。为进程创建单独的页表,申请一个物理页作为顶级页表,该页的起始地址即为页表基地址。

  3. 内核初始化:为了之后在内核中处理与进程相关的逻辑(如系统调用),内核还会预先分配物理页,作为进程的内核栈。

  4. 加载可执行文件到内存:内核将以可执行文件形式保存在硬盘中的程序加载到进程的虚拟地址空间中。为了方便操作系统加载,可执行文件通常都有固定的格式。

  5. 初始化用户栈及运行环境:进程在运行时,代码、数据、运行栈是必不可少的。代码和数据在前一阶段已经完成。因此,内核会申请物理内存并创建运行环境,通过填写页表项将其映射到进程的虚拟地址空间中。

    完成了栈结构的映射后,内核还需要准备应用程序运行时所需环境。

  6. 处理器上下文初始化:由于新进程还没哟执行过任何代码,因此大部分寄存器都未使用过,可以直接赋值为 0。但是,由于特殊寄存器(PC、SP 和 PSTATE)保存了与硬件状态相关的信息,需要专门考虑。

    PC:在加载可执行文件时,内核已经获取了程序的起始地址,应当把该地址赋值给 PC 寄存器。但是,此时的 PC 寄存器正在被内核使用,不能直接赋值。在内核态切换到用户态时,硬件会自动将 ELR_EL1 寄存器中的值恢复到 PC 寄存器中。因此,此时将程序的起始地址放到 ELR_EL1 寄存器中即可。

    PSTATE:与 PC 寄存器类似,此时不能直接设置 PSTATE,但可以通过修改 SPSR_EL1 寄存器,在从内核态切换到用户态时,将 SPSR_EL1 中的值写入到 PSTATE 寄存器中。

    SP:在AArch64 架构中,SP 寄存器有多份,此时可以直接修改 SP_EL0,不必担心影响内核执行。(内核使用的是 SP_EL1)。

经过以上步骤之后,进程就准备好执行了,当切换到用户态时,硬件会将 ELR_EL1 寄存器的值写入到 PC 寄存器中,程序就会从对应地址开始执行。

在第 4 步时,要加载可执行文件嘛,在 Linux 中可执行文件格式为可执行和可连接(Executable and Linkable Format, ELF)。结构如下图所示,除了保存程序运行时所需的代码和数据等信息外,可执行文件中还保存了一项重要内容——程序的起始地址

ELF 结构

ELF 文件中包含的内容较多,但不是整个文件都需要被加载到内存。ELF 文件在节头部表中已经标注了所有需要加载的程序节,内核会将这些程序节加载到进程的虚拟地址空间中。一般来说,需要加载的节包括:

  • .init:程序的初始化代码
  • .text:程序代码,是由一条条指令组成的
  • .rodata:程序中的只读数据,包括一些不可修改的数据常量。全局常量、字符串常量等。
  • .data:程序中初始化的全局变量或 C 语言的静态变量数据。定义在函数内部的局部非静态变量不在该段中存储。
  • .bss:程序中未初始化的全局变量或 C 语言的静态便量,例如 int i。

为了方便管理,ELF 文件使多个连续具有相似特性的程序节组成,并以段位单位进行加载。

6.1.3 进程退出的实现

以下伪代码展示了进程退出相关系统调用 process_exit 的实现。

void process_exit_v1(int id) {
    while(TRUE) {
        bool not_exit = TRUE;
        // 扫描内核的进程列表,寻找对应进程
        for (struct process *proc : all_processes) {
            // 若发现该进程还在进程列表中,说明还未退出
            if (proc->pid == id) {
                not_exist = FALSE;
            }
        }
        // 如果没有退出,则调度下一个进程执行,否则直接返回
        if (!not_exist) {
            schedule();
        } else {
            return;
        }
    }
}

process_exit 的实现实际上就是将进程持有的资源(处理器上下文、内核栈、虚拟地址空间等)一一销毁和回收,最后销毁和回收 PCB 占据的内存资源。由于进程在调用 process_exit 之后将不会返回用户态继续执行,因此最后还需要调用 schedule 告知内核选择其他进程执行。

6.1.4 进程等待的实现

前面介绍的进程创建和退出都是针对单一进程的功能,并未考虑进程间的协作。为了满足这一需求,就需要引入进程等待相关的系统调用。

process_waitpid 的伪代码实现

void process_waitpid_v1(int id) {
    while (TRHE) {
        bool not_exit = FALSE;
        // 扫描内核进程列表,查找对应进程
        for (struct process *proc : all_processes) {
            // 找到了,就要等待,不能执行
            if (proc->pid == id) {
                not_exit = TRUE;
            }
        }
        if (not_exit) {
            schedule();
        } else {
            // 进程不存在了,当前进程要执行了
            return;
        }
    }
}

为了使操作系统知道应该监控哪个进程,需要引入进程标识符(Process Identifier, pid)。pid 是进程创建时的返回值。

PCB 结构(第二版)

struct {
    struct context *ctx;           // 处理器上下文
    struct vmspace *vmspace;       // 虚拟空间
   	void *stack;                   // 内核栈
    int pid;                       // 进程标识符
}

6.1.5 exit 与 waitpid 之间的信息传递

目前的 process_waitpid 的功能比较受限:进程只知道被监控进程是否退出,并不知道它是正常退出还是异常退出。为了满足这一需求,可以使用 process_exit 以返回值形式记录进程退出时的状态,而 process_waitpid 可以获取该状态。

POSIX 定义的进程退出系统调用 exit 接收一个整数类型的参数,用于描述进程退出时的状态(如非 0 表示异常),其他进程可以通过调用 waitpid 获取这一状态。

假设内核维护着 curr_proc 变量,并总是执行当前正在运行的进程的 PCB。

process_exit 代码的变化。

// 进程退出时设置的状态
void process_exit_v2(int status) {
    destory_ctx(curr_proc->ctx);
    destory_vmspace(curr_proc->vmspace);
    // 保存退出状态
    curr_proc->exit_status = status;
    // 标记进程为退出状态
    curr_proc->is_exit = TRUE;
    // 告知内核选择下个需要执行的进程
    schedule();
}

PCB 结构(第三版)

struct {
    struct context *ctx;           // 处理器上下文
    struct vmspace *vmspace;       // 虚拟空间
   	void *stack;                   // 内核栈
    int pid;                       // 进程标识符
    int exit_status;               // 退出状态
    bool is_exit;                  // 标记是否退出
}

进程等待的实现(第二版)

void process_waitpid_v2(int id, int *status) {
    while (TRHE) {
        bool not_exit = TRUE;
        // 扫描内核进程列表,查找对应进程
        for (struct process *proc : all_processes) {
            // 找到了,就要等待,不能执行
            if (proc->pid == id) {
                not_exit = FALSE;
                if (proc->is_exit) {
                    // 已经退出,记录退出状态
                    *status = proc->exit_status;
                    destory_kern_stack(proc->stack);
                    destory_process(proc);
                    return;
                } else {
                    // 没有退出,调度下一个进程执行
                    schedule();
                }
            }
        }
        // 如果列表中不存在该进程,则直接返回
        if (not_exit) {
            return;
        }
    }
}

6.1.6 进程等待的范围与父子进程关系

如果一个进程不想将其退出状态泄露给另外一个进程该怎么办?为了满足这种需求,需要对 process_waitpid 可以等待的进程进行限制。

进程之间存在什么样的关系才能调用 process_waitpid 呢?

如果一个进程创建了另一个进程,那么创建者可以等待被创建者。我们将创建者成为父进程,被创建者成为子进程


为了维护进程间的创建关系为每个 PCB 都加入了包含其所有子进程的列表 children 。PCB 结构变化(第四版)

struct {
    struct context *ctx;           // 处理器上下文
    struct vmspace *vmspace;       // 虚拟空间
   	void *stack;                   // 内核栈
    int pid;                       // 进程标识符
    int exit_status;               // 退出状态
    bool is_exit;                  // 标记是否退出
    pcb_list *children;            // 子进程列表        
}

进程等待伪代码的实现(第三版)。只扫描自己的子进程列表,如果找不到对应的 pid 就会自动退出,从而限制了可等待的范围

void process_waitpid_v2(int id, int *status) {
    while (TRHE) {
        bool not_exit = TRUE;
        // 扫描内核进程列表,查找对应进程
        for (struct process *proc : curr_proc->children) {
            // 找到了,就要等待,不能执行
            if (proc->pid == id) {
                not_exit = FALSE;
                if (proc->is_exit) {
                    // 已经退出,记录退出状态
                    *status = proc->exit_status;
                    destory_kern_stack(proc->stack);
                    destory_process(proc);
                    return;
                } else {
                    // 没有退出,调度下一个进程执行
                    schedule();
                }
            }
        }
        // 如果列表中不存在该进程,则直接返回
        if (not_exit) {
            return;
        }
    }
}

进程资源的隐式回收

由于进程等待的调用并不是强制的,如果父进程不调用 process_waitpid ,那么子进程 PCB 是不是无法得到回收,进而导致可用的内存资源越来越少呢?

在 Linux 中,当父进程退出时,它的所有子进程会被操作系统创建的第一个进程(init 进程)“继承”,即变为 init 进程的子进程。当这些子进程退出时,内核会通知 init 进程完成对于这些进程资源的回收。

6.1.7 进程睡眠的实现

process_waitpid_v2 实现了一种使进程在内核中陷入等待的机制。但进程除了等待特定时间(如等待进程退出)以外,有时也需要等待一段时间之后再继续执行。为了实现该功能,就可以引入一种等待固定时间的系统调动——睡眠。

process_sleep 代码片段

void process_sleep(int seconds) {
    // 获取当前时间作为睡眠起始时间
    struct *date start_time = get_time();
    while (TRUE) {
        struct *date curr_time = get_time();
        if (time_diff(cur_time - start_time) < seconds) {
            // 时间未到,则调度下个进程执行
            schedule();
        } else {
            // 时间已到,直接返回
            return;
        }
    }
}

6.1.8 进程执行状态及其管理

进程的执行状态不是一成不变的,它们有时候在 CPU 上运行,有时再内核中等待,有时则在退出后变为“僵尸”。

进程的五状态模型图

进程的五状态模型

  • 新生(New):当 process_create 被调用后,内核通过分配 PCB 创建了一个全新的进程。此时,该进程的初始化还没完成,不能执行程序。
  • 就绪(Ready):当 process_create 执行完成之后(分配虚拟地址空间、内核栈等都已完成),进程已经准备好开始执行程序了。但是,计算机中的进程数量通常较多,刚创建完成的进程可能无法立即开始执行,他们会在内核中等待。
  • 运行(Running):当操作系统的调度器选择某个进程作为下一个执行的进程时,其状态切换为运行状态,而前一个处于运行状态的进程由于暂停执行,又切换回就绪状态。当进程在 process_waitpid 或 process_sleep 里面调用 schedule 函数时,它就会切换到就绪状态,而操作系统会选出下一个进程并使其处于运行状态。
  • 僵尸(Zomibe)状态:子进程退出后,由于父进程可能会调用 process_waitpid 获取其退出状态,因此子进程的 PCB 并不会马上销毁。此时,虽然子进程退出,但它所占据的资源(主要是 PCB)没有完全被操作系统回收。
  • 终止(Terminated):当一个进程退出且其占据的资源全部被操作系统回收时,它就进入了终止状态,这是进程声明周期的终结。

维护进程的执行状态对于进程管理非常重要。操作系统需要准确识别进程所处的状态来判断接下来的动作。

PCB 结构(第五版)

因为执行状态中已经包含了僵尸和退出状态,标记着进程是否退出的 is_exit 也就不需要了。

struct {
    struct context *ctx;           // 处理器上下文
    struct vmspace *vmspace;       // 虚拟空间
   	void *stack;                   // 内核栈
    int pid;                       // 进程标识符
    int exit_status;               // 退出状态
    // bool is_exit;                  // 标记是否退出
    pcb_list *children;            // 子进程列表
    enum exec_status exec_status;  // 执行状态
}

基于进程执行状态的调度选择的伪代码

struct process* pick_next(void) {
    // 遍历进程列表,寻找下一个可调度(处于就绪状态)的进程
    for (struct process *proc : all_processes) {
        if (proc->exec_status == READY) {
            // 将上一个正在运行的进程变为就绪,然后返回
            curr_proc->exec_status = READY;
            return proc;
        }
    }
}

void schedule() {
    curr_proc = pick_next();
    // 将选中的进程执行状态变为运行
    curr_proc->exec_status = RUNNING;
}

为了方便内核进行进程管理,还可以引入更多的状态类型。在上面的 process_sleep 伪代码中,如果设定时间过长,调用 process_sleep 的进程可能会被内核调度很多次,但都因为时间未到而再次调用 schedule,白白浪费 CPU 资源。为了解决这一问题,内核可以引入阻塞(Blocked)状态

6.2 案例分析:ChCore 微内核的进程管理

查看《操作系统:原理与实现》书籍

6.3 案例分析:Linux 的进程创建

6.3.1 经典的进程创建方法:fork

与前面介绍的 process_create 相比 fork 不会从头开始创建进程,而是已当前进程(父进程)为基础,创建一份从用户视角看一模一样的拷贝(子进程)。

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(void) {
    int x = 42;
    int ret = fork();
    if (ret == 0) {
        // 子进程
        printf("child: x = %d\n", x);
    } else {
        // 父进程
        printf("parent: x = %d\n", x);
    }
}

执行结果

image-20240424122301193

在调用完 fork 之后,用户可能想让父进程和子进程执行不同的任务。但父进程和子进程一模一样,该怎么分辨呢?

内核在 fork 的返回值上为父、子进程制造了一点不同:如果 fork 顺利完成,那么它在子进程中的返回值为 0,而在父进程中的返回值为子进程的标识符(PID)。

父进程和子进程的寄存器的值完全一样,因此,PC 执行的是 fork 指向的是下一条指令,他们都会从 fork 调用中返回(即“调用一次,返回两次”)。

fork 的实现相比于 process_create 来说简单许多,除了创建 PCB初始化内核栈之外,其他部分都是直接从父进程简单获取一份拷贝。

int fork(void) {
    struct process *new_proc = alloc_process();    // 创基建 PCB
    new_proc->vmspace->pgtbl = alloc_page();       // 创建虚拟地址空间
    copy_vmspace(new_proc->vmspace, curr_proc->vmspace);    // 拷贝虚拟地址空间(可执行文件初始化、用户栈和运行环境都有了)
    
    init_kern_stack(new_proc->stack);     // 创建内核栈
    copy_ctx(new_proc->ctx, curr_proc->ctx);     // 拷贝上下文
}

由于 fork 只能创建现有进程的拷贝,为了能创建对应新应用程序的进程操作系统还提供了 exec 调用,在 fork 的基础上载入新的可执行文件并执行。

exec 的实现与 process_create 的功能类似,它不需要创建全新的 PCB,只需要在 fork 创建的进程基础上载入新的可执行文件,重新初始化 PCB 中的内容(如虚拟地址空间 vmspace),并根据应用提供的参数准备运行环境。

fork 和 exec 的组合将进程的创建分为职责清晰的两部分

  • fork 从先用进程出发,通过拷贝一个新的可运行进程,为进程搭建了“骨架”。
  • exec 则填入了实际需要运行的可执行文件、虚拟内存映射、应用参数等,为进程添加了“血肉”。

这种解耦的设计也给进程管理留下了较大的空间:在使用 fork 创建子进程后,程序可以在调用 exec 前对子进程进行各种设定,例如限制子进程可以使用的资源。


fork 的设计受到了诸多挑战:

  1. fork 已经变的过于复杂:随着操作系统支持的功能越来越多,fork 的实现也越发复杂。fork 默认语义是构造与父进程一样的拷贝,因此它会使得子进程与父进程共享各种类型的状态。每当操作系统为进程添加新功能、扩展 PCB 的结构时,就必须考虑是否需要对 fork 的实现做相应修改。

    fork 的实现与进程、内存管理模块耦合程度过高,不利于内核代码的维护。

  2. fork 的性能太差:fork 默认语义是构造与父进程一样的拷贝,因此原进程包含的状态越多,fork 的性能就越差。

  3. fork 存在潜在安全漏洞:同一个程序每次启动的虚拟地址空间布局是随机的,这位攻击者带来了一定的困难:为了实施攻击,攻击者每次都需要先弄清楚进程的虚拟地址空间布局,再实施相应的攻击。但是,fork 创建的子进程与父进程的虚拟地址空间布局完全相同。攻击者一旦弄清楚了父进程的虚拟地址空间布局,就知晓了所有 fork 创建的子进程的地址空间布局。

6.3.2 其他进程创建方法

限定场景:vfork

fork 的实现就是简单的减父进程的内存完整拷贝一份,因此执行时间较长。为了解决执行时间较长的问题,引入 vfork ,作为 fork 在特定环境下的优化。

以下代码展示了 vfork 的伪代码实现

int fork(void) {
    struct process *new_proc = alloc_process();    // 创基建 PCB
    new_proc->vmspace->pgtbl = curr_proc->vmspace->pgtbl;       // 和父进程共享地址空间
    init_kern_stack(new_proc->stack);     // 创建内核栈
    copy_ctx(new_proc->ctx, curr_proc->ctx);     // 拷贝上下文
    
    // 父进程在内核中等待,直到子进程调用 exec
    while (!exec_or_exit(new_proc)) {
        shcedule();
    }
    // 返回
}

vfork 扔从父进程中创建子进程,但不会为子进程单独创建地址空间,子进程和父进程共享地址空间。

父、子进程对内存的任何修改都会对另一进程产生影响。

为了保证正确性,vfork 会使父进程在内核中等待,直到子进程调用 exec 创建自己独立的地址空间或者退出为止。

与 fork 相比,vfork 省去了一次页表的拷贝,因此性能有明显的提升。但是,vfork 的使用场景相对受限,只适用于进程创建之后立即使用 exec 的场景。

合二为一:posix_spawn

Linux 中 posix_spawn 的实现方式:

  • posix_spawn 调用 vfork 创建一个子进程
  • 创建的子进程进入一个专门的准备阶段,根据调用者提供的参数对子进程进行配置。
  • 子进程调用 exec,加载可执行文件并执行。

由于子进程调用了 exec ,父进程也能从 vfork 中返回。

在较新版本的 Linux 中,posix_spawn 的性能要明显优于 fork 和 exec 的组合,性能的提升还是来自页表的复制。

伪代码

int posix_spawn(pid_t *pid, const char *path
               ...,
               const posix_spawnattr_t *attrp,
               char *const argv[],
               char *const envp[]) {
    // 调用 vfork 创建子进程
    int ret = vfork();
    if (ret == 0) {
        // 子进程:在exec执行之前,根据参数对其进行配置
        prepare_exec(attrp, ...);
        // 执行 exec
        exec(path, atgv, envp);
    } else {
        // 父进程:将子进程的 pid 设置到传入的参数中
        *pid = ret;
        return 0;
    }
}

posix_spawn 能完全取代 fork + exec 的组合呢?

不能,虽然 posix_spawn 提供了 exec 之前的准备阶段来配置子进程,但它提供的参数表达能力是有限的,而“fork + exec ”则有任意多种配置的可能。

posix_spawn 是一种比 fork 效率更高但灵活度较低的进程创建方式。

精密控制:rfork / clone

由于 fork 接口过于简单,因此当应用希望选择性地共享父进程和子进程的部分资源时,fork 就爱莫能助了。

rfork 支持父子进程之间细粒度的资源共享。

Linux 操作系统借鉴了 rfork ,提出了类似的接口 clone。

同样是通过拷贝的方式创建新进程,clone 允许应用通过参数对创建过程进行更多的控制,在功能方面也有一些扩展。

clone 的过程和 fork 比较相似,也是从已有进程中创建一份拷贝。但相比于 fork 对父进程的所有结构一概进行复制,clone 允许应用传入参数 flags,指定应用不需要啊复制的部分。

int clone (..., int flags, ...) {
    struct *process new_proc = alloc_process();
    
    // 如果设置了 CLONE_VM,则直接使用父进程的页表,否则拷贝一份
    if (flags & CLONE_VM) {
        new_proc->vmspace->pgtbl = curr_proc->vmspace->pgtbl;
    } else {
        new_proc->vmspace->pgtbl = alloc_page();
        cppy_vmspace(new_proc->vmspace, curr_proc->vmspace);
    }
    
    // 内核初始化
    init_kern_stack(new_proc->stack);
    // 上下文初始化
    copy_ctx(new_proc->ctx, curr_proc->ctx);
    
    // 如果设置了 CLONE_VFORK ,则使父进程在内核中等待
    if (flags & CLONE_VFORK) {
        while (!exec_or_exit(new_proc)) {
            shcedule();
        }
    }
    // 返回
}

6.4 进程切换

进程切换机制:从一个进程的“上下文”切换到另一个进程额“上下文”执行,在切换过程中,由于进程的重要状态保存在处理器的寄存器中,因此在切换进程前需要保存这些状态(即处理器上下文),以便之后恢复执行。

处理器上下文等进程相关的信息保存在内核中,所以进程切换需要在内核中进行。

进程切换的主要工作流:

进程切换工作流

进程上下文与处理器上下文

进程上下文是操作系统提供的软件概念,表示操作系统目前正以哪个进程的身份运行。进程上下文在内核中常以一个指向当前运行进程 PCB 的指针形式出现(比如本章代码中使用的 curr_proc 变量),而 PCB 中包含的所有内容都属于进程上下文的范畴。

处理器上下文则是一个硬件概念,它包含的是处理器中当前寄存器的状态。

处理器上下文是进程上下文的一部分

6.4.1 进程的处理器上下文

处理器上下文数据结构(context)包含的内容。

struct context {
    // 通用寄存器
    u64 x0, x1, ..., x30;
    // 特殊寄存器
    u64 sp_el0;
    // 系统寄存器
    u64 elr_el1, spsr_el1;
}

context 包含以下寄存器中的值

  • 所有通用寄存器(X0 ~ X30)
  • 特殊寄存器的用户栈寄存器 SP_EL0。该寄存器并不会被硬件自动保存,所以需要手动保存,以便下次切换时恢复到正确的栈顶位置。
  • 系统寄存器中的 ELR_EL1 和 SPSR_EL1 。这两个寄存器在从用户态切换到内核态时分别保存了 PC 寄存器和 PSTATE 的值,因此保存他们可以使处理器硬件状态和程序计数器在进程切换后得到正确恢复。

6.4.2 进程的切换节点

进程切换的触发方式可以分为主动和被动两种。

主动:进程主动放弃 CPU 资源,并交给其他进程使用。当调用 process_exit、process_waitpid、process_sleep 等系统调用时,最终会调用 schedule 函数,使操作系统调度下一个进程执行。

被动:操作系统强制触发的,通常基于硬件中断实现。

6.4.3 进程切换的全过程

进程切换可以分为六个步骤:

  1. p0 从用户态进入内核态,此时硬件会自动将 PC 和 PASTE 寄存器的值分贝保存到 ELR_EL1 和 SPSR_EL1 寄存器中
  2. 内核获取 p0 的处理器上下文结构,并将前述寄存器依次保存到处理器上下文中。
  3. 内核获取 p1 页表基地址,并存储到 TTBR0_EL1 寄存器中,即完成了虚拟地址空间切换。可能需要添加 TLB 刷新指令,防止后续地址翻译错误。
  4. 内核将 SP_EL1 切换到进程 p1 私有的内核栈顶地址,从而完成内核栈的切换。至此,内核将不再访问任何与 p0 相关的数据,因此可以认为内核栈切换是进程切换的关键分界点,内核此时可以将 curr_proc 设置为 p1,从而完成进程上下文的切换。
  5. 内核从 p1 的 PCB 获取其处理器上下文结构,并以此恢复到前述寄存器中。
  6. 内核执行 eret 指令返回用户态,此时硬件会自动将 ELR_EL1 和 SPSR_EL1 寄存器中的值恢复到 PC 和 PSTATE 中。p1 恢复执行。

进程切换全流程

6.4.4 案例分析:ChCore 的进程切换实例

查看《操作系统:原理与实现》书籍

6.5 线程及其实现

6.5.1 为什么要有线程

通过实现进程及其相关接口,操作系统实现了对于运行中程序的管理。但是,随着硬件技术的发展,“多核”架构逐渐成为主流,其中的每个 CPU 核心都能独立运行程序,但截止目前介绍的进程只能运行在单个核心上,因而无法充分利用计算资源。

那么能否通过创建多进程的方式来利用多个 CPU 核心呢?

答案很明显是不能的

主要原因:

  1. 进程创建的开销:需要用得到创建进程的接口 fork 等来完成进程的创建。而且,当进程执行完毕后,还需要对进程占用的资源进行销毁。由于进程包含的内容较多(PCB,页表,上下文等),创建和销毁都会引入性能开销。这些开销可能成为性能瓶颈。

  2. 如何将处理结果告知父进程:在一些场景下,子进程是要将处理结果通知给父进程的,但是,由于父进程和子进程处于独立的虚拟地址空间中,父进程无法直接访问子进程的处理结果。

    可以使用进程间通信的方式来共享结果,但是进程间通信进制都需要内核的协助,因此开销远高于普通的内存读写。

由于进程包含的内容较多,且共享数据比较困难,基于多进程的方法会造成性能。那么,能否在进程的内部引入可并行的多个执行单元,并使其在不同的核心上独立执行呢?一方面,由于他们从属于进程,因此在创建和销毁式不涉及进程所有内容,其开销相对较低;另一方面,由于他们共享统一地址空间,因此可以直接读写批次的数据,无须使用耗时的进程间通信机制。这种执行单元就是线程(Thread)

线程是什么呢?

线程是一个比进程更轻量级的 CPU 核心执行单元,是操作系统进行调度执行的基本单位。一个进程拥有多个线程,线程在创建和销毁时的开销都比进程要小。由于,线程共享进程下的虚拟地址空间,可以很方便的进行通信。

6.5.2 从用户视角看线程

目前主流的线程库:POSIX 线程库 pthreads

针对进程创建开销较高的问题,pthread_create 创建的线程依然处于原进程内部,不需要新的页表,因此降低了创建开销。

针对进程间数据共享问题,由于线程都处于同一地址空间内部,因此可以方便的进行共享数据。

线程具有与进程类似的管理接口:线程创建和退出使用的 pthread_create 和 pthread_exit。

如果要实现线程等待,pthreads 也提供了与 process_waitpid 类似的合并(join)操作。

6.5.3 线程的实现:内核数据结构

由于每个线程都能独立执行,因此操作系统需要为它们分别维护处理器上下文结构。

内核为线程设计了专门的数据结构——线程控制块(Thread Control Block, TCB)

除处理器上下文,TCB 还包括以下内容

  • 所属进程。为方便内核管理进程及其包含的线程,TCB 往往包含执行其所属进程 PCB 的指针。
  • 内核栈。由于每个线程都是可独立执行的单元,操作系统为他们分别分配了内核栈。
  • 线程退出状态:与进程类似,线程在退出时也可以用整型变量表示其退出状态。
  • 线程执行状态:由于线程是调度的基本单元,它也拥有与进程相似的执行状态,调度器通过查看线程的执行状态进行调度。

TCB 结构(第一版)

enum exec_status {NEW, READY, RUNNING, ZOMBIE, TERMINATED};

struct tcb_v1 {
    struct context *ctx;
    struct process *proc;
    void *stack;
    int exit_status;
    enm exec_status exec_status;
}

引入线程也使进程的角色发生了变化,因此 PCB 的结构也需要调整。在引入线程之前,进程是操作系统进行资源分配和调度执行的单位;在引入线程之后,线程成为操作系统进行调度的单位,而进程主要负责资源管理。因此,与调度执行相关的信息(处理器上下文和执行状态)都长 PCB 中移入 TCB,而与资源管理相关的信息(如虚拟地址空间)依然保存在 PCB 中。另外,内核栈和退出状态也与执行状态有关,因此,进程也不再维护,改由线程(TCB)维护。

PCB 结构(第六版)

struct process_v6 {
    struct vmspace *vmspace;
    int pid;
    pcb_list *children;
    tcb_list *threads;
    int thread_cnt;
}

6.5.4 线程的实现:管理接口

线程创建

线程创建的伪代码实现

int thread_create(u64 stack, u64 pc, void *arg) {
    struct tcb *new_thread = alloc_thread();    // 创建 TCB 
    init_kern_stack(new_thread->stack);         // 初始化内核栈
    
    new_thread->ctx = create_thread_ctx();      // 线程处理器上下文
    init_thread_ctx(new_thread, stack, pc);     // 初始化线程处理器上下文(主要是用户栈和 PC)
    
    new_thread->proc = curr_proc;               // 线程所属的进程
    add_thread(curr_proc->threads, new_thread); // 将线程加入到进程的线程列表中
    set_arg(new_thread, arg);                   // 设置参数
}

thread_create 不包括虚拟地址空间初始化,可执行文件载入、用户栈分配等过程,使得线程的创建要比进程简单的多。

线程退出

线程在退出时,只需要销毁处理器上下文即可。

引入 curr_thread 指向当前正在执行的线程的 TCB。

为了支持监控功能,内核不应该销毁 TCB,而是需要在 TCB 中存储线程退出时的状态。此外, 进程还需要溢出当前终止的线程。如果当前线层是进程拥有的最后一个线程,那么线程退出就意味着进程页不再执行了。因此,内核会销毁当前 TCB,还会调用 process_eixt 销毁当前进程.

线程退出的伪代码实现(第一版)

void thread_exit_v1(int status) {
    // 当前线程所属进程
    struct *curr_proc = curr_thread->proc;
    // 存储返回值
    curr_thread->status = status;
    // 销毁进程的处理器上下文
    destory_thread_context(curr_thread->ctx);
    
    // 从进程的线程列表中移除当前线程
    remove_thread(curr_proc->threads, curr_thread);
    curr_proc->thread_cnt--;
    // 如果进程总不包含任何线程,销毁线程 TCB 和 进程
    if (curr_proc->thread_cnt == 0) {
        destory_thread(curr_thread);
        process_exit(curr_proc);
    }
    // 告知内核选择下个需要执行的线程
    schedule();
}

线程等待与分离

与进程等待功能相似的线程管理接口是合并操作 join。线程可以调用 thread_join 并指定需要监控的线程,当 thread_join 被调用后,线程会等待其指定的线程退出。当 thread_join 返回时,指定线程的返回值也会传给调用 thread_join 的线程。

线程监控的伪代码实现

void thread_join(struct tcb *thread, int *status) {
    // 等待,直到线程退出
    while (thread->exec_status != ZOMBIE) {
        schedule();
    }
    // 获取指定线程的返回值,存储到参数中
    *status = thread->status;
    // 销毁指定线程的 PCB 
    destory_thread(thread);
}

与 wait 相比,thread_join 的功能较为单一,它只能监控指定线程的退出事件,而 wait 和 waitpid 可以监控任意子进程的多种事件。这也就是线程监控的接口取名为 join 的原因:当 join 返回之后,两个线程将“合并”为一个。

thread_join 要求等待其他线程退出并回收资源。但是在现实场景中,可能线程没有时间执行 thread_join。针对这种场景,线程提供了分离接口,允许分离线程自行回收资源。为了支持线程分离,TCB 内部需要维护与分离相关的状态 is_detached。而当线程退出时,如果发现线程处于分离状态,则可以直接回收其 TCB。如果一个线程处于分离状态,则 join 操作对其无效。

分离接口 thread_detach 的代码片段

void thread_detach(struct tcb *thread) {
    // 将 thread 的状态改为分离
    thread->is_detached = TRUE;
}
struct tcb_v2 {
    struct context *ctx;
    void *stack;
    struct process *proc;
    int exit_status;
    enum exec_status exec_status;
    bool is_detached;
}

void thread_exit_v2(int status) {
    // 当前线程所属进程
    struct *curr_proc = curr_thread->proc;
    // 存储返回值
    curr_thread->status = status;
    // 销毁进程的处理器上下文
    destory_thread_context(curr_thread->ctx);
    
    // 从进程的线程列表中移除当前线程
    remove_thread(curr_proc->threads, curr_thread);
    curr_proc->thread_cnt--;
    // 如果进程总不包含任何线程,销毁线程 TCB 和 进程
    if (curr_proc->thread_cnt == 0) {
        destory_thread(curr_thread);
        process_exit(curr_proc);
    }
    // 如果是分离进程,则直接销毁其 TCB
    if (thread->is_detached) {
        destory_thread(curr_thread);
    }
    // 告知内核选择下个需要执行的线程
    schedule();
}

为什么线程可以回收资源,但进程需要由父进程帮助回收呢?

这是因为进程与线程之间的关系存在差别。进程间的关系是分层的,依照创建与被创建关系构成树状结构,根节点(父进程)有管理叶节点(子进程)的职责。而线程间的关系是扁平的,线程只存在创建先后关系,并不存在“父子线程”关系,因此每个线程都可以调用 detach 自行回收资源,也可以调用 join 回收同一进程中任意线程的资源(该线程不能是分离线程)

6.5.5 线程切换

由于线程取代进程成为内核调度的基本单位,因此分时执行中切换的单位也变为了线程。

线程的切换与进程切换大致相同。

线程切换流程

线程切换与进程切换最大的差别出现在上下文切换部分:进程的上下文切换包含虚拟地址空间和内核栈切换;而在线程的上下文切换中,如果两个线程从属于一个进程,那么就不会进行虚拟地址空间的切换,只进行内核栈切换。

6.5.6 内核态线程与用户态线程

在 thread_exit 的伪代码实现中,该返回值是存储在内核的 TCB 中的,如果返回值可以是任意大小的数据类型,那么 TCB 的内存开销也可能会大幅上升,增大内核的内存压力。如果内核占用的内存资源过多,那么应用执行也会受到影响,因此由内核管理该返回值是不合适的。

pthreads 该如何支持任意类型的返回值呢?

注意到 pthreads 提供的 POSIX 接口并不是系统调用,而是在用户态运行的线程库函数。因此,pthreads 可以在用户态设计相应的数据结构,用于保存线程的返回值。

如图所示,pthreads 与线程相关的数据结构实际上被拆分为两部分内核结构(TCB)依然保存与线程相关的重要信息(如所属线程和处理器上下文),这些信息不能被用户直接访问;其他信息则保存在用户态的数据结构(pthread)中,内核并不知晓这些数据与该线程的关系。

pthreads 两部分数据结构

由于线程在内核态和用户态的执行实际上拥有较强的独立性,我们可以将其视为两类线程。

内核态线程(Kernel Level Thread):由内核创建并直接管理的线程,内核维护了与之对应的数据结构——TCB。

用户态线程(User Level Thread):由用户态的线程库创建并管理的线程,其对应的数据结构保存在用户态,内核并不知晓该线程的存在,也不对其进行管理。

内核只能管理内核态线程,其调度器只能对内核态线程进行调度,因此用户态线程如果想要执行,就需要“绑定”到相应的内核态线程才能执行。

pthreads 作为线程库只是提供了更加方便的抽象,其背后还是对应着由由内核管理的线程,为什么要将其拆分为内核态线程和用户态线程呢?

在 pthreads 场景下,用户态线程和内核态线程始终是一一对应的。

用户态线程与内核态线程的关系还可以更加复杂,而刻画用户线程与内核态线程关系的方法就称为多线程模型(Multithreading Model)

一般来说,多线程模型分为以下三类

三种不同的多线程模型

  • 一对一模型:现在对为常见的多线程模型。每个用户态线程都对应一个单独的内核态线程。
  • 多对一模型:将多个用户态线程映射到单一的内核态线程。进程实际上只分配一个内核线程,其多线程环境完全由用户态的线程库实现。比较有名的使用多对一模型的线程库是 Green Thread,最初是由 Sun 公司为其 Java 应用开发的。
  • 多对多模型:多对一模型有一个问题:由于进程中只包含一个内核态线程,因此同时只能被调度到一个 CPU 上执行,这导致应用的可扩展性受限。多对多模型允许应用自定义进程中包含的内核态线程数量,之后由线程库将用户态线程映射到不同的内核态线程执行。

6.6 纤程

随着计算机的发展,一对一的线程模型的劣势逐渐凸显出来。

  1. 复杂的应用可能会包含大量线程,且每个线程各司其职。与操作系统调度器相比,应用对线程的语义和执行状态更加了解,因此可能做出更优的调度决策。
  2. 一对一模型的线程创建对应着内核态线程的创建,创建时间长,对于执行时间较短的任务时延会造成较大影响。
  3. 一对一模型中线程的切换涉及内核态线程切换,因此必须进入内核,其性能开销也相对较高,对一些交互性强的线程影响较大。

纤程(Fiber)的出现就是解决上述问题。

纤程、协程与用户态线程

协程为应用提供了轻量级的用户态可并行单元。

协程和纤程没有太大区别,只是所处的语境不同。纤程一般用来描述操作系统提供的用户态可并行单元支持(系统概念),而协程则用来描述程序语言提供的可并行抽象(语言概念)。

纤程 / 协程与用户态线程的关系是什么呢?

纤程 / 协程符合用户态线程的定义,由用户态的库函数创建和管理,内核并不知晓它们的存在。纤程 / 协程是用户态线程的一种。

现有纤程 / 协程通产比用户态线程抽象层次更高,因为它们往往是在现有线程库的基础上再实现的抽象。

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

生产者-消费者模型

两个线程,一个线程是生产者,一个线程是消费者,生产者在共享内存里写入数据,消费者使用数据。

假设计算机只有一个处理器,那么数据从生产者生成到消费者处理至少需要经历一次线程切换(即从生产者切换到消费者)。

在实际情况中,由于该计算机上可能运行了很多程序,而调度器并不知道生产者与消费者之间的关系,因此未必会优先选择消费者进行调度。因此,尽管生产者早已完成了数据的生成,但由于线程切换的开销和调度器的选择,消费者可能需要经历较长时间的延迟才能开始处理数据。为了消除这部分延迟,应用可以使用纤程。

6.6.2 POISX 的纤程支持:ucontext

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

#include <ucontext.h>

int getcontext(ucontext_h *ucp);
int setcontext(const ucontext_h *ucp);
void makecontext(ucontext_t *ucp, void (*func)(), int agrc, ...);
  1. getcontext 用来保存当前纤程上下文(主要包括栈和处理器上下文)
  2. setcontext 则用来切换到另一个纤程上下文执行。
  3. makecontext 会修改纤程上下文,使其从指定的地址开始执行。
#include <signal.h>
#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>

ucontext_t ucontext_1, ucontext_2;

int current = 0;

void produce() {
    current++:
    // 切换到消费者纤程执行
    setcontext(&ucontext_2);
}
void consume() {
    printf("current value: %d\n", current);
    // 切换到生产者纤程执行
    setcontext(&ucontext_1);
}

int main(int argc, const char *argv[]) {
    char iterator_stack1[SIGSTKSZ];
    char iterator_stack1[SIGSTKSZ];
    
    // 初始化生产者纤程
    getcontext(&ucontext1);
    ucontext1.uc_link            = NULL;
    ucontext1.uc_stack.ss_sp     = iterator_stack1;
    ucontext1.uc_stack.ss_size   = sizeof(iterator_stack1);
    makecontext(&context1, (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(&context2, (void (*) (void))consume, 0);
    
    // 切换到生产者纤程执行
    setcontext(&ucontext1)
}

通过纤程,当生产者完成任务后,可以立即切换到消费者继续执行。纤程的切换是在用户态的,不需要内核参与,性能很好。

6.6.3 纤程切换

操作系统可以通过中断的方式进入内核态,从而完成切换。这种“被动切换”是强制的,基于这种切换实现的协作方式称为抢占式任务处理(Preemptive Multitasking)模式

纤程库一般会提供 yield 接口,使得纤程主动调用该接口并切换到其他纤程执行。基于这种切换实现的协作方式需要多个纤程进行协作以完成任务调度,被称为合作式多任务处理(Cooperative Multitasking)模式

纤程在用户态切换的两点优势:

  1. 保存的状态较少。
  2. 不需要引入内核态和用户态之间的切换。
posted @ 2024-04-24 22:34  Sstarry  阅读(57)  评论(0)    收藏  举报