并行编程核心概念:进程、线程、协程、任务、同步、互斥、死锁、竞态条件

并行编程的复杂性并非源于算法设计本身,而在于对执行实体(进程、线程、协程、任务)的合理编排,以及对共享资源访问的精确控制。初学者往往混淆“并发”与“并行”的语义,更难以区分线程与协程在调度层级上的本质差异。本章将围绕八个核心概念,从定义、调度模型、协作机制到典型故障模式,构建一套完整的认知框架。理解这些概念是掌握后续锁机制、原子操作与无锁编程的基石。

并行编程核心概念关系图 进程 (Process) 线程 (Thread) 协程 (Coroutine) 任务 (Task) OS内核调度 OS内核调度 用户态协作调度 抽象执行单元 共享资源 (Shared Resource) 内存变量 / 文件句柄 / 数据库连接 / 硬件寄存器 同步 (Synchronization) 信号量 / 条件变量 / 屏障 互斥 (Mutual Exclusion) 锁 / 原子操作 / 临界区 竞态条件 (Race Condition) 不可预测的交替执行 死锁 (Deadlock) 循环等待 / 互斥持有 活锁 / 饥饿 资源分配不均 核心矛盾:执行实体并发访问共享资源 → 需要同步与互斥 → 设计不当引发竞态与死锁

上图展示了本章八个核心概念之间的逻辑关系:进程、线程、协程与任务构成了不同粒度的执行实体;它们并发访问共享资源时,必须借助同步与互斥机制来保证正确性;若协调不当,则会引发竞态条件与死锁等典型故障。下面我们将逐一深入剖析。

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 判断方法:如何选择执行实体

选择执行实体类型时,可遵循以下决策流程:

  1. 隔离性需求:若某个模块崩溃不应影响其他模块,或需要独立的安全权限边界,选择进程。
  2. CPU密集型 vs I/O密集型:CPU密集型任务适合线程(充分利用多核);I/O密集型任务(网络请求、磁盘读写)适合协程,避免线程阻塞浪费内核资源。
  3. 并发规模:若需要同时维持超过1万个连接,协程是唯一现实选择(线程栈内存开销过大)。
  4. 编程模型偏好:若团队熟悉回调/事件驱动,选择协程;若习惯命令式阻塞编程,选择线程。
经验提醒:在C++20/23中,协程是无栈的(stackless),这意味着协程不能调用普通函数后挂起——必须使用专门的协程感知库(如cppcoro)。而Go的goroutine是有栈协程,可以自由调用阻塞函数,但栈初始仅2KB,动态增长。

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秒,接近无锁方案。

风险提示:分片锁虽然降低竞争,但可能引入“死锁”风险——若线程A持有分片1的锁后请求分片2,而线程B持有分片2的锁后请求分片1,则发生循环等待。解决方法是规定所有线程必须按分片编号升序加锁。

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 常见误区与总结

  • 误区一:认为原子操作可以完全替代锁。原子操作只能保护单个变量,无法保护“多个变量的一致性状态”。
  • 误区二:死锁只发生在多线程中。协程同样可能死锁——例如两个协程互相等待对方发送消息。
  • 误区三:竞态条件只影响正确性,不影响性能。实际上,竞态可能导致数据损坏后的重试风暴,性能急剧下降。
核心总结:并行编程的本质是在“执行效率”与“数据正确性”之间寻找平衡。进程、线程、协程、任务提供了不同粒度的并发工具;同步与互斥是保证正确性的必要手段;竞态条件与死锁则是设计不当的必然代价。掌握这些概念后,下一步应深入理解内存模型与原子操作,才能写出既高效又正确的并行代码。