二、进程管理和调度
2.1 进程优先级
根据进程的关键程度可以用进程优先级表示外,还可以将进程分为几类:
- 硬实时进程: 有严格的时间限制,某些任务必须在指定时间内完成
- 软实时进程:弱化形式,稍微晚执行一些也无妨
- 大多数进程都是没有时间约束的普通进程
最开始通过简单的时间片轮转,让所有进程都有机会运行,会根据进程重要性分配不同的轮转时间片。执行时间到了,CPU强行分配给下一个进程,这种称为抢占式多任务处理, 时间段到期后,内核会回收进程的cpu控制权,让不同的进程执行,被抢占的进程运行环境,CPU寄存器内容和页表,都会保存,其执行结果不会消失。
但该模式存在两个问题:
- 有的进程因为条件不足,没办法运行,即使被分配CPU,也会处于空闲,此时cpu浪费
- 有的进程非常紧急,需要立刻执行,要抢占CPU控制权,时间片轮转也没有体现该情况
2.2 进程生命周期
进程创建:fork、vfork 与 clone
fork: 经典的写时复制 (COW);完全复制父进程的资源,但通过写时复制 (Copy-on-Write) 优化。子进程最初与父进程共享物理内存页,只有当其中一方尝试写入时,内核才会触发缺页中断,复制一份物理页。
vfork: 由于 fork 紧接着通常会调用 exec,拷贝页表也显得浪费。子进程直接在父进程的地址空间运行,父进程会被阻塞,直到子进程退出或执行 exec。如果子进程在调用 exec 前修改了全局变量,会直接影响父进程,存在风险。
clone: 可以精确控制哪些资源(内存、文件描述符、信号处理)是共享的,哪些是拷贝的;是 pthread_create 的底层实现,通过设置 CLONE_VM 等标志位来创建轻量级进程(线程)。
进程的状态转换:就绪、等待、运行、终止、完成
另外还有等待队列,就绪队列,运行队列等,很简单的IO知识,就不细说了。
僵尸进程:所有进程信息都会存储在进程表中,但有的进程虽然终止了,其相关资源被释放,但在进程表中仍有该进程项
UNIX操作系统下,进程异常终止需要由另一个进程/用户杀死,然后如果该进程有相关进程,例如如果是某个父进程的子进程,父进程需要回收该子进程相关信息(就是在内核的进程表中删除该子进程相关信息)。如果没有第二步,子进程只是终止,但父进程没有回收,那子进程就变成僵尸进程了
2.3 进程结构体
Linux系统 对线程和进程并不特别区分。线程仅仅被视为一个与其他线程共享某些资源的进程。每个线程都拥有唯一自己的task_struct。
核心成员分类:
-
标识符:pid_t pid(线程 ID)和 pid_t tgid(线程组 ID,即通常说的进程 PID)。在 Linux 中,主线程的 pid == tgid。
-
内存管理:struct mm_struct *mm。包含了页表、代码段、数据段等地址信息。若是内核线程,此项为 NULL。
-
文件系统:struct files_struct *files。维护打开的文件描述符表(fd array)。
-
信号处理:struct signal_struct *signal。记录哪些信号被屏蔽,哪些待处理。
-
调度信息:prio, static_prio, normal_prio(优先级相关)以及 sched_class(调度类)。
深度睡眠 (D态) vs 浅度睡眠 (S态)
TASK_INTERRUPTIBLE (S):进程在等待某个事件(如等待 Socket 数据)。此时收到 SIGINT 信号,内核会唤醒它去处理信号。
TASK_UNINTERRUPTIBLE (D):进程在等待硬件 I/O(如磁盘读写)。此时内核会屏蔽所有信号。
为什么需要 D 态? 如果一个进程正在等待磁盘 DMA 传输数据,此时如果被信号杀死,DMA 传输的目标内存可能会被释放,导致系统崩溃。
等待队列 (Wait Queue) 的实现:内核通过 wait_queue_head_t 实现“生产者-消费者”模型。当资源(如数据到达)不可用时,进程调用 prepare_to_wait() 将自己挂入该队列的链表,并标记为 TASK_INTERRUPTIBLE,然后主动调用 schedule() 放弃 CPU。
2.4 完全公平调度器调度器(CFS)
CFS 的核心思想非常简单:在一个理想的多任务处理器上,每个进程都应该获得 1/n的 CPU 运行时间(n 是进程总数)。但物理 CPU 只有一个,无法真正实现“并行”的绝对公平。于是 CFS 引入了 虚拟运行时间 (vruntime) 的概念。
关键机制: 内核通过 task_struct 中的 vruntime 字段记录进程已经运行的时间;调度器总是选择 vruntime 最小的进程来运行。
动态平衡:运行中的进程,其 vruntime 会随时间增加。
没运行的进程,其 vruntime 保持不变。
随着时间推移,没运行的进程 vruntime 终将成为最小,从而获得 CPU。
数据结构: 为了高效地找到 vruntime 最小的进程,内核并未使用简单的链表(遍历复杂度 O(n)),而是使用了红黑树。
虽然目标是“公平”,但有的进程(如系统关键任务)显然应该比普通任务获得更多时间。Linux 通过 Nice 值 来调节权重。Nice 值越低(负数),优先级越高,权重越大;权重越大,vruntime 增长得就越慢,从而在 CPU 上待得更久。
Linux 支持多种调度策略,它们按优先级排列:
-
Stop 调度类:最高优先级,用于停机、迁移等。
-
Deadline 调度类:针对有严格时间要求的实时任务。
-
Real-Time (RT) 调度类:包括 FIFO 和 Round Robin,用于实时任务。
-
CFS (Fair) 调度类:绝大多数普通进程所在的位置。
-
Idle 调度类:优先级最低,仅在 CPU 完全没事干时运行。
2.5 进程的基本概念与内核表示
- 进程定义:Linux内核将进程称为任务(task),它是处于执行期的程序及其相关资源(打开的文件、挂起的信号、内核内部数据等)的总称 [1-3]。
- 虚拟化机制:内核为每个进程提供两种虚拟机制:虚拟处理器(让进程觉得自己在独占CPU)和虚拟内存(让进程觉得拥有整个系统的内存资源) [1]。
- 核心数据结构
task_struct:- 内核管理进程的唯一凭证是进程描述符
task_struct,定义在<linux/sched.h>中 [1, 2]。 - 它包含了进程的所有信息:状态、标识符、亲属关系、调度参数(如
vruntime)、内存空间、文件系统信息等 [1, 3, 4]。 - 在内核中,通过
current宏可以快速获取当前正在运行进程的task_struct地址 [2, 4]。
- 内核管理进程的唯一凭证是进程描述符
- 内核栈与
thread_info:- 每个进程都有独立的内核栈。在 ARM64 架构中,内核栈长度通常为 16KB [1]。
thread_info存放特定于处理器架构的底层数据。在 ARM64 中,它是task_struct的第一个成员,其地址与task_struct相同 [1]。
2.6 进程标识符(PID)与命名空间
- PID(Process ID):每个进程在特定命名空间中都有一个唯一的数字标识 [1, 2]。
- TGID(Thread Group ID):线程组标识符。对于多线程程序,所有线程共享同一个 TGID(即主线程的 PID) [1, 4]。
- PID 命名空间(Namespace):
- 用于隔离进程号,支持容器技术 [1, 3]。
- 命名空间按层级组织,子空间对父空间可见,反之则不可见 [1, 3]。
- 进程在不同的命名空间中可以有不同的 PID [1, 3]。
2.7 进程生命周期与状态转换
进程在生命周期中会经历多种状态,主要由 task_struct 的 state 字段表示 [1, 2, 4]:
- TASK_RUNNING(运行/就绪):进程正在 CPU 上执行,或者在就绪队列中等待调度 [1, 3, 4]。
- 睡眠状态:
- TASK_INTERRUPTIBLE(轻度睡眠):进程因等待某种事件而阻塞,可以被信号唤醒 [1, 4]。
- TASK_UNINTERRUPTIBLE(深度睡眠):通常是在等待硬件 I/O,不响应异步信号 [1, 4]。
- TASK_STOPPED(停止):进程停止运行,通常由调试信号触发 [1]。
- EXIT_ZOMBIE(僵尸状态):进程已终结,但父进程尚未调用
wait()来回收其进程描述符。内核此时仅保留其退出码等少量信息 [1, 2, 4]。
2.8 进程创建、装载与线程实现
-
进程创建机制:
fork():通过复制当前进程创建子进程。- 写时复制(Copy-On-Write, COW):
fork()时并不立即复制物理内存页,只有在父子进程尝试写入时才触发物理页复制,从而显著提升性能 [1-3, 5]。 vfork():父进程会阻塞,直到子进程调用execve()或退出。由于fork()的 COW 技术已足够高效,vfork()现已基本废弃 [1, 2]。clone():可以精确控制父子进程共享哪些资源,是 Linux 实现线程的基础 [1, 2, 4]。
-
程序装载:
execve()系统调用用于将新程序加载到当前进程的内存空间并开始执行,它会替换原有的内存映像,但 PID 保持不变 [1, 4, 5]。 -
线程在 Linux 中的实现:Linux 不区分进程和线程,线程被视为与其他进程共享资源的进程(轻量级进程,LWP) [2-4]。
-
调度类(Sched Class):调度器按优先级分为停机、限期、实时、公平和空闲五个类 [1]。
-
完全公平调度(CFS):
- 针对普通进程,旨在根据进程权重公平分配 CPU 时间 [1-3]。
- 核心原理:使用红黑树管理就绪进程,根据进程的**虚拟运行时间(vruntime)**进行排序。调度时总是选择
vruntime最小的节点(树最左侧) [1-3]。
-
实时调度:
- SCHED_FIFO:先进先出,除非主动让出或被更高优先级抢占,否则一直运行 [1, 2]。
- SCHED_RR:同优先级任务按固定时间片轮流运行 [1, 2]。
-
核心步骤:
- 切换虚拟内存空间 (
switch_mm):更换进程的页表指针 [1, 2]。 - 切换处理器状态 (
switch_to):保存当前进程的寄存器值到其内核栈或task_struct,并恢复下一个进程的状态 [1, 4]。
- 切换虚拟内存空间 (
-
ARM64 实现:通过汇编函数
cpu_switch_to切换通用寄存器(x19-x28)、栈指针(sp)和返回地址(lr) [1]。 -
主动退出:通过
exit()系统调用(内核对应do_exit())释放内存描述符、文件描述符等大部分资源 [2-4]。 -
僵尸回收:
- 进程退出后保持僵尸状态,由父进程通过
wait()或waitpid()回收残余资源 [1, 2, 4]。 - 孤儿进程处理:如果父进程先退出,子进程会被所在的 PID 命名空间中的 init 进程(1号进程) 领养,并由其负责最终的回收 [1, 2, 4]。
- 进程退出后保持僵尸状态,由父进程通过
新)。
在 Linux 内核的语境中,进程(Process)和线程(Thread)并没有本质的物理边界,它们统一被称为任务(Task)。内核如何高效地创建、追踪、切换和销毁这些成百上千的任务,是这一章的核心。
核心数据结构:task_struct 全景图
task_struct 是 Linux 内核中最庞大、最复杂的结构体之一(在32位机器上大约占 1.7KB,64位上更大)。它包含了内核管理一个进程所需的所有信息。
关键字段分类
在 <linux/sched.h> 中,task_struct 的成员大致可以分为以下几个逻辑域:
- 状态与执行信息:
volatile long state:进程的当前状态(运行、睡眠、停止等)。void *stack:指向该进程内核栈的指针。
- 标识符(IDs):
pid_t pid:全局唯一的进程 ID。pid_t tgid:线程组 ID。工程重点:用户空间的 POSIX 线程(pthread)在内核中都是一个个独立的task_struct。属于同一个用户进程的所有线程,其tgid都等于主线程的pid。用户态调用的getpid()实际上返回的是内核的tgid。
- 内存空间(Memory):
struct mm_struct *mm:指向进程的虚拟地址空间描述符。struct mm_struct *active_mm:用于内核线程(内核线程没有自己的用户地址空间,所以mm为 NULL,调度时它会借用上一个用户进程的active_mm来避免 TLB 刷新)。
- 进程关系(Genealogy):
struct task_struct __rcu *real_parent:真正的父进程(创建者)。struct list_head children/sibling:子进程链表和兄弟进程链表。
- 文件与文件系统:
struct fs_struct *fs:当前目录和根目录信息。struct files_struct *files:打开的文件描述符表(fd table)。
内核栈与 thread_info
- 内核栈分配:通常通过 Slab 分配器分配,大小一般为 8KB(32位)或 16KB(64位)。
thread_info:早期 Linux 将thread_info结构体放在内核栈的最底端,以方便通过栈指针(SP)快速计算出当前进程的task_struct地址。在现代 ARM64 和 x86_64 架构中,为了安全性(防止栈溢出覆盖thread_info),硬件状态等底层上下文仍保留在thread_info中,但获取current宏的方式已经优化(例如使用专门的 CPU 寄存器存储当前task_struct的指针)。
进程状态机 (State Machine)
内核通过改变 task_struct->state 来控制进程的调度:
TASK_RUNNING(可运行态):- 进程正在 CPU 上执行,或者正在运行队列(Runqueue)中等待被调度。
TASK_INTERRUPTIBLE(可中断睡眠态):- 进程在等待某个事件(如 I/O 完成、获取锁),能够被信号(Signal)提前唤醒。
TASK_UNINTERRUPTIBLE(不可中断睡眠态 - D状态):- 工程痛点:此状态下的进程不响应任何异步信号(哪怕是
kill -9)。通常用于等待某些必须不被打断的硬件操作(如直接磁盘 I/O)。如果底层驱动出现 Bug 导致不唤醒,系统就会出现大量无法杀死的 "D状态" 进程,引起系统负载(Load Average)飙升。
- 工程痛点:此状态下的进程不响应任何异步信号(哪怕是
__TASK_STOPPED/__TASK_TRACED:- 进程收到
SIGSTOP等停止信号,或正被调试器(如 GDB/strace)通过ptrace追踪。
- 进程收到
EXIT_ZOMBIE(僵尸态):- 进程已经终止,内存、文件等主体资源均已释放。但为了让父进程能查到它的退出码(Exit Code),它的
task_struct仍然驻留在内存中。
- 进程已经终止,内存、文件等主体资源均已释放。但为了让父进程能查到它的退出码(Exit Code),它的
进程创建的底层艺术
Linux 打破了传统操作系统创建进程的单一做法,将其拆分为 fork() 和 exec() 两步。
写时复制 (Copy-on-Write, COW)
如果 fork() 粗暴地复制父进程所有的物理内存,不仅极慢,而且大多数时候子进程马上会调用 exec() 加载新程序,导致复制的内存被瞬间丢弃。
- COW 机制:
fork()时,内核只复制父进程的页表(Page Tables),并将双方的页表项都标记为只读。物理内存页是共享的。 - 触发异常:当父子进程中任何一方尝试写入时,硬件 MMU 触发缺页异常(Page Fault)。内核捕获异常后,才真正为这一页分配新的物理内存,并将数据复制过去,最后将页表权限改回可写。
创世三大系统调用
fork():经典的创建方式。vfork():历史遗留优化。不仅不复制物理页,连页表都不复制。子进程直接“借用”父进程的地址空间运行,此时父进程被强制挂起阻塞,直到子进程调用exec()或exit()。极度危险,现代已很少使用。clone():创建线程的基石。允许极其细粒度地控制父子进程需要共享的资源。- 例如,执行
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0)时,父子进程将共享内存空间、文件系统信息、打开的文件和信号处理程序——这在物理效果上就是一个“线程”。
- 例如,执行
统一的底层引擎:_do_fork()
无论上面哪种调用,最终都汇聚于内核核心函数 _do_fork()(在较新内核中重命名为 kernel_clone()),其核心执行流程 copy_process() 如下:
dup_task_struct():为新进程分配内核栈和task_struct,此时内容与父进程完全一样。- 校验与鉴权:检查系统总进程数是否超过
RLIMIT_NPROC限制。 - 清理标志位:清除一些不能继承的统计信息和状态。
- 分配 PID:通过 PID 命名空间分配全局唯一的 PID。
- 资源按需复制:根据传入的
clone_flags,决定是共享还是复制打开的文件(copy_files)、文件系统上下文(copy_fs)、信号(copy_sighand)和内存地址空间(copy_mm)。 - 唤醒调度:调用
wake_up_new_task(),将新诞生的进程放入调度的运行队列(Runqueue)中,等待 CPU 执行。
进程终结与资源回收
进程的死亡同样是一个需要精细管理的过程,主要由 do_exit() 函数完成:
- 触发
task_struct中的标志位,表明进程正在退出。 - 释放资源:
- 调用
exit_mm()释放虚拟内存(如果没有别人共享的话)。 - 调用
exit_files()和exit_fs()递减文件描述符的引用计数。
- 调用
- 处理遗孤(Orphaned Tasks):
- 如果该进程有子进程,内核必须为这些子进程寻找新的“养父”。同线程组的其他进程可以接管,如果不行,最终由
init进程(PID 1)或 Namespace 的 sub-reaper 接管。
- 如果该进程有子进程,内核必须为这些子进程寻找新的“养父”。同线程组的其他进程可以接管,如果不行,最终由
- 变身僵尸:将状态设置为
EXIT_ZOMBIE。 - 发送讣告:向父进程发送
SIGCHLD信号,通知其来收尸。 - 最终调度:调用
schedule()切换到其他进程,此进程再也不会被调度执行。
当父进程调用 wait() 或 waitpid() 读取了子进程的退出状态后,内核才会调用 release_task() 彻底释放这个 task_struct 占用的最后一点内存。
调度实体:struct sched_entity
在 CFS 中,调度的最小单位不是 task_struct,而是 sched_entity(调度实体)。这使得 CFS 能够实现组调度(Group Scheduling),即将一个进程组或 cgroup 视为一个整体参与资源分配。
struct sched_entity {
struct load_weight load; // 权重信息 (由 nice 值转换而来)
struct rb_node run_node; // 挂载到红黑树的节点
unsigned int on_rq; // 是否在运行队列中
u64 exec_start; // 本次执行开始的时间戳
u64 sum_exec_runtime; // 累计物理运行时间
u64 vruntime; // ★ 核心:虚拟运行时间
};
注:在 task_struct 中内嵌了三个调度实体变量:se (CFS用), rt (实时用), dl (Deadline用)。
CFS(完全公平调度器)算法硬核解析
CFS 抛弃了传统的时间片和启发式交互性追踪,只坚持一个信念:提供理想的多任务 CPU 模型。
权重的数学模型
nice 值(-20 到 19)在内核中被映射为权重(Weight)。基准 nice=0 的权重是 1024(NICE_0_LOAD)。
内核规定:nice 值每降低(变优先)1个等级,进程获得的 CPU 时间多 10%。
- 推导公式:Weight = 1024 / (1.25^{nice})
- 查表法实现:内核为了避免浮点运算,预先计算好了一个数组
sched_prio_to_weight[40]。
虚拟时间 vruntime 的推进
vruntime 记录了进程“逻辑上”运行了多久。计算公式为:
vruntime += 实际物理运行时间 * (NICE_0_LOAD / 当前进程权重)
- 推论 1:如果是普通进程 (nice=0,权重1024),
vruntime= 实际时间。 - 推论 2:高优先级进程 (权重 > 1024),
vruntime增长极慢;低优先级进程,vruntime增长极快。
红黑树的选择机制 (cfs_rq)
CFS 的运行队列本质上是一棵以 vruntime 为键值的红黑树。
- 入队:新唤醒或被抢占的进程,计算当前
vruntime后插入红黑树。时间复杂度 O(\log N)。 - 选择:调度器永远挑选红黑树最左侧(
vruntime最小,即最缺 CPU)的节点。为了实现 O(1) 的选择,内核维护了一个指针rb_leftmost始终指向最左侧节点。 - 休眠补偿:当睡眠的进程被唤醒时,它的
vruntime可能远远落后于时代。内核会将其vruntime强行拉升到接近队列当前的最小vruntime(cfs_rq->min_vruntime),防止它醒来后长期霸占 CPU。
抢占机制 (Preemption) 的触发与执行
抢占并非随机发生,它分为“标记”和“执行”两步。
打上抢占印记 (TIF_NEED_RESCHED)
何时会设置这个标志位?
- 时钟中断 (scheduler_tick):如果当前进程运行时间超过了其应得的份额(
sched_min_granularity_ns),或者运行队列中出现了vruntime明显小于当前进程的任务。 - 唤醒进程 (wake_up_process):当一个优先级更高、或
vruntime更小的进程被唤醒加入队列时。
真正执行抢占的时机
仅仅打上标记还不够,必须等到“安全期”才能进行上下文切换:
- 用户态抢占:
- 系统调用即将返回用户空间时。
- 中断处理程序即将返回用户空间时。
- 内核态抢占 (Kernel Preemption):
如果编译内核时开启了CONFIG_PREEMPT。
只要当前进程的 preempt_count 为 0(说明当前没有持有自旋锁等底层锁,且不在中断上下文中),且收到了TIF_NEED_RESCHED,内核会直接挂起当前正在内核态执行的任务,切换到新任务!
上下文切换机制 (Context Switch) —— 以 ARM64 为例
当 schedule() 函数调用 pick_next_task() 选中了下一个受宠的进程后,会调用终极魔法函数 context_switch()。它分为两大阶段:
阶段一:切换虚拟地址空间 (switch_mm)
这是为进程更换“视野”。
在 ARM64 架构下,用户空间的页表基地址寄存器是 TTBR0_EL1(Translation Table Base Register 0)。
switch_mm会将新进程的 PGD(页全局目录)物理地址写入TTBR0_EL1。- ASID (Address Space ID) 优化:为了避免切换页表时必须刷掉整个 TLB(导致严重的性能衰退),内核使用 ASID 技术。每个进程被分配一个硬件 ASID,写入
TTBR0_EL1的同时携带该 ASID,TLB 就能区分不同进程的缓存条目。
阶段二:切换处理器状态 (switch_to)
这是为进程更换“躯壳”。这是一个由纯汇编实现的宏/函数 (cpu_switch_to 在 arch/arm64/kernel/entry.S 中)。
- 保护被切换者(Prev):将当前的栈帧指针(FP)、返回地址(LR,即
switch_to的下一条指令地址)、以及属于 Calle-saved 的通用寄存器(x19 到 x28)保存到前一个进程的task_struct->thread.cpu_context中。 - 复活目标者(Next):从 Next 进程的
task_struct->thread.cpu_context中恢复 x19-x28、FP。 - 切换栈顶:将硬件的堆栈指针寄存器(SP)切换为 Next 进程的内核栈地址。
- 神奇的返回:当汇编代码执行
ret指令时,由于 LR 寄存器已经恢复成了 Next 进程上次休眠时的地址,CPU 就像穿越了一样,直接接着 Next 进程上次被挂起的地方继续执行了!