☰
Linux 0.11 fork与调度开销解析:页表复制与TSS切换
2026/10/14 17:33:47 网站建设 项目流程

我自己在啃 Linux 0.11 内核源码的那段时间,最想弄明白的一笔账就是:fork 子进程到底贵在哪,多进程调度又贵在哪。网上翻来覆去看到的说法,要么是“fork 会复制整个地址空间”,要么是“切换进程很慢”,但真拿到 0.11 源码里一对照,发现这些说法都不够准确。0.11 的 fork 没有 COW,它复制的是页表而不是物理页;0.11 的调度用的又是硬件 TSS 切换,和后来的软件切换完全是两本账。这篇文章就从do_fork和schedule两条主线,把创建子进程与多进程调度的开销一项项拆开,再给出可以照着做的测量思路和优化建议。适合正在读 0.11 源码、做内核实验,或者想理解早期 Linux 设计取舍的人。

1. 先把账算清楚:Linux 0.11的fork开销从哪来

1.1 一次fork背后到底发生了什么

很多人看 fork 只看返回结果,却不知道返回之前内核做了多少事。Linux 0.11 里一次完整 fork 大概要经过这些环节:

  1. 调用sys_fork,进入do_fork。
  2. find_empty_process分配一个空闲的进程槽位和 PID。
  3. 调用get_free_page申请一页物理内存,用来放新进程的task_struct。
  4. 把父进程的task_struct整体拷贝到新页里。
  5. 逐字段修正新进程的 pid、状态、父进程编号、启动时间、tss 寄存器现场。
  6. 复制 LDT 描述符,设置新进程的代码段和数据段基址。
  7. 调用copy_mem,再往下走copy_page_tables,把父进程的页表关系复制到子进程。
  8. 新进程状态置为可运行,父进程拿到子进程 PID,子进程返回 0。

这 8 步看着不算长,但每步付出的代价完全不同。用一个表大概圈一下:

环节主要开销量级估计
找空槽 / 分配 PID扫描 task 数组很小,O(64) 级别
分配 task_struct 页mem_map 找空闲页很小,几个循环
task_struct 整体拷贝几百字节到 1KB 以上的内存复制主要开销之一
设置 tss / ldt几十个字段赋值很小
copy_page_tables分配页表页 + 复制页表项主要开销之一
进入就绪 / 后续调度schedule + switch_to创建后第一次调度时爆发

这个表里最容易被忽略的是第 4 项。很多人说起 fork 开销只盯着内存页复制,但实际上 0.11 里*p = *current这一行代码搬运的数据量,可能比想象中要多得多。

1.2 必须先澄清:0.11 不是复制物理内存

网上流传“fork 会把父进程整个地址空间复制一份”,这句话放到 Linux 0.11 上是不对的。0.11 的copy_page_tables核心逻辑是:

*to_page_table = *from_page_table;

它复制的是页表项,不是页表项指向的物理页。也就是说,父子进程的页表内容一样,但映射到的物理页还是同一页。当时还没有写时复制机制,页表项里也没有刻意加只读保护,父子进程甚至会对同一块物理内存产生冲突。

拿图书馆打比方:父进程手里有一张检索卡片,写明了某本书在哪个书架第几层。fork 做的事情不是把书复印一本,而是把这张卡片抄了一份给子进程,书还是原来那本。所以 0.11 的 fork 在物理内存复制上其实是便宜的,真正的成本在“抄卡片”这个动作本身。

到了 0.12 以后,内核才慢慢引入“先共享、写时再复制”的思路。0.11 作为教学内核,反而把这些历史取舍暴露得很直观。

1.3 真正的大头只有三个

把整条 fork 路径捋完,真正值得关注的开销点其实只有三个:

  • task_struct整块拷贝。
  • copy_page_tables里页表页的分配和页表项复制。
  • fork 之后进程进入就绪队列,将来被schedule选中时的调度切换。

其余像 PID 分配、字段修正、状态置位,全是 O(64) 级别的小账,不值得花精力优化。这个结论很多人一开始不信,但如果你亲手在模拟器里跑过批量 fork 测试,就会发现单次 fork 的耗时通常落在几十到几百微秒量级,主要时间确实不在那几次循环扫描上。

2. 逐行拆解do_fork:创建子进程的成本明细

2.1 find_empty_process与pid分配开销

do_fork的第一步是find_empty_process,先看这个函数的逻辑。

