%% # 纲要 > 主干纲要、Hint/线索/路标 # Q&A #### 已明确 #### 待明确 > 当下仍存有的疑惑 **❓<font color="#c0504d"> 有什么问题?</font>** # Buffer ## 闪念 > sudden idea ## 候选资料 > Read it later %% # 什么是进程? **程序** 是**机器指令和静态数据**的集合,而 "**进程**" 是**一个运行中的程序实例**。 "**运行中的程序**" 即称之为 "**进程**"——存储在硬盘上的**二进制可执行文件**,**被装载到内存中运行**、**由 CPU 执行其中的指令**。 > [!NOTE] Linux 下的进程启动与终止 > > - 内核 **使一个程序运行** 的 **唯一方法** 是调用一个 **`exec` 函数**; > - 进程 **主动终止** 的方式是显式或隐式(通过 `exit`)地**调用 `_exit` 或 `_Exit`**,或者调用 `abort()` 表明 "**异常终止**"。 > - 进程 **被动终止** 的方式是收到一个 "**终止进程**" 的信号,例如 `SIGKILL`。 <br> # 进程状态 进程包括以下状态: - (1)**新建**(New):进程正在被创建 & 分配资源,尚未加入就绪队列。 - (2)**就绪**(Ready):进程位于 "**就绪队列**",等待被调度**获取 CPU 时间片**后即可执行。 - (3)**运行**(Running):进程 **正占用 CPU 执行其指令**。 - (4)**阻塞/等待**(Blocked/Waiting):进程正在等待某个事件完成,例如等待 I/O 操作结束、等待某个信号量等。 - (5)**终止**(Exit):进程已经完成运行或被终止,等待其父进程或 OS 内核回收其资源。 - (6)**==挂起==**(Suspended):Linux 下,进程收到 `SIGSTOP`、`SIGTSTP`、`SIGTTIN`、`SIGTTOU` 信号时**被挂起**,且不会被调度,**停止直至收到 `SIGCONT` 信号**。 > [!NOTE] 进程状态变迁图[^1] > > 下图中,将 "**挂起**" 状态细分为了 "**就绪挂起**" 与 "**阻塞挂起**"。 > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-E8E2034B5583A9A5EDDAE4CA47F776AF.png|678]] > <br><br> # Linux 中的任务 Linux 内核中,进程与线程都通过**结构体 `task_struct`** 表示,统称为 "**==任务==**" 。 (**进程控制块 PCB**、**线程控制块 TCB** 在实现上都是用的 `task_struct` 表示) 内核中 **调度器的调度对象** 即是 `task_struct`。 > [!NOTE] 线程的 `task_struct` 结构体中**共享进程已创建的资源**(虚拟内存空间,代码段、文件描述符等) ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-DD39F0C9D497332D10784D0BBC362243.png|524]] ## Linux 任务状态 任务状态由 **`task_struct` 结构体中的 `state` 字段**描述,进程与线程都具有独立的任务状态。 例如, `top -H` 或 `ps -eLF` 可查看线程信息,**每个线程都有自己的 TID 和任务状态**。 主状态码: | 状态标记 | 含义 | 说明 | 备注 | | ---- | ------------------------ | -------------------------------------------------------- | --------------------------------------------------------------------------------- | | R | Runnable | 正在运行或**正在就绪队列中等待调度** | | | S | Interruptible Sleeping | **可中断**休眠状态(**可被信号中断,唤醒**) | 阻塞 I/O 下,进程阻塞时就处于该状态 | | D | UnInterruptible Sleeping | **不可中断**休眠状态(无法被任何信号唤醒,直至等待事件完成) | | | T | Stopped | 收到 **暂停信号(19 or 20)** 而暂停,仅当收到 **SIGCONT(18) 信号** 才会恢复执行 | - 收到 `SIGSTOP(19)` 或 `SIGTSTP(20)` 信号暂停<br>- 收到 `SIGCONT(18)` 信号继续执行(例如 `fg`)<br> | | t | Traced | **被附加调试**而暂停 | 例如被 `gdb -p <pid>` 附加调试时 | | Z | Zombie | **僵尸进程**(进程已终止,但未被父进程回收) | | | X | Dead | **已终止**(用于内核回收时标记进程已销毁,用户空间通常不会看见该状态) | | | I | Idle | 空闲(内核线程特有) | | 状态修饰符: | 修饰符 | 含义 | | ----- | ------------------------------------- | | s(小写) | **会话首进程**(**session leader**),例如 bash | | + | 位于**前台进程组** | | l | 多线程(内核线程组) | | L | **有页面锁定**(real-time memory locking) | | < | 高优先级 | | N | 低优先级(nice 值为正) | | n | 低优先级任务 | > [!example] 常见组合状态码示意 > > 例如,常见组合状态码含义如下: > > - `Ss`:休眠中的 bash; > - `Ss+`:休眠中的前台 bash; > - `R+`:正在运行中的前台进程; > - `Sl`:休眠 + 多线程; > - `Sl+`:休眠 + 多线程 + 前台进程; > > ![[_attachment/02-开发笔记/11-Linux/Linux 命令行/Linux-进程相关命令.assets/IMG-Linux-进程相关命令-A49E51736CF27788290E0DE2B9FDD59F.png|965]] > > > [!info] 僵尸进程(zombie)[^2] [^3] > > 僵尸进程:该**子进程已终止**,但其**父进程尚未通过 `wait()` 或 `waitpid()` 回收其退出信息**,故其 **任务控制块 `task_struct`** 仍残留于内核中。 > > 由于进程终止后**内核会释放掉其绝大部分资源**(页表与内存映射、文件描述符), > 故僵尸进程**几乎不占用资源**,但 `task_struct` 中仍保留有 **退出状态码** `exit_code`、**进程使用的 CPU 时间总量**等信息,且**仍==占用一个 PID==**。 > > 由于僵尸进程已终止,故**无法被 `kill -9` 再清除**,只能等待其父进程通过 `wait()` 或 `waitpid()` 回收。 > > [!info] 会话首进程 > > Linux 中一个 "**会话(Session)**" 是一个或多个进程的集合,其共享同一个 **会话 ID(==SID==)**。 > > 通常,**一个用户每==登录一次==就创建==一个会话==**(例如通过 `ssh`、终端、shell)。 > > 一个会话只有**一个==会话首进程==(session leader)**,通常即为: > > - **登录 shell,例如 bash**; > - `sshd` 的子进程 > - 守护进程启动时的初始进程(指尚未脱离 session 时;守护进程通常要脱离原来的 session,从而不受控制终端的信号影响) > > 会话首进程可以打开一个 "**控制终端**",即控制着终端设备,例如 `/dev/pts/0`。 > [!NOTE] 任务的 `T` 与 `t` 状态 > > - T(Stopped):当直接向进程发送 `SIGSTOP(19)` 或 `SIGTSTP(20)` 信号时,**进程默认进入 `TASK_STOPPED` 状态而暂停**。 > - t(Traced):**进程被 `ptrace` 系统调用附加调试而暂停** > > 当通过 **`ptrace(PTRACE_ATTACH, pid, ...)` 系统调用**附加调试进程时,该系统调用的操作为: > > 1. 为目标进程设置 `ptrace` 标志位(`tast_struct` 中) > 2. 向目标进程发送 `SIGSTOP(19)` 信号; > > 由此,目标进程会**进入 `TASK_STOPPED` 状态**并且**被标记为"==正被 `ptrace` 跟踪=="**,故显示的是 `t` 状态。 > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-F4EF8F832D4BA8B4E1C377D25BA154F9.png|439]] > > >[!example] `ps` 下显示的进程状态示例 > > > >`gdb -p <pid>` 附加调试时: > > ![[_attachment/88-求职面试/FAQ-OS.assets/IMG-FAQ-OS-7FB4ED70D1454C7E865FD0F57E04D6F8.png|649]] > > > >`ctrl + z` **发送 `SIGTSTP(20)` 信号**暂停时: >> ![[_attachment/88-求职面试/FAQ-OS.assets/IMG-FAQ-OS-43A4003627162310CBDFEDEEF5917EA5.png|646]] > > > <br><br><br> # 进程信息——进程控制块 PCB > **进程控制块**(Process Control Block, **==PCB==**),也称 "**进程描述符**"。参见 [^4] [^5] [^6] [^7] OS 内核通过一个称为 "**进程控制块**" 的结构体来 **跟踪/记录每一个进程的相关信息**,该结构体保存在进程的 ==**内核地址空间**== 中 "**进程各异**" 的部分。 PCB 中包含了**内核管理一个进程所需的所有信息**,包括但不限于: - 进程描述信息:PID、PPID、GID、进程名、进程组等; - 进程状态:新建、就绪、运行、阻塞等; - 进程树信息:指向**父进程 PCB** 的指针、指向**子进程 PCB 链表**的指针; - CPU 调度信息:进程优先级、调度参数等; - **CPU 寄存器值**:当发生中断(用户态->内核态)、进程上下文切换时,**进程当前的 CPU 寄存器值**会保存在 PCB 中,以便进程重新运行时恢复; - 信号相关信息:**信号屏蔽字**、**信号处理函数集** - 记账信息:进程开始时间、占用的 CPU 时间等; - 资源信息: - 内存相关:**0 级页表基地址**、**已分配的虚拟地址空间**; - 文件相关:**打开的文件列表**(文件描述符表)、**当前工作目录**、根目录等; - ....... > [!info] Linux 中用作进程 PCB 的 **`task_struct` 结构体**在 32 位系统下约 1.7KB [^5],包含上百项成员。 <br> ## task_struct 结构体 Linux 下的**进程/线程控制块** 均为 **`task_struct` 结构体**,内核**以 `task_struct` 作为调度对象**进行调度。 (从内核角度来说,不区分 "进程" 与 "线程" 两个概念,统一视为由 `task_struct` 所表示的 "**任务**") `task_struct` 中涉及到的几个重要结构体: | 相关结构体 | | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `struct thread_struct thread` | 用以保存 **内核栈指针**、**用户态寄存器**、**段寄存器等**相关值(例如用户态 PC `%rip`、用户态栈指针); | | `struct pt_regs` | 保存了 "**用户态->内核态**" 切换时,"**==用户态的寄存器值==**"(按寄存器值在 "**内核栈**" 上的存储顺序保存)<br>(末尾五项由 **CPU 硬件**自动压入 "内核栈";其余项由**内核代码通过汇编指令**压入 "内核栈") | | `struct mm_struct *mm` | 进程的 "**用户地址空间**"。 `mm_struct` 中有一个**由 `vm_area_struct` 结构体构成的链表**,每一项代表一个用户地址空间下 "**已分配的虚拟地址区域**; | | `struvt file_struct` | **打开的文件描述符表** | > [!info] Linux 下的 `task_struct` 、 `thread_struct` 、`pt_regs` 、`mm_struct` 结构体 > > ![[Excalidraw/Excalidraw-Solution/os-进程基础概念.excalidraw.svg|1022]] > %% [[Excalidraw/Excalidraw-Solution/os-进程基础概念.excalidraw|🖋 Edit in Excalidraw]] %% > ![[02-开发笔记/05-操作系统/os-中断机制#^41h6l9]] <br> ### PCB 的组织方式 参见 [^1] ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-F431057237C7E300CACA7C3BDD8B9BA2.png|613]] <br><br><br> # 进程创建 Linux 下进程创建的几种方式 - `fork()` :创建一个独立的 "**子进程**"。 - `clone()` :Linux 特有,支持控制父子进程共享的内存部分。 - `exec`系列函数:将当前进程**完全替换为一个新程序**——从新程序的 main 函数开始执行。 > [!danger] 不应当使用 vfork ! > > POSIX 相关标准 SUSv4 中已将 `vfork()` 完全移除,尽管 Linux 内核仍然保留了该系统调用,glibc 也支持。 > > vfork 创建子进程后: > > - **父进程将被挂起**,直至子进程调用 `exec` 或 `exit` 后,才恢复父进程; > - **子进程==直接使用==父进程的地址空间**(无写时拷贝机制),**子进程修改的任何内存页对父进程完全可见**。 > > [!NOTE] OS 内核创建进程的伪代码 > > ```cpp > int process_create(char *path, char *argv[], char *envp[]) { > // 创建一个新的PCB, 用于管理进程 > struct process *new_proc = alloc_process(); > // 虚拟内存初始化: 初始化虚拟地址空间及页表基地址 > init_vmspace(new_proc->vmspace); > new_proc->vmspace->pgtbl = alloc_page(); // pgtbl, 页表基地址 > > // 内核栈初始化 > 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); > // 准备运行环境: 将参数和环境变量放到栈上 > prepare_env(stack, agrv, envp); > // 上下文初始化 > init_process_ctx(new_proc->ctx); > // 返回 > } > ``` > <br> ## fork 函数 > 位于头文件 `<unistd.h>` 中,参见[^8] [^9] ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-CDC526E3B132A5BB0723D0B0FAAC64A8.png|735]] `fork()` 创建子进程后,父、子进程均从 **调用 `fork` 系统调用** 的代码位置返回,若创建成功则: - 父进程获得的返回值是其 **创建的子进程的 PID**; - 子进程获得的**返回值是 0**。 > [!caution] 对于多线程进程而言,**某个线程调用 `fork` 创建子进程后,子进程中只保留 "==调用 fork 的线程=="** > [!info] Linux 下通过 `clone()` 系统调用实现 `fork()`。该系统调用可通过一系列参数**指明父、子进程需要共享的资源** [^10]。 <br> ### 子进程获得的副本 fork 后,子进程将获得**父进程的以下副本**: 1. **==用户级==虚拟地址空间**——初始时,父子进程 **共享==用户地址空间==对应的 "==所有物理页=="** - 对于**原本 "只读"** 的页,例如 **文本段/代码段**( `.text` 段),**父子进程==始终共享==相同物理页**,无 "写时拷贝"。 - 对于**原本 "可写"** 的页,例如 "**==数据段、堆、栈、映射段==**",将应用 "**写时拷贝**" 机制。 2. **父进程中已打开的==所有文件描述符==的副本**(拷贝副本) > [!caution] 父、子进程各自拥有独立的 "**==内核资源==**" > > 内核地址空间中,进程相关的内核数据结构例如 "**==PCB、页表、内核栈==**" 等是进程各异的,这部分始终不会共享! <br> ### 写时拷贝 写时拷贝机制(Copy-On-Write,**==COW==**)[^8] [^11] [^12]: 1. `fork()` 创建的子进程会与父进程 "**==共享用户地址空间的所有物理页帧==**",并将 "**原本可写的页**" 标记为 "**==只读==**"。 2. 当父进程或子进程 **试图向某个 "原本可写的页帧`X`" 写入时**,将触发 **缺页异常**。在异常处理程序中,内核将执行以下操作: - **为该物理页==分配一页副本== `X'`并拷贝原页数据 `X->X'`**; - 对触发缺页异常的进程,**更新其页表映射**,修改**该虚拟页访问权限为 "==可读可写=="**。 在写时拷贝机制下,`fork` 的 "**实际开销**" 即为 "**复制父进程的页表**" 以及 "**为子进程创建唯一的进程控制块 PCB**" [^10]。 > [!note] "写时拷贝" 示意图[^11] > > 下图中进程 1 与进程 2 是 fork 创建的父子进程关系。 > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-DE0315C8C6C67CC25FE2C5FD31C7C6AD.png|347]] > > [!faq] ❓ fork 采用写时拷贝的原因 > > 很多场景下,**`fork` 创建子进程后会紧接着执行 `exec` 变成一个新进程**,因此**没必要拷贝一份父进程的地址空间副本**。 <br><br> ## exec 系列函数 exec 系列函数作用:在当前进程上下文中**加载并运行一个新程序**,即将当前进程 **完全替换为一个==新程序==** [^13]。 > [!info] exec 调用成功时不会返回!将跳转至 **“==新程序的 main 函数==”** 开始执行。 以 `execve` 为例: ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-B80F09C7A3337F4E66B92C8BA0E48350.png|761]] 该函数将**加载并运行指定的可执行文件**,传入**参数列表 `argv`** 以及**环境变量列表 `envp`**。 > [!note] 关于 exec 系列函数接收的 "参数列表" 与 "环境变量列表" > > - **参数列表** `argv`:将传递为新程序的 `main(int argc, char** argv)` 中的 `argv`。 > - **环境变量列表** `envp`:需要提供的是 "**==完整的新环境变量列表==**",即 **旧进程的原有环境变量将==全部失效==**。 > > 对于参数列表,根据惯例,`argv[0]` 应当传入 "**可执行文件的名称**"。 > > 示例:shell 通过 `execve` 运行 `ls -lt ...` > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-C6984F931CBB6A1085BA82B13F24C686.png|490]] > > [!NOTE] Linux 提供的环境变量列表处理 API > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-2F50CD8A1E125F2069B124DF69A02D9D.png|678]] <br> ### exec 的作用效果 进程执行 exec 函数后,**PID 不变**,而将 **当前进程的执行上下文(代码段、数据段、堆栈等)** 完全替换为新进程的上下文。 其将从**新程序的 `main` 函数**开始执行,同时继承 **==原进程==** 的下列属性: - **进程 PID 与父进程 PPID**; - **当前工作目录** - **实际用户 ID、实际组 ID** - **进程组 ID、附属组 ID** - **会话 ID** - **控制终端** - **文件模式创建屏蔽字**(`umask`) - **==文件锁==** - **==未处理信号==** - **==进程信号屏蔽==** - **资源限制**(`ulimit`) - **环境变量**(调用的并非 `execle` 或 `execve` 或 `fexecve` 设置环境时 ) - **文件描述符**(除非设置了 `FD_CLOEXEC` ,否则默认保留) > [!faq] ❓ 文件描述符的保留与否 > > exec 运行新进程后,原进程中每个文件描述符是否被保留&继承取决于 **`FD_CLOEXEC` 标志**,未设置时**默认 "==保留文件描述符=="**。 > > 文件描述符的 `FD_CLOEXEC` 属性的作用是:**=="close-on-exec==**",通过 `fcntl` 设置该属性后,**在进程执行 `exec` 时该文件描述符将被关闭**。 > `socket`、`timerfd`、`eventfd` 在创建时均可设置该属性(例如 `TFD_CLOEXEC`,`EFD_CLOEXEC`)。 <br> ### exec 函数的七种变体 exec 系列函数共 7 种变体[^14]: `execl()`,`execle()`,`execlp()`,`execv()`,`execve()`, `execvp()`, `fexecve()` ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-C4B092D3EA094AD0D8CF2A9F3B5DCB48.png|820]] ` > [!NOTE] 不同 `exec` 函数的区别 > > 区别在于: > - (1)**新程序** 如何指定:**完整路径名** or **仅文件名** or **文件描述符** > - (2)**新程序的参数** 如何给出:**逐一列出**(可变参数) or **由一个指针数组给出** > - (3)**继承调用进程的环境** or **指定新环境** > > 具体来说,**不同后缀 l、v、p、e** 对应不同含义: > > - 必带 `l` 或 `v`: > - `l`:预期接收**不定长的参数列表**,参数列表**要求以 ==`NULL` 指针==作为结束项**; > - `v`:预期接收一个**以 ==`NULL` 指针结尾==的、字符指针数组** 作为参数列表。 > - 可选 `p` 或`e`: > - `p`:表示可以 **仅接收 "文件名"**,并将**在环境变量 `PATH` 对应的目录下搜索该可执行程序**。 > - `e`:表示函数参数列表中最后一项接收一个**字符指针数组** `envp`,用以接收**环境变量**,而**不使用当前环境** > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-363EA61B0C58CCFEE664D0DE82C43732.png|669]] > > [!info] exec 的 7 种接口中,仅 `execve` 是内核调用,其余均为 "库函数" 封装,均会调用前者。 > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-9E3FB231C80C8263782122004724CBEC.png|695]] > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-9DDBD6B93417241A598D0EBD60341DFA.png|630]] > <br><br><br> # 进程终止 ### 进程终止的触发情况 正常终止情况主要包括: - 从 `main()` 返回 - 调用 `exit()` 或 `_Exit()`——来自 `<stdlib.h>` - 调用 `_exit()` ——来自 `<unistd.h>` 异常终止情况主要包括: - 调用 `abort()`; - 收到**终止信号**——`SIGKILL`、`SIGTERM` 等 > [!info] 一个进程正常或异常终止时,内核会向其父进程发送 "**==SIGCHLD 信号==**" > [!info] 无论进程以哪种形式终止,内核都会为其 "**==关闭所有已打开的文件描述符==**"、"**==释放其占用的所有内存==**"。 > [!NOTE] "`main` 函数中 `return status;`" 与 "直接调用 `exit(status)`" 等价 <br><br> ## exit 相关调用——正常终止 ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-0E9F1462D6DA40EC14928B6929FB480C.png|695]] 三个函数均接收一个 "**==退出状态==**" 参数,该值可被进程的 **父进程** 通过 `wait(&status)` 或 `waitpid(&status)` 获得。 三个函数中, `exit` 会先**执行清理处理**,再**调用 `_Exit` 或 `_exit` 返回到内核**,而后两者直接返回到内核。 `exit(int status)` 调用执行以下工作: 1. **按注册顺序的 "==逆序==" 执行 `atexit()` 注册的所有回调函数**; 2. **逆序** 遍历 **`.fini_array` 中的函数指针**,执行退出清理函数; 3. **调用 `_exit(status)`** 退出进程 → 通知内核终结进程。 > [!example] `exit` 实现的伪代码 > > ```c > void exit(int status) { > __run_exit_handlers(status, ...); > _exit(status); > } > > void __rund_exit_handlers(, ...) { > // 1. 执行通过 atexit() 注册的回调函数(按注册顺序的逆序执行) > while (has_registered_handlers) { > handler(); > } > // 2. 执行 `.fini_array` 中的函数 > if (__libc_fini_array) { > __libc_fini_array(); // 调用 .fini_array 中的函数(逆序执行) > } > } > ``` > > [!NOTE] 调用 `exit()` / `_Exit()` / `_exit()` 终止进程时的不同行为 > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-E342EFC6B8840FC107FFE965E363B50C.png|721]] > > ^1v4ilv ![[02-开发笔记/03-计算机基础/编译与链接/目标文件#^1zl2gv]] <br> ## atexit 函数——注册退出回调 > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-8B7D545AAC0F676E4DCD8802B281C093.png|700]] <br> ## abort 函数——异常终止 ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-8D9BB3D08D08B7EB865E276920FE9C78.png|761]] `abort` 函数用于触发进程 "**异常终止**",其实现本质上是 **令内核==发送 `SIGABRT` 信号==给进程**,并在信号处理程序返回时**终止进程**。 > [!example] abort 的实现伪代码 > > ```c > void abort(void) { > raise(SIGABRT); // 向自己发送 SIGABRT 信号 > // 若没有终止, 则直接调用 _exit(127) > } > ``` > > [!tip] 进程可以**捕捉 `SIGABRT` 信号并自定义处理**——例如完成**清理**操作 or 直接**调用 `exit` 相关函数终止自身**。 <br><br><br> # 进程回收 进程终止后,必须**由其父进程手动通过 `wait` 或 `waitpid` 函数回收其资源**,否则将作为 "**==僵尸进程==**",尽管占用极少资源但**会占据一个 pid**。 父进程调用 `wait` 或 `waitpid` 函数后,**将==阻塞等待==子进程运行结束,回收子进程资源**。 > [!info] 若一个子进程的父进程崩溃,**子进程会被内核==自动分配==作为 init 进程的子进程**,**子进程运行结束后将由 ==init 进程自动进行回收==**。 <br> ## waitpid 与 wait 函数 ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-050524EB07410C0BB68AF697AC363366.png|709]] `waitpid` 函数: - pid: - `pid > 0`:等待 "**指定子进程**" 终止后才返回。 - `pid = -1`:等待 **==任意一个==子进程终止后返回**。 - `pid = 0`: 等待 "**组 ID = 调用进程组 ID**" 的 **任意一个子进程** 终止后返回。 - `pid < -1`:等待 "**组 ID = pid 绝对值**" 的 **任意一个子进程** 终止后返回。 - staloc: - 用于**接收子进程终止时的状态信息**,可用于判断子进程是否为通过 `exit` 或从 `main` 返回而正常终止,或是获取令子进程终止的信号。 - options: - 为 0 表示**默认行为**,可通过 `WNOHANG` 等常量设置不同选项,例如 "**不阻塞直接返回**"。 > [!NOTE] `wait(&status)` 等价于 `waitpid(-1, &status, 0)`,即等待 "任意一个子进程" 终止后即返回 > [!caution] `wait()` / `waitpid(-1,...)` 回收子进程的顺序是 "不确定的",除非通过 `waitpid(x,...)` 指定回收特定线程 > [!tip] 一个进程正常或异常终止时,内核会向其父进程发送 "**SIGCHLD 信号**" > > 故**可将 `wait()` / `waitpid()` 作为 SIGCHILD 信号的处理函数**。 > > Linux 中 `init` 进程**保证其所有子进程不会成为 "僵尸进程"** 的做法便是如此,伪代码示意如下: > > ```c > // 伪代码示意: > signal(SIGCHLD, handler); > > void handler(int sig) { > pid_t pid; > int status; > // 不断地回收所有已终止的子进程 > while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { > // 处理状态,比如日志、重启等 > } > } > ``` > <br><br><br> # 进程加载 ⭐ 程序的加载过程: 1. shell 通过 `fork` **创建==子进程==**; 2. 子进程随后 **调用 `execve` 或其它 exec 函数==加载可执行程序==**; 3. exec 函数完成 "**进程加载**",将控制传递给 **新程序的 main 函数**。 > [!NOTE] ELF 文件中,被加载至内存的 "节" 主要包括 `.text`、`.rodata`、`.bss`、`.data` 等。 > > 下图参见[^15] > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-8E140C6A7BAAB07C346D19B19468971D.png|553]] > > [!info] 程序加载器(loader) > > "程序加载器" 是指 **exec 系列函数背后的一整套实现逻辑**,并非一个独立进程 or 系统服务,而是 **OS 内核代码的一部分(位于 `.text` 代码段)** <br> ## exec 加载 ELF 可执行文件的过程 大致过程如下 [^16]: 1. 读取 **ELF 头部**,获取 "**程序头部表**" 的地址; 2. **==分段映射 + 权限设置==**——遍历 ELF 文件中的 "**==程序头部表==**",**加载 ==`PT_LOAD` 类型的段== 到内存**: - **内核将该段在 ELF 文件中的起始偏移 `p_offset` 起的 `p_filesz` 字节==映射至虚拟内存地址== `p_vaddr`**,并根据 `p_flags` 设置内存页的 **访问权限**。 - 若 `p_memsz > p_filesz`,则将多出来的部分 "**==清零==**" - (例如 `.bss` 段,在 ELF 文件中不占空间,但加载到内存后占 `p_memsz` 大小) 3. 完成 "**动态链接**"(仅针对 "动态链接的程序") 1. 读取**程序头部表**中 **`PT_INTERP` 段**,获取 "**动态链接器路径**"; 2. 内核加载并启动 **动态链接器**(`/lib/ld-linux.so`); 3. 动态链接器**加载程序依赖的所有共享库**,并完成重定位、符号解析等处理。 4. 构建 "**==用户栈==**" 1. 内核映射得到栈区; 2. 压入**命令行参数列表 `argv`**、**环境变量列表 envp** 以及 `auxv` 等辅助信息,构建初始栈帧,设置**栈指针寄存器 `rsp`**。 5. 设置 "**==程序入口地址==**" 1. 读取 ELF 头部中的 **`e_entry` 字段**,获取 **程序入口地址**(通常是 `_start` 函数的地址); 2. 设置 **指令指针 rip**(即程序计数器 PC) 为 **程序入口地址**。 6. 跳转至 "**==程序入口点==**" (`_start()`函数)开始执行 - `_start` 函数将调用 `__libc_start_main()` 函数,而**后者再进一步调用 `main()` 函数**。 7. **执行 `main()` 函数** > [!important] 除了一些头部信息,整个进程加载过程中,没有任何从磁盘到内存的 "**数据复制**" [^16] > > 加载过程,OS 内核进行**内存映射**时,并不涉及 "**磁盘到内存**" 的数据拷贝, > 例如对 ELF 文件中的 `.text` 与 `.data` 段,内核实际上只是 "**分配了虚拟页并==标记无效==**" [^17], > **直至 CPU 首次引用一个被映射的虚拟页** 而触发 "**缺页异常**"后,才由缺页异常处理程序 **真正分配物理页**。 > > > [!NOTE] Linux 下所有 ELF 可执行程序的 "默认入口点" 为 `_start()` 函数 > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-B99FFFCC7BEE3C0ABA48D0BF06649FBC.png|570]] > > 进程加载的实际调用链: > > ```text > _start() > └──libc_start_main() > ├── 完成初始化——包括注册 atexit 回调,执行 .init_array 中初始化函数 > ├── 调用 main() > └── 调用 exit()(执行析构函数、清理资源) > ``` > > > [!NOTE] 可手动指定 ELF 文件的 "**==程序入口点函数==**" > > ELF 文件的入口点是在 "**==链接阶段==**" 由 **链接器** 指定的,**默认即为 C/C++运行时库 `crt1.o` 中提供的 `_start()` 函数**。 > 在使用 gcc/g++编译时,可通过特定选项设置 "**指定入口点函数**"。 > > > [!example] 示例:以汇编代码**编写一个最小可执行的 C++程序**,不依赖 libc 运行时库,不走 `_start`,**==手动指定入口点==** > > > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-95327DF7A3A9B96794F9BBD0F51CF1F6.png|404]] > > > > 编译指令:`g++ main.cpp -o myprog -nostdlib -nostartfiles -Wl,-e,my_entry`, > > > > - `-nostdlib`:指示编译器**不使用 stdlib 运行时库** > > - `-nostartfiles`:指示编译器**不使用默认的启动文件**(比如 `crt1.o`) > > - `-Wl,-e,my_entry`:传给链接器 `ld`,**==指定入口点==为源代码中自定义的 `my_entry`函数** > > > > 运行结果: > > > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-F811FD664ED172F360349F59D774EBD7.png|610]] > > > ![[02-开发笔记/03-计算机基础/机器视角下的进程#初始函数栈的结构示意|机器视角下的进程]] <br> ## `_start` 函数与 `__libc_start_main` 函数 > [!info] 关于 `_start()` 函数 > > `_start()` 函数是由 **glibc 运行时库**提供的**启动代码**,位于 **系统库的目标文件 `ctr1.o`** 中。 > 当用户程序与 glibc 库链接形成**最终的可执行文件**后,该函数即作为 "**==程序入口点==**"(ELF 头中的 Entry Point)。 > > `_start()` 会**调用 `__lib_start_main()` 函数**,再 **==由后者进一步调用 `main()` 函数==**。 > > [!NOTE] 关于 `__libc_start_main()` 函数 > > `__libc_start_main()` 负责**初始化整个 C/C++ 运行时环境**,主要步骤包括: > > 1. 初始化**线程局部存储 TLS**; > - 若 ELF 中 `PT_TLS` 段存在,则根据该段**为主线程分配 ==TLS 区域==**; > - 配置寄存器指向该 TLS 区(如 x86-64 中的段寄存器 `fs.base`) > 2. **==注册 `atexit()` 回调==** > 3. 遍历 **==初始化函数数组==** `.init_array`,**调用各个初始化函数**。 > 4. 调用 main 函数,**传递参数列表 `argv`、环境变量列表 `envp` 等参数**; > 5. 等待 main 函数返回,**获取其退出状态码**,根据其返回值 **调用`exit()`** ,触发清理。 > > ```cpp > // __lib_start_main() 中对`main`的调用示意 > main_retval = main(argc, argv, envp); > exit(main_retval); // 将main的返回值直接传递给exit(), 执行清理 > ``` > ![[02-开发笔记/03-计算机基础/编译与链接/目标文件#^jxfmj2]] > [!NOTE] `__attribute__((constructor))` 标记的函数将在 `main()` 函数之前被执行 > > ```cpp > > __attribute__((constructor)) > void my_ctor() { > printf("Hello from constructor!\n"); > } > > int main() { > printf("Hello from main!\n"); > return 0; > } > ``` > <br><br><br> # 进程打开文件 > 参见;《CSAPP》P634 OS 内核使用了三个数据结构来共同维护/记录 "**进程打开的文件**"[^18] [^19]: - **每个进程独立的 "==文件描述符表=="**(File Descriptor Table)——记录了**该进程打开的所有文件**,"**文件描述符**" 值即用作该表的索引。 - **内核中的 "==文件表=="**(File Table)——每个被打开文件对应一个 "**文件表项**"(同一文件被不同进程同时打开时,**对应不同表项**) - **内核中的 "==v-node 表=="**(v-node Table)——每个打开文件对应一个 **v-node** > [!info] 内核中的 "**文件表**" 与 "**v-node 表**" 由所有进程==共享== > [!info] Linux 中没有单独定义 v-node,而是 **统一使用 i-node 数据结构** 来表示 v-node > > 区分 `i-node` 数据结构中记录的信息: > > - 一种是 **==充当 v-node==**,用以表示 "**打开文件**" 的 i-node; > - 一种是文件系统相关的,记录文件元数据的 i-node; > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-0072B1E7EF150F996C30A4B50423A0D8.png|798]] ## 三个表中的信息 - **文件描述符表项** - 记录 "**描述符**" 的状态标志,例如 **==`FD_CLOEXEC` 标志==**; - 记录指向 "**文件表项**" 的指针。 - **文件表项** - 记录 "**打开文件**" 的状态标志,例如 **==`r/w/append`==**,**==阻塞与非阻塞==** 等,即 `open()` 或 `fcntl()` 设置的**打开模式与属性**。 - 记录 "**当前文件的偏移量**" - 记录指向 "**v-node 表项**" 的指针。 - **v-node 表项** - 记录 "**文件信息**,包含了 **`stat` 结构体** 中的大多数信息,例如**文件类型、文件大小** 等。 - 记录指向文件 "**i-node**" 的指针。 ## 文件共享 每个 "**被打开文件**" 只会**存在==唯一的 v-node 表项==**。 当**多个进程打开同一文件**、或是**单个进程多次打开同一文件**时,都将创建一个 "==**独立的文件表项**==",指向 "**同一个 v-node 表项**"。 > [!example] 文件共享:不同进程打开同一文件 > > > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-552B39CB852A4A35CFF765F80AF131E1.png|606]] > > [!example] 文件共享:同一进程多次打开同一文件 > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-CDDA9BFE9FF34ACBA6C54FD13965E61D.png|567]] > [!example] 文件共享:`fork` 创建的子进程继承 "**父进程已打开的文件描述符表**" 的副本 > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-39586C3E06D3AE63A8976028663058EC.png|558]] > > [!NOTE] `dup(fd)` 的本质 > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-CE17F4D09B2E655B9CBC32118F80EAC1.png|713]] <br><br><br> # 进程上下文切换 ⭐ > **上下文切换**(Context Switch) OS 暂停当前执行的进程,转而**切换执行另一个进程**时,需要**保存当前进程的上下文,恢复另一进程的上下文**,该过程即称之为 "**上下文切换**"。 上下文切换是现代 OS **支持==多任务==** 的关键机制,使得单个 CPU 可被多个进程或线程共享,从而**实现并发**——CPU 在进程或线程间切换,从而**表现为在并发地执行多个任务**。 进程上下文切换可以由多种因素触发,包括: - **任务调度** - **时间片用尽**:分配给当前进程的时间片耗尽时,触发时钟中断,进而执行进程调度; - **高优先级任务就绪**:高优先级任务到来时,OS 可能会中断当前正在执行的低优先级任务,先执行高优先级任务。 - **阻塞等待**: - **等待 I/O**:进程执行 I/O 操作而阻塞等待时,OS 会将其挂起,转而执行其他任务; - **主动挂起**:进程调用 `sleep()` 主动陷入睡眠时; - **主动放弃 CPU**:进程调用 `yield()`,主动放弃时间片; <br> ## 进程的上下文 进程的上下文是指 "**程序正确运行所需的==所有状态信息==**",由**内核为每个进程维护**。包括但不限于: - **用户上下文**——进程虚拟地址空间中的 "**用户地址空间部分**" - 进程的栈、堆、代码/文本段、程序段; - **内核上下文**——进程虚拟地址空间中的 "**内核地址空间中==进程特异==的部分**" - **内核栈** - 内核数据结构:**进程页表**、**进程表**(包含当前进程信息)、**文件表**(当前进程已打开文件的信息) - .... - **硬件上下文**(CPU 寄存器值) - **程序计数器 PC `%rip`** - **栈指针 `%rsp`** - **通用寄存器值** - **页表基址寄存器值**(即进程 0 级页表的物理地址) - .... <br> ## 进程上下文切换的过程 步骤概述: 1. OS **保存当前进程的上下文**; 2. OS **加载/恢复另一个进程的被保存的上下文**; 3. **将 CPU 控制权传递给被恢复的进程** #### 详细步骤 > 参见[^20] - (1)触发**时钟中断**,**由 "用户态" 陷入 "内核态"**。 1. CPU 特权级由 CPL=3 (用户模式)切换到 CPL=0(内核模式); 2. CPU 修改栈指针寄存器值 `%rsp` 为进程的"**内核栈指针**"; 3. CPU 将 "**==用户态的寄存器值==**" 压入 "**==内核栈==**" 中:**用户态 PC 值 `%rip`、用户态栈指针 RSP、状态寄存器 RFLAGS**等; - (2)CPU 查询中断向量表,跳转执行 **中断处理程序**(内核的 **进程调度程序**) 1. 内核保存 "**当前进程的==内核态寄存器值==**" 到进程控制块 PCB 中。例如,**==内核态 PC 值==**、**==内核态栈指针==** 等; - Linux 下保存到 `task_struct->thread` ,**`thread_struct` 结构体** 2. 内核通过调度器,**获取下一个进程的 `task_struct`**: - (1)**恢复新进程的==内核栈态寄存器值==**:读取其 `task_struct->thread`,**存入到 CPU 寄存器里**; - 例如,**新进程的内核态 PC 值、内核态栈指针** - (2)**切换到新进程的==页表==**:从 `task_struct->mm` 中获取新进程的 **==页表基地址==**,存入到 **页表基址寄存器** `CR3`。 - (3)内核调度程序返回,执行 `sysretq` 或 `iretq` 指令,从 "**内核态**" 切换回 "**用户态**" 1. CPU 从新进程的 **内核栈** 上弹出其 **新进程的==用户态寄存器值==**:**用户态 RIP**、**用户态栈指 RSP** 等,恢复到对应寄存器; 2. CPU 特权级切换回到 CPL=3(用户模式) ```c // 进程上文切换伪代码: save_context(current->thread_struct) // 保存"当前CPU寄存器值" next = pick_next_task(); // 获取下一个task_struct if (current->mm != next->mm) { // 如果两个task属于不同的地址空间(针对进程间切换) switch_mm(prev->mm, next->mm, next); // 切换页表基地址寄存器CR3, 同时需要刷新TLB } restore_context(next->thread_struct); // 恢复"CPU寄存器值" current = next; ``` <br> ## 进程上下文切换保存的内容 进程上下文切换时,首先涉及到 "**用户态->内核态**" 的切换,而后才是 "**进程间切换**",因此保存内容分成两部分: 1. "**旧进程的==用户态寄存器值==**" => 保存到 "**旧进程的==内核栈==**"。("用户态->内核态" 切换所要求的) 2. "**旧进程的==内核态寄存器值==**" => 保存到 "**旧进程的 `task_struct->thread`**" 中。("进程间切换" 所要求的) 其他信息不需要保存,各进程 `task_struct` 常驻内核地址空间,当 **`next` 指向调度器返回的下一个`task_struct` 后就可以直接获取其中信息**。 切换到新进程时,需恢复的内容如下: 1. "**新进程的==内核态寄存器值==**" <= 从 "**新进程的 `task_struct->thread` 中读取**",恢复到对应寄存器。例如内核态栈指针 `%rsp`、内核态 `%rip`; 2. "**新进程的==页表==**" <= 从 "**新进程的 `task_struct->mm`**" 中读取,加载到**页表基址寄存器** `CR3` 中。 3. "**新进程的==用户态寄存器值==**" <= 由 "内核态->用户态" 时,从新进程的 "**内核栈**" 上弹出,恢复到对应寄存器。 > [!NOTE] 进程上下文切换过程中,保存到 "**进程控制块 PCB**" 中的是 "**==内核态寄存器值==**"。 > > 下图参见[^4] > > ![[_attachment/02-开发笔记/05-操作系统/进程管理/进程基础概念.assets/IMG-进程基础概念-A3A119EA5BFE73D40ACE758D1F709AA3.png|466]] > > ^6dpkiu <br> ## 进程上下文切换的开销 来自于两方面,影响最大的是第二点: 1. **时间开销**:不同进程上下文(**寄存器值**) 的保存/恢复操作。 2. **==缓存失效==**:进程运行时**在 CPU Cache、TLB、分支预测器和其他硬件中建立了大量缓存状态**,**切换后会刷新这些缓存**。 <br><br><br> # ❓ 进程相关 FAQ #### <font color="#c00000">❓父进程崩溃时,其创建的子进程是否受影响?</font> **子进程独立于父进程运行**,当其**父进程先正常退出 or 异常崩溃时,==子进程不会崩溃/退出,其会被 OS 重新分配给 `init` 进程==**(Linux 上 `PID=1`),由 `init` 负责接管,此时该子进程称为 "**==孤儿进程==**",其调用 `getppid()` 返回的是 `init`的 1。 若**子进程依赖于父进程的资源**,例如其**需要与父进程通信而共享文件描述符**(如管道、套接字等),若父进程崩溃关闭了文件描述符,则**子进程可能会因为无法继续进行 I/O 操作**而受到影响。 #### ❓<font color="#c00000">一个多线程程序,其中一个线程调用 `fork()` 创建了子进程,则子进程中具有几个线程?</font> **子进程只会==保留一个线程==——调用 `fork()` 的那个线程**。 `fork()` 创建的子进程会**复制/继承父进程的整个地址**,包括父进程中的**互斥量、读写锁、条件变量**等,但是**子进程中只会保留 "调用 `fork()`" 的这一个线程**。因此,当子进程从 `fokr()` 返回后: - 若立即调用 `exec()` 的话,则 **==旧的地址空间被丢弃==**,因此无需再考虑处理锁。 - 若没有立即调用 `exec()` 的话,就**需要==清理==这些继承而来的锁** => 由父进程调用 `fork()` 前,**调用 `pthread_atfork()` 预先建立 fork 处理程序**。 <br><br> # 参考资料 # Footnotes [^1]: [《小林 Coding》](https://xiaolincoding.com/os/4_process/process_base.html#%E8%BF%9B%E7%A8%8B%E7%9A%84%E7%8A%B6%E6%80%81) [^2]: 《深入理解计算机系统》(P514) [^3]: 《UNIX 环境高级编程》(P189) [^4]: 《操作系统概念》(P74) [^5]: 《Linux 内核设计与实现》(P21) [^6]: 《现代操作系统》(P53) [^7]: 《深入理解 Linux 内核》(P86) [^8]: 《UNIX 环境高级编程》(P182) [^9]: 《深入理解计算机系统》(P514) [^10]: 《Linue 内核设计与实现》(P27) [^11]: 《操作系统概念》(P271) [^12]: 《深入理解计算机系统》(P584) [^13]: 《深入理解计算机系统》(P521~523) [^14]: 《UNIX 环境高级编程》(P202) [^15]: 《深入理解计算机系统》(P584) [^16]: 《深入理解计算机系统》(P485,第 7.9 节) [^17]: 《深入理解计算机系统》(P566) [^18]: 《UNIX 环境高级编程》P60、P184 [^19]: 《深入理解计算机系统》(P634) [^20]: 《深入理解 Linux 内核》(P111)