从 fork 到协程:一次完整的操作系统并发编程实操
环境:华为云 ECS(Ubuntu 24.04,8 核,GCC 13.3.0) 代码仓库:
/root/operating-system/(四个子项目,全部附带 Makefile 与运行输出)
一、项目背景与总体结构
操作系统课程第 6~8 章讲的是同一个主题的不同侧面:并发。
- 第 6 章是"进程"------资源分配的最小单位,fork、五状态模型、IPC、僵尸/孤儿进程;
- 第 7 章是"线程同步"------互斥量、自旋锁、读写锁、条件变量,以及躲不开的死锁;
- 第 8 章是"线程池与异步"------把裸线程封装成可复用的执行引擎,再往上构建异步任务与协程。
书本上的状态转换图和伪代码看十遍,不如亲手让程序踩一次坑。我在云服务器上完成了四个递进式项目:
perl
operating-system/
├── project1_process/ # 进程管理: fork树/五状态/三种IPC/僵尸孤儿
├── project2_sync/ # 线程同步: mutex/spinlock/rwlock/条件变量/死锁
├── project3_threadpool/ # 线程池: 优先级队列/动态扩缩容/性能测试
└── project4_async/ # 异步框架: future/回调/依赖DAG + 协程调度器
四个项目不是孤立的:项目 4 的异步框架直接构建在项目 3 的线程池之上,这正对应了真实工程中"同步原语 → 执行引擎 → 异步抽象"的演进路径。本文按项目顺序展开,每个项目先给实现,再给实测数据,最后归因分析。
二、项目 1:进程管理------看得见的状态转换
2.1 fork 进程树与写时复制
fork_tree.c 递归创建一棵深度为 3 的二叉进程树(共 7 个进程)。关键点在于每个非叶进程 fork 两个子进程后,必须用 wait() 收集退出码,否则子进程退出后会变成僵尸。运行输出清晰地展示了创建与回收的交错:
ini
[根进程] PID=15928 PPID=15927
[父进程] PID=15928 创建子进程 PID=15929 (level 0 -> 1)
[父进程] PID=15929 创建子进程 PID=15931 (level 1 -> 2)
[叶子] PID=15931 PPID=15929 level=2 工作 1 秒后退出
[回收] PID=15929 等到子进程 PID=15931, 退出码=2
值得补充一个容易被忽略的机制:现代 Linux 的 fork() 并不复制父进程的整个地址空间,而是采用写时复制(Copy-On-Write)。父子进程共享同一份物理页,页表项标记为只读;任何一方写入时触发缺页异常,内核这才复制该页。这就是为什么 fork 一个大内存进程依然很快------复制的只是页表,不是数据。上面这个 7 进程的树之所以能在瞬间创建完成,COW 是底层原因。
2.2 让五状态模型"活"起来
五状态模型(创建/就绪/运行/阻塞/终止)在课本上是一张静态图。five_states.c 的做法是:子进程先忙等 3 秒(Running),再 sleep(5)(Blocked/S),然后退出;父进程每秒读取一次 /proc/<pid>/stat 的 state 字段,实时打印状态迁移:
ini
[2] fork 完成, 子进程 PID=16110 进入就绪队列(Ready)
t= 0s 子进程状态: R - Running/Runnable (运行/就绪)
t= 1s 子进程状态: S - Sleeping (可中断阻塞)
t= 6s 子进程状态: Z - Zombie (僵尸/已终止待回收)
[5] 父进程调用 wait() 回收子进程...
子进程退出码=42, 进程彻底终止(Terminated), PCB 被系统回收
R → S → Z 的迁移被 /proc 文件系统忠实地记录下来。这里有个细节值得注意:Linux 的 R 状态其实合并了课本里的"就绪"和"运行"两个状态------调度器把可运行的进程放在同一个运行队列里,从 /proc 的角度无法区分"正在跑"和"等待 CPU"。
把五状态模型与 Linux 实际状态字段的对应关系整理如下:
| 课本五状态 | Linux state 字段 | 含义 | 触发条件 |
|---|---|---|---|
| 创建 | (fork 返回前) | PCB 构建中 | fork/clone 系统调用 |
| 就绪 + 运行 | R | 可运行/正在运行 | 被调度或等待 CPU |
| 阻塞(可中断) | S | 可中断睡眠 | sleep、wait、可中断 IO |
| 阻塞(不可中断) | D | 不可中断睡眠 | 磁盘 IO、NFS(kill 不掉) |
| 暂停 | T | 被作业控制停止 | SIGSTOP/SIGCONT |
| 终止(待回收) | Z | 僵尸 | exit 后父进程未 wait |
D 状态是运维中最怕遇到的一种:进程卡在不可中断的内核 IO 里,连 kill -9 都无效,只能等底层 IO 完成。这也解释了为什么状态机里"阻塞"要拆成两种------能否被信号打断,决定了系统的可控边界。
2.3 三种 IPC 的对比实验
三个程序分别演示了匿名管道、System V 消息队列、共享内存:
| 方式 | 数据形态 | 速度 | 同步责任 | 演示要点 |
|---|---|---|---|---|
| 匿名管道 | 字节流,半双工 | 慢(两次内核拷贝) | 内核自带 | 写端关闭后读端 read 返回 0(EOF) |
| 消息队列 | 带类型的消息边界 | 中(仍经内核) | 内核自带 | 按 mtype 选择性接收,父进程优先取 mtype=3 的紧急消息 |
| 共享内存 | 任意结构,零拷贝 | 最快 | 完全由用户负责 | 子进程累加 1..100=5050 写入,用 ready 标志同步 |
共享内存的例子最能说明问题:快是要付出代价的 。它只给你一块映射到两个进程地址空间的内存,互斥、同步全都要自己做。演示里用了一个 volatile int ready 标志 + __sync_synchronize() 内存屏障------先写数据、再置标志,屏障保证其他 CPU 看到标志为 1 时数据一定已经可见。这个"先数据、后标志、中间插屏障"的次序,是多核内存可见性的最小完整模型,后文竞态调试一节会再次用到同样的推理。
2.4 僵尸与孤儿:两个最容易混淆的概念
zombie_orphan.c 一次性演示两者:
- 僵尸进程 :子进程退出但父进程没
wait(),/proc/<pid>/stat显示 Z 状态。僵尸不占用 CPU 和内存,但它占着 PID 和 PCB(里面有退出码)------父进程迟早要收尸; - 孤儿进程 :父进程先退出,子进程被 PID 1(init/systemd)收养,PPID 改变。孤儿是正常状态,不是错误。
记忆法:僵尸是"儿子死了爹不收尸",孤儿是"爹死了儿子被领养"。工程上的对应解法也不同:孤儿不需要处理(systemd 会兜底 wait),僵尸则要求父进程正确处理 SIGCHLD 信号或在循环里 waitpid。
三、项目 2:线程同步------一次真实的踩坑
3.1 踩坑实录:竞态条件"复现不了"
mutex_demo.c 的初衷很简单:8 个线程各做 100 万次 counter++,无锁版应该因为竞态而结果偏小。然而第一版跑出来的结果竟然是:
scss
[无锁] 结果 = 8000000 (丢失 0 次更新, 错误率 0.00%)
完全没有丢失更新。排查后发现两个叠加的原因,先看完整的复现路径。
原因一:线程串行启动。8 个线程是 for 循环里依次 pthread_create 的,每个线程 100 万次加法只需几毫秒,第一个线程很可能在第八个线程创建之前就跑完了。复现路径可以画成时间线:
arduino
第一版(无 barrier):
线程1 ━━━━━━━━━━━━━━━━━━━┓ 跑完退出
线程2 ━━━━━━━━━━━━━━━━━━━┓
线程3 ━━━━━━━━━━━━━━━━━━━┓
... ━━━┓ 线程8 才刚开始
↑ 各线程的活跃窗口几乎不重叠,时间上根本没机会撞车
第二版(barrier 对齐):
│ barrier 释放点(8 线程同时起跑)
线程1 ────────┣━━━━━━━━━━━━━━━━━━
线程2 ────────┣━━━━━━━━━━━━━━━━━━
... ┣
线程8 ────────┣━━━━━━━━━━━━━━━━━━
↑ 8 条时间线完全重叠,读-改-写窗口必然互相覆盖
pthread_barrier_t 的作用机制:pthread_barrier_wait 让先到的线程在内核里睡眠挂起,直到第 N 个(最后一个)线程也到达,内核一次性唤醒全部等待者。之后 8 个线程几乎在同一微秒内冲进计数循环,竞态窗口从"理论存在"变成"必然命中"。
原因二:编译器优化。即便同时起跑,-O2 下 GCC 可能发现循环里只有 counter++,把整个循环折叠成一次 counter += 1000000------每个线程只做一次原子性的读改写,竞态自然消失。volatile long *p 的作用机制正在这里:它禁止编译器把多次内存访问合并成一次,强制每次迭代都真实地读内存、写内存,从而把"读-改-写非原子"这一底层事实暴露出来。
还需要一层内存可见性分析才能解释 82.5% 这个量级。counter++ 在 x86-64 上编译为三条指令:
perl
mov (%counter), %rax ; 1. 读
add $1, %rax ; 2. 改(仅修改寄存器)
mov %rax, (%counter) ; 3. 写
两个线程的交错序列 读A → 读B → 写A → 写B 会让两次加法只生效一次。8 线程同时竞争时,一个线程在"读"之后、"写"之前被换下 CPU 的概率极高,而任何一次这样的交错都抹掉 7 个并发对手中的一部分成果。丢失是级联的:一轮 8 次并发写可能只留下 1 次效果,理论错误率上限逼近 7/8 = 87.5%。实测落在 82.53%,与该上限量级吻合。
修改后的结果终于"如预期般错误":
scss
[无锁] 结果 = 1397823 (丢失 6602177 次更新, 错误率 82.53%)
[加锁] 结果 = 8000000 (与期望值一致, 正确)
8 个线程抢一个计数器,82.5% 的更新丢失------这个数字恰恰说明 x86 上 count++ 的"读-改-写"三步在并发下有多么脆弱。这个坑的教训是普适的:多线程 bug 不复现,不代表不存在,可能只是时序没撞上。barrier 负责制造时间窗重叠(调度层面),volatile 负责阻止指令合并(编译层面),两者缺一不可。
3.2 自旋锁 vs 互斥锁:实测数据与量化归因
spin_vs_mutex.c 用相同负载(8 线程 × 50 万次极短临界区)对比两种锁:
| 锁类型 | 结果正确性 | 耗时 | 相对速度 |
|---|---|---|---|
| 自旋锁 | 4000000(正确) | 0.305 秒 | 慢 2.30 倍 |
| 互斥锁 | 4000000(正确) | 0.133 秒 | 基准 |
教科书常说"临界区短用自旋锁",为什么这里自旋锁反而输了?把一次临界区获取的完整代价拆开算:
自旋锁在竞争失败时不让出 CPU,失败者高速空转、反复执行 xchg/cmpxchg 原子指令。这个行为的真实代价有三个层次:
- 缓存行弹跳(cache line bouncing):锁变量所在的缓存行在 8 个核之间来回失效。每次原子指令都要求该核拿到缓存行的独占权(x86 上通过缓存锁定,其它核的副本被置为 Invalid),于是这条 64 字节的缓存行在 8 个 L1 之间来回搬运,延迟约几十到上百纳秒一次,而失败者每秒发起数百万次这样的指令;
- 拖慢持锁者:持锁线程释放锁时要写的,恰好是所有自旋者疯狂争抢的同一缓存行------它几乎必然处于 Invalid 状态,释放动作本身就要付一次跨核一致性开销。更糟的是临界区内线程要访问的其他数据,也可能被自旋者的总线流量干扰;
- 抢占放大:8 个自旋线程占满 8 个 vCPU,持锁线程若恰在临界区中被调度器换出,没有任何空闲 CPU 能让它尽快回来------所有核都在空转等它。
互斥锁的路径则完全不同:glibc 的 pthread_mutex_lock 实际是"先自旋几十圈,仍拿不到再 futex 系统调用睡眠"。睡眠这一步看似昂贵(一次系统调用加两次上下文切换,量级 1~10 微秒),但它换来的是:CPU 让给持锁线程尽快跑完临界区,锁变量的缓存行不再被无谓的原子指令轰炸。当竞争线程数等于核数时,让出比空转划算得多。
粗略对账:0.305s vs 0.133s,差值约 0.172s,摊到 400 万次临界区获取上,每次多花约 43 ns------正是每轮锁获取期间缓存行弹跳若干次的量级,与上述机制一致。
结论不是"互斥锁永远更快",而是:锁的选择取决于临界区长度与竞争烈度的乘积。竞争线程数少于核数、临界区只有几条指令时,自旋省下的上下文切换会占优;内核中断处理里自旋锁依然是主力,因为中断上下文不能睡眠。用户态 8 核争一把锁这种场景,恰恰落在自旋最不利的象限。
3.3 读写锁与条件变量
rwlock_demo.c 用 5 读者 + 2 写者验证了读写分离:日志里能观察到"当前并发读者=3"这样的输出,证明读锁是共享的;而写锁持有期间绝没有任何读者插入,证明写锁独占。读多写少的场景(配置读取、路由表查询)用读写锁能获得明显优于互斥锁的并发度。代价是写者可能饥饿(glibc 默认实现偏向读者),需要写者优先策略时要用条件变量自行搭建。
cond_producer_consumer.c 是有界缓冲区(容量 5)+ 2 生产者 + 3 消费者的经典模型。三个要点:
- 必须用
while而非if检查条件 ------pthread_cond_wait可能虚假唤醒(spurious wakeup),或被 signal 唤醒后条件又被别的线程破坏; - signal 时锁的持有时机:先改条件再 signal,signal 可以持锁发出(glibc 保证正确性);
- 优雅退出 :生产结束后投放与消费者数量相等的"毒丸"(POISON 哨兵),消费者吃到毒丸就退出,比
pthread_cancel干净得多。
至此项目 2 用过的同步原语汇总对比:
| 原语 | 语义 | 失败时行为 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| mutex | 互斥 | 自旋若干圈后 futex 睡眠 | 通用临界区 | 死锁、优先级反转 |
| spinlock | 互斥 | 忙等(不睡眠) | 极短临界区、不可睡眠上下文 | 缓存行弹跳、浪费 CPU |
| rwlock | 读共享/写独占 | 睡眠 | 读多写少 | 写者饥饿 |
| condition variable | 等待/通知 | 睡眠在条件队列 | 等待状态变化 | 虚假唤醒、丢信号 |
| barrier | 集合点 | 睡眠至全员到齐 | 阶段性并行计算 | 阶段负载不均 |
| 原子操作(CAS) | 无锁更新 | 重试或失败返回 | 计数器、无锁结构 | ABA 问题、总线争抢 |
3.4 死锁:制造它,再消灭它
deadlock_demo.c 演示了三种模式:
erlang
--- 模式1: 相反顺序加锁 (产生死锁) ---
[线程A] 持有 lock1, 1秒后申请 lock2...
[线程B] 持有 lock2, 1秒后申请 lock1...
[线程A] 申请 lock2 超时 -> 检测到死锁!
[线程B] 申请 lock1 超时 -> 检测到死锁!
--- 模式2: 固定加锁顺序 (避免死锁) --- 双方均顺利完成
--- 模式3: trylock 退避重试 (避免死锁) --- 双方均顺利完成
为了让演示程序不真的卡死,模式 1 用 pthread_mutex_timedlock 设置 3 秒超时来"检测"死锁------这也是工程上定位死锁的思路之一(另两条思路:gdb attach 后 thread apply all bt 看锁等待环;pstack/gcore 离线分析)。死锁四要素(互斥、占有且等待、不可剥夺、循环等待)里,模式 2 破坏了"循环等待"(所有线程按全局固定顺序加锁),模式 3 破坏了"占有且等待"(拿不到第二把锁就放弃第一把重来)。两种策略对应两种工程取舍:定序法零开销但要求全局锁顺序可设计,退避法更灵活但有活锁风险(两个线程可能反复互相谦让------实际中要加随机退避)。
四、项目 3:手写一个生产级雏形的线程池
4.1 核心结构与代码走读
线程池(threadpool.c)= 任务队列 + 工作线程组 + 同步原语,但加了两个课本里不常讲的特性:
c
struct threadpool {
pthread_mutex_t lock;
pthread_cond_t not_empty; /* 队列非空 -> 唤醒 worker */
pthread_cond_t not_full; /* 队列未满 -> 唤醒提交者 */
pthread_cond_t idle; /* pending==0 -> 唤醒 wait 者 */
task_t *head; /* 按优先级排序的任务链表 */
pthread_t *threads; /* 追加式线程句柄数组 */
int thread_count, shrink; /* 存活数 / 待缩容数 */
int pending; /* 未完成任务总数 */
int shutdown;
...
};
三把条件变量各管一条唤醒链路,避免误唤醒:worker 只在 not_empty 上等,提交者只被 not_full 唤醒,threadpool_wait 的调用者只被 idle 唤醒。若共用一把条件变量,一次 broadcast 会把三类等待者全部惊起,醒来发现与自己无关再睡回去,白白多付两次上下文切换。
任务优先级用排序链表实现,插入路径的走读:
c
/* threadpool_submit 内核路径(省略锁与队列满检查) */
task_t *t = malloc(sizeof *t);
t->fn = fn; t->arg = arg; t->priority = prio;
task_t **pp = &pool->head; /* 指向"指向节点的指针"的二级指针 */
while (*pp && (*pp)->priority >= prio) /* 1) 跳过所有优先级不低于我的 */
pp = &(*pp)->next;
t->next = *pp; /* 2) 新节点接上后继 */
*pp = t; /* 3) 前驱(或头指针)指向新节点 */
pthread_cond_signal(&pool->not_empty); /* 4) 通知一个等待的 worker */
二级指针 pp 让"插到表头"与"插到中间"统一成同一段代码,不用单独处理头节点。插入按优先级定位是 O(n),取任务永远取表头是 O(1),同优先级保持 FIFO------因为 while 条件里的 >= 保证了相等优先级不会越过先来的节点。验证效果(单线程池,先提交的任务 0 占住 worker,后续 5 个任务在队列里重排):
css
[任务 0] 优先级=低 ...
[任务 4] 优先级=高 <- 提交顺序是 低/低/普通/高/高
[任务 5] 优先级=高 <- 但高优先级插队先执行
[任务 3] 优先级=普通
[任务 1] 优先级=低
[任务 2] 优先级=低
worker 线程主循环的走读:
c
static void *worker(void *arg) {
threadpool_t *pool = arg;
for (;;) {
pthread_mutex_lock(&pool->lock);
while (pool->head == NULL && !pool->shutdown && pool->shrink == 0)
pthread_cond_wait(&pool->not_empty, &pool->lock); /* (a) */
if (pool->shutdown) break;
if (pool->shrink > 0) { /* (b) 缩容名额:自愿退出 */
pool->shrink--;
pool->thread_count--;
pthread_mutex_unlock(&pool->lock);
pthread_exit(NULL); /* 句柄留在 append-only 数组里待 join */
}
task_t *t = pool->head; /* (c) 取队头,O(1) */
pool->head = t->next;
pool->pending--;
pthread_mutex_unlock(&pool->lock);
t->fn(t->arg); /* (d) 执行任务:不持锁! */
free(t);
}
pthread_mutex_unlock(&pool->lock);
return NULL;
}
四个要点:位置 (a) 用 while 而非 if 判空,防虚假唤醒;位置 (c) 取完任务立刻解锁再执行 (d),任务的运行时间不计入锁持有期,这是线程池吞吐的关键------持锁时间被压到微秒级,8 个 worker 的队列锁才不会成为瓶颈;位置 (b) 就是缩容的"自杀名额"机制。
动态调整线程数 的难点在缩容:不能从外部"杀死"线程(pthread_cancel 在线程持锁时会造成死锁------取消点若发生在持锁区,锁永远不会释放),只能让多余线程自愿退出 。实现是设置 shrink 计数器并 broadcast 唤醒所有等待线程,每个被唤醒的线程先检查 shrink > 0,是则递减计数并 pthread_exit------名额先到先得。为什么必须 broadcast 而不是 signal:signal 只唤醒一个等待者,它取走名额退出后,其余该退出的线程仍在睡眠,缩容就停摆了;broadcast 把所有等待者惊起,让它们逐个核对名额。
另一个容易踩的坑是:退出线程的 pthread_t 句柄必须保留用于最终 join(否则线程资源泄漏------线程栈与内核 task 结构在 join 或 detach 之前不会被回收),所以句柄数组设计成只增不减的 append-only 结构,销毁时 join 全部历史线程。
4.2 性能测试与 Amdahl 定律数值推导
pool_bench.c 用 CPU 密集负载(200 个任务,每个统计 2 万个整数中的素数个数)测试:
| 配置 | 耗时 | 加速比 |
|---|---|---|
| 单线程 | 0.923 秒 | 1.00x(基线) |
| 线程池 2 线程 | 0.463 秒 | 1.99x |
| 线程池 4 线程 | 0.246 秒 | 3.76x |
| 线程池 8 线程 | 0.231 秒 | 3.99x |
(所有配置素数总数均为 283146,结果一致。)
2 线程加速比 1.99x 接近线性;4 线程 3.76x 仍然健康;8 线程只有 3.99x,几乎不再提升。机器是 8 核,为什么跑不满?用 Amdahl 定律做一次数值推导。
设并行部分占比 p、串行部分占比 s = 1-p,则 N 核加速比上限为 S(N) = 1 / (s + p/N)。用 8 线程实测值 3.99x 反解串行占比:
ini
1 / (s + (1-s)/8) = 3.99
s + (1-s)/8 = 1/3.99 = 0.2506
(7/8)s = 0.2506 - 0.125 = 0.1256
s = 0.1436 即串行部分约占 14.4%
再把这个 s 代回去,检验其余两个数据点并外推:
| 线程数 N | 实测加速比 | Amdahl 预测(s=0.144) | 吻合度 |
|---|---|---|---|
| 2 | 1.99x | 1/(0.144+0.856/2) = 1.75x | 偏保守 |
| 4 | 3.76x | 1/(0.144+0.856/4) = 2.77x | 偏保守 |
| 8 | 3.99x | 1/(0.144+0.856/8) = 3.99x | 精确吻合 |
| 16 | --- | 4.55x | 外推 |
| ∞ | --- | 1/0.144 ≈ 6.96x | 理论上限 |
低线程数时实测优于 Amdahl 预测,说明此阶段的加速还受益于缓存局部性(任务数据集变小后驻留 L2/L3);8 线程时正好落在理论值上,串行部分------任务提交、队列锁竞争、结果汇总------开始成为瓶颈。此外云服务器 8 个 vCPU 的物理拓扑(超线程共享执行单元、共享 L3)也会让并行效率打折扣。还有一个微妙因素:200 个任务分给 8 个线程,任务粒度已不算粗,队列锁的争抢开销开始显现。这些因素共同把有效串行占比推到了 14% 左右------这就是"8 核只有 4x"的定量答案。
五、项目 4:异步框架与协程
5.1 future + 回调 + 依赖 DAG
异步框架(async.c)构建在项目 3 的线程池之上,核心是 future_t:
c
struct future {
async_fn fn; void *arg;
int done; void *result;
int dep_count; /* 未完成的依赖数 */
cb_node_t *callbacks; /* 完成回调链表 */
dep_node_t *dependents; /* "谁依赖我"链表 */
...
};
任务依赖的实现是事件驱动 而非轮询:每个 future 维护 dependents 链表(谁依赖我),自己完成时遍历链表给每个 dependent 的 dep_count 减一,减到 0 就把 dependent 提交进线程池。整张依赖 DAG 由完成事件一级级点燃,不需要任何中心调度线程。回调在持锁外触发(先拷贝回调链表再释放锁),避免回调里再操作 future 造成死锁------这是从实际框架(如 Java 的 CompletableFuture)借鉴的教训。
演示是一条数据分析流水线:两个 fetch 并行 → merge 等两个 fetch → report 等 merge:
css
[fetch] source-B 完成, value=2
[回调] fetch B 已完成
[fetch] source-A 完成, value=40
[回调] fetch A 已完成
[merge] source-A(40) + source-B(2) = 42
[report] 最终报告: merged, 总和=42
最终数值 = 42 (预期 42)
5.2 协程调度器:用户态的线程
最后一块拼图是用 ucontext.h 实现协作式协程:每个协程一个 ucontext_t + 64KB 独立栈,coro_yield() 通过 swapcontext 切回调度器,调度器 round-robin 选择下一个就绪协程切入。
ucontext 家族共四个函数,各司其职:
| 函数 | 作用 | 保存当前上下文 | 恢复目标上下文 |
|---|---|---|---|
getcontext |
把当前执行点(寄存器、栈指针、返回地址)快照进 ucontext_t | 是 | 否 |
setcontext |
无条件切换到某个已保存的上下文,不返回 | 否 | 是 |
makecontext |
在 ucontext_t 上绑定一个函数与参数栈,需先 getcontext 初始化 | 否 | 否 |
swapcontext |
原子地"保存当前 + 切到目标",协程切换的主力 | 是 | 是 |
调度器启动与切换的完整调用时序:
scss
main() 调度器上下文 协程A
| | |
| makecontext(ctxA, coroA_fn) ---+ |
| makecontext(ctxB, coroB_fn) ---+ (为每个协程绑定入口) |
| | |
| swapcontext(&sched, ctxA) ---->|---- swapcontext 恢复 ctxA ->| A 开始执行
| | |
| | | ...运行...
| | |
| | <- swapcontext(&ctxA, sched)--
| | coro_yield(): A 保存自己,
| | 切回调度器 |
| |--- swapcontext(&sched, ctxB)| B 开始执行
| | |
| | <- swapcontext(&ctxB, sched)--
| |--- round-robin 回到 ctxA ->| A 从 yield 点恢复
| | |
| |<- setcontext(&main_ctx) | 全部结束,
| 调度器退出 | (调度器返回 main) | 栈被释放
时序中的关键机制:swapcontext 一次调用完成"保存自己 + 恢复对方"两个动作,且不会返回到调用者------它返回的位置是对方上次保存的执行点。协程 A 在 coro_yield() 里调用 swapcontext(&ctxA, sched) 后,下一次调度器执行 swapcontext(&sched, ctxA) 时,控制流回到的正是 A 当年那次 swapcontext 调用的返回处,局部变量与调用栈原封不动。三个协程的输出完美交错:
css
[协程0-数字] 1
[协程1-字母] A
[协程2-监控] 第 1 次心跳
[协程0-数字] 2
[协程1-字母] B
...
性能测试:100 万次协程切换耗时 0.706 秒,平均每次约 706 ns------比线程上下文切换(通常数微秒)快一个数量级。差距的来源可以用一张代价清单说明:
| 开销项 | 线程切换(内核) | 协程切换(用户态) |
|---|---|---|
| 陷入内核(系统调用) | 有 | 无 |
| 寄存器保存/恢复 | 全量(含浮点、信号掩码) | 最小集(被调保存寄存器) |
| 调度器决策 | CFS 完全公平调度,需加锁运行队列 | 一个函数指针/索引 |
| TLB/缓存影响 | 换页表可能刷 TLB | 地址空间不变,缓存仍然热 |
| 栈切换 | 内核栈 + 用户栈 | 用户栈,64KB 常驻内存 |
代价是"协作式"三个字:协程必须自觉 yield,一个死循环就能卡死全部协程。这也是为什么 Go 的 goroutine 要在抢占式调度上做那么多工作(函数插入抢占检查点、信号驱动的异步抢占)。另外注意协程无法利用多核------整个调度器只跑在一个 OS 线程上,多核扩展仍要靠项目 3 的线程池,两者是互补关系而非替代关系。
ucontext 已被 POSIX.1-2008 标记废弃,但它仍是学习协程原理的最佳教具------Boost.Context、libco 等生产级库用的是手写汇编做同样的事(保存被调保存寄存器、切换栈指针、跳转),原理一致。
六、总结与踩坑清单
四个项目串起了从进程到协程的完整链条:fork 创建执行实体 → 锁保护共享状态 → 线程池复用执行实体 → 异步/协程榨干单机并发。
本次实操踩过的坑,逐条给出根因:
- 竞态不复现 ≠ 不存在 。根因有两层:调度层面,串行启动让各线程的活跃时间窗不重叠,竞争根本没发生;编译层面,
-O2把counter++循环折叠成单次counter += N,读改写被合并成原子操作。barrier 制造时间窗重叠、volatile 阻止访问合并,两者缺一不可。修正后错误率 82.5%,接近 8 线程级联丢失的理论上限 87.5%。 -std=c11会隐藏 POSIX 声明 。根因:严格 C11(-std=c11)下__STRICT_ANSI__生效,glibc 头文件里所有 POSIX 扩展(usleep、pthread_spin_*等)被条件编译剥掉。改用-std=gnu11或定义_GNU_SOURCE/_DEFAULT_SOURCE即可恢复。- 自旋锁不是"更快的互斥锁"。8 核争抢下实测 0.305s vs 0.133s、慢 2.3 倍。根因是竞争线程数等于核数时,7 个自旋者的原子指令让锁所在缓存行在 8 个 L1 间持续弹跳,独占权反复易手,持锁者释放锁本身也要付跨核一致性开销;互斥锁让失败者睡眠,把 CPU 留给持锁者。
- 线程池缩容只能"劝退"不能"裁员" 。根因:
pthread_cancel的取消点可能落在线程持锁的代码段,线程被终止后锁永远处于锁定状态,其余 worker 全部死锁。自愿退出协议(shrink 名额 + broadcast)把退出点固定在锁已释放的检查处。 - 回调里别碰锁。根因:回调通常持有 future 完成时的内部锁上下文,回调内再调用框架 API 会二次申请同一把非递归锁,形成同线程自死锁。先拷贝回调链表、释放锁、再逐个触发,切断了这条路径。
wait()不是可选项 。根因:子进程 exit 后 PCB(含退出码)必须保留给父进程读取,父进程不 wait,内核就不能释放 PCB,僵尸随时间累积,最终耗尽 PID 空间,fork开始返回 EAGAIN。
所有代码、Makefile、运行输出均保存在服务器 /root/operating-system/ 下,每个子目录的 run_output.txt 是完整可复核的运行记录。