int find_empty_process(void) { int i; repeat: if ((++last_pid) < 0) last_pid = 1; for (i = 0; i < NR_TASKS; i++) if (task[i] && task[i]->pid == last_pid) goto repeat; for (i = 1; i < NR_TASKS; i++) if (!task[i]) return i; return -EAGAIN; }

这个函数分两段:先试着找一个不冲突的 PID,再在 task 数组里找空槽。PID 冲突时需要反复重扫,最坏情况是 O(NR_TASKS 的平方)。但 0.11 最多 64 个任务槽,64 的平方也就 4096 次比较,放到 fork 总成本里几乎可以忽略。

真正要小心的不是性能,而是逻辑:task[0]是空闲任务,永远不能作为新进程槽位,所以第二段扫描从i = 1开始。如果 task 数组里堆满了僵尸进程,空槽耗尽,fork 就会返回 -EAGAIN。这和“创建开销”没关系,却比任何性能问题都更早找上门。

2.2 get_free_page:一页物理内存怎么分

拿到进程槽位后,下一步是给新进程分配存储task_struct的物理页。

p = (struct task_struct *) get_free_page(); if (!p) return -EAGAIN;

这里要回到 0.11 的经典设计:每个进程的task_struct和内核栈共用一个 4KB 物理页。具体体现在sched.h里的task_union:

union task_union { struct task_struct task; char stack[PAGE_SIZE]; };

get_free_page返回这一页后,task_struct从页底往上放,内核栈从页顶往下长。初始化时有一句很关键:

p->tss.esp0 = PAGE_SIZE + (long) p;

这表示新进程的内核栈栈顶指向这一页的最顶端。所以每个子进程至少消耗一页物理内存,同时这页内存还兼职做内核栈。

get_free_page本身不慢,它扫描mem_map找第一个空闲物理页。但在频繁 fork/exit 的场景下,物理页分配会出现碎片化,分配路径上可能要多跳几个页才能找到空闲块。这只影响分配速度,不会影响功能。

2.3 *p = *current:最被低估的整块拷贝

有了物理页,最重的一步来了:

*p = *current;

这一行看似简单,实际是把父进程整个task_struct从旧地址拷贝到新地址。0.11 的task_struct里装了信号位图、文件数组、寄存器现场tss_struct、局部描述符表 LDT 等,整体大小在 1KB 以上。如果你在源码里翻过它的定义,会发现tss_struct本身就包含大量保存现场用的字段。

这个拷贝属于纯内存读写,虽然不至于成为系统瓶颈,但它确实是 fork 路径上最大的一块数据搬运。后面那些逐字段修改,比如:

p->state = TASK_RUNNING; p->pid = last_pid; p->father = current->pid; p->start_time = jiffies;

加起来也不如这一行*p = *current花的时间多。所以在分析 fork 开销时,不要只盯着页表,结构体拷贝这笔账要先记上。

另外有个小细节:子进程返回 0 的秘密也在这里。copy_process里会设置:

p->tss.eax = 0;

而父进程的系统调用返回值放在原来的eax里。这一手直接在 TSS 寄存器现场上做差异,等子进程真正被调度执行时,用户态拿到的返回值就是 0。

2.4 copy_mem与copy_page_tables:页表复制怎么算账

结构体拷贝之后是copy_mem,它负责把父进程的地址空间关系复制给子进程。0.11 的进程线性地址布局和后来的 Linux 很不一样:每个任务有独立的 4MB 线性区间,任务号 n 的代码段和数据段基址通常放在nr * 0x400000附近。因此copy_mem要做的就是:

  1. 取出父进程当前 LDT 里的代码段基址和数据段基址。
  2. 计算子进程的新基址。
  3. 调用copy_page_tables把旧基址对应的页表项复制到新基址。
  4. 回写子进程 LDT 里的代码段和数据段描述符基址。

copy_page_tables的核心循环类似这样:

for (size = 4MB; size > 0; size -= 4MB) { if (!(1 & *from_dir)) continue; to_page_table = (unsigned long *) get_free_page(); if (!to_page_table) return -ENOMEM; *to_dir = ((unsigned long) to_page_table) | 7; for (nr = 0; nr < 1024; nr++) *to_page_table++ = *from_page_table++; }

它每次处理一个 4MB 的页目录项。如果父子线性地址落在不同的 4MB 区间,目标目录项是空的,就要先get_free_page分配一张新的页表页,再把父进程的 1024 个页表项复制过去。

这里有两个关键点:

