1. 概述
本文将探讨多年来在构建高并发应用过程中形成的一些设计原则与经典模式。
需要强调的是,高并发系统设计是一个广泛且复杂的领域,任何一篇文章都不可能做到面面俱到。我们这里聚焦的是那些在实际开发中被广泛验证、频繁使用的“实战技巧”。
2. 并发基础
在深入设计模式前,有必要先厘清一些核心概念。所谓并发程序,指的是多个计算任务在同一时间段内同时进行。
注意,这里说的是“同时进行”,而不是“同时执行”。多个任务可以并发但未必并行——只有真正同时执行的计算才称为并行(parallel)。理解这个区别至关重要。
2.1. 如何创建并发单元?
实现并发的方式有很多,我们重点关注两种主流方式:
- 进程(Process):一个正在运行的程序实例,与其他进程隔离。每个进程拥有独立的内存空间,通常无法直接共享内存,通信需通过消息传递。
- 线程(Thread):进程内的一个执行单元。同一进程内的多个线程共享内存空间,但各自拥有独立的调用栈和优先级。线程可分为:
- 原生线程(Native Thread):由操作系统直接调度
- 协程/绿色线程(Green Thread):由运行时库(如JVM)调度
2.2. 并发单元如何交互?
理想情况下并发模块无需通信,但现实往往并非如此。由此衍生出两种主流并发编程模型:
✅ 共享内存(Shared Memory)
并发模块通过读写共享内存中的对象进行交互。这种方式容易导致计算交错(interleaving),引发竞态条件(race condition),从而造成非确定性的错误状态。✅ 消息传递(Message Passing)
并发模块通过通信通道互相发送消息。每个模块顺序处理收到的消息。由于无共享状态,编程模型更清晰,但依然可能遇到竞态问题(比如消息顺序)。
2.3. 并发单元如何执行?
随着摩尔定律在CPU主频上的失效,硬件厂商转而采用多核架构。但即便如此,主流CPU核心数也通常不超过32个。
我们知道,单个核心同一时间只能执行一个线程。然而,系统中可能同时存在成千上万个线程。这是如何实现的?答案是:操作系统通过时间片轮转(time-slicing)模拟并发。CPU在多个线程间快速、不可预测地切换,从而“看起来”是并行执行。
3. 并发编程的典型问题
在讨论设计模式前,先认清常见的“坑”:
⚠️ 互斥与同步(Mutual Exclusion)
多个线程访问共享状态时,必须保证其独占性以确保正确性。常用手段是使用同步原语(synchronization primitives),如锁(lock)、监视器(monitor)、信号量(semaphore)、互斥量(mutex)等。
❌ 踩坑点:容易引发死锁(deadlock)、活锁(livelock)、性能瓶颈。⚠️ 上下文切换开销(Context Switching)
操作系统在调度线程时需保存和恢复其状态,这个过程称为上下文切换。线程越“重”(heavyweight),切换成本越高,直接影响系统吞吐量。
4. 高并发设计模式
理解了基础与问题后,我们来看几种主流的解决方案。
4.1. 基于 Actor 的并发模型
Actor 模型是一种将一切视为“Actor”的并发计算数学模型。Actor 之间通过消息通信,并基于消息做出本地决策。该模型最早由 Carl Hewitt 提出,深刻影响了 Scala、Erlang 等语言。
以 Scala 为例,其并发核心就是 Actor:
class myActor extends Actor {
def act() {
while(true) {
receive {
// 处理收到的消息
}
}
}
}
上述代码中,receive
方法会阻塞当前 Actor,直到有消息到达。消息从其私有邮箱中取出并顺序处理。
✅ 优势:彻底规避了共享内存带来的竞态问题。
⚠️ 注意:Actor 仍运行在原生线程池之上,若线程本身较重,仍可能成为瓶颈。
4.2. 事件驱动并发(Event-Based Concurrency)
为解决原生线程开销大的问题,事件驱动模型应运而生。其核心是事件循环(event loop),它监听事件源并在事件到达时分发给对应的处理器。
一个极简的事件循环伪代码如下:
while(true) {
events = getEvents();
for(e in events)
processEvent(e);
}
事件循环通常只运行在一个线程上,所有事件处理器顺序执行,天然避免了锁和死锁。
✅ 典型代表:JavaScript 的事件循环机制。
- 维护一个调用栈(call stack)和事件队列(event queue)
- 异步操作交由浏览器 Web API(可能在其他线程)处理,完成后回调入队
- 事件循环持续检查调用栈是否空闲,若有则从队列取任务执行
4.3. 非阻塞算法(Non-Blocking Algorithms)
阻塞式算法会显著降低吞吐量。非阻塞算法的核心是利用硬件提供的比较并交换(Compare-and-Swap, CAS) 原子指令。
CAS 操作:比较内存值与预期值,若一致则更新为新值。这一操作是原子的,无需加锁。
Java 提供了丰富的非阻塞工具类:
// 错误示范:非线程安全
boolean open = false;
if(!open) {
// Do Something
open = true; // 可能被多个线程同时执行
}
// 正确做法:使用 AtomicBoolean
AtomicBoolean open = new AtomicBoolean(false);
if(open.compareAndSet(false, true)) {
// Do Something
}
✅ 使用 AtomicBoolean
等类,可实现无锁(lock-free)的线程安全代码,性能更高。
5. 编程语言的支持
原生线程在扩展性上已遇到瓶颈,我们需要更轻量的并发抽象。绿色线程(Green Threads) 是一种由运行时库而非操作系统调度的线程,能显著提升并发能力。
但绿色线程的支持依赖于语言本身。以下是几种典型实现:
5.1. Go 语言的 Goroutine
Goroutine 是 Go 的轻量级并发单元:
- ✅ 栈初始仅几KB,创建成本极低
- ✅ 多路复用到少量原生线程上
- ✅ 通过 channel 通信,避免共享内存
简单粗暴地用 go func()
就能启动一个并发任务,堪称并发编程的“降维打击”。
5.2. Erlang 的进程(Process)
Erlang 中的“进程”并非操作系统进程,而是:
- ✅ 轻量级,内存占用小,创建销毁快
- ✅ 由运行时调度,调度开销低
- ✅ 完全隔离,通过消息传递通信
这种设计使其成为电信级高可用系统的首选。
5.3. Java 的纤程(Fibers)—— Project Loom
Java 长期以来依赖原生线程,但 Project Loom 正在改变这一现状。
Project Loom 提案引入了 Continuation 和 Fiber,旨在提供类似协程的轻量级并发模型:
- ✅ Fiber 由 JVM 调度,可轻松创建百万级并发
- ✅ 保持与现有 Thread API 兼容
- ✅ 将彻底改变 Java 高并发应用的编写方式
虽然尚未正式发布,但值得密切关注。
6. 高并发应用架构
真实世界的高并发系统是分层的,我们来看各层的典型技术选型。
6.1. Web 层
用户请求的入口,必须能扛住高并发:
- ✅ Node.js:基于 V8 引擎和事件循环,擅长处理大量 I/O 密集型请求。
- ✅ nginx:异步事件驱动,单 master 进程管理多个 worker 进程,每个 worker 单线程处理请求,性能极佳。
6.2. 应用层
业务逻辑层的并发框架:
- ✅ Akka:基于 Actor 模型的 JVM 工具包,适合构建高并发、分布式系统。
- ✅ Project Reactor:响应式编程库,支持非阻塞、背压(back-pressure),被 Spring WebFlux 采用。
- ✅ Netty:异步事件驱动的网络框架,基于 Java NIO,用于构建高性能协议服务器。
6.3. 数据层
高并发下的数据存储:
- ✅ Cassandra:分布式 NoSQL 数据库,高可用、线性扩展,适合弱一致性场景。
- ✅ Kafka:分布式流处理平台,通过分区和副本实现高吞吐、高可靠的消息传递。
6.4. 缓存层
减少数据库压力的关键:
- ✅ Hazelcast:分布式内存数据网格,支持多种数据结构,内置分片与复制。
- ✅ Redis:内存数据结构存储,常用作缓存。支持持久化,性能卓越,社区生态丰富。
💡 提示:技术选型应以业务需求为准。没有“最好”的技术,只有“最合适”的方案。
7. 总结
本文梳理了高并发应用的设计基石:
- 理解并发与并行、共享内存与消息传递的本质区别
- 认清原生线程带来的同步与性能问题
- 掌握 Actor、事件循环、非阻塞算法等核心模式
- 了解 Go、Erlang、Project Loom 等语言级创新
- 熟悉 Web、应用、数据、缓存各层的主流技术栈
最终,构建高并发系统不是堆砌技术,而是在正确的地方使用正确的工具。希望这些经验能帮你少走弯路。