并行编程核心概念:进程、线程、协程、任务、同步、互斥、死锁、竞态条件
并行编程的复杂性并非源于算法设计本身,而在于对执行实体(进程、线程、协程、任务)的合理编排,以及对共享资源访问的精确控制。初学者往往混淆“并发”与“并行”的语义,更难以区分线程与协程在调度层级上的本质差异。本章将围绕八个核心概念,从定义、调度模型、协作机制到典型故障模式,构建一套完整的认知框架。理解这些概念是掌握后续锁机制、原子操作与无锁编程的基石。
上图展示了本章八个核心概念之间的逻辑关系:进程、线程、协程与任务构成了不同粒度的执行实体;它们并发访问共享资源时,必须借助同步与互斥机制来保证正确性;若协调不当,则会引发竞态条件与死锁等典型故障。下面我们将逐一深入剖析。
3.1 执行实体:进程、线程、协程与任务
3.1.1 核心原理与调度模型
进程是操作系统进行资源分配的基本单位,拥有独立的地址空间、文件描述符表与信号处理机制。进程间通信(IPC)必须通过管道、消息队列、共享内存或套接字等显式机制完成,上下文切换开销较大(通常需要数百纳秒到微秒级)。线程是进程内的执行流,同一进程的所有线程共享地址空间与大部分资源,但各自拥有独立的栈与寄存器上下文。线程切换由内核调度器完成,开销远小于进程切换,但线程安全问题随之而来。
协程(Coroutine)是用户态协作式调度的执行单元,其切换不经过内核,而是由程序自身在特定挂起点(如 await 或 yield)主动让出控制权。协程的栈通常较小(几KB到几十KB),可以轻松创建数十万个实例。任务(Task)则是一个更抽象的概念,通常指代一个可调度的逻辑工作单元——在C++中对应 std::async 或线程池中的工作项,在Go中对应 goroutine,在Java中对应 Runnable 或 Callable。任务可以映射到线程或协程上执行,是并行算法设计中的“逻辑原子”。
| 维度 | 进程 | 线程 | 协程 | 任务 |
|---|---|---|---|---|
| 调度者 | OS内核 | OS内核 | 用户态运行时 | 运行时/线程池 |
| 地址空间 | 独立 | 共享 | 共享(通常) | 共享 |
| 切换开销 | 高(~μs) | 中(~100ns) | 极低(~ns) | 取决于映射 |
| 创建数量 | 百级 | 千级 | 十万级 | 百万级 |
| 通信方式 | IPC(管道/共享内存) | 共享内存+锁 | 通道/事件循环 | Future/Promise |
3.1.2 判断方法:如何选择执行实体
选择执行实体类型时,可遵循以下决策流程:
- 隔离性需求:若某个模块崩溃不应影响其他模块,或需要独立的安全权限边界,选择进程。
- CPU密集型 vs I/O密集型:CPU密集型任务适合线程(充分利用多核);I/O密集型任务(网络请求、磁盘读写)适合协程,避免线程阻塞浪费内核资源。
- 并发规模:若需要同时维持超过1万个连接,协程是唯一现实选择(线程栈内存开销过大)。
- 编程模型偏好:若团队熟悉回调/事件驱动,选择协程;若习惯命令式阻塞编程,选择线程。
3.1.3 实战场景:Web服务器并发模型对比
假设我们要实现一个支持10万并发连接的HTTP服务器。若采用线程模型,每个连接分配一个线程,则需10万线程——每线程默认栈8MB,仅栈内存就需要800GB,显然不可行。若采用协程模型(如Go的goroutine),每个goroutine初始栈2KB,10万并发仅需约200MB内存,且调度器可高效管理。实际测试中,Go标准库 net/http 在单机即可轻松支撑5万以上并发连接,而同等配置下线程模型在5000连接时就会出现明显的内存压力和上下文切换开销。
3.1.4 常见误区
- 误区一:认为协程能自动利用多核。实际上,协程默认运行在单线程事件循环上,若需并行计算,必须配合多线程调度器(如Go的GMP模型)。
- 误区二:任务与线程混为一谈。任务只是逻辑描述,最终仍需映射到具体执行实体;线程池中的任务队列并不等同于协程。
- 误区三:忽略进程间通信成本。共享内存虽然快,但需要处理缓存一致性;消息传递虽然慢,但天然避免数据竞争。
3.2 协作机制:同步与互斥
3.2.1 核心原理
同步(Synchronization)解决的是“执行顺序”问题——确保多个执行实体按照特定时序协作。典型工具包括:信号量(Semaphore)用于控制同时访问资源的实体数量;条件变量(Condition Variable)用于等待某个条件成立;屏障(Barrier)用于等待一组线程全部到达某点。互斥(Mutual Exclusion)解决的是“资源独占”问题——确保同一时刻只有一个执行实体进入临界区访问共享资源。互斥通常通过互斥锁(Mutex)、读写锁(RWLock)或原子操作实现。
两者的关系可以这样理解:互斥是同步的一种特殊形式(互斥锁本质上是值为1的信号量),但同步更关注“顺序协调”,互斥更关注“排他访问”。例如,生产者-消费者问题中,生产者需要同步(等待缓冲区有空位),消费者也需要同步(等待缓冲区有数据),而缓冲区本身的读写需要互斥保护。
3.2.2 操作流程:正确使用锁与条件变量
// C++ 伪代码:生产者-消费者模型
std::mutex mtx;
std::condition_variable cv;
std::queue<int> buffer;
const int MAX_SIZE = 10;
void producer(int item) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return buffer.size() < MAX_SIZE; }); // 等待空间
buffer.push(item);
cv.notify_all(); // 通知消费者
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return !buffer.empty(); }); // 等待数据
int item = buffer.front();
buffer.pop();
cv.notify_all(); // 通知生产者
}
关键操作流程:① 使用 unique_lock 而非 lock_guard(因为条件变量需要临时解锁);② 必须使用谓词形式的 wait 防止虚假唤醒;③ 通知时尽量使用 notify_one 而非 notify_all 以减少惊群效应。
3.2.3 案例拆解:多线程统计词频
假设有4个线程并行统计一个大文件的词频。初始实现为每个线程维护自己的哈希表,最后合并——这不需要互斥。但若所有线程共享一个全局哈希表,则每次插入都需要加锁。实测数据:在8核CPU上处理1GB文本,独立哈希表方案耗时2.1秒;全局加锁方案耗时7.8秒(锁竞争严重)。进一步优化:使用分片锁(Sharded Lock),将哈希表分为16个分片,每个分片独立加锁,耗时降至2.4秒,接近无锁方案。
3.2.4 常见误区
- 误区一:锁粒度越细越好。过细的锁会增加获取/释放次数和缓存行同步开销,反而降低性能。
- 误区二:忘记使用
volatile或原子操作。普通变量在编译器优化下可能被缓存到寄存器,导致其他线程看不到更新。 - 误区三:条件变量必须与互斥锁配合使用,不能单独使用。
3.3 故障模式:竞态条件与死锁
3.3.1 核心原理
竞态条件(Race Condition)指多个执行实体以不可预测的顺序访问共享资源,导致结果依赖于调度时序。经典的“检查后执行”(check-then-act)模式最容易引发竞态:例如两个线程同时检查余额充足后各自扣款,导致余额变为负数。死锁(Deadlock)指多个执行实体互相等待对方释放资源,导致所有实体永久阻塞。死锁产生的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。
3.3.2 判断方法:死锁检测与预防
| 策略 | 方法 | 适用场景 | 代价 |
|---|---|---|---|
| 预防 | 破坏循环等待(按序加锁) | 锁数量少且固定 | 降低并发度 |
| 避免 | 银行家算法(资源分配前判断安全性) | 资源可预知 | 算法复杂度高 |
| 检测 | 等待图检测 + 超时回滚 | 分布式系统 | 需要事务支持 |
| 鸵鸟策略 | 忽略死锁,重启恢复 | 单机低概率场景 | 可能丢失数据 |
3.3.3 实战案例:数据库连接池死锁
某支付系统使用连接池(最大连接数10)处理事务。事务A需要同时获取数据库连接1和连接2;事务B也需要连接1和连接2。当A持有连接1等待连接2,B持有连接2等待连接1时,发生死锁。初始现象:系统吞吐量骤降为0,连接池监控显示10个连接全部被占用但无活动SQL。
分析过程:通过 SHOW PROCESSLIST 发现两个事务均处于 Waiting for lock 状态。进一步检查代码,发现事务A和B的加锁顺序不一致——A先获取连接1再获取连接2,B先获取连接2再获取连接1。
解决方案:统一加锁顺序为“先连接1后连接2”,并设置连接获取超时(如5秒)。修改后,系统恢复稳定,死锁消失。复盘经验:① 所有多资源获取必须全局排序;② 连接池应提供 tryAcquire(timeout) 接口;③ 监控工具应能展示锁等待链。
3.3.4 常见误区与总结
- 误区一:认为原子操作可以完全替代锁。原子操作只能保护单个变量,无法保护“多个变量的一致性状态”。
- 误区二:死锁只发生在多线程中。协程同样可能死锁——例如两个协程互相等待对方发送消息。
- 误区三:竞态条件只影响正确性,不影响性能。实际上,竞态可能导致数据损坏后的重试风暴,性能急剧下降。