第一,复制的是页表项,不是物理页。物理页仍然父子共享,这是 0.11 没有 COW 的直接体现。

第二,段 limit 决定了要复制多少页表项。0.11 里一个进程默认可以管理到 4MB 线性空间,所以最常见的 fork 会复制一张完整页表。如果程序其实只用了几十 KB,但 limit 被设置成 4MB,copy_page_tables还是会老老实实复制整张页表,这里存在理论上可以优化的空间。

2.5 fork最后一步:把新进程交出去

copy_process成功之后,新进程的task_struct已经被放到全局 task 数组里,状态置为就绪。真正让新进程跑起来的是下一次schedule。

这里有个很容易忽略的点:fork 创建阶段本身不包含调度切换。do_fork返回后,父进程可能继续执行很多指令,直到时钟中断或主动调用schedule,子进程才有机会被选中。所以你要是把“创建子进程”和“子进程第一次运行”两段时间混在一起测,会把调度切换开销也算进去。测量时务必要分开看。

如果copy_mem或者copy_page_tables失败,0.11 会释放掉刚才分配的 task_struct 页,并返回错误码。调试时如果看到 fork 返回 -EAGAIN 或 -ENOMEM,优先检查任务槽是否耗尽、物理页是否不足,而不是怀疑调度器。

3. schedule与switch_to:调度开销的另一本账

3.1 schedule的O(64)扫描不是瓶颈

子进程创建完了,不等于完事了。系统里进程一多,调度器就开始贡献开销。0.11 的调度器核心是schedule,它有一个非常朴素的抢占逻辑:

while (1) { int i, next = 0, c = -1; for (i = 0; i < NR_TASKS; i++) { if (task[i]->state == TASK_RUNNING && task[i]->counter > c) c = task[i]->counter, next = i; } if (c) break; for (i = 0; i < NR_TASKS; i++) task[i]->counter = (task[i]->counter >> 1) + task[i]->priority; } switch_to(next);

这段代码先扫描所有任务,找出counter最大的可运行进程;如果所有可运行进程的counter都耗尽,就把所有任务的counter按“右移一位再加 priority”的方式重新生成,再重新选一次。

扫描范围最多 64 个任务,每次比较不过是读一个数组项、比较一个整数。即使最坏情况下重复一轮 counter 重置,开销也压得极低。所以在 0.11 上,schedule本身的预算可以忽略,真正的大头在最后那一步switch_to(next)。

3.2 switch_to里的硬件任务切换

switch_to在 0.11 里是个宏,核心是一条ljmp指令:

#define switch_to(n) { \ struct {long a,b;} __tmp; \ __asm__("cmpl %%ecx,_current\n\t" \ "je 1f\n\t" \ "movw %%dx,%1\n\t" \ "xchgl %%ecx,_current\n\t" \ "ljmp %0\n\t" \ "cmpl %%ecx,%2\n\t" \ "jne 1f\n\t" \ "clts\n" \ "1:" \ ::"m" (*&__tmp.a), "m" (*&__tmp.b), "m" (last_task_used_math), \ "d" (_TSS(n)), "c" ((long) task[n])); \ __tmp.b = 0; \ }

这里的ljmp %0不是普通代码跳转,而是跳到目标任务的任务状态段选择子。一旦 CPU 执行这次远跳转,硬件会自动完成一整轮任务切换:

  • 保存当前任务的通用寄存器到当前 TSS。
  • 加载目标 TSS 里的寄存器现场。
  • 更新 LDTR、TR 等系统寄存器。
  • 做权限检查和段描述符校验。

硬件任务切换的好处是“无脑”,操作系统不用手工保存恢复一堆寄存器。但代价也高:CPU 内部的调度逻辑要为任务切换做大量验证和寄存器搬运,这比软件上手工压栈、弹栈要重得多。后来的 Linux 版本基本都抛弃了硬件任务切换,改用纯软件保存通用寄存器,原因就在这里。

性能判断:schedule的 for 循环在整数比较层面只有几百个周期量级,而一次硬件 TSS 切换通常会高一个甚至两个数量级。所以多进程调度开销的大头,基本就压在switch_to的ljmp上。

3.3 FPU与last_task_used_math

switch_to宏里还藏了一笔浮点开销:

cmpl %ecx, last_task_used_math jne 1f clts

当目标任务不是上一个使用数学协处理器的任务时,CPU 需要清掉 TS 标志;如果是浮点任务之间切换,那么浮点状态保存工作早就安排好了。0.11 在copy_process里会检查当前进程是否使用过浮点单元,如果用了,就会用fnsave把协处理器状态保存到子进程的 TSS 中。

