NJU OS 多处理器编程:从入门到放弃

多线程不是“多开几个函数一起跑”这么简单。它真正引入的是:同一地址空间里出现了多个执行流,而“下一步轮到谁执行”不再确定。

线程的基本执行模型

从操作系统视角看,线程可以先抓成一句话:

thread = 独立栈 + 独立寄存器上下文 + 共享地址空间

和进程相比,线程不再复制整个地址空间,而是在同一个进程里共享:

  • 代码段
  • 全局变量 / 静态存储区
  • 打开的文件描述符等进程级资源

每个线程自己单独拥有:

  • PC/RIP
  • 通用寄存器

所以“多线程程序”的本质不是魔法,而是:

同一份共享内存上,同时挂了多个“CPU 状态机”

这也是讲义里最核心的模型:每个线程独立栈,共享全局。

线程作为并发抽象的动机

父子进程之间通信和切换代价更大,因此很多“同一任务里的并发活动”更适合放到同一个地址空间里做。线程带来的直接好处是:

  • 共享数据便宜,不必每次 IPC
  • 创建 / 销毁成本通常低于新进程
  • 一个进程内部可以更自然地表达并发结构

但代价也很直接:共享内存把顺序程序的确定性打碎了。

并发与并行的概念区分

  • 并发:逻辑上同时推进。单核靠来回切换也能并发。
  • 并行:物理上同时执行。至少需要多个核。

关系是:

parallelism => concurrency
concurrency =/=> parallelism

讨论 bug 时,重点通常不是“有没有两个核”,而是“多个执行流是否可以以非确定顺序访问共享状态”。

线程栈的结构与性质

栈的功能与存储内容

线程栈主要放:

  • 调用帧
  • 返回地址
  • 保存的寄存器
  • 局部变量
  • 部分临时对象

它不是语言层面的“神秘盒子”,本质上就是线程私有的一段地址空间。

线程栈大小的来源与决定因素

C 语言标准并不规定线程栈大小;它由 OS、线程库和创建参数共同决定。

在本课讲义和常见 Linux pthread 环境里,实验常见结果是:

每个新线程默认栈大约 8 MB

这也是为什么讲义里说:10000 个线程大约就会吃掉 80 GB 量级的虚拟地址空间。

要注意:主线程栈和 pthread_create 创建出来的线程栈不一定遵守同一套默认规则;课程里的 8 MB 更接近“新建线程的常见默认值”。

更准确地说,线程栈大小由这些因素决定:

  • OS / ABI 默认策略
  • 线程库默认值,如 pthread 默认栈大小
  • 进程资源限制,如 RLIMIT_STACK
  • 显式配置,如 pthread_attr_setstacksize
  • guard page、对齐、TLS 等运行时细节

线程栈的私有性与可访问性

“独立”指的是:

  • 正常调用约定下,每个线程主要在自己的栈上活动
  • 栈帧生命周期和寄存器上下文由该线程自己推进

但它们仍然都在同一个进程虚拟地址空间里。
所以如果你把一个线程局部变量的地址传给另一个线程,另一个线程仍然可能读写它。换句话说:

线程栈是按使用习惯划分的私有区域,不是硬件隔离的独立地址空间。

pthread 与线程模型的程序接口

课程里用了 spawn/join 的极简 API,本质上就是对 pthread_create/pthread_join 的做减法封装。

常见 C 接口可以直接记成:

pthread_create
pthread_join
pthread_mutex_lock / unlock
pthread_cond_wait / signal / broadcast

调试多线程时,gdb 很重要的命令是:

info threads
thread <id>
set scheduler-locking on

其中 set scheduler-locking on 的含义是:单步时尽量只让当前线程前进,避免别的线程在你 step 的空档里继续乱跑。

并发执行中的非确定性

单线程程序大致可以看成:

state_{n+1} = f(state_n)

给定初始状态,程序行为基本是确定的。

多线程以后,不再只是“执行下一条语句”,而是变成:

1. 选一个线程
2. 让它执行一步
3. 再选下一个线程

这个“选哪个线程”的动作通常不受你控制,所以程序从“函数”变成了“带分叉的状态图”。

这就是并发最根上的困难:不是代码更长了,而是执行路径指数爆炸了。

读改写操作的竞态条件

sum++ 在机器上通常不是一条不可分割的神谕,而更接近:

load sum
add 1
store sum

两个线程如果都这么做,就可能出现:

  1. A 读到 sum = 0
  2. B 也读到 sum = 0
  3. A 写回 1
  4. B 再写回 1

最后只加了一次。

所以 sum++ 的问题本质不是“加法错了”,而是:

一个本来你以为是单步的操作,被撕成了多个可交错的内存动作。

这和转账里的 check-then-act 是同一类 bug:

if (balance >= amount) balance -= amount;

两个线程可能都通过检查,然后都扣款。

trace recovery 问题及其复杂性

讲义提到的 trace recovery,核心是:

已知每个线程各自做了哪些局部动作,是否存在某种合法交错,使全局执行满足目标结果?

比如 k 个线程各做若干次 sum++,问最终 sum 的最小可能值,本质上就在问:

  • 能否安排一条全局执行轨迹
  • 让大量中间结果被后来的写覆盖
  • 最后只留下极少数“有效写入”

它难,不是因为表达式复杂,而是因为:

  • 每个线程内部顺序必须保持
  • 不同线程之间可以交错
  • 要搜索的合法全局轨迹数目非常大

所以这是一个典型的“局部顺序 + 全局约束 + 搜索交错”的问题。讲义里把它归到 NP-complete,直觉上就是:

你需要在指数级候选 interleaving 里,找一个满足目标条件的执行轨迹。

