第9章:并发(Concurrency)
一、导读
1.1 学习目标
本章探讨程序中多个执行流同时运行的机制与挑战。通过本章学习,读者应当能够:理解并发与并行的本质区别;掌握线程模型的基本原理及其生命周期管理;深入理解各种同步机制的工作原理与适用场景;认识消息传递并发模型的设计哲学;了解Actor模型和软件事务内存等高级并发范式;能够分析和避免竞态条件、死锁等并发编程中的经典问题。
1.2 并发编程的时代意义
随着处理器核心数量的持续增长和互联网应用对高吞吐量的需求,并发编程从专业领域走向了主流开发实践。现代应用程序——从Web服务器到移动应用,从数据分析到游戏引擎——都需要有效地利用并发来提升性能和响应性。然而,并发编程引入了全新的复杂性:多个执行流共享资源时的协调问题、执行顺序的不确定性、以及难以复现和调试的并发bug。
1.3 并发与并行的区别
并发(Concurrency) 是指系统同时处理多个任务的能力,这些任务可能在单个处理器上交替执行(时间片轮转),也可能在多个处理器上真正同时执行。并发关注的是程序的结构——如何分解任务、如何协调资源访问。
并行(Parallelism) 是指多个任务真正同时执行,需要多核或多处理器的硬件支持。并行关注的是执行效率——如何充分利用硬件资源来加速计算。
一个程序可以是并发的但不并行(单核上的多任务交替执行),也可以是并行的但不并发(SIMD指令同时处理多个数据)。理想的并发程序能够根据硬件条件自动调整并行度,在单核上表现为并发,在多核上表现为并行。
二、核心概念详解
2.1 线程模型
线程(Thread)是进程内的轻量级执行单元,是操作系统调度的基本单位。一个进程可以包含多个线程,它们共享进程的内存空间但拥有独立的执行栈和程序计数器。
线程的生命周期 包括以下状态:新建(New)→ 就绪(Runnable)→ 运行(Running)→ 阻塞(Blocked)→ 终止(Terminated)。线程调度器负责在就绪线程之间分配CPU时间。
用户级线程与内核级线程:用户级线程的创建和调度在用户空间完成,不需要操作系统内核的参与,创建和切换开销小,但一个线程的阻塞会导致整个进程阻塞。内核级线程由操作系统直接管理,一个线程的阻塞不影响其他线程,但创建和切换需要内核态切换的开销。现代语言通常采用混合模型(如M:N调度),在少量内核线程上调度大量用户级协程。
线程共享的资源 包括堆内存(所有线程可以访问堆上的对象)、文件描述符、信号处理器等。线程私有的资源包括栈(局部变量)、寄存器状态和线程局部存储(TLS)。共享内存是线程间通信的基础,也是数据竞争的根源。
线程池(Thread Pool) 是管理线程资源的常用模式。预先创建一组工作线程,将任务提交到任务队列中,由空闲线程取出执行。线程池避免了频繁创建和销毁线程的开销,限制了并发线程的数量,防止资源耗尽。Java的 ExecutorService、Python的 concurrent.futures 和Node.js的Worker Pool都实现了线程池模式。
2.2 同步机制
同步(Synchronization)是协调多个线程对共享资源访问的机制,防止数据竞争和不一致状态。
互斥锁(Mutex / Mutual Exclusion Lock) 是最基本的同步原语。同一时刻只有一个线程可以持有锁,其他试图获取锁的线程被阻塞。互斥锁保护的代码区域称为临界区(Critical Section)。使用互斥锁的关键原则是:保持临界区尽可能短、以固定顺序获取多个锁、确保在所有路径上释放锁。
读写锁(Read-Write Lock) 区分读操作和写操作。多个读线程可以同时持有读锁,但写锁是排他的。在读多写少的场景中,读写锁比互斥锁提供更高的并发度。
信号量(Semaphore) 是一个计数器,控制同时访问共享资源的线程数量。二元信号量等价于互斥锁。计数信号量允许指定数量的线程同时访问资源,适用于连接池、限流器等场景。
条件变量(Condition Variable) 允许线程在某个条件不满足时等待,并在其他线程使条件满足时被唤醒。条件变量通常与互斥锁配合使用,实现生产者-消费者模式等经典的线程协作场景。
原子操作(Atomic Operation) 是不可被中断的操作,在硬件层面保证了执行的原子性。原子操作不需要锁就能实现线程安全,性能更高。现代处理器提供了比较并交换(CAS, Compare-And-Swap)等原子指令,许多并发数据结构基于CAS实现无锁算法。
2.3 竞态条件与死锁
竞态条件(Race Condition) 发生在多个线程的执行结果取决于线程调度的时序。当多个线程对共享数据进行读写操作且没有适当的同步时,最终结果可能因执行顺序的不同而不同。
竞态条件的经典案例是"检查然后执行"(Check-Then-Act)模式:
if (!map.containsKey(key)) {
map.put(key, value); // 在 containsKey 和 put 之间,其他线程可能已经插入了相同的 key
}解决方案是使用原子操作(如 ConcurrentHashMap.putIfAbsent)或扩大锁的范围。
死锁(Deadlock) 发生在两个或多个线程互相等待对方持有的锁,导致所有线程都无法继续执行。死锁的四个必要条件(Coffman条件)是:互斥、持有并等待、不可抢占、循环等待。
打破死锁的方法包括:
- 破坏"持有并等待":一次性获取所有需要的锁
- 破坏"循环等待":以固定顺序获取锁(锁排序)
- 使用超时机制:获取锁失败时释放已持有的锁并重试
- 使用死锁检测算法:定期检查等待图是否存在环路
2.4 消息传递
消息传递(Message Passing)是另一种并发模型,线程之间通过发送和接收消息来通信,而非共享内存。
通道(Channel) 是消息传递的通信管道。发送方将消息放入通道,接收方从通道中取出消息。通道可以是同步的(发送方等待接收方就绪)或异步的(通道充当缓冲区)。Go语言的channel是消息传递并发模型的典型代表:
ch := make(chan int)
go func() {
ch <- 42 // 发送
}()
value := <-ch // 接收消息传递的优势:
- 避免了共享内存的同步问题——数据通过复制传递,不存在数据竞争
- 更容易推理程序行为——消息的发送和接收是显式的
- 天然支持分布式部署——消息传递可以跨越进程和网络边界
消息传递的劣势:
- 数据复制带来额外开销
- 不适合需要频繁共享大量数据的场景
- 某些算法(如并发数据结构)用共享内存表达更自然
2.5 Actor模型
Actor模型是由Carl Hewitt在1973年提出的并发计算模型。在Actor模型中,系统是独立Actor的集合,每个Actor拥有私有状态,通过异步消息传递进行通信。
Actor的核心特性:
- 每个Actor独立运行,不共享状态
- Actor之间只能通过异步消息通信
- Actor处理消息时可以:发送有限条消息、创建有限个新Actor、指定下一个消息的行为
- 消息传递不保证顺序,但保证最终送达
Actor模型的优势:
- 完全消除了共享状态的同步问题
- 天然支持容错——Actor崩溃不影响其他Actor
- 适合构建松耦合的分布式系统
- 监督层次结构(Supervision Hierarchy)提供了优雅的故障恢复机制
Erlang/OTP 是Actor模型最成功的工业实现。Erlang进程完全隔离,通过消息传递通信,支持热代码升级和自动故障恢复。WhatsApp使用Erlang支撑了数亿用户的实时通信。Akka框架将Actor模型引入了JVM平台,支持Java和Scala。
三、深入分析
3.1 内存模型与可见性
在共享内存并发模型中,内存模型(Memory Model) 定义了一个线程对内存的写入何时以及对另一个线程可见。这个问题看似简单,但在现代硬件架构上变得极其复杂。
处理器缓存一致性:现代处理器使用多级缓存来提升内存访问速度。每个核心有自己的缓存,对共享内存的写入可能暂时只存在于某个核心的缓存中,其他核心看到的是过时的值。缓存一致性协议(如MESI)确保缓存之间的数据一致性,但不同架构提供的一致性保证不同。
编译器重排序:编译器和处理器可能对指令进行重排序以优化性能。在单线程程序中,这种重排序不影响观察到的行为;但在多线程程序中,重排序可能导致一个线程看到另一个线程的操作以意外的顺序执行。
Java内存模型 通过 happens-before 关系定义了线程间操作的可见性保证。如果操作A happens-before 操作B,那么A的效果对B可见。同步操作(如获取锁、释放锁、volatile读写)建立happens-before关系。Java内存模型允许不违反happens-before约束的重排序。
C++内存模型 提供了更细粒度的控制。std::memory_order 枚举允许程序员指定每个原子操作的内存序约束:seq_cst(全序)、acquire/release(获取/释放语义)、relaxed(仅保证原子性)。这种灵活性允许在正确性和性能之间精细权衡。
3.2 无锁编程
无锁编程(Lock-Free Programming)使用原子操作而非互斥锁来实现并发数据结构,避免了锁带来的问题(死锁、优先级反转、上下文切换开销)。
CAS(Compare-And-Swap) 是无锁编程的基础原语。CAS操作原子地执行以下逻辑:如果内存位置的值等于预期值,则将其更新为新值并返回true;否则不做修改并返回false。基于CAS可以实现无锁栈、无锁队列等数据结构。
ABA问题 是CAS的经典难题:线程1读取值A,线程2将值改为B又改回A,线程1的CAS成功(因为值仍是A),但中间状态的变化可能被忽略。解决方案包括使用版本号(Stamped Reference)或 hazard pointer。
无锁编程的正确性证明极其复杂,通常只在标准库或高性能基础设施中使用。对于大多数应用,使用经过验证的并发库比自行实现无锁算法更加安全可靠。
四、实践案例
4.1 案例一:生产者-消费者模式
生产者-消费者是经典的并发协作模式。生产者生成数据放入缓冲区,消费者从缓冲区取出数据处理。使用通道实现:
func producer(ch chan<- int) {
for i := 0; i < 100; i++ {
ch <- i // 生产数据
}
close(ch)
}
func consumer(ch <-chan int) {
for value := range ch {
process(value) // 消费数据
}
}
func main() {
ch := make(chan int, 10) // 带缓冲的通道
go producer(ch)
consumer(ch)
}带缓冲的通道允许生产者在消费者未就绪时继续生产(直到缓冲区满),提升了并发度。通道的关闭通知消费者所有数据已生产完毕。
4.2 案例二:Go语言的并发安全设计
Go语言通过设计哲学来避免并发问题,而非依赖程序员的谨慎。"不要通过共享内存来通信,而要通过通信来共享内存"是Go的并发格言。
// 不推荐:使用互斥锁保护共享状态
type Counter struct {
mu sync.Mutex
count int
}
// 推荐:使用通道集中管理状态
type Counter struct {
updates chan int
value int
}
func (c *Counter) Run() {
for delta := range c.updates {
c.value += delta
}
}
func (c *Counter) Increment() {
c.updates <- 1
}通道方案将状态管理集中在一个goroutine中,消除了数据竞争的可能性。其他goroutine通过发送消息来请求状态变更,而非直接修改共享数据。这种设计更加安全,也更容易测试和维护。
五、常见误区
5.1 误区一:更多线程一定更快
增加线程数量并不总能提升性能。线程的创建和切换有开销,过多的线程会导致频繁的上下文切换,反而降低吞吐量。此外,当并发任务存在锁竞争时,增加线程只会增加等待时间。Amdahl定律指出,程序的并行加速比受限于其串行部分的比例。合理的线程数量通常与处理器核心数相当,或使用线程池根据工作负载动态调整。
5.2 误区二:使用了锁就不会有并发问题
使用锁是避免数据竞争的必要条件,但不是充分条件。锁的粒度不当(过大降低并发度,过小无法充分保护)、锁的顺序不当(导致死锁)、锁的遗漏(某些访问路径未加锁)都会导致并发问题。此外,即使锁使用正确,程序仍可能存在逻辑层面的竞态条件(如检查然后执行模式)。并发正确性需要系统性的设计而非仅仅依赖锁。
5.3 误区三:函数式编程完全避免了并发问题
纯函数式编程确实消除了可变状态的并发问题,但现实程序总有需要状态管理的部分。I/O操作、状态缓存、用户交互等都需要处理可变状态。此外,即使是纯函数,如果共享大量不可变数据结构,内存管理和垃圾回收也可能成为并发瓶颈。函数式编程简化了并发问题,但没有完全消除它。
5.4 误区四:消息传递比共享内存更优越
消息传递和共享内存各有适用场景,不存在绝对的优劣。消息传递在避免数据竞争和构建分布式系统方面具有优势,但在需要频繁共享大量数据或实现低延迟的并发数据结构时,共享内存可能更加高效。许多成功的系统混合使用两种模型——在高层使用消息传递组织架构,在底层使用共享内存优化性能关键路径。
5.5 误区五:并发bug可以通过测试完全发现
并发bug(如竞态条件、死锁)通常是非确定性的,依赖于特定的线程调度序列才能触发。测试只能覆盖有限的调度组合,无法保证发现所有并发bug。形式化验证、并发压力测试、线程安全分析工具(如ThreadSanitizer、Go的race detector)可以帮助发现部分并发bug,但不能提供完全保证。编写并发程序的正确之道是从设计层面避免问题,而非依赖事后检测。
六、本章小结
本章系统讲解了并发编程的基本概念与实现机制。线程提供了轻量级的并行执行能力,但需要谨慎处理共享数据的同步问题。互斥锁、读写锁、信号量和条件变量等同步原语帮助我们构建正确的并发程序,但使用不当可能导致死锁和性能问题。竞态条件是并发编程的核心挑战,需要通过系统性的设计来避免。消息传递和Actor模型提供了另一种并发范式——通过通信而非共享内存来协调并发执行,从根本上消除了数据竞争。内存模型定义了多线程环境下操作的可见性保证,是理解并发行为的基础。无锁编程使用原子操作实现了更高的并发度,但正确性证明极其复杂。理解这些并发概念有助于我们充分利用现代多核硬件的计算能力,设计高效可靠的并发应用程序,并为后续学习分布式系统和云计算奠定基础。
七、思考题
请解释并发与并行的区别。一个程序可以并发但不并行吗?可以并行但不并发吗?请各举一个例子。
比较互斥锁、读写锁和原子操作三种同步机制的适用场景。在什么情况下你会选择其中一种而非另一种?
什么是死锁?请描述死锁的四个必要条件,并给出一种预防死锁的具体策略。
Go语言提倡"通过通信来共享内存"。请分析这种设计哲学与传统的"通过共享内存来通信"相比有什么优势和局限性。
Actor模型如何保证容错性?请解释监督层次结构(Supervision Hierarchy)的工作原理,并与传统的异常处理机制进行比较。
为什么并发bug特别难以调试和测试?除了使用同步机制外,还有哪些设计策略可以减少并发问题的发生?