这个设计的代价是:涉及浮点运算的进程切换时,需要额外搬运一整套 i387 状态,开销比普通整型运算进程切换大得多。反过来,纯整型计算进程之间的切换不受影响,clts只是一条指令的事。

3.4 调度切换开销怎么量化

一次完整调度切换的成本可以拆成三部分:

  1. schedule里的任务扫描和 counter 重置,O(64) 比较。
  2. switch_to里的硬件 TSS 切换。
  3. 可选的 FPU 状态保存/恢复。

其中第 2 项是绝对大头。如果系统里进程数不多但频繁抢占,调度开销不会因为进程少而大幅下降,因为硬件任务切换和进程数没有关系。有时候你优化了半天schedule的 for 循环,反而忽略了一个事实:真正把时间吃掉的是那条ljmp。

4. 实测:用jiffies给fork和切换开销上秤

4.1 先准备好计时工具

0.11 没有像样的性能分析工具,但内核里有一个全局变量jiffies,每 10ms 加一次。单次 fork 通常远小于一个 tick,没法直接测,所以必须用“批量测、除总数”的思路。

写一个简单的循环测试:

extern unsigned long jiffies; void fork_test(int n) { int i; unsigned long t0 = jiffies; for (i = 0; i < n; i++) { int pid = fork(); if (pid == 0) _exit(0); wait(NULL); } unsigned long t1 = jiffies; printk("fork %d times: %d ticks\n", n, t1 - t0); }

这里有两个注意点。

第一,必须等子进程退出后再开始下一轮 fork,否则进程数量会越积越多,最后把 task 数组占满。第二,wait(NULL)会顺带把进程回收、僵尸清理也算进去。所以这个测试测到的是“创建子进程 + 子进程运行一小段 + 父进程回收”的整体开销,而不是纯do_fork的开销。想只测创建阶段,可以在父进程 fork 后不 wait,但那会导致任务槽很快耗尽,不适合大批量测试。

4.2 想办法放大调度切换

调度切换的测量比 fork 更麻烦。一个可行的做法是在schedule里加一个计数器:

unsigned long switch_counter = 0; void schedule(void) { ... switch_counter++; switch_to(next); }

然后跑一个多进程程序,运行几秒钟,定时读一次计数器,看增长了多少。0.11 默认的时钟频率是 100Hz,每秒有 100 次时钟中断,结合计数器增长量,可以大概算出系统每秒发生多少次任务切换。

更简单的做法是创建两个进程,各自while(1) pause();,让时钟中断不断唤醒调度器。由于 0.11 会在当前进程 counter 耗尽后重新调度,这个过程就自然演变成一轮轮 TSS 切换。计数器增长越快,说明调度切换越频繁。

但要注意:在模拟器环境下测出来的绝对时间没有多少参考意义,真正有价值的是修改前后计数器和 tick 消耗的相对变化。

4.3 怎么解读结果

假设你跑了 50 次 fork 加 wait,如果jiffies只增加了 2 到 3 个 tick,说明系统整体 fork 成本在几十毫秒的 1/10 以下,单次大概在几十到几百微秒量级。这里的浮动主要来自模拟器指令执行速度、内存拷贝量和调度切换次数。

如果发现批量 fork 时间明显偏大,优先怀疑copy_page_tables。你可以临时把子进程的数据段 limit 改小,再跑一次同样的测试。如果 tick 数明显下降,说明页表复制的确被 limit 放大了;如果 tick 数没怎么变,那瓶颈可能出在schedule和 TSS 切换上。

另外,switch_counter 的增长速度和 tick 消耗可以交叉验证。如果每次调度切换都伴随明显的时间消耗,就说明 TSS 硬件切换是主要开销。这时候再去优化 fork 时的结构体拷贝,已经意义不大了。

4.4 优化:哪些改动性价比最高

根据上面拆出来的开销分布,可以按性价比排个序:

  1. 减少 fork 次数。这是最有效的优化。能用 exec 复用进程的场景,就别反复 fork。
  2. 及时 wait 回收子进程。僵尸占着 task 槽位,会让后续 fork 因为无槽位而失败。
  3. 缩短段 limit。如果程序实际用不满 4MB,把 LDT 里的 limit 调小,能直接减少copy_page_tables里复制的页表项数量。
  4. 降低调度切换频率。比如把多个短任务合并成事件循环,减少抢占次数。
  5. 如果对切换延迟极度敏感,可以尝试按 0.12/0.13 的做法把switch_to改成软件上下文切换。但这是内核级大手术,要做好回归测试。

