跳转到内容
← 返回核心概念
系统与架构计算机科学 · 操作系统 · 分布式系统15 分钟阅读

并发与并行

Concurrency and Parallelism

1965 年,英特尔联合创始人戈登·摩尔观察到:集成电路上可容纳的晶体管数目,大约每两年翻一番。这个预测维持了数十年,让单核处理器的速度持续攀升。 2004 年,Intel 宣布放弃 4GHz 的 Pentium 4 计划,转向多核处理器。不是因为不想更快,而是因为功耗和散热的物理极限已经到来。从那时起,计算机的性能提…

并发并行线程死锁竞态条件

1965 年,英特尔联合创始人戈登·摩尔观察到:集成电路上可容纳的晶体管数目,大约每两年翻一番。这个预测维持了数十年,让单核处理器的速度持续攀升。

2004 年,Intel 宣布放弃 4GHz 的 Pentium 4 计划,转向多核处理器。不是因为不想更快,而是因为功耗和散热的物理极限已经到来。从那时起,计算机的性能提升主要来自"更多核心",而不是"更快的单核"。

这意味着,不理解并发与并行的程序员,写出的程序只能用到处理器能力的一小部分。

2005 年 3 月,C++ 专家 Herb Sutter 在《Dr. Dobb's Journal》发表《免费午餐结束了》(The Free Lunch Is Over),给这件事起了一个流传至今的名字。他的论点很直接:过去几十年程序员什么都不用做,光靠新一代 CPU 主频提升,软件每年就自动变快——这是"免费午餐"。如今主频被功耗墙锁死,免费午餐结束;想吃到多核的红利,就必须主动写并发程序。Sutter 称这是面向对象革命以来软件开发最大的一次范式转变。

破除误解:并发不等于并行

这是最重要的区分:

并发(Concurrency):多个任务在同一时间段内都在进行,但在任意瞬间不一定都在执行。单核 CPU 通过快速切换任务实现并发——你感觉多个程序"同时"运行,实际上 CPU 在它们之间高速轮换。

并行(Parallelism):多个任务在同一瞬间字面上同时执行。需要多个物理执行单元(多核 CPU、GPU 的数千个核心)。

Rob Pike(Go 语言设计者之一)的经典表述:"并发是同时处理多件事(dealing with lots of things at once);并行是同时做多件事(doing lots of things at once)。"

并发是一个程序结构问题——如何组织代码让多个任务可以交叉进行;并行是一个硬件利用问题——如何让代码真正同时在多个处理单元上执行。正确的并发设计是实现并行的前提。

进程与线程:并发的基本单元

进程(Process):操作系统分配资源的基本单位。每个进程有独立的内存空间、文件描述符等。进程之间默认完全隔离,一个进程崩溃不影响另一个。

线程(Thread):进程内的执行单元。同一进程的多个线程共享内存空间,线程之间通信代价低,但也因此引发并发问题。

线程是大多数并发程序的基础:一个 Web 服务器为每个请求启动一个线程(或从线程池取一个),多个请求可以被同时处理。

代价:线程创建有开销;上下文切换(操作系统从一个线程切换到另一个)有代价;共享内存导致竞态条件。

竞态条件与互斥

竞态条件(Race Condition):两个线程同时访问和修改同一数据,结果取决于哪个线程先执行,导致不可预测的错误。

经典例子:两个线程同时执行 count = count + 1

线程A读取 count = 0
线程B读取 count = 0
线程A计算 0 + 1 = 1,写入
线程B计算 0 + 1 = 1,写入
结果:count = 1,而非预期的 2
```

这类错误极难调试——它们只在特定时序下触发,可能在开发时从未出现,在生产环境高并发时突然爆发。

解决方案:互斥锁(Mutex / Lock)。同一时刻只允许一个线程进入"临界区"(访问共享数据的代码段):

python
with lock:
    count = count + 1  # 这段代码每次只有一个线程执行
```

互斥锁其实是更一般机制的特例。1965 年,Dijkstra 在手稿 EWD123《协作的顺序进程》(Cooperating Sequential Processes,1968 年正式发表)中提出了信号量(semaphore)——历史上第一个进程同步原语。信号量是一个带两个原子操作的计数器:P(申请,计数减一,为零则阻塞)和 V(释放,计数加一)。当计数上限为 1 时,信号量退化成互斥锁;上限大于 1 时,它可以限制同时进入临界区的线程数(例如"最多 10 个数据库连接")。

