引言:多任务系统的核心问题
在一个现代操作系统中,往往同时运行着数十甚至数百个进程。而CPU核心数通常只有几个(甚至一个)。那么,操作系统如何决定"下一个该运行哪个进程"?这个问题就是进程调度 要解决的核心。调度器的好坏直接影响系统的响应速度、吞吐量和公平性。Linux作为通用操作系统,其调度器经历了多次重大演进:从O(n)调度器到O(1)调度器,再到完全公平调度器(CFS)。本文将以Linux 2.6内核中的O(1)调度算法为重点,详细解读进程优先级、nice值、运行队列、活动/过期队列等关键概念。
一、进程优先级:PRI与NI
1.1 为什么需要优先级?
假设你的系统同时运行着两个进程:一个是需要即时响应的文本编辑器,另一个是在后台压缩文件。如果调度器对它们一视同仁,每个进程获得50%的CPU时间,那么当你敲击键盘时,编辑器可能响应缓慢,带来糟糕的体验。为了解决这种问题,调度器引入了优先级:高优先级的进程更频繁地获得CPU,低优先级的进程则谦让。
在Linux中,进程优先级是一个数值,数值越小优先级越高。普通进程的优先级范围通常是100~139(对应nice值-20~19),实时进程的优先级范围是0~99。
1.2 nice值
nice值是Unix系统中用来微调优先级的用户友好接口。nice值越大,表示进程越"谦让"(优先级越低)。它的取值范围是 -20 ~ 19。普通用户只能增大nice值(降低优先级),而root用户可以减小nice值(提高优先级)。
PRI与NI的关系:
最终优先级(PRI) = 基准优先级 + nice值
在Linux 2.6中,普通进程的基准优先级是120。因此,一个nice值为0的进程,其PRI=120;nice值为10的进程,PRI=130;nice值为-10的进程,PRI=110。数值越小,被调度的机会越大。
注意:
ps -l输出中的PRI列并不是直接的120+nice,因为内核还会根据进程的交互性动态调整。更准确的字段是PRI和NI,其中NI就是nice值。
1.3 查看和修改nice值
-
使用
top:进入后按r,输入PID,再输入新的nice值(需确认权限)。 -
使用
nice命令启动程序:nice -n 10 ./long_task。 -
使用
renice修改已运行进程:renice -n 5 -p 12345。 -
系统调用:
int setpriority(int which, int who, int prio);。
二、调度相关的概念:竞争、独立、并行与并发
在深入调度算法前,我们需要厘清四个重要概念:
-
竞争性:多个进程争夺有限的CPU资源,因此需要优先级和调度策略。
-
独立性:进程之间拥有独立的地址空间和资源,一个进程崩溃通常不会影响其他进程(除非使用共享内存等IPC机制)。
-
并行:真正意义上的"同时执行",需要多核CPU或多处理器系统。多个进程在不同的CPU核心上同时运行。
-
并发:在单核CPU上,通过快速切换(时间片轮转)制造出"同时运行"的假象。每个进程在一小段时间内运行,然后被切换出去。
现代操作系统都是分时系统,即把CPU时间切割成很短的时间片(通常1ms~100ms),每个进程每次获得一个时间片。时间片用完就会发生进程切换。
三、进程切换(上下文切换)
进程切换是指内核暂停当前正在运行的进程,恢复另一个之前暂停的进程。这个过程主要包括:
-
保存当前进程的CPU上下文(即所有通用寄存器、程序计数器、堆栈指针、状态寄存器等)到该进程的内核栈或
task_struct中。 -
选择下一个要运行的进程(调度算法决定)。
-
从新进程的
task_struct中恢复其上次保存的CPU上下文。 -
开始执行新进程。
上下文切换是纯内核开销,不涉及用户态数据。频繁的切换会增加系统开销,但能提高交互性。在Linux中,可以使用vmstat 1观察cs(context switch)列。
四、Linux 2.6 O(1)调度算法
4.1 为什么需要O(1)调度?
早期的Linux调度器(2.4及之前)在每次调度时需要遍历所有进程,找出优先级最高的进程。这种算法的时间复杂度是O(n) ,其中n是系统中的进程数量。当服务器有上千个进程时,调度本身就会浪费大量CPU时间。2.6内核重写了调度器,目标是无论进程数量多少,调度决策时间都保持常数 ,即O(1)。
4.2 核心数据结构:runqueue
每个CPU核心都有自己独立的运行队列 (runqueue),避免多核之间频繁加锁。runqueue结构体定义在kernel/sched.c(早期版本)中,其关键成员包括:
-
active:指向活动优先队列的指针。 -
expired:指向过期优先队列的指针。 -
arrays[2]:两个prio_array结构体,分别作为活动队列和过期队列的存储。
4.3 prio_array:按优先级组织的队列
prio_array的定义如下:
struct prio_array {
unsigned int nr_active; // 队列中进程总数
DECLARE_BITMAP(bitmap, MAX_PRIO+1); // 优先级位图,共140位
struct list_head queue[MAX_PRIO]; // 每个优先级一个链表头
};
-
MAX_PRIO通常为140(0~139)。其中0~99为实时进程优先级,100~139为普通进程优先级。 -
每个优先级对应一个双向链表,相同优先级的进程通过
list_head串在一起,采用FIFO规则。 -
bitmap是一个位图,每一位代表对应优先级的队列是否为空。例如,bitmap的第100位为1表示优先级为100的队列非空。通过位操作可以快速找到最小的非空优先级,而不用遍历140个链表。
查找最高优先级进程的步骤:
-
使用
__ffs()(find first set bit)函数在bitmap中查找第一个为1的位。 -
该位的索引就是最高优先级的数值。
-
从
queue[priority]链表中取出第一个进程。 -
调度该进程运行。
由于位图查找和链表取头都是常数时间,所以总复杂度为O(1)。
4.4 活动队列与过期队列
调度器维护两个prio_array:
-
活动队列(active):存放尚未耗尽时间片的进程。当一个进程的时间片用完时,它会被移出活动队列。
-
过期队列(expired):存放已经耗尽时间片的进程。这些进程在新的周期开始前不能获得CPU。
调度器总是从活动队列中选择下一个进程。当活动队列为空(即所有进程都已用完时间片),调度器会交换 active和expired指针,使过期队列成为新的活动队列。同时,所有进程的时间片会被重新计算(通常基于nice值重新分配)。通过这种方式,既保证了低优先级进程不会永久饥饿,又实现了O(1)的切换开销。
4.5 动态优先级和时间片计算
在O(1)调度器中,进程的最终优先级(动态优先级)会根据其交互性进行微调:睡眠多的进程(如GUI程序)被判定为交互式,动态优先级会提高;消耗CPU多的进程(如编译任务)动态优先级会降低。这种设计使得桌面应用响应更流畅。
时间片的计算公式(简化):
时间片 = (MAX_TIMESLICE * (140 - static_prio) / 40)
静态优先级越低(nice值越小),获得的时间片越长。实时进程的时间片可以独立配置。
4.6 O(1)调度器的局限性
尽管O(1)调度器在当时非常先进,但它也存在一些问题:
-
交互性判断依赖于睡眠时间,在某些负载下容易误判。
-
对于NUMA(非一致内存访问)架构优化不足。
-
内核代码复杂,难以维护。
因此,从Linux 2.6.23开始,O(1)调度器被**完全公平调度器(CFS)**取代。CFS使用红黑树和虚拟运行时间的概念,更加公平和简洁。但O(1)调度器的设计思想------常数时间、优先级位图、活动/过期双队列------至今仍有重要的学习价值。
五、总结:调度算法决定系统体验
-
进程优先级是调度的核心依据,
nice值可用来调整普通进程的优先级。 -
并行与并发是多任务系统的两大特征,分时系统通过时间片实现并发。
-
进程切换开销不可忽视,频繁切换会降低吞吐量。
-
Linux 2.6的O(1)调度器通过位图查找和双队列交换实现了常数时间的调度决策,是操作系统调度领域的一个里程碑。
-
理解调度算法有助于我们编写对调度策略友好的程序,例如适当设置nice值、避免无意义的忙等待等。
在下一篇博客中,我们将从环境变量入手,再深入到程序地址空间的本质------虚拟内存,揭开"同一个地址,不同内容"的神秘面纱。