第8章:并发(同时执行) Concurrency: Executing Simultaneously
一、导读
并发编程是现代软件开发中不可回避的核心主题。随着多核处理器的普及和分布式系统的广泛部署,程序需要同时处理多个任务以充分利用硬件资源、提升响应性和吞吐量。
然而,并发也带来了前所未有的复杂性:竞态条件、死锁、数据竞争等问题使得并发程序的正确性难以保证。不同的编程语言采用了截然不同的并发模型来应对这些挑战。
学习目标:
理解并发与并行的区别
掌握线程模型和同步机制
理解消息传递并发模型
了解 Actor 模型和 CSP 模型
掌握异步编程和 async/await 模式
核心问题: 如何让多个执行流安全地共享数据?如何避免并发程序中的竞态条件和死锁?不同的并发模型各有什么优劣?
二、核心概念详解
2.1 并发与并行(Concurrency and Parallelism)
并发(Concurrency) 是指程序同时处理多个任务的能力,这些任务可能在时间上重叠但不一定同时执行。在单核处理器上,通过时间片轮转实现并发。
并行(Parallelism) 是指多个任务在同一时刻真正同时执行,需要多核或多处理器的硬件支持。
并发是程序的结构属性(程序设计为可以同时处理多个任务),并行是执行属性(任务确实在同一时刻运行)。一个并发程序可以在单核上运行(通过上下文切换),也可以多核上并行执行。
2.2 线程模型(Thread Model)
线程(Thread) 是进程内的轻量级执行单元。同一进程内的多个线程共享内存空间,但拥有独立的执行栈和寄存器上下文。
用户级线程(User-Level Threads):
由运行时库管理,不需要操作系统支持。切换开销小,但一个线程阻塞会导致整个进程阻塞。Go 的 goroutine 是用户级线程的典型例子(更准确地说是 M:N 调度模型)。
内核级线程(Kernel-Level Threads):
由操作系统内核管理,每个线程可以独立调度。一个线程阻塞不影响其他线程。但线程创建和切换的开销较大。POSIX Threads(pthreads)、Java Threads 属于此类。
协程(Coroutine):
可以暂停和恢复执行的函数。协程的挂起是协作式的(由协程自身决定),而线程的切换是抢占式的(由操作系统决定)。Python 的 async/await、C# 的 async/await、Kotlin 的 suspend 函数都基于协程概念。
2.3 同步机制(Synchronization)
同步机制用于协调多个线程的执行顺序和数据访问:
互斥锁(Mutex):
确保同一时刻只有一个线程能访问临界区。获取锁的线程进入临界区,其他线程阻塞等待。
mutex.lock();
// 临界区:共享数据的操作
mutex.unlock();信号量(Semaphore):
维护一个计数器,允许多个线程同时访问有限数量的资源。P 操作减少计数器,V 操作增加计数器。
条件变量(Condition Variable):
允许线程在某个条件不满足时等待,当其他线程改变条件时唤醒等待的线程。通常与互斥锁配合使用。
读写锁(Read-Write Lock):
允许多个读者同时访问共享数据,但写者独占访问。适用于读多写少的场景。
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 一次只处理一条消息,天然避免了数据竞争。
Erlang 和 Akka(Scala/Java)是 Actor 模型的典型实现。Erlang 的 Actor(称为"进程")是轻量级的,创建和销毁的开销极低,适合构建高并发的容错系统。
三、深入分析
3.1 竞态条件与数据竞争
竞态条件(Race Condition) 是指程序的正确性依赖于多个线程的执行顺序。当执行顺序不符合预期时,程序产生错误的结果。
数据竞争(Data Race) 是一种特殊的竞态条件,指两个或多个线程同时访问同一内存位置,且至少有一个是写操作,且没有同步机制保护。
经典案例——银行转账:
// 线程1: 从账户A转100元
balance_a -= 100;
balance_b += 100;
// 线程2: 从账户B转50元(同时执行)
balance_b -= 50;
balance_a += 50;如果两个线程交错执行,可能导致余额计算错误。解决方案是使用互斥锁保护整个转账操作(而非单个余额修改)。
3.2 死锁(Deadlock)
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行的状态。
死锁的四个必要条件(Coffman 条件):
互斥:资源不能被多个线程同时使用
持有并等待:线程持有至少一个资源,同时等待获取其他资源
不可抢占:资源只能由持有者主动释放
循环等待:存在线程之间的环形等待链
预防死锁的方法包括:
- 锁排序:所有线程按相同顺序获取锁
- 超时机制:获取锁时设置超时,超时后释放已持有的锁并重试
- 无锁数据结构:使用原子操作实现不需要锁的并发数据结构
3.3 async/await 与异步编程
传统的多线程并发模型面临线程创建开销大、上下文切换频繁、调试困难等问题。async/await 提供了一种更轻量的异步编程模型:
// 异步函数
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data;
}async/await 的本质是协程:当遇到 await 时,当前函数挂起,控制权返回给事件循环;当等待的操作完成时,函数恢复执行。
事件循环(Event Loop):
单线程的调度器,维护一个任务队列。当一个异步操作完成时,其回调被放入队列,事件循环在适当的时机执行回调。Node.js、Python asyncio、浏览器 JavaScript 都采用事件循环模型。
优势:
- 比多线程更轻量(成千上万的协程 vs 几十个线程)
- 无需锁和同步(单线程执行,无数据竞争)
- 代码比回调风格更易读
局限:
- CPU 密集型任务会阻塞事件循环
- 不适合需要真正并行的计算
- 需要专门的异步 I/O 库支持
四、实践案例
案例1:Go 的 goroutine 与 channel
Go 语言的并发模型基于 CSP(Communicating Sequential Processes),使用 goroutine 和 channel 作为核心抽象:
func main() {
ch := make(chan string)
// 启动多个 goroutine
go worker("Alice", ch)
go worker("Bob", ch)
// 接收结果
fmt.Println(<-ch)
fmt.Println(<-ch)
}
func worker(name string, ch chan<- string) {
result := fmt.Sprintf("%s completed a task", name)
ch <- result
}Go 的设计哲学是"不要通过共享内存来通信,而要通过通信来共享内存"。goroutine 极其轻量(初始栈仅 2KB),可以轻松创建数十万个并发执行流。
案例2:Rust 的 "无畏并发"
Rust 通过类型系统在编译期防止数据竞争,实现了"无畏并发"(Fearless Concurrency):
use std::thread;
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}Rust 的所有权系统和 Send/Sync trait 确保了:
- 数据竞争在编译期被检测
- 跨线程共享数据必须显式使用
Arc<Mutex<T>> - 不可能出现未同步的共享可变状态
五、常见误区
误区1:多线程总是比单线程快
多线程只有在以下场景才能提升性能:任务可以并行化(Amdahl 定律限制了加速比)、存在 I/O 等待可以重叠执行。对于 CPU 密集型且不可并行化的任务,多线程反而因为上下文切换和同步开销而更慢。此外,错误的并发设计可能导致锁竞争,使多线程程序比单线程更慢。
误区2:使用了锁就不会有并发问题
加锁只是解决了数据竞争的原子性问题,但并不能解决所有并发问题。死锁、活锁(Livelock)和饥饿(Starvation)仍然可能发生。此外,锁的粒度选择不当(过大导致并行度降低,过小导致无法保护完整的操作)也是常见的错误来源。
误区3:函数式编程不需要关心并发
虽然纯函数式程序天然没有数据竞争(因为没有可变状态),但实际的函数式程序仍然需要处理 I/O 和状态。Haskell 通过 Monad Transformer 和 STM(Software Transactional Memory)来处理并发状态。Erlang 虽然是函数式语言,但通过 Actor 模型显式管理并发。
误区4:async/await 可以替代所有并发场景
async/await 适合 I/O 密集型任务(网络请求、文件读写),但不适合 CPU 密集型任务。CPU 密集型任务会阻塞事件循环,导致其他异步任务无法执行。对于计算密集型场景,应该使用线程池或进程池来并行处理。
误区5:无锁编程总是比有锁编程快
无锁数据结构(基于原子操作)在某些场景下确实比有锁数据结构更快,但并非总是如此。无锁算法的实现复杂,容易出现 ABA 问题等微妙错误。在高竞争场景下,原子操作的重试开销可能超过锁的开销。选择有锁还是无锁应该基于实际的性能测试结果。
六、本章小结
本章系统讲解了并发编程的基本概念与实现机制。并发是程序同时处理多个任务的能力,并行是任务真正同时执行的状态。线程提供了轻量级的并行执行能力,但需要谨慎处理共享数据的同步问题。互斥锁、信号量、条件变量等同步原语帮助我们构建正确的并发程序,但也引入了死锁和性能开销的风险。消息传递和 Actor 模型提供了避免共享状态的并发范式,通过通信而非共享来协调并发执行。async/await 为 I/O 密集型任务提供了轻量的异步编程模型。Rust 通过类型系统在编译期防止数据竞争,实现了"无畏并发"。理解这些并发概念有助于我们充分利用现代多核硬件的计算能力,设计高效可靠的并发应用程序。
七、思考题
解释并发与并行的区别。一个并发程序是否一定是并行的?为什么?
描述死锁的四个必要条件,并给出一种预防死锁的策略。
比较共享内存并发模型和消息传递并发模型的优缺点。各举一个适合使用的场景。
解释 async/await 的工作原理。为什么它不适合 CPU 密集型任务?
分析 Rust 的 "无畏并发" 是如何通过类型系统保证并发安全的。这种方法的代价是什么?