4、宏内核的进程管理:进程控制块(PCB)、进程调度策略(CFS、O(1))、进程创建与销毁。
好,咱们今天聊聊宏内核里最核心的一块——进程管理。说实话,搞操作系统这么多年,我始终觉得进程管理是理解整个系统运转的钥匙。你想想看,CPU就那么几个核,成百上千个进程在排队等着跑,怎么管?怎么调度?怎么创建和销毁?这里面门道可不少。
4.1 进程控制块(PCB)——进程的“身份证”
每个进程在系统里都有一个唯一的“身份档案”,这就是PCB(Process Control Block)。在Linux里,它对应的是task_struct结构体。我个人习惯把PCB看作是进程的“户口本”,里面记录着这个进程从出生到消亡的所有关键信息。
PCB里到底装了什么?我把它归纳成几个核心部分:
- 进程标识符(PID):每个进程的唯一编号。就像身份证号,不能重复。
- 进程状态:运行、就绪、阻塞、停止、僵尸……状态切换是调度的基础。
- 程序计数器(PC):下一条要执行的指令地址。进程切换时,这个值必须保存好。
- CPU寄存器:通用寄存器、栈指针、状态寄存器等。说白了就是进程的“工作现场”。
- 内存管理信息:页表、段表、内存限制等。每个进程都有自己的虚拟地址空间。
- 文件描述符表:进程打开的文件列表。嗯,这里要注意,文件描述符是有限的,用完了得关。
- 调度信息:优先级、时间片、调度策略等。这部分直接影响进程能不能抢到CPU。
核心要点:PCB是进程存在的唯一实体。内核通过PCB来感知和管理进程。没有PCB,进程就不存在。
我曾经在调试一个嵌入式系统时遇到过一个问题:系统运行一段时间后,新进程创建失败。查了半天,发现是PCB池用完了。内核里预分配的task_struct数量是有限的,默认值往往不够用。那次之后,我养成了一个习惯——上线前先算好最大并发进程数,调大kernel.pid_max和threads-max。
4.2 进程调度策略——谁先跑?跑多久?
调度策略,说白了就是决定“下一个该谁上CPU跑”。宏内核里,调度器是重中之重。Linux历史上用过两种经典的调度器:O(1)调度器和CFS调度器。我两个都接触过,各有千秋。
4.2.1 O(1)调度器——快,但不够公平
O(1)调度器,名字就告诉你它的特点——调度开销是常数时间,跟进程数量无关。它用多个优先级队列来管理进程,每个优先级对应一个队列。调度时,直接从最高优先级的非空队列里取一个进程。
它的核心数据结构是这样的:
struct prio_array {
unsigned int nr_active; // 活跃进程数
unsigned long bitmap[BITMAP_SIZE]; // 优先级位图
struct list_head queue[MAX_PRIO]; // 每个优先级一个队列
};
O(1)调度器有两个数组:active(活跃)和expired(过期)。进程用完时间片后,从active移到expired。当active空了,交换两个数组的指针。整个过程是O(1)的。
但O(1)有个问题——它不够公平。高优先级进程可能一直抢占CPU,低优先级进程容易“饿死”。我在做服务器优化时遇到过这种情况:一个高优先级的日志进程疯狂写盘,导致业务进程响应变慢。嗯,这就是O(1)的“优先级固化”问题。
4.2.2 CFS调度器——公平才是王道
CFS(Completely Fair Scheduler)是Linux 2.6.23引入的,它彻底改变了调度思路。CFS不搞优先级队列那一套,它追求的是“每个进程获得公平的CPU时间”。
CFS的核心思想是:维护一个红黑树,键值是vruntime(虚拟运行时间)。每次调度时,选择vruntime最小的进程来运行。说白了,谁跑得少,谁就先跑。
个人经验:我在调优数据库服务器时,发现CFS对交互式进程特别友好。比如SSH会话,即使后台有大任务在跑,敲命令也不会卡顿。这是因为CFS会动态调整vruntime,让交互式进程优先响应。
CFS的几个关键参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
sched_latency_ns |
调度延迟目标 | 6ms(桌面) / 20ms(服务器) |
sched_min_granularity_ns |
最小时间片 | 0.75ms |
sched_nr_latency |
延迟周期内可运行的进程数 | 24 |
CFS的计算公式也很直观:
vruntime += 实际运行时间 * (NICE_0_LOAD / 进程权重)
权重越高(优先级越高),vruntime增长越慢,进程就能跑更久。反之,低优先级进程的vruntime增长快,很快就会被换下。
避坑指南:我曾经在实时性要求高的场景下,直接用了CFS默认配置,结果发现音频处理有卡顿。后来把sched_latency_ns调小到3ms,并给音频进程设置了SCHED_FIFO实时调度策略,问题才解决。记住:CFS适合通用场景,实时场景得用RT调度器。
4.3 进程创建与销毁——从fork到exit
进程的创建和销毁,是操作系统里最频繁的操作之一。Linux里,创建进程用fork(),销毁进程用exit()。这两个系统调用,背后是一整套复杂的流程。
4.3.1 进程创建:fork()的魔法
fork()的奇妙之处在于:它调用一次,返回两次。父进程返回子进程的PID,子进程返回0。为什么能做到?因为内核在fork()里复制了一份PCB。
创建流程大致如下:
- 调用
fork(),陷入内核。 - 内核分配一个新的
task_struct,并复制父进程的PCB内容。 - 复制父进程的地址空间(写时复制,COW)。
- 分配新的PID。
- 设置子进程的状态为
TASK_RUNNING,加入调度队列。 - 返回两次——父进程返回子进程PID,子进程返回0。
这里有个关键点:写时复制(COW)。子进程刚创建时,并不真正复制物理内存,而是共享父进程的页表。只有当某一方写入时,才复制页面。这大大提高了fork()的效率。
实际案例:我在优化Web服务器时,发现fork()后立即exec()的场景特别多。这种情况下,COW的优化效果有限,因为exec()会直接替换地址空间。后来我改用vfork(),它不复制页表,父进程会阻塞直到子进程exec()或exit()。性能提升了约30%。
4.3.2 进程销毁:exit()与僵尸进程
进程销毁也不是简单地把PCB删掉。调用exit()后,进程会经历几个阶段:
- 释放大部分资源:关闭文件描述符、释放内存、释放信号量等。
- 状态变为
TASK_ZOMBIE(僵尸状态)。 - 向父进程发送
SIGCHLD信号。 - 父进程调用
wait()或waitpid(),读取子进程的退出状态。 - 内核释放PCB,进程彻底消亡。
为什么要保留僵尸状态?因为父进程需要知道子进程的退出码。如果子进程直接消失,父进程就永远不知道它为什么退出了。
避坑指南:我曾经遇到过一个严重的线上问题——服务器上出现了大量僵尸进程。查了半天,发现是父进程没有调用wait(),导致子进程的PCB一直无法释放。僵尸进程虽然不占CPU,但会占用PID和PCB资源。当PID耗尽时,新进程就创建不了了。解决方案很简单:父进程注册SIGCHLD信号处理函数,或者在子进程退出后及时wait()。
4.4 小结
宏内核的进程管理,核心就是三件事:PCB管身份,调度器管顺序,fork/exit管生死。我个人觉得,理解这些概念最好的方式就是动手写代码。你可以写一个简单的内核模块,打印出当前所有进程的PCB信息,或者自己实现一个简单的调度器。纸上得来终觉浅,绝知此事要躬行。
下一章,我们会深入宏内核的内存管理,看看虚拟地址空间、页表、缺页中断这些硬核内容。到时候再聊。