OSETP | 第6 章 机制:受限直接执行
受限执行协议
核心矛盾:速度 vs. 安全
- 追求速度:最快的方式,就是让程序(比如你写的代码)直接在CPU上跑,没有任何中间商赚差价。这就是“直接执行”。
- 保证安全:但是,如果程序能为所欲为,它可能会访问不该访问的内存(比如窃取微信的聊天记录),或者直接操作硬件(比如格式化你的硬盘)。这是绝对不能允许的。
为了解决这个矛盾,操作系统和硬件(CPU)联手打造了一套精妙的机制,也就是“受限直接执行协议”。
两大“法宝”:用户模式与内核模式
想象一下,CPU有两种工作状态:
- 用户模式 (User Mode):这是一个“受限”状态。运行在这个模式下的程序,就像一个戴着手铐跳舞的演员,它的权限很低,不能执行一些危险操作(比如I/O操作、访问关键内存)。 你编写的应用程序,几乎所有时间都运行在这个模式下。
- 内核模式 (Kernel Mode):这是一个“特权”状态,拥有最高权限。运行在这个模式下的代码,可以访问计算机的任何资源,执行任何指令。只有操作系统核心才能运行在这个模式下。
关键点: 应用程序平时在“用户模式”下快速裸奔,一旦需要做一些“出格”的(受限制的)事情,就必须请求“内核模式”下的老大哥——操作系统来帮忙。
流程详解:结合图表的深度解读
我们把你的图表和文字描述串联起来,看看一个程序从启动到退出的完整生命周期。
阶段一:启动与准备(内核模式)
- 初始化陷阱表 (Operating System @ boot time)
- 原文解读: 操作系统启动时(内核模式下),会初始化一个“陷阱表”。硬件会记住这张表的位置。
- 通俗解释: 操作系统一开机,就先跟CPU对好暗号。它会建立一个“求助热线电话簿”(陷阱表 Trap Table)。这个电话簿记录了各种“求助”对应的处理程序地址。 比如,“我要读文件”该找谁,“我要联网”该找谁,都记录得明明白白。这个设置过程是特权操作,只有内核模式才能做,防止用户程序篡改求助电话。
阶段二:创建进程(内核模式)
当你要运行一个程序(比如双击 a.exe),操作系统会在内核模式下为你做一系列准备工作:
- 在进程列表上创建条目:在内核的“户口本”上登记,给你一个进程ID。
- 为程序分配内存:划拨一块专属的内存空间。
- 将程序加载到内存中:把程序代码和数据从硬盘搬到内存。
- 根据 argv 设置程序栈:准备好程序运行需要的初始参数。
- 用寄存器/程序计数器填充内核栈:为稍后的模式切换做好准备。
阶段三:从内核切换到用户,程序开始执行
- 从陷阱返回 (Return from Trap)
- 原文解读(硬件侧): 从内核栈恢复寄存器 -> 转向用户模式 -> 跳转到
main。 - 通俗解释: 准备工作完成!现在要正式把舞台交给你的程序了。硬件执行一条特殊的“从陷阱返回”指令,这个过程会发生两件大事:
- 身份切换:CPU的状态从拥有最高权限的“内核模式”降级为受限制的“用户模式”。
- 控制权转移:程序的执行指针(程序计数器)直接跳转到
main函数的入口地址。
- 原文解读(硬件侧): 从内核栈恢复寄存器 -> 转向用户模式 -> 跳转到
- 运行
main(程序 - 应用模式)- 原文解读(程序侧): 运行
main... 调用系统调用 ... 陷入操作系统。 - 通俗解释: 现在,你的程序正式在 用户模式 下开始执行
main函数里的代码。它在自己的内存空间里自由、快速地运行。
- 原文解读(程序侧): 运行
阶段四:需要帮助?发起“系统调用”
当你的程序需要执行一个受限操作时,比如 read() 一个文件:
- 调用系统调用 (System Call):你的代码实际上调用的是一个库函数(比如C库)。
- 执行
trap指令:这个库函数里包含一条特殊的汇编指令——trap(陷阱)。 - 陷入操作系统 (Trap to OS)
- 原文解读(硬件侧): 将寄存器保存到内核栈 -> 转向内核模式 -> 跳转到陷阱处理程序。
- 通俗解释:
trap指令就像是按下了求助按钮,硬件会立即做出反应:- 保存现场:把当前程序的寄存器等状态信息,保存到该进程专属的内核栈上,方便待会儿回来。
- 身份切换:CPU的状态从“用户模式”升级为“内核模式”。
- 查找热线:硬件根据“求助类型”(系统调用号),去查询启动时设置好的“陷阱表”,找到对应的处理程序地址。
- 跳转执行:控制权跳转到内核中相应的代码去执行。
阶段五:内核处理并返回
- 处理陷阱 (Handle Trap)
- 原文解读(内核侧): 做系统调用的工作 -> 从陷阱返回。
- 通俗解释: 现在又回到了内核模式。操作系统这个老大哥开始工作,它会检查你的请求是否合法(权限检查),然后帮你完成磁盘读取等特权操作。
- 从陷阱返回 (Return from Trap)
- 原文解读(硬件侧): 从内核栈恢复寄存器 -> 转向用户模式 -> 跳转到陷阱之后的程序计数器。
- 通俗解释: 工作完成。硬件再次执行“从陷阱返回”指令,将CPU降级回用户模式,并从内核栈中恢复之前保存的状态,让你的程序从刚才被中断的地方继续往下执行,就好像什么都没发生过一样。
阶段六:程序结束
- 从
main返回 / 调用exit():当你的程序执行完毕,会通过return或者直接调用exit()来结束。这本质上也是一次特殊的系统调用,再次触发trap陷入内核。 - 释放资源(内核模式):内核收到退出信号后,开始回收该进程的所有资源,如释放内存、关闭文件等。
- 从进程列表清除:最后,从内核的“户口本”上将该进程注销。
总结
这份材料精辟地阐述了现代操作系统的核心工作机制之一:
- 模式区分是基石:通过硬件支持的用户模式和内核模式,实现了权限隔离,保障了系统安全。
- 系统调用是桥梁:应用程序通过系统调用(System Call),向操作系统请求服务。
- 陷阱是切换手段:陷阱(Trap)是实现从用户模式“陷入”内核模式的具体硬件指令,是这座桥梁的入口。
- 陷阱表是导航图:内核通过预设陷阱表,精确控制了程序陷入内核后应该执行哪些代码,防止控制权被滥用。
最终,通过这套“用户模式(直接执行) -> 陷阱 -> 内核模式(处理请求) -> 从陷阱返回 -> 用户模式(继续执行)”的闭环流程,操作系统完美地平衡了运行效率和系统安全。
进程间的切换
这份材料的核心是解决“受限直接执行”中一个非常棘手的问题:一个程序正在CPU上运行时,操作系统本身并没有在运行。那么,操作系统如何能“插手”并夺回CPU的控制权,以便暂停当前程序,换另一个程序上场呢?
让我们用一个生动的比喻来理解这一切。
比喻:一位导演(操作系统)和多位演员(进程)
想象一个舞台,舞台中央只有一个聚光灯(CPU)。导演(操作系统)的工作是安排不同的演员(进程A, 进程B, ...)轮流上台表演。
问题:演员在台上时,导演如何换人?
一旦演员A在聚光灯下开始表演,导演就回到了后台。如果演员A一直演下去,不主动下台,导演该怎么让演员B上场呢?
两种方案:“自觉”与“强制”
材料中讨论了两种解决这个问题的方案:
1. 协作方式(演员自觉下台)
- 原文解读: 操作系统相信进程会“合理运行”,并定期放弃CPU。进程通过进行系统调用(如
yield()、文件读写)或发生非法操作,将控制权交还给操作系统。 - 导演比喻: 这是一种“君子协定”。导演指望每个演员都非常自觉,演一小段后,会主动走下舞台(执行
yield系统调用),或者需要向导演要个道具(执行文件读写等系统调用)。只要演员一和后台的导演说话,导演就获得了机会,可以决定是让他继续演,还是换演员B上台。 - 致命缺陷: 如果某个演员(流氓进程)耍赖,或者陷入了死循环(在台上不停地转圈),他既不主动下台,也不需要任何道具。那么,只要他不犯错(比如摔倒,即非法操作),导演就永远无法打断他。整个剧场只能卡住,唯一的解决办法就是清场重启(重启电脑)。
显然,这种被动的协作方式是不可靠的。
2. 非协作方式(导演强制换人)
- 原文解读: 借助时钟中断 (timer interrupt)。硬件时钟每隔几毫秒就中断一次CPU,强制性地将控制权交给操作系统预设好的“中断处理程序”。
- 导演比喻: 导演想出了一个绝妙的办法。他在舞台边上装了一个闹钟(时钟硬件),并设定它每分钟响一次。这个闹钟是导演专属的,演员们无法关闭它。
现在,我们来结合图表,详细看看这个“强制换人”的流程是如何运作的:
图表 6.3 流程详解:一次强制的“上下文切换”