不要一上来就改调度器。先测量,再对症下药。很多时候你花一晚上优化了schedule的 for 循环,结果性能一点没变,因为时间全花在硬件 TSS 切换上。

5. 实战中踩过的坑:进程创建与调度常见问题

5.1 子进程和父进程数据互相覆盖

这是 0.11 最容易踩的坑。由于copy_page_tables直接复制页表项,不设只读保护,父子进程映射到同一物理页。子进程一旦对全局变量或数组做写操作,父进程看到的数据也会变。反过来也一样。

网上很多人用 0.11 做实验时,发现 fork 之后父进程打印出来的数据被“污染”了,第一反应是代码写错了,其实是被 0.11 的内存模型坑了。解决思路很朴素:fork 出来的子进程要么尽快_exit(0),要么马上exec一个新程序,不要让父子进程在同一份可写数据上同时操作。

5.2 copy_page_tables 报 already exist

如果在修改 fork 代码时看到panic("copy_page_tables: already exist"),说明目标线性地址的页目录项已经有映射存在。这通常不是因为两条进程真的重叠,而是因为进程槽位分配出了问题,或者 GDT 里的 LDT 描述符没有同步更新。

排查时先确认find_empty_process返回的 nr 是否合法,再检查对应任务是否已经被设置过 LDT。手动往 task 数组里塞任务时最容易触发这个 panic,因为跳过了正规的槽位分配流程。

5.3 task数组满,fork返回-EAGAIN

0.11 最多支持 64 个进程,但这里的“进程”包括了僵尸进程。很多实验程序 fork 之后没有 wait,子进程变成僵尸,长期占着 task 槽位。等槽位耗尽,再 fork 就会返回 -EAGAIN。

解决办法最简单:父进程循环 fork 时,每轮都用 wait 回收子进程。如果业务场景确实需要后台多进程,也要在适当时间统一回收。

5.4 计时结果不稳定

用jiffies做计时时,不要在循环里频繁调用printk。0.11 的 printk 本身就是耗时大户,还会影响调度和中断路径。正确做法是先累加起始和结束的jiffies,测试跑完再统一打印。

还有个细节:如果修改了schedule加入计数器,不要在中断上下文里 printk,否则可能破坏栈结构。可以在一个可控的系统调用里读取计数器,返回给用户态进程。

现象原因解决方向
fork 后父子数据互相变页表项直接复制,物理页共享子进程尽快 exec / exit
panic already exist目标目录项已有映射检查任务槽分配和 LDT 描述符
fork 返回 -EAGAINtask 数组满,僵尸占位主动 wait 回收子进程
批量 fork 耗时不稳定printk 干扰、页表复制量变化统一在测试结束后打印
切换慢但找不到原因硬件 TSS 切换开销试改软件切换对比

5.5 硬切换改软切换的注意

最后聊一个进阶话题。如果你确实想实验一下把 0.11 的硬件 TSS 切换改成软件上下文切换,有几个点必须小心:

  • 手工保存和恢复寄存器时,绝对不能破坏ebx、esi、edi、ebp,否则恢复现场会乱套。
  • 内核栈只有 4KB 空间,和task_struct共用一页,别在临界区里压入太多调试数据。
  • 中断返回路径也要同步修改,不能只改switch_to。
  • 0.11 作为教学内核,最大的价值就是能让你大胆动刀。改坏了重编译烧录就是,改一次体会一次为什么当时的人会觉得 Linux 0.11 的调度“重”。

我自己在实验里还有一个体会:读这种老内核,最好的方式不是对着文章看结论,而是开着源码清单自己推一遍。我第一次看fork源码时,也以为它复制了整个物理内存,后来默默对着copy_page_tables一行行看,才发现物理页纹丝没动,动的只是页表项。开销这个东西,和设计阶段的技术路线绑定得非常深。0.11 里 task_struct 整块拷贝、页表整张复制、TSS 硬件切换,都是当时能最快实现、最不容易出错的做法。今天当然有 COW,有更轻量的上下文切换,但只要你把 0.11 这笔账算明白了,再去看现代调度器会顺很多。如果有朋友也在做类似的实验,建议先把 fork、wait、schedule 三条路径分别测出耗时,再决定要不要动手优化。别一上来就改调度器,先让数据告诉你钱花在哪。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询