并发 & 并行:从模型到运行
并发 & 并行:从模型到运行
在大多数技术文章、教程甚至招聘 JD 里,“并发(Concurrency)”和“并行(Parallelism)”这两个词几乎被完全混用,比如说async/await 叫并发,多线程叫并行,Go 协程叫并发,异步叫并发,但实际上在计算机底层对两者有着严格的分类,但是这两者其实是相辅相成的,我们首先回顾一下这两者的概念。
并发 & 并行的定义
并发计算
我们来看看wiki的定义:
并发计算,简单来说,就是将一个计算任务,分割成几个小的部分,让它们同时被计算,之后再汇整计算结果,以完成任务。它跟并行计算(Parallel computing)与分布式计算,有重叠之处,在概念上不同,但常会让人混淆。 并发计算是一种程序运算的特性,可以被视为是并行运算的进一步抽象,它包涵了时间片这种可以被用来实现虚拟并行运算(pseudoparallelism)的技术,因此在实际的物理运作中,计算过程可能是并行,或非并行的。
那么我们可以直接理解为:在同一时间段内,系统能够处理多个逻辑任务,并通过调度机制在它们之间进行切换,从而使它们“看起来像是在同时进行”。并在任务执行过程中他是并行的也可能是非并行的。
并行计算
我们同样来看看wiki的定义:
并行计算(英语:parallel computing)一般是指许多指令得以同时进行的计算模式。在同时进行的前提下,可以将计算的过程分解成小部分,之后以并发方式来加以解决。
电脑软件可以被分成数个运算步骤来执行。为了解决某个特定问题,软件采用某个算法,以一连串指令执行来完成。传统上,这些指令都被送至单一的中央处理器,以循序方式执行完成。在这种处理方式下,单一时间中,只有单一指令被执行(processor level: 比较微处理器,CISC, 和RISC,即流水线Pipeline的概念,以及后来在Pipeline基础上以提高指令处理效率为目的的硬件及软件发展,比如branch-prediction, 比如forwarding,比如在每个运算单元前的指令堆栈,汇编程序员对programm code的顺序改写)。并行运算采用了多个运算单元,同时执行,以解决问题。
那么我们也可以直接理解为:多个计算在同一时刻真实地同时执行
由此我们可以得到一个非常关键的结论:
并发既可以通过并行来实现,也可以在完全不并行的情况下成立;而并行既可以建立在并发结构之上,也可以通过纯数据并行的方式直接实现。 在现实系统中,并发与并行往往同时出现,但二者在本质上关注的是不同层次的问题:
- 并发更偏向于软件层面的任务结构与调度组织方式
- 并行则更偏向于硬件层面的同时执行能力 因此,在本文后续的讨论中,我们将主要从软件侧视角出发,系统性地梳理并发模型、调度机制与并行执行之间的关系。
软件里面的并发与并行
我们从原本的定义中就能意识到,并发的前提就是多任务同时进行,那么我们从一个宏观的视角看,多任务到计算机软件里面是如何被处理的:
从这个流程图中,我们可以直接的将软件里面的多任务处理简单调度分为五层,分别是:
- 现实任务层
- 并发模型层
- 并发实现层
- 调度层
- 执行层 需要特别说明的是,尽管上述五个层级可以完整覆盖软件并发体系的全流程,但它们各自的内容体量与技术深度差异极大。受限于篇幅与讨论重心,本文将主要聚焦于前三个层级:现实任务层、并发模型层与并发实现层,重点回答“为什么需要并发”以及“并发在语义与程序结构层面是如何被表达的”。至于调度层与执行层,本文仅作必要的概念性说明,用于建立从上层模型到底层运行时与硬件执行之间的基本联系,而不展开完整的实现细节与性能调优问题。这部分内容将在后续文章中作为独立主题系统展开。
现实任务层
我们不妨从最根本的问题出发:为什么要多任务一起处理?一项一项顺序处理不好吗? 答案通常只有一个:不好,或者来不及。 我们用一个现实世界的例子来说明这个问题:假设A现在管理着一个苹果园,苹果已经成熟,如果不能在短时间内采摘完毕,就会腐烂变质。A一个人每小时最多只能摘100个苹果,而整个果园里大约有10000个苹果。显然,在这种时间约束下,仅靠A一个人顺序处理是不可行的。 为了在有限时间内完成任务,A只能选择同时雇佣多名工人一起采摘。当多个工人在同一时间真实地同时摘苹果时,这个过程本质上就是并行。 但问题并没有结束。当A雇佣了多名工人之后,还必须解决另一个更关键的问题:这些工人如何被组织起来,才能最高效地协作完成任务? 于是A想到了一个办法: 将整个果园按照工人人数划分成若干个区域,每个工人负责其中一块区域。当分工完成、任务划分明确之后,在统一的开始时刻,所有人同时开始工作。 在这个过程中:
- “多个工人同时摘苹果”是并行
- “划分区域、分配任务、统一调度开始工作”则是并发 也就是说:
并行描述的是:在某一时刻是否真的有多个执行单元同时工作
并发描述的是:是否存在一套机制用来组织多个任务同时推进
进一步地,我们还可以提出一个极端问题:
能不能直接请 10000 个工人,一人摘一个苹果,一瞬间完成任务? 在理论上,如果只考虑“摘苹果”这一个动作,似乎是可行的;
但一旦考虑到现实世界的约束——例如工人的数量、工资成本、管理复杂度、销售渠道等——工人的数量就不可能无限增长。 这也揭示了一个非常重要的事实:多任务并发与并行的能力,从来都不是“无限的”,而是始终受到现实资源条件的严格约束。 总的来说:
- 多任务一起处理,是为了在时间受限的条件下提升整体效率
- 但无论是并发还是并行,都必须受制于客观资源条件的约束
- 并行受限于物理资源数量
- 并发受限于组织与调度能力
并发模型层
我们在前一小节知道为什么要多人任务一起处理,那么在这一节里,我们专注一个事情:如何规划多任务的执行计划。 在主流的学术以及工程界中,对于这一层有着多种形式的设计模式,并且在分类上,有人认为是七种,有人认为是5种,在这里,我们直接通过模型设计来梳理这些设计模型。
朴素并发模型
朴素并发模型(Naive Concurrency Model)是指一种由硬件并行能力直接诱导的软件并发建模方式:以操作系统线程作为执行实体,以共享内存作为主要通信媒介,通过锁、原子操作、条件变量等同步原语来保证并发安全。 这种模型几乎不引入额外的语义抽象,其并发结构与底层硬件/操作系统调度模型保持最大程度的一一对应,因此也可以被视为“硬件并行在软件层的直接投射”。 这种方法在早期的编程语言和现代编程语言几乎完全支持,因为他是最直接的,直接由硬件直接映射的(硬件是怎么样他就是怎么样的)。我们来看一个例子: 场景:多个线程给同一个计数器加一
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
static int counter = 0; // 共享内存
static std::mutex mtx; // 同步原语:锁
void worker(int id, int times) {
for (int i = 0; i < times; ++i) {
std::lock_guard<std::mutex> lock(mtx); // 加锁
++counter; // 临界区
}
}
int main() {
const int thread_count = 4;
const int times_per_thread = 100000;
std::vector<std::thread> threads;
for (int i = 0; i < thread_count; ++i) {
threads.emplace_back(worker, i, times_per_thread);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter = " << counter << std::endl;
// 理论结果:4 * 100000 = 400000
}执行结果:
Final counter = 400000在这个例子中:
- 并发的执行实体是 OS 线程(
std::thread) - 线程之间通过共享内存(
counter)通信 - 并发安全完全依赖显式同步原语(
std::mutex) - 调度方式由操作系统内核以抢占式方式完成
这正是典型的“朴素并发模型”:硬件并行能力 → 操作系统线程 → 共享内存 → 锁同步
那它的优缺点是什么呢?
优点:
- 直接操控 OS 线程,几乎没有运行时抽象开销
线程由操作系统直接调度,不依赖语言级调度器或运行时,无需经历任务封装、状态机拆分、Poll/Waker 等额外流程,在 CPU 密集型任务中能获得非常直接的性能表现。 - 执行模型与硬件拓扑高度一致,易于进行低层性能调优
程序员可以直接结合:- CPU 核数
- NUMA 拓扑
- 缓存层级
- 亲和性(CPU affinity)
进行针对性的并行设计,这是高性能计算、图像处理、游戏引擎等领域仍大量使用该模型的原因。
- 语言与平台支持极其广泛,几乎是“并发的最低公共分母”
从 C、C++、Java 到 Rust、Python(通过原生线程),所有主流语言都支持这一并发范式,具备极强的可移植性和长期稳定性。 - 程序员拥有极高的控制权和自由度
无论是:- 线程生命周期
- 调度策略偏好
- 同步方式组合
都可由程序员自行决定,适合对并发行为有极端定制需求的场景。
缺点:
线程创建和切换成本高,系统资源消耗巨大
OS 线程属于“重量级并发单元”,涉及:- 栈空间分配
- 内核调度结构
- 上下文切换
当并发规模扩大时,很容易因线程过多导致调度开销陡增,内存占用失控,线程抖动(thrashing)等等并发问题
并发安全完全依赖程序员,极易引入隐蔽且灾难性的错误
包括但不限于:数据竞争(data race),死锁(deadlock),活锁(livelock),优先级反转
这些问题往往难以复现,难以调试,并且一旦发生,后果严重对 IO 密集型、小任务场景极其不友好
在网络请求、磁盘 IO、RPC 等高 IO 场景中:- 大量线程会频繁阻塞
- CPU 实际利用率反而下降
- 系统被调度开销“吃空”
扩展性差,并发规模容易撞上系统天花板
在 Web Server、代理、爬虫等高并发场景中,若采用“一请求一线程”模式:- 数千线程即可耗尽系统资源
- 并发能力与内存容量强耦合
使系统难以平滑扩展到百万级并发。
程序结构高度耦合共享状态,维护成本随规模急剧上升
共享内存 + 锁天然鼓励:- 可变状态泛滥
- 模块之间强耦合
系统越大,锁的组织就越难以理解和维护,极易演化为“并发泥潭”。
总结:朴素并发模型具备最直接的性能表达能力和最极端的控制自由度,但同时也承担了最高的工程复杂度与最危险的错误风险, 所以它非常适合:高性能数值计算,游戏引擎,实时系统;但并不适合:高 IO 密度网络服务,超大规模并发系统
除了“多线程 + 锁”之外,朴素并发模型在工程上还常见另一种实现形式:多进程并发。此时并发的执行实体由“线程”变为“进程”,每个进程拥有独立的地址空间,进程之间通过 IPC(如管道、消息队列、共享内存、socket)进行通信。需要强调的是,多进程并发同样不构成一种新的语义级并发抽象模型,它只是朴素并发模型在“进程粒度”上的一种工程实现方式。本文在后续“执行实体层”中将再次提及该形式。
CSP 并发模型
CSP——全称叫做Communicating Sequential Processes(通信顺序进程;这个可不是隔壁的Certified Software Professional或者是Certified Safety Professional)这个模型是真正的抽象语义模型,他有一个宗旨就是:
不要通过共享内存来通信,而是通过通信来共享内存。
我们可以看一下他的执行流程:
在CSP模型中:
- 每个 Process 都是“顺序执行”的
- Process 之间不通过共享内存通信
- 所有同步关系都隐含在 Channel的send/recv 上
- 通信本身即是同步点(communication = synchronization) CSP还有另外一种实现模式:
这个模式在“阻塞行为”上与前面的朴素并发模型存在相似之处:二者都会在资源不可用时主动阻塞执行单元。 不同之处在于:
- 在 CSP(无缓冲 channel)中,阻塞发生在通信双方尚未匹配时,既可能阻塞发送方,也可能阻塞接收方;
- 在朴素并发模型中,阻塞发生在共享资源的互斥访问阶段,阻塞的是“未获得锁的一方”。 两者的阻塞语义来源不同:前者来自通信同步,后者来自互斥同步。
在实际的编程中,Golang的并发模型在语义层面高度契合 CSP 的设计思想,我们来看具体的代码: 场景:多个线程给同一个计数器加一
package main
import (
"fmt"
"sync"
)
func main() {
const workerCount = 4
const timesPerWorker = 100000
// 用于传递“加一请求”的 channel
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(workerCount)
// 汇总者(唯一修改 counter 的 goroutine)
go func() {
counter := 0
for v := range ch {
counter += v
}
fmt.Println("Final counter =", counter)
}()
// 多个 worker:只负责发送,不接触共享内存
for i := 0; i < workerCount; i++ {
go func() {
defer wg.Done()
for j := 0; j < timesPerWorker; j++ {
ch <- 1 // 发送“加一请求”
}
}()
}
// 等待所有 worker 结束
wg.Wait()
close(ch)
}执行结果:
Final counter = 400000在这个示例中:
- 并发执行单元是 goroutine;
- 所有“加一”操作不通过共享内存完成;
- 并发同步完全由channel的send/recv隐式完成;
- 真实的共享状态(counter)只存在于单一goroutine中。 这正是 CSP 模型的核心思想:通过通信来实现同步(communication = synchronization)。
那它的优缺点是什么呢?
优点:
- 从语义层面消除共享内存带来的并发复杂性
CSP 以“通信”而非“共享内存”作为并发的基本单位,从根源上避免了数据竞争(data race),极大降低了并发程序的心智负担。 - 同步关系内嵌在通信语义中,程序结构更加直观
在 CSP 中,同步不再依赖显式的锁,而是自然地体现在send / recv这一对通信操作上,使并发控制从“隐式约束”变为“显式结构”。 - 天然适合构建流水线、生产者-消费者等并发结构
通过 channel 连接多个顺序执行的 process,可以非常自然地表达数据流、任务流与并发管道,程序结构清晰、可组合性强。 - 并发安全性更容易被形式化分析与验证
由于不依赖共享可变状态,CSP 的并发行为更易建模,也更适合用于并发协议验证与死锁分析。
缺点:
- 通信本身带来额外的调度与数据传递开销
相比直接访问共享内存,基于 channel 的通信在高频、小粒度操作场景下可能引入不必要的性能损耗。 - 不擅长表达“高度共享的全局状态”
当多个任务需要频繁读写同一份全局数据时,CSP 模型往往需要额外引入汇总者(aggregator)或转发节点,结构上不如共享内存直观。 - 在复杂系统中,Channel 拓扑本身也可能变得难以维护
随着系统规模扩大,channel 的连接关系可能形成复杂的通信网络,其可读性与可维护性同样会成为新的工程挑战。 - 表达能力偏向“通信驱动”,对纯计算并行并非最优选择
在纯 CPU 密集型并行计算场景下,CSP 的通信抽象并不能带来明显优势,反而可能限制对底层硬件并行能力的直接利用。
总结: CSP(Communicating Sequential Processes)以“通信即同步”为核心思想,将并发从“共享内存 + 锁”的控制方式,提升为“以通信结构组织并发”的语义级模型。
在该模型中:
- 每个并发单元内部仍然是顺序执行的;
- 并发关系不通过共享内存体现,而是由 channel 连接关系显式表达;
- 同步不再依赖锁,而是自然地嵌入到
send / recv的通信过程中。 从并发抽象的角度看,CSP 代表了一条与“朴素并发模型”正交的设计路径: - 朴素模型强调 共享状态下的同步控制;
- CSP 模型强调 通信结构下的协作关系。 它并不是对“多线程 + 锁”的简单封装,而是在语义层面对并发组织方式的一次根本性重构,也正因如此,CSP成为了Go等现代语言并发体系的理论基础。
异步并发模型(Async/Future)
我们在学习编程的时候,当学习到网络的时候,经常会听到这样的一个词:异步编程,然后对应的教材就是大量篇幅代码论述,但实际上异步编程事实上是此节的异步并发模型,那他是一个什么东西呢?一句话总结:
一种以“可挂起的计算状态机”为核心抽象,通过任务的主动让出与恢复来实现并发推进,而非依赖共享内存或阻塞同步的并发语义模型。
在异步并发模型中,一个任务的执行不再是“从开始一次性跑到结束”,而是被拆分为多个由“等待条件”划分的执行片段:任务被创建后开始执行->当计算遇到尚不可立即完成的条件时,任务会主动挂起->外部事件满足后,任务被恢复执行;这一过程不断重复,直到任务最终完成。
异步并发的核心不在于“是否使用线程”,而在于:计算是否具备“可被中断、可被恢复”的语义结构。一下是具体的流程图:
需要特别澄清一个常见的概念混淆:在大量编程教程与工程语境中,“异步编程”往往被用来泛指一切“非阻塞的并发编程方式”,但从并发模型的角度来看,异步并发模型(Async/Future)本身是一种具有明确语义抽象的并发模型,而不仅仅是“是否阻塞 IO”的问题。 在工程实现中,当我们谈论 async/await、Promise、Future 这一整套机制时,实际上指的正是这种以“可挂起计算状态机”为核心的异步并发模型。现代主流语言(如 Rust、JavaScript、Python、C#)都在不同层级上原生支持这一模型。这里有一个例子rust: 场景:多个线程给同一个计数器加一 依赖:
[dependencies]
tokio = { version = "1", features = ["full"] }
futures = "0.3"代码:
use std::sync::Arc;
use tokio::sync::Mutex;
use futures::future::join_all;
#[tokio::main]
async fn main() {
const WORKER_COUNT: usize = 4;
const TIMES_PER_WORKER: usize = 100_000;
// 共享计数器:Async 任务共享 + Mutex 保护
let counter = Arc::new(Mutex::new(0i32));
// 收集所有异步任务的 Future
let mut tasks = Vec::new();
for _ in 0..WORKER_COUNT {
let counter_clone = Arc::clone(&counter);
let task = tokio::spawn(async move {
for _ in 0..TIMES_PER_WORKER {
let mut guard = counter_clone.lock().await;
*guard += 1;
// guard 在这里自动释放
}
});
tasks.push(task);
}
// 等待所有异步任务完成(Future 级聚合)
join_all(tasks).await;
// 读取最终结果
let final_result = *counter.lock().await;
println!("Final counter = {}", final_result);
}运行结果:
Final counter = 400000为了照顾一些读者,我们这里提供另外一个编程语言c#:
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
const int workerCount = 4;
const int timesPerWorker = 100_000;
int counter = 0;
object locker = new object(); // 共享内存锁
var tasks = new List<Task>();
// 启动多个异步任务(Future / Task)
for (int i = 0; i < workerCount; i++)
{
tasks.Add(Task.Run(async () =>
{
for (int j = 0; j < timesPerWorker; j++)
{
lock (locker) // 显式互斥同步
{
counter++;
}
// 这里的 await 只是为了体现 async 任务调度语义
await Task.Yield();
}
}));
}
// 等待所有异步任务完成(Future 聚合)
await Task.WhenAll(tasks);
Console.WriteLine($"Final counter = {counter}");
}
}示例结果:
Final counter = 400000在这些示例中:
- 并发单元是由运行时调度的异步任务(Future);
- 多个异步任务共享同一份内存状态(counter);
- 并发安全通过异步互斥锁显式保证;
- 所有任务的完成通过Future/Task组合器统一等待。 示例表明:Async/Future模型并不强制要求“通信即同步”,它同样可以与“共享内存 + 锁”的并发范式相结合。 那么它的优缺点是什么呢?
优点:
- 从“线程阻塞”转向为“任务挂起”,极大提升并发伸缩性 Async/Future 将“等待”从操作系统线程层面提升为任务语义的一部分,使大量并发任务可以复用极少量的执行线程,从根本上突破了“一任务一线程”的并发上限。
- 天然适配IO密集型场景,是现代网络服务的主流并发范式
在网络请求、磁盘 IO、RPC 调用等高 IO 场景中,任务可以在等待期间被安全挂起,线程资源被立即让渡给其他任务,整体系统吞吐能力显著提高。 - 并发推进方式由语言与运行时显式建模,可被统一调度与组合
Future/Task作为一等公民,可以被组合、等待、取消与传播错误,使并发从“隐式的线程行为”转变为“显式的程序结构”。 - Async/Future 关注的是“调度与等待语义”,而非通信方式本身,因而具有极强的并发范式兼容性。
缺点:
- 并发复杂性从“锁竞争”转移为“控制流与生命周期管理”
程序员需要同时理解:挂起点、恢复点、任务取消、异常传播、资源释放时机等问题,并发复杂性被迁移到了状态机与控制流层面。 - 对运行时调度器依赖极强,性能与公平性高度实现相关
Async/Future 本身并不规定调度策略,其行为在不同运行时(Tokio、.NET、Node.js)下可能存在显著差异。 - 对纯 CPU 密集型并行计算并不友好
在没有 IO 等待的场景中,Async 的优势难以体现,反而可能因为频繁调度带来额外开销,此时更适合直接使用多线程并行计算模型。 - 调试与问题定位难度较高
由于执行路径被拆分为多个异步阶段,调用栈被打断,调试时难以像同步程序那样直观地还原完整执行路径。
总结: Async/Future 并发模型以“任务可挂起、可恢复”为核心语义,将并发从“线程是否阻塞”的问题,上升为“计算如何在时间轴上被切分与推进”的问题。
它并不试图替代共享内存模型或通信模型,而是通过对“等待”这一行为的语义重构,成为现代高并发 IO 系统中最具扩展性的并发范式。 如果说,朴素并发模型关注 “谁在同时改内存”;CSP 关注 “谁在和谁通信”; 那么 Async/Future 关注的则是:“一个计算,在何时主动让出执行权,又在何时被继续推进。”
Actor并发模型
如果说朴素并发模型试图通过“锁”来约束共享状态,CSP 试图通过“通信”来规避共享状态,那么 Actor 模型则选择了另一条更加激进的道路:彻底消除共享状态本身。 Actor(参与者)并发模型是一种以“状态私有 + 消息驱动”为核心语义的并发抽象模型。在该模型中每一个 Actor 都拥有完全私有的状态,并且Actor 之间不共享内存,所有交互只能通过异步消息完成,每个 Actor 在任一时刻只处理一条消息,从而在语义层面天然避免了数据竞争。
在 Actor 并发模型中,每一个 Actor 都像一个独立的小型状态机,Actor 被创建后进入就绪状态,其唯一的输入来源是自己的消息邮箱(Mailbox),Actor 每次只从邮箱中取出一条消息进行顺序处理,在处理过程中,它只能做三类事情:
- 修改自身的私有状态;
- 向其他 Actor 发送异步消息;
- 创建新的 Actor; 消息处理完成后,Actor 重新回到就绪状态,等待下一条消息。 由于任何时刻 Actor 都只处理一条消息,且状态对外完全不可见,因此 Actor 模型在语义层面天然避免了数据竞争问题。以下是执行流程图:
Actor并发模型最先被Erlang支持,其实Erlang是专门根据Actor设计的,后续的现代的编程语言如:JAVA/Scala,提供一种强Actor并发模型风格支持,其实我们进一步从流程图观察,他其实与高并发基础设施解决方案中的消息队列中间件十分的相似,甚至在行为上可以说是同构的,我们通过一个例子理解: 场景:多个线程给同一个计数器加一
-module(counter_example).
-export([run/0, counter/1, worker/3]).
-define(WORKERS, 4).
-define(TIMES_PER_WORKER, 100000).
%% 计数器 Actor:唯一维护计数状态
counter(Count) ->
receive
inc ->
counter(Count + 1);
{get, From} ->
From ! {count, Count},
counter(Count)
end.
%% worker Actor:给 counter 发 N 次 inc,然后告诉父进程自己完成了
worker(CounterPid, 0, Parent) ->
Parent ! done;
worker(CounterPid, N, Parent) ->
CounterPid ! inc,
worker(CounterPid, N - 1, Parent).
%% 等待所有 worker 完成
wait_workers(0) -> ok;
wait_workers(N) ->
receive
done ->
wait_workers(N - 1)
end.
%% 场景入口:多个 worker 给同一个计数器加一
run() ->
CounterPid = spawn(?MODULE, counter, [0]),
Parent = self(),
%% 启动多个 worker
[spawn(?MODULE, worker, [CounterPid, ?TIMES_PER_WORKER, Parent])
|| _ <- lists:seq(1, ?WORKERS)],
%% 等待所有 worker 完成
wait_workers(?WORKERS),
%% 请求最终计数
CounterPid ! {get, self()},
receive
{count, final, Value} -> io:format("Final counter = ~p~n", [Value]);
{count, Value} -> io:format("Final counter = ~p~n", [Value])
end.最后结果:
1> c(counter_example).
2> counter_example:run().
Final counter = 400000为了照顾一些读者,我们这里提供另外一个编程语言Java: Maven依赖:
<dependencies>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor-typed_2.13</artifactId>
<version>2.8.5</version>
</dependency>
</dependencies>例子:
import akka.actor.typed.*;
import akka.actor.typed.javadsl.*;
import java.time.Duration;
// ========================
// 消息协议(Protocol)
// ========================
interface CounterCommand {}
record Increment() implements CounterCommand {}
record GetCount(ActorRef<CountResult> replyTo) implements CounterCommand {}
record CountResult(int value) {}
record StartWork() {}
record WorkDone() {}
// ========================
// Counter Actor(唯一修改计数状态)
// ========================
class CounterActor extends AbstractBehavior<CounterCommand> {
private int count = 0;
public static Behavior<CounterCommand> create() {
return Behaviors.setup(CounterActor::new);
}
private CounterActor(ActorContext<CounterCommand> context) {
super(context);
}
@Override
public Receive<CounterCommand> createReceive() {
return newReceiveBuilder()
.onMessage(Increment.class, msg -> {
count++;
return this;
})
.onMessage(GetCount.class, msg -> {
msg.replyTo().tell(new CountResult(count));
return this;
})
.build();
}
}
// ========================
// Worker Actor(负责发送 +1)
// ========================
class WorkerActor extends AbstractBehavior<StartWork> {
private final ActorRef<CounterCommand> counter;
private final int times;
private final ActorRef<WorkDone> coordinator;
public static Behavior<StartWork> create(
ActorRef<CounterCommand> counter,
int times,
ActorRef<WorkDone> coordinator
) {
return Behaviors.setup(ctx -> new WorkerActor(ctx, counter, times, coordinator));
}
private WorkerActor(
ActorContext<StartWork> context,
ActorRef<CounterCommand> counter,
int times,
ActorRef<WorkDone> coordinator
) {
super(context);
this.counter = counter;
this.times = times;
this.coordinator = coordinator;
}
@Override
public Receive<StartWork> createReceive() {
return newReceiveBuilder()
.onMessage(StartWork.class, msg -> {
for (int i = 0; i < times; i++) {
counter.tell(new Increment());
}
coordinator.tell(new WorkDone());
return this;
})
.build();
}
}
// ========================
// Coordinator Actor(等待所有 worker 完成)
// ========================
class CoordinatorActor extends AbstractBehavior<WorkDone> {
private int remaining;
private final ActorRef<CounterCommand> counter;
private final ActorSystem<?> system;
public static Behavior<WorkDone> create(
int workerCount,
ActorRef<CounterCommand> counter,
ActorSystem<?> system
) {
return Behaviors.setup(ctx -> new CoordinatorActor(ctx, workerCount, counter, system));
}
private CoordinatorActor(
ActorContext<WorkDone> context,
int workerCount,
ActorRef<CounterCommand> counter,
ActorSystem<?> system
) {
super(context);
this.remaining = workerCount;
this.counter = counter;
this.system = system;
}
@Override
public Receive<WorkDone> createReceive() {
return newReceiveBuilder()
.onMessage(WorkDone.class, msg -> {
remaining--;
if (remaining == 0) {
// 所有 worker 完成,请求最终计数
ActorRef<CountResult> replyTo =
getContext().messageAdapter(CountResult.class, res -> {
System.out.println("Final counter = " + res.value());
system.terminate();
return new WorkDone(); // 占位,不再使用
});
counter.tell(new GetCount(replyTo));
}
return this;
})
.build();
}
}
// ========================
// 程序入口
// ========================
public class ActorCounterApp {
public static void main(String[] args) {
final int workerCount = 4;
final int timesPerWorker = 100_000;
ActorSystem<Void> system = ActorSystem.create(
Behaviors.setup(ctx -> {
// 创建 Counter Actor
ActorRef<CounterCommand> counter =
ctx.spawn(CounterActor.create(), "counter");
// 创建 Coordinator
ActorRef<WorkDone> coordinator =
ctx.spawn(
CoordinatorActor.create(workerCount, counter, ctx.getSystem()),
"coordinator"
);
// 创建并启动多个 Worker Actor
for (int i = 0; i < workerCount; i++) {
ActorRef<StartWork> worker =
ctx.spawn(
WorkerActor.create(counter, timesPerWorker, coordinator),
"worker-" + i
);
worker.tell(new StartWork());
}
return Behaviors.empty();
}),
"ActorCounterSystem"
);
}
}运行结果:
Final counter = 400000在这些示例中:
- 每个 Worker 与 Counter 都是独立的 Actor;
- Counter Actor 是唯一拥有计数状态的实体;
- 多个 Worker 仅通过异步消息向 Counter 发送“+1”请求;
- 整个过程无锁、无共享内存,完全依赖消息驱动。 这正是 Actor 并发模型的典型特征: 状态私有、消息驱动、顺序处理、天然避免数据竞争。 那么它的优缺点是什么呢?
优点:
- 从语义层面彻底消除共享状态,天然避免数据竞争
Actor 的核心特征是“状态私有”,任何状态只属于某一个 Actor,外部只能通过消息与之交互,从源头上消除了数据竞争与锁冲突问题。 - 无锁并发,编程模型在概念上高度统一与干净
Actor 不依赖互斥锁、条件变量等同步原语,并发控制由“邮箱 + 顺序处理消息”这一规则自动完成,使并发语义更加纯粹。 - 同时适用于单机并发与分布式并发场景
在 Actor 语义下,本地消息与远程消息在模型上是等价的,因此该模型天然适配分布式系统,是大规模集群系统(如电信系统、IM 系统)的经典选择。 - 具备良好的故障隔离能力与系统鲁棒性
Actor 之间状态完全隔离,一个 Actor 的崩溃不会直接污染其他 Actor,使系统在结构上更容易实现容错与恢复机制。 - 并发结构以“对象 + 行为”方式组织,可映射为清晰的系统拓扑
Actor 模型非常适合表达“大量独立实体协作”的系统,例如订单、用户、会话、连接等高内聚业务对象。
缺点:
- 消息驱动带来额外的调度与通信开销
相比直接访问共享内存,消息传递不可避免地引入序列化、入队、出队与调度成本,在高频细粒度操作场景中可能影响性能。 - 不适合高度依赖共享全局状态的计算模型
当多个执行单元需要频繁访问同一份大型数据结构时,Actor 模型往往需要通过“集中式 Actor”转发请求,可能形成性能瓶颈。 - 系统行为由“消息时序”决定,调试难度较高
Actor 程序的执行路径高度依赖消息到达顺序,而该顺序通常是非确定的,这使得问题复现与调试比同步程序更加困难。 - 设计不当时,Actor 间通信关系可能迅速复杂化
当 Actor 数量与消息类型急剧膨胀时,系统通信拓扑本身可能演化为新的复杂系统,需要额外的架构约束与治理手段。
总结: Actor 并发模型以“状态私有 + 消息驱动 + 顺序处理”为核心语义,通过彻底消除共享内存,从根源上规避了传统并发中最棘手的数据竞争问题。它代表了一条与“共享内存 + 锁”、以及“通信管道模型(CSP)”并列的并发设计路线,尤其擅长表达高隔离性、高容错性、分布式友好的并发系统结构。
如果说:
- 朴素并发模型解决的是 “如何在共享内存上安全并发”;
- CSP 解决的是 “如何用通信组织并发协作”;
- Async/Future 解决的是 “如何以挂起与恢复推进计算”; 那么 Actor 模型关注的核心问题是:“如何将并发系统拆解为一组相互隔离、通过消息协作的独立实体。”
总结
这四个模型几乎是整个现代并发模型的主要设计,几乎所有的现代主流编程语言对这些模型都有实现,这些模型都在对应的业务领域都有优势,比如说朴素并发模型擅长处理CPU密集任务,CSP并发模型擅长应对生产者——消费者场景,异步并发模型擅长解决IO密集型任务,Actor并发模型则擅长实现分布式并发解决方案,每种模型各有各的好处,我们需要做的就是根据业务需要使用模型设计并发程序,这里回答一下开头的问题,为什么我要采用4种分类方式去描述此层模型,因为这些并发模型几乎都能做到编程语言无关(大多数语言都有对应的实现方案)。当然,有一些特定的实现方案我将会在下一章具体描述,毕竟这些方案几乎是并发解决方案的具体实施,而不是语义建模。最后我们用一个表格来总结这一章:
| 模型 | 核心思想 | 通信方式 | 状态管理 | 典型代表 | 适用场景 |
|---|---|---|---|---|---|
| 朴素并发 | 线程+锁 | 共享内存 | 共享且可变 | C++, Java, Python | CPU密集型,底层系统 |
| CSP | 通信即同步 | Channel | 局部持有,传递副本 | Go, Clojure | 流水线,任务编排 |
| Async | 状态机挂起 | Future/Promise | 共享或闭包捕获 | Rust, JS, C# | 高并发 IO,网关 |
| Actor | 对象+消息 | 邮箱消息 | 绝对私有 | Erlang, Akka | 分布式,高容错系统 |
并发实现层
我们通过前面的并发模型层知道目前业界最流行的4大并发模型,但是这些并发模型不是说设计出来就可以跑了,就像你规划好了如何摘苹果,也是要执行这个规划的,那么此层就是描述执行之前的代码具体设计,而且我们会具体刨析,实现这些模型的最小单元是什么?并且在具体的实现中,这些单元是如何被创建、等待、组合和取消的。
进程 & 线程 & 协程 & 任务(Task)
当我们从“并发模型”走向“实际落地”,我们必须面对一个关键问题:并发在程序运行时到底被“实体化”为哪种对象? 换言之,前一章讲的 CSP、Actor、Async/Future、朴素模型只是“并发的语义与方法论”,但真正能在操作系统上执行的,是下面四类最基本的实现单元:
- 进程(Process)
- 线程(Thread)
- 协程(Coroutine / Goroutine / Fiber)
- 任务(Task / Future) 这四者构成了现代并发实现的“基石层”,不同的并发模型,最终都必须映射到它们上面。接下来我们分别看看它们是什么、解决什么问题,以及在代码中如何被创建、等待、组合和取消。除了这四个例子,还有一个特例,这个特例将在后文揭晓。
进程(Process):最强隔离度的并发单元
是什么?
进程(Process)是操作系统在设计并发时提供的最大隔离级别的执行单元。它不仅仅是“可以并发执行的一段程序”,而是一个拥有完整资源边界的运行环境。换句话说,一个进程就是一个小型操作系统环境 —— 它对外界几乎是完全封闭的,因此它也具备了并发实现中最高等级的可靠性与安全性。这种隔离特性决定了进程非常适合解决以下问题:
- 跨核并行执行(真正的 Parallelism)
每个进程都可以被 OS 调度到不同 CPU 核心上执行,物理上做到“真正同时运行”。 - 错误隔离(Fault Isolation)
一个进程崩溃不会影响其他进程,这是构建高可靠长期运行服务的基础。 - 安全隔离(Security Isolation)
因为没有共享可写内存,进程之间几乎无法直接破坏对方的状态(除非通过特定 IPC)。 - 多租户 / 插件式系统结构
当不同组件不可信、独立生命周期不一致时,进程是最可靠的运行单元。 也正因如此,现代 Web 服务器、数据库、浏览器等高可靠系统几乎都基于多进程结构(如 Chrome 的多进程标签页)。
代码示例
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class ProcessExample {
public static void main(String[] args) throws Exception {
// 创建(启动)一个外部进程(这里用 ping 模拟一个长期运行的程序)
ProcessBuilder pb = new ProcessBuilder();
pb.command("ping", "127.0.0.1");
Process process = pb.start();
System.out.println("Process started. PID = " + process.pid());
// 异步读取子进程输出(防止阻塞)
Thread reader = new Thread(() -> {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println("[Child] " + line);
}
} catch (Exception e) {
e.printStackTrace();
}
});
reader.start();
// 等待一段时间再决定是否取消
Thread.sleep(3000); // 3 秒
System.out.println("3 seconds passed, cancelling the process...");
// 取消(终止)进程
process.destroy(); // 优雅终止
// process.destroyForcibly(); // 强制终止
// 等待进程真实退出(相当于 join)
int exitCode = process.waitFor();
System.out.println("Process exited with code: " + exitCode);
}
}优缺点一句话总结
优点:提供最强的运行时隔离、最高级别的错误安全性,并能在多核 CPU 上实现真正的物理并行,是高可靠系统和多进程架构的基础。 缺点:进程创建、销毁与 IPC 成本极高;跨进程通信必须序列化且带大量数据拷贝;进程之间缺乏可组合性与共享语义,因此不适合构建高数量级、强耦合的并发结构。
线程(Thread):经典且直接的并发执行单元
是什么?
线程(Thread)是操作系统调度的最小执行单位,是进程内部的轻量级执行流。与进程强隔离不同,线程之间共享同一进程的全部资源:
- 共享相同的地址空间
- 共享堆与静态数据
- 共享文件描述符与打开的资源
- 拥有独立的栈与指令指针 换言之,一个线程不是独立的“小系统”,而是同一进程内部的并发执行代理。
线程模型的最大特征就在于:“共享内存 + 抢占式调度”
这种组合带来了极强的灵活性,也带来了巨大的复杂性,线程特别擅长解决以下问题:
- CPU 密集型并行计算(真正占用多个 CPU 核)
多个线程可以并行执行不同的任务,提升吞吐量。 - 需要频繁访问共享状态的任务
因为线程共享内存,所以访问统一数据结构非常方便(虽然需要同步原语保证安全)。 - 调用阻塞型系统调用时不影响整体并发性
一个线程阻塞,进程内其他线程仍可继续执行。 线程模型成为现代操作系统与主流编程语言(Java、C++、Python threading、Rust std::thread)的基础并发构件,其优势在于“直接接触硬件并行能力”,但代价则是并发控制复杂度极高。
代码示例
下面以 Java 为例,展示线程的创建、等待与取消(中断):
public class ThreadExample {
public static void main(String[] args) throws InterruptedException {
// 创建一个执行计数任务的线程
Thread worker = new Thread(() -> {
try {
for (int i = 0; i < 10_000; i++) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("Thread interrupted, exiting...");
return; // 响应取消
}
// 模拟计算
if (i % 2000 == 0) {
System.out.println("Working... step = " + i);
}
}
System.out.println("Thread finished normally.");
} catch (Exception e) {
e.printStackTrace();
}
});
System.out.println("Starting thread...");
worker.start();
// 等待一段时间
Thread.sleep(1500);
// 取消(中断)线程
System.out.println("Interrupting thread...");
worker.interrupt();
// 等待线程结束(join)
worker.join();
System.out.println("Main finished.");
}
}这段代码展示了线程最典型的行为:
start():创建并启动interrupt():请求取消(线程必须自行响应)join():等待结束
优缺点一句话总结
优点:直接映射 CPU 并行能力,具备共享内存带来的高灵活性;适合 CPU 密集型任务、异步阻塞场景及需要频繁访问统一状态的数据结构,是现代并发中最经典、最通用的执行单元。 缺点:线程数量受限于 OS 与内存,创建与上下文切换成本高;共享内存导致数据竞争、死锁、竞态条件的复杂性极高;取消机制依赖协作式设计,不具备强制终止能力;不适合构建大规模(百万级)并发结构。
协程(Coroutine):用户态的轻量并发单元
是什么?
协程(Coroutine)是一种由语言运行时在用户态管理的可挂起、可恢复的执行单元。与由操作系统调度的线程不同,协程不依赖内核调度器,而是在语言层构建自己的轻量级任务调度体系。协程具有以下关键特征:
- 用户态调度(User-space Scheduling)
不需要陷入内核,也不需要切换 OS 线程,因此成本极低。 - 可挂起、可恢复
协程能够在 I/O 或逻辑条件下主动挂起(yield/await),并在事件准备好时恢复执行。 - 轻量级栈(甚至无栈)
允许创建成千上万个协程而不会造成系统压力。 - 与事件驱动模型天然结合
如 Go 的 netpoller、Rust 的 async runtime、Python asyncio 都围绕协程构建。 但是Rust async/await 实际编译为状态机,不分配独立栈称之为无栈协程;Go goroutine 是有栈协程,会在用户态动态增长/收缩。 简而言之:协程是语言级“软线程”,数量级更高,切换更便宜,语义更强,适合大量并发 I/O。 所以协程特别擅长解决以下问题: - 高并发 I/O(网络服务、RPC、微服务)
大量任务在等待 I/O,当 I/O 就绪即可恢复执行。 - 流水线式、管道式的并发结构
协程 + channel 能形成良好的“并发数据流”。 - 百万级的轻量任务管理
使用线程无法承载的数量级,协程可以轻松做到。 - 避免共享内存复杂性(依赖消息通信或 async 语义)
协程更倾向于“不要共享内存”,而倾向于管道、future 等同步方式。
协程模型在现代系统中极其常见:Go、Kotlin、Rust async/await、Python asyncio、Java Loom 都属于同一语义家族。
代码示例(Go)
代码示例
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 创建一个可取消的上下文,用于取消协程
ctx, cancel := context.WithCancel(context.Background())
// 启动协程(goroutine)
go func(ctx context.Context) {
i := 0
for {
select {
case <-ctx.Done():
fmt.Println("Goroutine cancelled, exiting...")
return
default:
if i%200000 == 0 {
fmt.Println("Working... step =", i)
}
i++
}
}
}(ctx)
// 让协程运行一小段时间
time.Sleep(2 * time.Second)
// 取消协程
fmt.Println("Cancelling goroutine...")
cancel()
// 等待协程打印退出(示意用)
time.Sleep(500 * time.Millisecond)
fmt.Println("Main exit.")
}这段示例完整展示了协程的核心特性:
go func() { ... }():创建协程ctx.Done():接收取消信号cancel():发送取消- 协程主动退出:协作式取消
优缺点一句话总结
优点:协程创建与切换极轻量,能以用户态调度支撑百万级并发任务;天然适配异步 I/O 与事件驱动程序,使并发结构更加语义化、可组合、可编排,是现代服务端高并发模型的主力抽象。 缺点:协程调度成本全部落在运行时(而非操作系统),导致调度器设计复杂、难以完全预测执行顺序;协作式取消要求任务自行遵循约定;如果滥用共享内存,仍然会出现数据竞争,且调试难度比线程更高。
任务(Task):可组合的异步计算对象
是什么?
任务(Task)或未来值(Future)是一种用于描述“尚未完成的计算”的抽象。与线程或协程这种“执行流”不同,Task 的本质不是“跑代码的实体”,而是:一个可以等待、可以组合、可以取消、可以返回结果的异步计算对象。 换句话说,Task 更像是“计算的承诺(promise of computation)”,而不关心执行的物理细节。它不代表“在哪里执行”,也不代表“以什么方式调度”,而是一个纯粹的语义抽象:
- 还没执行的时候,它是一个潜在的计算
- 执行中,它持有一个计算中的状态
- 执行完成后,它持有一个结果或错误 Task能够与 async/await 语法深度结合,使异步代码保持同步结构,而无需回调地狱或复杂的状态机。 Task在现代并发体系中极为重要,主要用于解决以下问题:
- 异步 I/O(网络、磁盘、RPC)
Task 完美表达“等待事件发生”的语义。 - 并发任务的组合与编排(whenAll / race / chain)
不同任务可以用声明式方式组合。 - 跨协程、跨线程的结果传递
Task 是语言之间、运行时之间共享异步结果的通用格式。 - 结构化异步(structured concurrency)
Task 能与 async/await 组合成语义清晰、可取消、可跟踪的并发结构。 换句话说:Task 是异步世界的“值语义执行单元”,是现代异步并发抽象的基石。
代码示例
using System;
using System.Threading;
using System.Threading.Tasks;
class TaskExample {
static async Task Main(string[] args) {
var cts = new CancellationTokenSource();
// 1. 创建 Task(异步执行)
Task<int> work = Task.Run(async () =>
{
int sum = 0;
for (int i = 0; i < 1000000; i++) {
if (cts.Token.IsCancellationRequested) {
Console.WriteLine("Task cancelled, exiting...");
return sum;
}
sum += i;
if (i % 200000 == 0) {
Console.WriteLine($"Working... step = {i}");
await Task.Yield(); // 模拟挂起点
}
}
Console.WriteLine("Task finished normally.");
return sum;
}, cts.Token);
// 让任务运行一小段时间
await Task.Delay(500);
// 2. 取消任务
Console.WriteLine("Cancelling task...");
cts.Cancel();
// 3. 等待任务结果(join 等价物)
int result = await work;
Console.WriteLine($"Task result = {result}");
}
}这个示例同时展示了 Task 的四大关键操作:
- 创建任务:
Task.Run(...) - 挂起与恢复:
await Task.Yield() - 取消任务:
cts.Cancel() - 等待任务完成:
await work非常典型,非常适合作为你文章中的示例。
优缺点一句话总结
优点:Task 以“未来值”形式捕获异步计算,使等待、组合、取消、错误传播都具有值语义;与 async/await 结合后,异步代码完全保留同步结构,具备强大的可组合性与结构化并发能力,是现代异步 I/O 的标准抽象。 缺点:Task 本身不代表执行流,必须依赖调度器(线程池或事件循环)才能推进;取消机制为协作式,不能强制终止底层执行;对于 CPU密集工作仍需线程支撑;过度嵌套或错误的链式组合可能导致难以追踪的异步 bug(如 Task 泄漏、死锁)。
一个特殊的对象——Actor:状态与行为绑定的并发对象
是什么?
Actor(参与者)并不是传统意义上的“执行流”,而是一种把状态、行为与消息收发整合在一起的并发实体。Actor 模型与线程、协程、Task 最大的区别在于:Actor 不共享内存,不暴露状态,不使用锁,它只通过消息通信来驱动自己的行为。 每个 Actor 内部拥有:
- 一个私有状态(private state)
- 一个消息邮箱(mailbox)
- 一个顺序执行的事件循环(single-threaded execution)
- 一个行为(receive → new state) 当消息到达,Actor 依序处理,每次处理一个消息,然后更新自己的状态——整个模型天然顺序,无需锁。
Actor 模型特别擅长解决以下问题:
- 高度独立的有状态对象(购物车、订单、玩家、设备)
- 复杂业务流程(Workflow / Saga / FSM)
- 分布式消息系统(Cluster Sharding、Event Sourcing)
- 跨进程甚至跨机器的透明并发(分布式 Actor) 换句话说:Actor 是“带邮箱的有状态对象”,由消息驱动行为,是最接近真实业务实体的并发抽象。 它不是“线程的高级封装”,而是一种全新的并发范式。
代码示例
import akka.actor.ActorRef;
import akka.actor.ActorSystem;
import akka.actor.Props;
import akka.actor.AbstractActor;
public class ActorExample {
// 定义 Actor
public static class CounterActor extends AbstractActor {
private int count = 0;
// 定义消息
public static class Add { public final int value; public Add(int v){ value = v; } }
public static class Get {}
@Override
public Receive createReceive() {
return receiveBuilder()
.match(Add.class, msg -> { count += msg.value; })
.match(Get.class, msg -> {
System.out.println("Current count = " + count);
})
.build();
}
}
public static void main(String[] args) throws Exception {
ActorSystem system = ActorSystem.create("MySystem");
// 1. 创建 Actor
ActorRef counter = system.actorOf(Props.create(CounterActor.class), "counter");
// 2. 发送消息
counter.tell(new CounterActor.Add(10), ActorRef.noSender());
counter.tell(new CounterActor.Add(20), ActorRef.noSender());
counter.tell(new CounterActor.Get(), ActorRef.noSender());
// 等待一小段时间示意
Thread.sleep(500);
// 3. 停止 Actor(取消/终止)
System.out.println("Stopping actor...");
system.stop(counter);
// 停止整个 Actor 系统
system.terminate();
}
}该示例展示了 Actor 的核心特性:
- send(tell)= 异步消息
- receive = 顺序行为
- update state = 私有且无锁
- stop = 终止 Actor 这正是 Actor 的最小可运行模型。
优缺点一句话总结
优点: 天然避免共享内存竞争,将“状态 + 行为 + 并发”封装成独立个体;利用消息传递实现强一致的顺序执行,非常适合构建复杂业务实体、分布式系统、事件驱动架构与长生命周期服务(如订单、会话、设备)。 缺点: Actor 之间通信的开销高于共享内存;Actor 拓扑结构增大后可能难以推理;顺序邮箱使其在高并发热点场景可能出现瓶颈;取消/重启机制依赖运行时框架,不适合需要“硬实时”或强并行计算的场景。
并发实现方法
在前几节中,我们已经介绍了现代并发的主要“执行单元”——进程、线程、协程、任务与 Actor,并解释了它们如何承载不同的并发模型。
但是,并发模型本身并不能直接落地运行;要让一个并发系统真正运转起来,还需要一套工程层面的具体机制来支撑任务的调度、通信、同步与隔离。 这些工程实现方式并不属于新的“并发模型”,也不属于语言层面的抽象,而是现实系统在落地时普遍采用的基础机制。本节我们主要讨论三类在工业界极其重要的并发实现方案:
- 多进程模式(Process-based Concurrency)
操作系统级的隔离与 IPC 带来的高可靠并发结构。 - 事件循环模式(Event Loop)
现代异步编程(Promise、Future、async/await)的核心推进机制。 - STM(Software Transactional Memory)模式
替代锁的事务型内存同步方式,解决共享内存并发的难题。 这些机制不是并发模型本身,但它们构成了实际工程落地并发系统的“支撑层”。 理解它们,才能真正理解并发代码在现实世界中是如何被运行时和操作系统推进的。 需要强调的是:上一章中我们已经讲过各种并发模型的典型落地方式,这里不会重复讲解。
这一节只关注工程里常见、但并发模型本身没有覆盖的额外实现手段。
多进程模式(Process-based Concurrency)
主要思想
多进程并发模式的核心思想是:利用操作系统进程的强隔离特性,让每个任务、组件或服务在独立进程中运行,通过进程间通信(IPC)协作完成并发工作。 在这种模式下:
- 每个进程都有完全独立的地址空间、资源表、堆栈
- 一个进程的崩溃不会影响另外的进程
- 不存在“共享可写内存”,因此从根本上避免了数据竞争和锁复杂度
- 多进程能够自然分布到不同 CPU 核心上,实现真正的物理并行 这种模式通常不是作为“语言层的并发模型”出现,而是作为工程体系中的可靠性增强手段使用:
多进程用于容错、隔离、并行执行及高可用架构,而不是小粒度并发。它在以下场景中极其常见: - Web 服务器(Nginx、Apache)
- 浏览器(Chrome 多进程标签页)
- 数据库(PostgreSQL)
- 高可靠后端服务(多 worker / 多 daemon) 这一模式强调的是:
- 隔离 > 灵活性
- 稳定性 > 易组合性
- 安全性 > 共享内存的便利性
具体流程
多进程模式一般有一套标准化的运行流程,可概括为:
代表软件
多进程模式在现代工业软件中极为常见。Nginx 通过 “Master + Worker” 多进程结构实现高性能事件驱动服务器;Chrome 浏览器将每个标签页、插件和 GPU 模块拆分为独立进程以实现安全隔离;PostgreSQL 采用“每个连接一个进程”的方式提供稳定可靠的数据库服务;PHP-FPM、Python 的 multiprocessing、Node.js 的 Cluster 模式也都依靠多进程架构绕开语言自身的并发局限;各类系统服务管理器(如 systemd)更是将所有服务默认运行在独立进程中,以确保故障不会跨边界扩散。
事件循环模式(Event Loop)
主要思想
事件循环(Event Loop)模式的核心思想是: 用一个或少量线程,通过不断轮询与分发事件的方式,驱动大量异步任务前进,从而实现高并发而无需大量线程。 与多进程/多线程依赖“物理并行”不同,事件循环模式依靠的是:
- 单线程或少量线程执行事件分发
- 异步 I/O(非阻塞系统调用)驱动任务前进
- 回调、Promise、Future、async/await 构建成的异步状态机 事件循环本身不是“并发模型”,而是现代异步模型(Async/Future、Promise、Coroutine)的调度核心。
它通过以下方式实现高效并发: - I/O 操作全部变为非阻塞
- 线程不会因等待 I/O 而挂起
- 大量任务在同一个线程中轮流推进
- 状态机(async/await)提供“像同步一样写异步”的结构 这使得事件循环特别适用于:
- 高并发 I/O 服务(网络、RPC、数据库代理)
- 浏览器脚本环境(JS)
- Serverless、轻量后台任务
- 大量短生命周期异步操作 本质上:事件循环是把 I/O 等待交给操作系统,把 CPU 仅用于“推进状态机”,从而实现极高的并发密度。
具体流程
事件循环的执行流程可以抽象为以下标准模式:
该流程体现了事件循环的核心特征:
- I/O 完全异步
- 任务由事件驱动推进
- 线程从不阻塞在 I/O 上
- 大量任务在少量线程上协作运行
代表软件
事件循环模式广泛应用于现代编程语言与高性能服务器。Node.js 通过 libuv 实现跨平台事件循环,为 JavaScript 带来了高并发能力;浏览器使用事件循环驱动 DOM、计时器与异步网络,使前端脚本无需多线程即可响应交互;Python 的 asyncio、C# 的 async/await、Rust 的 Tokio 和 Java 的 Netty 也都以事件循环作为核心运行机制,将异步状态机与非阻塞 I/O 相结合,构建出高性能的网络与 RPC 服务。无论是轻量微服务、代理服务器、边缘计算环境,还是浏览器 JavaScript 执行环境,事件循环都是当代软件架构中最基础、最重要的异步执行机制之一。
STM 模式(Software Transactional Memory)
主要思想
STM(Software Transactional Memory,软件事务内存)的核心思想是: 把“访问共享内存”这件事,转换成类似数据库事务(transaction)的操作:要么全部成功提交,要么全部回滚,从而避免显式加锁带来的复杂性。 换句话说,STM 的目标是:
- 让多个线程可以安全地读写同一份共享数据
- 不需要手动管理锁(mutex / rwlock)
- 不会出现死锁、锁顺序反转、优先级反转等问题
- 并发冲突由系统自动检测与回滚处理 STM 中一次共享数据的读写被视为一个原子事务,事务包含:
- 读取共享变量
- 计算新的值
- 尝试提交(commit)
- 提交失败时自动回滚并重试 它最接近现实生活中的“买东西”场景:
- 你取了一篮子商品(读)
- 去收银台结账(尝试提交)
- 如果商品价格突然变化(冲突),收银员会拒绝(回滚),你必须重新拿一遍(重试) 其关键特征是:
- 读写冲突自动处理
- 不共享锁,只共享内存的“版本”
- 事务失败时自动重算
- 开发者只需写业务逻辑,不需要思考锁 因此 STM 特别适合:
- 大量共享可变状态
- 读写冲突较少
- 锁管理复杂且错误代价高的场景 Haskell 是最早为 STM 提供“一等公民”级支持的语言,而在其他语言中 STM 一般作为运行时或库层机制存在。
具体流程
以下是 STM 的经典执行流程,省略掉具体实现细节(如版本号、日志、冲突检测机制):
这个流程展示了 STM 的核心机制:
- 读取阶段是乐观的(不锁)
- 冲突检测在提交时进行(版本对比)
- 出现冲突则自动重试
- 整个过程无锁,事务语义由运行时保证 这使得 STM 在高层语义上极度简单,但底层实现极其复杂(版本控制、日志、冲突检测、回滚、重试策略等)。
代表软件
STM 在工业界的普及度没有事件循环或多进程模式那么高,但在特定生态中极为重要。Haskell 是支持 STM 最彻底的语言,其 STM 语义由编译器与运行时直接保证,使并发程序能像写数据库事务一样安全;Clojure 的 data refs/STM 体系为多线程更新提供了简洁语义;Java 世界曾出现多种 STM 框架(如 Multiverse、Akka STM),用于替代“锁”来管理共享状态;Rust 虽然不支持 STM,但其 community 曾多次尝试以库形式模拟;Erlang/Elixir 生态因 Actor 存在而减少了对 STM 的需求,但某些数据库与缓存组件仍使用事务内存思想。
虽然 STM 不如异步模型或 Actor 模型那样广泛应用,但它在“解决共享可变状态并发问题”上提供了一个极具理论美感、工程上可行的替代方案,是并发控制语义中的重要一环。
总结
本章从“抽象的并发模型”落地到“真实可执行的实体”,说明了并发在运行时究竟以什么形式存在:进程、线程、协程、任务,以及作为特例的 Actor。它们分别在隔离性、性能、可组合性与语义表达力之间做出不同取舍,构成现代软件并发执行的基础。随后介绍的多进程模式、事件循环模式与 STM,并不是新的并发模型,而是工程中让这些执行单元真正安全、高效运转所必需的机制。至此,我们已经看清并发模型如何映射到具体的执行对象,以及工程世界如何支撑它们落地。下一章将进一步深入到调度层,解释这些执行单元是如何被运行时“推进起来”的。
调度层
当我们从“并发模型”(语义层)进入“并发实现层”(进程、线程、协程、任务),下一步就必须回答一个关键问题:这些执行单元究竟是谁来推进?以什么顺序推进?在什么条件下挂起/恢复? 这就是“调度层”——一切并发系统真正开始“动起来”的地方。
调度层决定了上层模型的实际表现,也决定了系统的吞吐量、公平性与延迟。
总体流程
此图展示了调度层的核心循环:
- 可运行 → 运行中 → 挂起 → 唤醒 → 回到队列
- 最终完成或取消后退出系统 调度器本质就是不断决定“下一个执行谁”。
主要解决什么问题?
调度层的职责不在于“语义建模”,而在于“资源分配与推进逻辑”。
它主要解决 4 类问题:
- 如何从众多执行单元中挑选下一个?
- 执行单元在什么时候挂起?谁唤醒它?
- 如何最大化利用 CPU,使之无空转?
- 如何在多核环境下分配执行单元? 本质上:调度层位于语义模型与硬件并行能力之间,是“如何跑起来”的关键桥梁
常见调度方式
调度器有无数实现,这里按运行时常见分类简洁列出:
抢占式调度(Preemptive Scheduling)
- 由 OS 决定线程何时暂停/切换
- 优点:不需要协作
- 缺点:上下文切换重、不可预测
协作式调度(Cooperative Scheduling)
- 执行单元主动让出执行权(yield/await)
- 典型:Rust async、JS promise、Go runtime mixed
- 非阻塞 I/O 下非常高效
时间片轮转(Round Robin)
- 每个可运行单元获得固定时间片
- 经典 OS 策略
多队列调度(Multi-Queue)
- 每个 worker thread 有自己的 run queue
- 解决多核扩展性问题
- 代表:Tokio、Go、Java ForkJoinPool
Work Stealing(工作窃取)
- 空闲线程从其他 queue 偷任务
- 保证负载均衡
事件驱动调度(Event-driven)
- 由 epoll/kqueue/IOCP 等事件源驱动
- Node.js / asyncio / Tokio I/O 子系统
优先级调度(Priority Scheduling)
- 多级队列
- 实时系统常用
总结
调度层是整个并发体系的动力核心,它负责:
- 管理可运行与等待队列
- 决定执行单元的执行顺序
- 在 I/O、锁、信号条件下挂起/恢复执行
- 将协程/任务映射到线程与 CPU
- 在多核条件下实现高效分布与负载均衡 它不负责语义建模,只负责把所有“可并发的东西”——推进起来。
注意事项
需要特别说明的是:
调度层属于操作系统、运行时系统与性能工程的深水区,细节远超一般应用层开发的范畴。
调度器的完整工程实现涉及操作系统原语、锁自由队列、cache/NUMA 拓扑、work-stealing 算法、线程池模型、I/O 多路复用、负载均衡、优先级反转避免策略等大量复杂机制。受限于篇幅与文章重点,本章仅做总体视角的架构化总结,旨在帮助读者建立模型层 → 实现层 → 调度层之间的清晰认知,而不展开任何具体运行时(如 Go / Tokio / JVM / Erlang BEAM)的细节级源码分析。 未来如果专门讨论某个运行时的调度器,将作为独立专题文章进行深入拆解。
执行层
调度层决定“谁先后执行”、何时挂起与恢复;而执行层关心的问题更底层:并发执行最终是如何在不同操作系统与硬件上真正跑起来的? 这一层处于调度器与 CPU/内核之间,是所有并发系统的“物理落地面”。不同操作系统在调度策略、线程模型、I/O 模型、计时器、内核结构等方面差异巨大,这些差异直接影响语言运行时(Tokio、Go、JVM、BEAM)的实际表现。由于执行层属于系统工程的领域,本节仅给出 高层差异概览,不进入 OS 源码细节。
Linux:以 epoll 与 NPTL 为核心的现代并发执行环境
Linux 是当前服务器市场的事实标准,其执行层有以下关键特征:
- 线程 = 内核线程(1:1 NPTL 模型) 每个用户态线程直接映射到一个内核线程,上下文切换、调度由 CFS(完全公平调度器)负责,这是 Java、C++、Rust、Go 在 Linux 下的基础执行结构
- 最强的异步 I/O 支持(epoll → io_uring)epoll:多路复用基础,Node.js、Nginx、Tokio 都依赖它,io_uring:下一代高性能异步 I/O,绕过传统系统调用路径
- 内核事件驱动友好 timerfd、eventfd、signalfd 等专用文件描述符,方便 runtime 把“所有事件”统一放进 epoll
- NUMA 影响明显 多 CPU 插槽系统中,跨节点内存访问成本高,Work-stealing、线程绑定(affinity)都需考虑 NUMA 简言之:Linux 是最完善的并发执行土壤。
Windows:IOCP 主导的高吞吐异步内核
Windows 的执行层有其独特之处:
- 线程模型不同(Windows Thread ≠ Linux Thread) 内核对象更重并且创建成本高于 Linux 线程,但调度机制较成熟
- I/O 完全依赖 IOCP(I/O Completion Port) IOCP 是原生事件驱动模型,天然支持高并发 I/O,Node.js、Rust、C#的 Win 实现全部基于 IOCP
- 没有 epoll/kqueue,此处是根本差异 Rust/Tokio 在 Windows 下的性能表现与 Linux 不同,而且Go runtime 也必须为Windows维护独立的 netpoller 逻辑
- Timer / Waitable Object 体系复杂 有 event、semaphore、mutex、waitable timer 等多个内核对象,与 Linux 的“全部当文件描述符”截然不同,导致跨平台 runtime 需要两套实现 简言之:Windows 虽无 epoll,但 IOCP 本身是极强的异步执行引擎。
macOS / BSD:kqueue 驱动的高效率事件系统
苹果家族的执行层基于 BSD 内核,其并发能力主要来自 kqueue。
- 线程模型接近 Linux,但更重量级 用户态线程 1:1 映射到内核线程,而且调度器策略与 CFS 不同,线程创建成本普遍高于 Linux
- kqueue:比 epoll 更灵活的事件通知机制 可以监听文件、socket、进程、信号、定时器等多种事件,比 epoll 的 API 更一致
- Apple 设备中 OpenBSD/Hardened 特性影响深 沙盒体系导致系统调用限制,大量文件事件不可直接访问 ,这使得某些 runtime(如 Node/Tokio)需要额外补丁。 简言之:macOS 的并发模型优雅但非为“海量并发”场景设计。
FreeBSD / OpenBSD:高度强化的 kqueue 模型
- 与 macOS 同源
- kqueue 非常强大
- FreeBSD 的网络栈在高负载下表现优异
- 许多高性能服务器(如 Netflix)使用 FreeBSD 对于 async runtime、actor runtime、网络服务来说:FreeBSD ≈ 更稳定、网络栈更强的 macOS。
总结
执行层直接决定:
- async runtime 的 I/O 能力
- actor runtime 的 mailbox 唤醒延迟
- goroutine/netpoller 的效率
- thread pool 的可扩展性
- 多核性能是否能真正发挥
- 系统是否支持“百万协程并发” 换句话说:不同 OS 下,同一段并发代码表现完全不同,根源就在执行层。
注意事项
执行层涉及操作系统内核、调度器实现、线程栈管理、I/O 子系统、NUMA 拓扑、文件描述符语义、内核锁与中断,属于系统工程的专业领域。
本文仅给出各主流操作系统并发执行体系的高层差异,用于帮助读者建立“模型 → 实现 → 调度 → 执行”的全局认知,而不涉及任何 OS 源码或运行时内部的精细实现。
全文总结
本篇文章从“并发模型”出发,一路向下梳理到“实现层、调度层与执行层”,构建了一个从语义到操作系统的完整并发认知框架。并发并非单一技术,而是由多个抽象层次共同组成:模型定义任务间如何协作(例如线程+锁、CSP、Async、Actor),实现层将这些语义映射为可执行单元(进程、线程、协程、任务),调度层决定这些单元何时执行、何时挂起与恢复,而执行层则由不同操作系统与硬件真正落地。 通过这一套分层视角,我们能够清楚理解:同样的语言特性(如 async/await、goroutine、actor),其真正表现取决于底层执行环境与调度策略;同样的并发设计,落在 Linux、Windows、macOS 上,其扩展性与性能也会显著不同。因此,选择并发模型从不是语法问题,而是工程权衡:业务特点、I/O 密度、CPU 负载、隔离需求与部署环境都会影响最佳方案。 最终,希望读者在阅读本文之后,对并发能拥有更清晰的整体认知:并发是一条从抽象语义到物理执行的连续链路,而成熟的工程能力,需要在不同层次之间做出正确拆分与选择。理解这一点,比记住任何一种具体并发 API 都更加重要。
本文旨在构建并发知识的整体框架,个别细节可能有简化或疏漏之处,欢迎读者指正补充。真正的学习往往始于发现不完美并共同完善。