背景: 演员A(进程A)正在台上表演。
-
准备阶段 (操作系统@启动)
- 原文: 初始化陷阱表;启动中断时钟。
- 导演比喻: 演出开始前,导演(操作系统)就和舞台总监(硬件)定好了规矩:
- 他告诉总监:“这个是我的对讲机频道(时钟处理程序地址),等会儿闹钟一响,你必须立刻用这个频道联系我。” (初始化陷阱表)
- 然后,导演设定好闹钟,并按下了启动按钮(启动中断时钟)。
-
“意外”发生 (时钟中断)
- 原文(硬件侧): 时钟中断 -> 将寄存器(A)保存到内核栈(A) -> 转向内核模式 -> 跳转到陷阱处理程序。
- 导演比喻: 演员A正在台上念着台词,突然,“叮铃铃!”——闹钟响了!
- 舞台总监(硬件)立刻按下暂停键。
- 他拿出相机给演员A拍了一张“快照”,记录下A的姿势、表情、说到哪个字了(将寄存器保存到内核栈)。这张快照是A的“存档点”。
- 然后,总监立刻把对讲机交给了导演(转向内核模式),并告诉他闹钟响了(跳转到处理程序)。
- 此刻,演员A被“冻结”在舞台上,控制权回到了导演手中。
-
导演的决策与操作 (处理陷阱)
- 原文(内核侧): 调用
switch()例程 -> 将寄存器(A)保存到进程结构(A) -> 将进程结构(B)恢复到寄存器(B) -> 从陷阱返回(进入B)。 - 导演比喻: 导演(操作系统里的调度程序)拿到控制权后,看了一下演出计划表,决定:“A的表演时间够了,现在该B上场了。”
- 他执行了一个名为
switch()的核心操作,这就像一个“换人魔术”:- 保存A的存档: 把刚刚拍下的演员A的“快照”(寄存器信息),从临时存放地(内核栈)拿出来,郑重地放进A的个人档案袋里(进程结构/PCB)。
- 读取B的存档: 接着,他找出演员B的档案袋,里面有B上次下台时保存的“快照”。
- 准备B的恢复: 他把B的“快照”内容(B的寄存器信息)加载好,准备让B从上次离开的地方继续。
- 他执行了一个名为
- 原文(内核侧): 调用
-
B登场,演出继续
- 原文(硬件侧): 从内核栈(B)恢复寄存器(B) -> 转向用户模式 -> 跳转到B的程序计数器。
- 导演比喻: 导演对舞台总监说:“准备让B上场!”
- 总监(硬件)根据B的“快照”信息,把演员B恢复到他上次被“冻结”时的状态。
- 聚光灯(CPU)重新亮起,但这次照在了演员B身上(转向用户模式),B从他的存档点无缝地开始了他的表演(跳转到B的程序计数器)。
最终效果: 对演员A和B来说,他们只是感觉自己表演了一会儿,然后被暂停了,之后又继续表演。他们完全不知道后台发生了如此复杂和强制的切换。而导演(操作系统)则通过这个“时钟中断 + 上下文切换”的机制,牢牢地掌握了整个舞台的控制权,实现了多个进程在单个CPU上的分时复用,创造出程序在“同时”运行的假象。
作业
核心目标:从理论到实践
这个作业的目的是让你亲手去“摸一摸”操作系统的脉搏。课本上说系统调用和上下文切换有开销,但这个开销到底是多少?是10纳秒?还是10微秒?通过这个实验,你能得到一个具体的、有体感的数字。
任务一:测量系统调用的成本 (The Easy Part)
这个任务相对简单,因为“系统调用”是一个你可以主动、直接发起的动作。
1. 核心思路
就像你想测量按一下门铃需要多长时间。如果只按一下,时间太短了,你的秒表根本反应不过来。最好的办法是:
- 看好表,开始计时。
- 连续快速地按门铃100万次。
- 停止计时,看看总共花了多长时间。
- 用 总时间 / 100万次,就得到了按一次门铃大概需要的时间。
测量系统调用也是完全一样的逻辑。
2. 具体步骤
- 选择一个“最轻”的系统调用:作业推荐
read(fd, buf, 0),即读取0个字节。这个调用几乎什么实际工作都不做,它进入内核,发现要读0字节,然后立刻返回。因此,它所花费的时间,基本上就是“进入内核再出来”这个往返过程的纯粹开销。 - 编写程序:
- 用
gettimeofday()获取开始时间t_start。 - 写一个大循环,比如循环100万次 (N=1,000,000)。
- 循环体内就只有一行代码:
read(0, buffer, 0); - 循环结束后,再次用
gettimeofday()获取结束时间t_end。 - 计算平均成本:
(t_end - t_start) / N。
- 用
3. 关键的“坑”:你的秒表准吗?(时钟精度问题)
- 问题:
gettimeofday()虽然能显示到微秒,但并不代表它每次跳动都是1微秒。它可能每10微秒或15微秒才更新一次。这就像一个能显示到小数点的秒表,但它可能是每0.1秒才跳一下。 - 如何验证:你可以写个小程序,连续调用
gettimeofday(),看看两次调用之间的最小时间差是多少。这个差值就是你的“时钟精度”。 - 解决方案:你必须让你的实验总时长,远大于这个时钟精度。如果你的时钟精度是10微秒,那你整个实验至少要跑几百毫秒甚至几秒,这样误差才能被巨大的循环次数摊薄。这就是为什么循环次数N要足够大。
- 高级工具:
rdtsc是x86上的一个特殊指令,它能读取CPU内部的时钟周期计数器。这几乎是你能拿到的最精确的时间源了,但用起来也更复杂(需要处理CPU频率、多核等问题)。对于这个练习,gettimeofday()加上足够大的循环次数,已经够用了。
任务二:测量上下文切换的成本 (The Tricky Part)
这个任务更复杂,因为你不能直接调用一个叫context_switch()的函数。上下文切换是操作系统在特定条件下(比如一个进程等待资源)被动触发的“副作用”。我们的目标,就是创造一个能高频率、可控地触发这个副作用的环境。
1. 核心思路 (lmbench的智慧)
如何强制让两个进程来回切换?答案是:让它们互相依赖,形成一个“你等我,我等你”的死循环。
想象两个工人(进程A,进程B)通过两根管道(Pipe 1,Pipe 2)传递一个球(数据)。
- 启动!
- 工人A:把球扔进 管道1。
- 工人A:然后立刻伸手去 管道2 等着接球。但他发现管道2是空的,他什么也做不了,于是他只能原地打盹(阻塞)。
- 导演(操作系统):看到工人A在打盹,立刻喊道:“A休息,B上场!” (这里发生了一次从A到B的上下文切换!)
- 工人B:他从 管道1 里拿到了A扔过来的球。
- 工人B:然后他把球扔进了 管道2。(这个动作将会唤醒正在打盹的工人A)
- 工人B:接着,他伸手去 管道1 等球。但他发现管道1现在是空的,于是他也开始打盹(阻塞)。
- 导演(操作系统):看到B也在打盹,立刻喊:“B休息,A上场!” (这里发生了第二次从B到A的上下文切换!)
- 回到第2步,工人A被唤醒,从管道2拿到球,再次扔进管道1,循环开始...
2. 具体步骤
- 创建管道:使用
pipe()系统调用创建两个管道。 - 创建进程:使用
fork()创建一个子进程。现在你有了父、子两个进程。 - 设置“你等我,我等你”的逻辑:
- 父进程:向管道1写,然后从管道2读。
- 子进程:从管道1读,然后向管道2写。
- 循环测量:
- 获取开始时间
t_start。 - 让这两个进程来回传递数据N次(比如10万次)。
- 获取结束时间
t_end。
- 获取开始时间
- 计算成本:
- 一次完整的“往返”(A写->B读->B写->A读)包含了两次上下文切换。
- 所以,单次上下文切换的成本 =
(t_end - t_start) / N / 2。
4. 关键的“坑”:多核CPU的干扰
- 问题:在多核CPU上,操作系统可能非常聪明地把父进程放在CPU核1上,子进程放在CPU核2上。这样,当父进程等待时,子进程在另一个核上可以立刻开始工作,它们俩是并行的,根本没有发生我们想测量的那种“在同一个CPU上换人”的上下文切换!
- 解决方案:我们需要把这两个进程“绑”在同一个CPU核上。Linux提供了
sched_setaffinity()系统调用,你可以用它来告诉操作系统:“我命令你,无论如何都要让我的父进程和子进程只在CPU核0上运行!” - 为什么要这么做:通过绑定CPU,我们确保了这两个进程是在竞争同一个CPU资源。这样,当一个进程阻塞时,操作系统为了不让这个CPU核闲下来,就必须进行上下文切换,换上另一个在等待的进程。这才是我们想要测量的场景。
通过完成这个作业,你不仅能得到两个具体的性能数字,更能深刻体会到操作系统内部机制的精妙与复杂,以及在做性能测量时,控制实验环境是多么重要。
解答
任务一:测量系统调用的成本
我们将遵循“多次调用,求平均值”的策略,并使用 read() 这个轻量级的系统调用。
代码 (measure_syscall.c)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
// 定义循环次数,可以根据需要调整
#define ITERATIONS 1000000
int main() {
struct timeval start, end;
long total_micros;
double avg_micros;
char buffer; // 一个无意义的缓冲区,因为我们读取0字节
// 1. 获取开始时间
gettimeofday(&start, NULL);
// 2. 重复执行一个非常轻量级的系统调用
for (int i = 0; i < ITERATIONS; i++) {
// read(0, ...) 从标准输入读取
// 读取0字节,这意味着系统调用几乎什么都不做,立即返回
// 这样测量到的时间,就非常接近系统调用本身的开销
read(0, &buffer, 0);
}
// 3. 获取结束时间
gettimeofday(&end, NULL);
// 4. 计算总耗时 (单位:微秒)
total_micros = (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_usec - start.tv_usec);
// 5. 计算单次系统调用的平均成本
avg_micros = (double)total_micros / ITERATIONS;
printf("执行了 %d 次系统调用。\n", ITERATIONS);
printf("总耗时: %ld 微秒。\n", total_micros);
printf("单次系统调用的平均成本: %f 微秒。\n", avg_micros);
return 0;
}
代码解读
#include:包含了时间测量 (sys/time.h) 和read系统调用 (unistd.h) 所需的头文件。ITERATIONS:定义一个很大的循环次数,这是为了让总的执行时间足够长,从而可以忽略单次gettimeofday()的测量误差。gettimeofday(&start, NULL);:在循环开始前,记录下当前墙上时间。for循环: 这是实验的核心。它循环一百万次,每次都执行read(0, &buffer, 0);。这个调用会触发一次陷入内核的过程,但由于读取长度为0,内核几乎不做任何实际的I/O工作就返回了,因此它的耗时非常接近一次纯粹的系统调用的开销。- 计算时间:结束时间减去开始时间,并将秒(
tv_sec)和微秒(tv_usec)统一换算成微秒,得到总耗时。 - 求平均值:用总耗时除以循环次数,得到单次系统调用的估算成本。
如何编译和运行
gcc -o measure_syscall measure_syscall.c
./measure_syscall
任务二:测量上下文切换的成本
这个任务更巧妙。我们将创建两个进程,用两根管道逼迫它们高频率地进行“等待-唤醒”的循环,从而触发密集的上下文切换。
代码 (measure_context_switch.c)
// 定义这个宏是为了使用 sched_setaffinity
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sched.h>
#include <sys/time.h>
#include <sys/wait.h>
#define ITERATIONS 100000
int main() {
// 1. 将父进程绑定到CPU核心0上
cpu_set_t set;
CPU_ZERO(&set); // 清空集合
CPU_SET(0, &set); // 将核心0添加到集合中
// sched_setaffinity 将当前进程绑定到指定的CPU核心集合
if (sched_setaffinity(0, sizeof(set), &set) == -1) {
perror("sched_setaffinity failed");
exit(EXIT_FAILURE);
}
// 2. 创建两根管道用于进程间通信
int pipe1[2]; // 父 -> 子
int pipe2[2]; // 子 -> 父
if (pipe(pipe1) == -1 || pipe(pipe2) == -1) {
perror("pipe failed");
exit(EXIT_FAILURE);
}
// 3. 创建子进程
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// --- 子进程代码 ---
// 将子进程也绑定到CPU核心0上,确保在同一核心竞争
if (sched_setaffinity(0, sizeof(set), &set) == -1) {
perror("child sched_setaffinity failed");
exit(EXIT_FAILURE);
}
char buf;
for (int i = 0; i < ITERATIONS; i++) {
// 从父进程读取,如果管道为空,则阻塞,触发上下文切换
read(pipe1[0], &buf, 1);
// 写入管道,唤醒正在等待的父进程
write(pipe2[1], "c", 1);
}
} else {
// --- 父进程代码 ---
struct timeval start, end;
long total_micros;
double cost_per_switch;
char buf;
// 获取开始时间
gettimeofday(&start, NULL);
for (int i = 0; i < ITERATIONS; i++) {
// 写入管道,唤醒可能正在等待的子进程
write(pipe1[1], "p", 1);
// 从子进程读取,如果管道为空,则阻塞,触发上下文切换
read(pipe2[0], &buf, 1);
}
// 获取结束时间
gettimeofday(&end, NULL);
// 等待子进程结束
wait(NULL);
// 计算总耗时
total_micros = (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_usec - start.tv_usec);
// 每次循环包含两次上下文切换 (父->子, 子->父)
// 所以总成本除以循环次数,再除以2
cost_per_switch = (double)total_micros / ITERATIONS / 2.0;
printf("执行了 %d 次 ping-pong 往返,触发了 %d 次上下文切换。\n", ITERATIONS, ITERATIONS * 2);
printf("总耗时: %ld 微秒。\n", total_micros);
printf("单次上下文切换的平均成本: %f 微秒。\n", cost_per_switch);
}
return 0;
}
代码解读
#define _GNU_SOURCE: 这是使用sched_setaffinity功能所必需的。sched_setaffinity: 这是实验的关键!我们用它把父子进程都强制绑定在同一个CPU核心(核心0)上。这可以防止操作系统将它们调度到不同的核心上并行运行,从而确保它们之间必须通过上下文切换来共享同一个CPU。pipe()和fork(): 创建两根管道和两个进程,搭建起“你等我,我等你”的通信框架。- 子进程逻辑: 它的循环是“读管道1,写管道2”。当它尝试从空的
pipe1读取时,它会阻塞,操作系统就会把它换下,让父进程上场。 - 父进程逻辑: 它的循环是“写管道1,读管道2”。当它写完后,尝试从空的
pipe2读取时,它也会阻塞,操作系统就会把它换下,让子进程上场。 - 测量与计算:
- 计时器包裹了父进程的整个循环。
- 一个完整的循环(父写->子读->子写->父读)包含了一次从父到子的切换和一次从子到父的切换,共两次上下文切换。
- 因此,最终的平均成本需要用总时间除以循环次数,再除以2。
如何编译和运行
gcc -o measure_context_switch measure_context_switch.c
./measure_context_switch
通过运行这两个程序,你就能亲手测量出你电脑上系统调用和上下文切换的大致开销了。你会发现,上下文切换的成本通常会比系统调用高一个数量级,因为它包含了调度器决策等更复杂的操作。
上下文切换详解
这是一个非常棒的问题,直击了这个实验设计的核心!你观察到的“为什么只在父进程里统计时间”是理解整个测量过程的关键。
答案可以分为两部分:可行性和精确性。
-
可行性上,这是最简单的实现方式:父进程是整个实验的发起者和总控者。由它来启动计时器,然后等待整个“乒乓游戏”结束后停止计时,逻辑最清晰。如果父子进程各自计时,你将需要额外的机制来同步它们的开始和结束,并把各自的时间结果汇总,这会让测量过程本身变得复杂,并引入新的开销,干扰测量结果。
-
精确性上,这恰好能测量我们想要的东西:父进程的
for循环不能独立完成。它每执行一次,都必须等待子进程也执行一次。这个“等待”正是通过上下文切换实现的。因此,父进程包裹住的这段时间,天然地包含了父子进程交替运行的所有时间。
上下文切换的细节分解 (一步一步看)
让我们把一次完整的“乒乓往返”(for循环的一轮)中发生的事件放慢,看看里面到底发生了什么。
背景设定:
- 我们有两个进程:父进程(P) 和 子进程(C)。
- 它们都被
sched_setaffinity钉在同一个CPU核心(比如核心0)上。 - 我们有两根管道:
pipe1(P->C) 和pipe2(C->P)。
一次往返的生命周期:
第 1 步: 父进程(P)启动一轮
- P 运行中: 父进程在核心0上运行。
- P 执行
write(pipe1[1], "p", 1);: 它成功地向pipe1写入了一个字节。这个操作通常很快就返回了。 - P 执行
read(pipe2[0], &buf, 1);: 这是第一个关键点。父进程尝试从pipe2读取数据,但pipe2现在是空的。 - P 陷入内核:
read是一个系统调用,P从用户模式切换到内核模式。 - 内核发现: 内核检查
pipe2,发现没有数据可读。进程P无法继续执行。 - P 被阻塞: 内核将进程P的状态从“运行(Running)”改为“阻塞(Blocked)”或“等待(Waiting)”。它被放入一个等待队列,等待
pipe2变得可读。
第 2 步: 第一次上下文切换 (P → C)
- 调度器决策: 内核的调度器看到核心0上的当前进程(P)已经阻塞了。它必须找另一个进程来运行,以避免CPU空闲。
- 调度器发现 C: 它在就绪队列中找到了子进程(C),C处于“就绪(Ready)”状态。
- 执行
swtch: 内核执行上下文切换。- 保存父进程P的所有寄存器状态(到P的进程控制块中)。
- 加载子进程C之前保存的寄存器状态(从C的进程控制块中)。
- CPU的控制权正式移交给子进程C。
第 3 步: 子进程(C)的回应
- C 运行中: 现在,子进程C在核心0上开始运行。
- C 执行
read(pipe1[0], &buf, 1);: 它尝试从pipe1读取。因为父进程刚刚写入了数据,所以这个read操作立即成功,并取走那个字节。 - C 执行
write(pipe2[1], "c", 1);: 子进程向pipe2写入一个字节。这个动作非常重要,它使得pipe2中有了数据,满足了父进程P的等待条件。内核会因此将父进程P的状态从“阻塞”改回“就绪”,并把它放回就绪队列。 - C 循环到下一轮,执行
read(pipe1[0], &buf, 1);: 这是第二个关键点。子进程再次尝试从pipe1读取,但pipe1里的数据已经被它自己读走了,现在是空的。 - C 被阻塞: 和第6步一样,子进程C因为等待
pipe1而陷入内核并被阻塞。
第 4 步: 第二次上下文切换 (C → P)
- 调度器决策: 调度器再次看到核心0上的当前进程(C)阻塞了。
- 调度器发现 P: 它在就绪队列中找到了现在处于“就绪”状态的父进程(P)。
- 执行
swtch: 内核执行另一次上下文切换。- 保存子进程C的状态。
- 加载父进程P的状态(它被冻结在第6步的位置)。
- CPU的控制权交还给父进程P。
第 5 步: 父进程(P)完成一轮
- P 恢复运行: 父进程P从它被中断的地方——
read(pipe2[0],...)系统调用内部——继续执行。 read成功: 因为子进程C已经向pipe2写入了数据,所以这次read操作立即成功。- 完成一轮循环: 父进程的
for循环完成了一次迭代。然后它会立即开始下一次迭代,回到第1步。
结论
- 父进程的计时器包裹了整个过程:从P开始写,到它最终成功读回C的响应,这期间包含了P和C的运行时间,以及两者之间两次至关重要的上下文切换时间。
- “你等我,我等你”:这个设计的精妙之处在于,
read()操作强制性地让一个进程停下来(阻塞),为另一个进程创造了运行的机会,从而稳定、高频地触发了上下文切换。 - 计算成本: 因为一次完整的“乒乓往返”包含了两次上下文切换(P→C 和 C→P),所以我们将总时间除以循环次数后,还需要再除以2,才能得到单次上下文切换的平均成本。

浙公网安备 33010602011771号