8编程语言实现模式(第2版)

并发

同时执行

线程同步消息传递Actor模型

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 的 "无畏并发" 是如何通过类型系统保证并发安全的。这种方法的代价是什么?

8/9并发
0%