放弃
进入并发领域,我们将要放弃很多默认的条件:
- 原子性:
"程序独占处理器执行"的假设不再成立。程序可能在运行中被打断,甚至一条汇编指令也可能被中断。例如,更新共享变量时可能因中断导致数据不完整。为解决此问题,需通过同步原语(如锁)将操作封装为临界区,确保原子性。 - 顺序性:
编译器将代码视为单线程优化,可能导致多线程中变量未被实际访问。例如,若同步变量因编译器重排而未被及时刷新,将引发竞态条件(race condition)。这要求开发者显式使用内存屏障或原子操作保证可见性。 - 可见性:
现代处理器通过指令重排和缓存优化提升性能,但可能导致线程间数据不一致。例如,线程A的写入可能因缓存未同步而对线程B不可见。需通过硬件支持(如CPU内存模型)和操作系统机制(如缓存行冲洗)保证可见性。
并发 vs 并行:
| 特征 | 并发(Concurrency) | 并行(Parallelism) |
|---|---|---|
| 执行方式 | 逻辑上同时执行(时间片轮转模拟) | 物理上真正同时执行(多核支持) |
| 核心需求 | 协调资源共享与执行顺序 | 充分利用多核提升性能 |
| 典型场景 | 响应式系统(如Web服务器) | 计算密集型任务(如矩阵运算) |
线程
线程和进程区别:
- 线程共享进程的虚拟地址空间,但拥有独立寄存器和栈。因此线程切换需保存/恢复寄存器状态,但无需切换页表(与进程相比显著降低开销)。
- 线程和进程的栈不同:
- 进程:单栈位于地址空间底部,向上生长。
- 线程:多栈分散在地址空间中,每个线程独立管理自身栈。虽然可能导致地址空间布局碎片化,但提升并发效率。
实验
为了测试多线程是否真正的在不同核心上并行执行,可以用 sched_getcpu() 打印当前线程的 CPU 核心编号,在一个有 16 个逻辑核心的处理器上开启 16 个线程,结果如下:
|
|
操作系统(如 Linux 的 CFS 调度器)会尝试将线程分散到不同核心以实现负载均衡。同时,调度器优先将线程保留在同一核心以利用缓存局部性(cache affinity),但实验中部分线程仍被迁移,说明调度器动态调整以应对系统负载变化。
线程栈
编写一段测试代码,用以观察栈的分布情况:
|
|
使用 mmap-md.py 观察进程的地址空间,可以发现存在多个匿名内存区域。这就是每个线程自己独立的栈空间,但共享全局变量。
| Start Address | End Address | Size | Offset | Permissions | Mapped File |
|---|---|---|---|---|---|
| 0x7ffff53fb000 | 0x7ffff53fc000 | 0x1000 | 0x0 | ---p |
[anonymous] |
| 0x7ffff53fc000 | 0x7ffff5bfc000 | 0x800000 | 0x0 | rw-p |
[anonymous] |
| 0x7ffff5bfc000 | 0x7ffff5bfd000 | 0x1000 | 0x0 | ---p |
[anonymous] |
| 0x7ffff5bfd000 | 0x7ffff63fd000 | 0x800000 | 0x0 | rw-p |
[anonymous] |
| 0x7ffff63fd000 | 0x7ffff63fe000 | 0x1000 | 0x0 | ---p |
[anonymous] |
| 0x7ffff63fe000 | 0x7ffff6bfe000 | 0x800000 | 0x0 | rw-p |
[anonymous] |
| 0x7ffff6bfe000 | 0x7ffff6bff000 | 0x1000 | 0x0 | ---p |
[anonymous] |
| 0x7ffff6bff000 | 0x7ffff73ff000 | 0x800000 | 0x0 | rw-p |
[anonymous] |
| 0x7ffff73ff000 | 0x7ffff7400000 | 0x1000 | 0x0 | ---p |
[anonymous] |
| 0x7ffff7400000 | 0x7ffff7c00000 | 0x800000 | 0x0 | rw-p |
[anonymous] |
放弃原子性
从学会 if 语句之后,我们编写代码是有一个默认的约定:
|
|
试着在多线程环境中执行以下函数:
|
|
输出:
|
|
这是由于在进行 if 语句判断时 flag 为 true,但 sleep 时其他线程修改 flag 为 false,但此时已经进入 if 内,代码将打印出 0 值。
放弃顺序性
使用两个线程运行以下函数:
|
|
输出:
|
|
这是由于编译器假定代码是单线程执行,编译器只保证单线程下的优化是无害的。因此随着优化等级的提高,编译器会倾向于直接合并步骤。这进一步导致并发程序的 BUG 存在随机性。