破除一个常见混淆:数据竞争(data race)不等于竞态条件(race condition)。 数据竞争是一种具体的执行性质——多个线程无同步地访问同一内存且至少一个在写。竞态条件更抽象——程序结果依赖事件的相对时序。两者可以各自独立出现:你可能用锁消除了所有数据竞争,却因为加锁的粒度或顺序设计错了,仍然得到错误结果(竞态条件);反过来,某些刻意设计的无锁算法存在数据竞争却逻辑正确。一句话:"加了锁"不等于"并发正确"。

死锁:锁带来的锁

锁引入了新的风险:死锁(Deadlock)

线程A持有锁1,等待锁2
线程B持有锁2,等待锁1
两者互相等待,永远无法继续
```

死锁的四个必要条件(Coffman 条件,1971): 1. 互斥:资源一次只能被一个线程持有 2. 占有并等待:持有资源的线程可以请求新资源 3. 不可抢占:资源不能被强制夺走 4. 循环等待:存在等待关系的环

破坏任何一个条件,死锁就不会发生。常见策略:规定所有线程必须按固定顺序请求锁(破坏循环等待)。

哲学家就餐问题是死锁最经典的隐喻,同样出自 Dijkstra(1965 年作为考试题提出,后被改写成五位哲学家围桌的形象)。相邻两人之间只有一只叉子,每人要同时拿起左右两只叉子才能进餐。如果所有人都先拿起左手的叉子、再去等右手那只,就会全体卡死——恰好凑齐上面四个条件。它还顺带演示了另一个并发陷阱:饥饿(starvation)——即使没有死锁,某个哲学家也可能因为邻座总抢先而永远吃不上饭。

锁的隐性代价:优先级反转

死锁不是锁唯一的麻烦。优先级反转(priority inversion)更隐蔽:一个低优先级线程持有锁,恰好被一个中优先级线程抢占了 CPU,于是真正需要这把锁的高优先级线程只能干等——优先级的语义被彻底颠倒。

这不是教科书里的假想。1997 年,火星探路者号(Mars Pathfinder)登陆火星后频繁自动重启,险些葬送任务。事后查明:一个高优先级的总线管理任务和一个低优先级的气象数据任务共享同一个 mutex,而这个 mutex 在 VxWorks 系统里关掉了优先级继承(priority inheritance)。当低优先级任务持锁、又被中优先级任务长期抢占时,高优先级任务超时,看门狗就重启了系统。修复办法是远程打开那个 mutex 的优先级继承标志——让持锁的低优先级任务临时"借用"高优先级,尽快把锁还回去。

教训是:锁不只关乎正确性,还和调度纠缠在一起。这也解释了为什么实时系统对锁格外谨慎。

不用锁:原子操作与无锁编程

既然锁这么麻烦,能不能不用?现代 CPU 提供了原子指令,最核心的是 CAS(Compare-And-Swap,比较并交换):硬件保证"读取某地址的值,若等于期望值就写入新值"这一整步不可分割。基于 CAS 可以构造无锁(lock-free)数据结构——线程不阻塞,操作失败就重试。

无锁不是免费的午餐。权衡很清晰:争用低时,无锁结构省掉了加锁与上下文切换的开销,吞吐更高、延迟更稳,还天然免疫死锁;但争用高时,大量线程的 CAS 不断失败重试,CPU 空转做无用功,反而可能比锁更慢,也容易让个别线程长期重试不成(活锁、饥饿)。而且无锁算法极难写对——ABA 问题、内存何时安全回收都是出名的坑。结论和并发的其他部分一样:没有银弹,只有针对争用程度与延迟要求的取舍。

并发模型的演化

锁和线程是"共享内存"并发模型,是最底层也是最容易出错的方式。学界和工业界发展了更高层次的并发模型:

模型思路代表语言/框架
共享内存 + 锁共享状态,用锁保护C/C++、Java
消息传递(Actor模型)每个Actor独享状态,只通过消息通信Erlang、Akka、Go goroutines
软件事务内存(STM)把内存操作当数据库事务,失败自动重试Haskell STM、Clojure
异步/事件驱动单线程,非阻塞I/O + 事件循环JavaScript (Node.js)、Python asyncio
CSP(通信顺序进程)通过通道(channel)同步而非共享状态Go

Go 语言的设计哲学体现了 CSP 思想:"不要通过共享内存来通信,而要通过通信来共享内存。"这并不是说锁就不对,而是把通信作为首选的同步机制,可以减少许多并发错误。

表中的 Actor 与 CSP 都不是新发明,各有明确出处。Actor 模型由 Carl Hewitt 与 Bishop、Steiger 在 1973 年的论文《A Universal Modular ACTOR Formalism for Artificial Intelligence》中提出,最初是为人工智能设计的计算模型。它真正落地靠工业界:1986 年,Joe Armstrong 在爱立信(Ericsson)创造了 Erlang——每个 actor(Erlang 里叫进程)独享状态、只靠消息通信、可以单独崩溃重启而不拖垮别人。这套"让它崩溃"(let it crash)的容错哲学,至今仍是电信级高可用系统的范本。CSP(通信顺序进程)则由 Tony Hoare 在 1978 年提出,把同步建立在通道(channel)而非共享内存上,正是上文 Go 设计哲学的理论源头。

并行:GPU 与数据并行

现代 GPU 有数千个小核心,适合数据并行(data parallelism):对大量数据的每个元素执行相同操作:

对每个像素 i:color[i]=f(input[i])\text{对每个像素}\ i: \quad \text{color}[i] = f(\text{input}[i])

机器学习的崛起很大程度上依赖于 GPU 的这种能力——训练神经网络就是对海量数据做矩阵乘法,天然适合数据并行。NVIDIA 的 CUDA(2007 年推出)使 GPU 并行编程变得可用,深刻改变了科学计算和 AI 领域。

代价与争议

  • 并发正确性极难保证:Dijkstra 1968 年提出信号量,此后数十年里,科学家们持续发现更多并发陷阱。即使经验丰富的程序员也会在并发代码里犯错。
  • Amdahl 定律的上限:Amdahl 定律指出,若程序有一部分必须顺序执行(比例 $s$),则无论增加多少并行核心,加速比的上限是 1s\frac{1}{s}。如果 10% 的代码是串行的,100 核只能加速 10 倍,而非 100 倍。
  • 函数式编程的立场:Haskell 等函数式语言主张:用不可变数据(immutable data)彻底消灭共享状态,竞态条件就无从发生。这是一个根本不同的哲学立场,以限制表达力为代价换取并发安全。

跨域连接

  • 实时系统:锁不只关乎正确性,还与调度纠缠。低优先级线程持锁又被中优先级抢占,真正需要锁的高优先级线程只能干等,优先级语义被彻底颠倒。优先级继承让持锁者临时借用高优先级——可见可预测的阻塞时间必须被显式设计。临界区的长度决定的因此不只是吞吐,还有最坏响应时间。
  • 图论:死锁等价于资源等待图上出现环,四个必要条件里最容易破坏的正是循环等待。给所有锁定一个全序、要求按序申请,等于让加锁路径永远沿拓扑序前进,环在结构上就无法形成。但要分清:消除数据竞争不等于消除竞态,加锁粒度或顺序设计错了照样出错。
  • 博弈论基础:哲学家就餐是协调失败的标本:每人都先拿左手那只叉子是各自最优,合起来却全体卡死。破解办法不是让谁更聪明,而是打破对称——给叉子编号,或引入一个仲裁者,这正是协调装置的作用。
  • 决定论:并发程序的行为不由源代码唯一确定,而由调度确定。"可复现"因此不是自然属性,而要靠确定性调度、记录回放这类手段构造出来;这也解释了竞态缺陷为何常在开发期沉默、到生产高并发时才爆发。推论是:想复现一个竞态,必须先能控制调度,而不是多跑几遍。
  • 劳动与组织:加人不等于加速。生产线上不可拆的工序,再多工人也压缩不了它的时长;程序里的串行段同理。只要一成工作无法拆分,加速比就封顶在十倍——分工的收益递减,在硬件和车间是同一条曲线。推论是:动手提高并行度之前,先量清楚串行段有多长。

参考文献

  • Dijkstra, E. W. Solution of a Problem in Concurrent Programming Control. CACM 8(9), 1965.
  • Dijkstra, E. W. Cooperating Sequential Processes (EWD123). 1965 手稿,1968 年发表(信号量与 P/V 操作)。
  • Hoare, C. A. R. Communicating Sequential Processes. CACM 21(8), 1978.
  • Hewitt, C., Bishop, P. & Steiger, R. A Universal Modular ACTOR Formalism for Artificial Intelligence. Proc. IJCAI, 1973.
  • Sutter, H. The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software. Dr. Dobb's Journal 30(3), 2005.
  • Jones, M. B. What Really Happened on Mars Rover Pathfinder.(基于 JPL Glenn Reeves 的事故复盘,1997,优先级反转案例)
  • Herlihy, M. & Shavit, N. The Art of Multiprocessor Programming. Revised ed. Morgan Kaufmann, 2012.

延伸阅读

  • Cox, R. Bell Labs and CSP Threads. (swtch.com,Go 并发模型的历史背景)