你可以把它理解成:并发程序的很多“结果可不可达”问题,本质上都像在做组合搜索。

编译器优化对并发语义的影响

单线程优化默认依赖一个强假设:

没有别的执行流在偷偷改我的内存

因此编译器会放心做这些事:

  • 把多个 x++ 合并成 x += k
  • 把循环不变量提到循环外
  • 把某个内存值缓存进寄存器,不再重读

这在单线程里完全合理,但在共享内存并发里会炸。

最经典的是:

while (!flag) { }

如果 flag 不是原子同步对象,编译器可能把它理解成:

flag 在循环体里没被改过,所以读一次就够了

于是优化成近似死循环。

这也是为什么:

  • volatile 不是通用并发同步方案
  • 裸共享变量上的 busy wait 是危险的

处理器执行与内存可见性的偏离

即使编译器不乱动,硬件也不会老老实实按你脑中的“共享内存教科书图”执行。

现代 CPU 为了性能,至少会做两类事:

  • 指令乱序执行
  • store buffer + cache coherence 延迟传播

所以你写:

x = 1;
r = y;

另一边写:

y = 1;
r = x;

按顺序一致性直觉,你会以为不可能两个读都看到 0
但真实硬件上 (0, 0) 是可以出现的,因为:

  • 写先进入各自核心的 store buffer
  • 本核后续 load 可能先执行
  • 别的核心还没来得及看到这个写

内存系统中的最终一致性

这里的“最终一致性”不是数据库课里那套 marketing 词,而是一个很弱的硬件直觉:

如果某个地址不再被继续写入,那么过一段时间后,各核心最终会收敛到同一个值。

它只保证“最终会一致”,不保证:

  • 刚写完别人立刻看到
  • 多个地址按程序顺序一起对外可见
  • 某个复合不变量一直成立

所以它很弱,只能说明“副本不会永远分叉”,完全不足以支撑并发程序正确性。

MESI 协议的保证范围

根据 MESI 一类缓存一致性协议,很多人会误以为:

某核心一写,别的核心立刻就能观察到

这不对。

MESI 主要保证的是 cache coherence,也就是:

  • 对同一个 cache line,不会长期存在多个彼此矛盾的可写副本
  • 其他核心以后再读这个地址时,会通过一致性协议收敛到合法的新值

但它不保证

  • 某次写一完成,所有核心立刻同步完成更新
  • 已经在路上的读操作必须马上改看到新值
  • 多个地址的写入对外具有统一顺序

所以更准确的说法是:

MESI 解决“同一地址的副本如何收敛”,不解决“多个地址的可见性顺序如何推理”。

后者属于 memory consistency / memory model 的范围。

跨地址可见性与顺序约束问题

看这个最经典的模式:

data = 42;
flag = 1;

另一线程:

if (flag == 1) {
  assert(data == 42);
}

即使 flagdata 各自都受 cache coherence 保护,第二个线程仍然可能先看到 flag == 1,却还没看到 data == 42

原因是:

  • coherence 只管单个地址
  • flagdata 是两个不同位置
  • 它们对别的核心可见的先后顺序未必和程序顺序一致

所以并发正确性需要的不是“每个地址最终会一致”,而是:

某些写必须在语义上先于某些读可见

这就需要:

  • mutex
  • atomic
  • acquire / release
  • fence

来建立 happens-before

线程安全与原子性的区别

这两个词很容易混:

  • thread-safe:多个线程一起调用,不会把库内部状态搞坏
  • atomic:这次操作对外表现得像不可分割的单步

例如:

  • printf 内部通常加锁,所以是 thread-safe
  • 但它并不意味着整个输出过程对你程序里的其他逻辑是原子的
  • memcpy 即使是 thread-safe,也不代表两个线程同时往同一目标拷贝会得到“某一个完整版本”

所以以后看到“线程安全”,先问一句:

它保证的是内部不炸,还是保证对共享状态的操作具备我想要的原子性?

对顺序程序直觉的修正

不是放弃并发编程,而是放弃这些顺序程序直觉:

  • 相邻语句会按写的顺序执行
  • 一个表达式就是一步
  • 共享变量的最新值别人立刻能看到
  • 测了很多次没出错就说明程序正确

多线程里更靠谱的心智模型是:

线程 = 多个局部顺序流
共享内存 = 可被并发读写的全局状态
正确性 = 在所有允许的 interleaving + 编译器优化 + CPU 重排下都成立

所以课程后面引入 mutex、condition variable、semaphore、atomic,不是为了“加点 API”,而是为了把并发重新约束回一个可以证明的模型里。

本节知识点总结

1. 线程 = 独立栈 + 独立寄存器 + 共享地址空间。
2. 线程栈通常是固定配额;Linux/pthread 常见默认约 8 MB,但本质由 OS/线程库/配置决定。
3. 并发的根问题不是语法,而是“下一步谁执行”带来的非确定性。
4. sum++ / check-then-act 出错,是因为一个你以为的“单步”被撕成了可交错的 load/store。
5. trace recovery 关注“是否存在某种合法执行轨迹”,其搜索空间巨大,所以结果可达性会很难。
6. 编译器会重写代码,CPU 会乱序和延迟传播写入,shared memory 并不是“所有核立刻共享同一个值”。
7. eventual consistency 只保证最终收敛,不保证立即可见,更不保证跨地址顺序。
8. MESI 解决的是单地址 coherence,不是完整并发语义。
9. 并发正确性最终要靠 mutex / atomic / happens-before,而不是靠直觉。
posted @ 2026-06-12 23:34  Katyusha_Lzh  阅读(2)  评论(0)    收藏  举报