文章

[C++并发编程] 并发思维与基础

[C++并发编程] 并发思维与基础

并发思维与基础

并发与并行的区别

并发是一种程序的组织方式:把一个复杂的问题拆成多个独立、可以交替推进的子任务,然后用某种机制(线程、协程、事件循环)来管理它们的执行顺序。 并发不要求多个CPU核心,在单核机器上也可以通过操作系统的时间片轮转让多个线程交替使用CPU。

并行则是指多个操作真正在同一时间物理上同时执行,这需要多核CPU、多处理器或GPU等硬件支持。

在C++内,我们用std::thread、std::async、协程这些机制来表达并发。至于这些并发任务最终是分时复用一个核心,还是跑到不同的核心上,取决于操作系统的调度和硬件。

吞吐量与延迟

吞吐量是指单位时间内能完成的任务总数,延迟是指单个任务从提交到完成的时间。在并发设计中,这两者的优化方向经常冲突。

一个很典型的例子是批处理。假设你有一个任务队列,每处理一个任务需要 1ms 的 CPU 时间。如果你来一个任务就立刻处理,每个任务的延迟是 1ms,但线程切换、锁竞争的开销会让总吞吐量不高。如果你把任务攒成一批,每批 100 个一起处理,批内可以做一些优化(比如合并 I/O 操作),总吞吐量会大幅提升,但排在队列后面的任务延迟就从 1ms 变成了接近 100ms。

另一个经典的例子是负载均衡策略。最短队列优先(把新任务分配给当前队列最短的 worker)可以最小化平均延迟,但它的调度开销比简单的轮询(round-robin)更高。轮询的吞吐量通常更好,但个别任务可能被分配到已经很忙的 worker 上,导致尾部延迟(tail latency)飙升。

这种权衡没有”正确答案”,取决于你的业务需求。实时交易系统优先降低延迟,批处理数据管道优先提高吞吐量,而大多数 Web 服务需要在一个合理的延迟范围内最大化吞吐量。在开始设计并发架构之前,先想清楚你的系统更关心哪个指标。

任务粒度

任务粒度是指把工作拆分成多大的单元交给并发处理。 粒度太细的话,每次创建或调度一个并发任务都有开销:线程的创建和销毁、上下文切换、锁的获取和释放、缓存失效。如果任务本身的计算量比这些开销还小,引入并发反而会拖慢程序。

粒度太粗的话,把所有工作打包成一个大任务交给一个线程处理,那和单线程没什么区别。

所以,任务粒度的选择需要在并发开销和并发收益间找一个平衡点。在实际工程中,任务的粒度是通过实验确定的。可以从较大的粒度开始,逐步细化,每次都测量总执行时间和吞吐量,找到性能的最佳拐点(benchmark驱动的调优方式)。

什么时候不该用并发?

如果程序是CPU密集型的单任务,没有I/O等待,引入多线程可能没有帮助,甚至变慢。除非我们的算法是可并行化的。如果程序对确定性有严格要求,多线程可能会引入不确定性。

当我们需要的部署并行计算而是异步I/O时,例如一个网络服务需要同时处理上千个连接,线程数量会成为瓶颈。这种场景更适合事件驱动或者协程的方式,用少量线程通过I/O多路复用管理大量连接。

并发基本问题

  • 数据竞争
  • 竞态条件
  • 死锁、活锁
  • 饥饿与优先级反转

数据竞争

数据竞争是指两个线程访问同一个内存位置,并且这两个访问之间没有确定的先后关系(不是时间顺序,而是因果关系,即两个线程间没有join、mutex、原子同步等),因此编译器和CPU可以重排优化,从而导致未定义行为,这意味着编译器在数据竞争期间的行为我们无法确定,例如返回错误结果、崩溃或者可以正常编译通过。

要避免数据竞争,就要用互斥量、原子操作配合正确的内存序、线程join,在冲突访问之间建立先后的因果关系。

竞态条件

竞态条件是指程序的输出依赖于线程的执行顺序,但是这个顺序没有被程序显式的规定,所以程序的结果是不确定的。

死锁与活锁

死锁的定义是两个或多个线程互相等待对方持有的资源,导致所有线程都无法继续执行。 死锁的预防和修复策略有:

  1. 统一锁顺序:如果需要同时获取多个锁,就按照相同的顺序获取,C++17提供了std::scoped_lock,可以一次性获取多个互斥量,内部使用了避免死锁的算法,尝试不同的获取顺序,如果有获取失败的,就释放已获取的锁并重试,这种方式的话可以避免死锁,但不保证公平性。
#include <thread>
#include <mutex>
#include <iostream>

std::mutex mtx_a, mtx_b;

void worker(int id) {
    std::scoped_lock(mtx_a, mtx_b); //同时获取两把锁,内部避免死锁
    //...
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    t1.join();
    t2.join();
    return 0;
}

活锁是指一个或多个线程没有被阻塞,仍在运行,但系统无法取得有效的进展。例如两个线程要访问同一个临界区,冲突后双方都回退重试,但是回退的节奏一致,它们就会互相推让。解决思路的话就是引入随机退避,冲突后不要立刻重试,而是等待一个随机时间再重试。

饥饿

饥饿是指由于不公平的调度策略,某个或多个线程长期拿不到资源,导致任务无法推荐,但系统整体仍在运行。 例如:优先级调度,低优先级永远被高优先级抢占、读写锁,读线程源源不断,导致写线程一直在等待。解决方案 就是任务队列可以用优先级老化、读写锁可以换成写者有限策略。

优先级反转

假设有三个任务 high_prio_task、mid_prio_task、low_prio_task,优先级依次递减。low_prio_task 先拿到一把锁,正在用它;这时候 mid_prio_task 就绪了,优先级更高,于是抢占了 low_prio_task。紧接着 high_prio_task 也就绪了——它优先级最高,但需要 low_prio_task 持有的那把锁,于是只能阻塞等待。可问题是,low_prio_task 此刻已经被 mid_prio_task 抢占了,根本没机会运行,自然也没办法释放锁。结果就是:high_prio_task 这个最高优先级的任务,被优先级比自己低的 mid_prio_task 间接卡住了。

解决方式就说优先级继承,当 low_prio_task 持有 high_prio_task 需要的锁时,临时把 low_prio_task 的优先级拉到和 high_prio_task 一样高,这样 mid_prio_task 就抢不过它了,low_prio_task 能尽快把锁释放掉,high_prio_task 也就不用一直干等。

CPU cache与缓存一致性

CPU太快,而内存太慢,如果CPU直接从内存读数据的话,每次都要空转几百个周期等数据回来。解决方案就是在CPU和内存之间加几层更小、更快的存储。也就是CPU cache。现代多核处理器通常有三层缓存(L1-L3)。

L1和L2 cache是每核独占的,L3 cache则是所有核心共享。所以L3也负责核间数据的共享。一致性协议就说在这个层面上协调的。

缓存行

cache并不是一个字节一个字节的与主存交换数据,它以缓存行为单位进行操作,一行是64个字节。

缓存一致性与MESI协议

缓存一致性的目的就是解决核间数据的共享与同步,现代x86和ARM处理器使用MESI协议来维护多核之间的缓存一致性。MESI给每个缓存行四个状态。

Modified(M):这条缓存行被当前核心修改过了,跟主存中的值不一致。当前核心是唯一持有这条数据的有效副本的——其他核心的 cache 里如果有同一地址的数据,状态必须是 Invalid。当这条缓存行被驱逐(evict)时,必须写回主存。

Exclusive(E):这条缓存行跟主存中的值一致,而且只有当前核心持有它。虽然数据没被修改,但”独占”意味着当前核心可以随时修改它而不用通知其他核心——因为其他核心都不持有它的副本。

Shared(S):这条缓存行跟主存一致,而且可能同时存在于多个核心的 cache 中。当前核心可以读它,但不能直接写——写之前必须先让其他核心的副本失效。

Invalid(I):这条缓存行无效,相当于没有缓存任何有用的数据。访问 Invalid 状态的缓存行会触发 cache miss,需要从主存或其他核心的 cache 中重新加载。

状态之间的迁移由总线监听/目录协议来负责。这里需要注意的一点是缓存一致性不代表立即可见。虽然缓存一致性保证所有核心最终看到的是一致的,但是一个核心写入的值,到其它核心可见。中间会有个传播窗口期,窗口期内,其它核心看到的一直是旧值。这也就是为什么std::atomic需要不同的内存序来控制可见性的粒度。

False sharing(伪共享)

False sharing(伪共享) 是指多个线程分别访问不同的变量,但这些变量恰好位于同一个缓存行中。由于缓存一致性协议以缓存行为单位管理,一个线程写自己的变量时,会导致其他核心上的整个缓存行失效,迫使其他线程重新加载。结果是:逻辑上无冲突,物理上却互相干扰,性能大幅下降。

解决方法就是让不同的线程访问的变量位于不同缓存行,可以在结构体内手动填充或者使用alignas对齐到64字节。

OS线程

在操作系统视角下,线程是 CPU 调度的基本单位,进程是资源分配的基本单位。同一进程内的线程共享地址空间、文件描述符、信号处理等资源,但各自拥有独立的栈、寄存器和程序计数器。线程能“同时”运行,靠的是内核的上下文切换:把当前线程的寄存器状态保存到它的 TCB,再恢复下一个线程的状态并跳转执行。每个线程都有 TCB 记录完整运行状态,加上默认约 8 MB 的栈空间,线程的基础开销并不小,所以不能随意开几万个线程。

上下文切换开销

上下文切换的开销分两部分:直接开销是保存和恢复通用寄存器、浮点/SIMD 寄存器及系统寄存器,通常几微秒;间接开销更大,包括 TLB 失效导致 page table walk,以及新线程数据不在 cache 里引发 cache miss 风暴,冷热 cache 性能差距可达十倍甚至百倍。一次切换总代价约几微秒到几十微秒。如果任务粒度只有几微秒,切换开销可能比计算本身还大,这就是任务粒度过细在硬件层面的代价。

Linux线程实现

线程创建:Linux 线程的底层创建原语是 clone(),它是 fork() 的精细控制版:fork() 创建全新进程、资源全部复制,而 clone() 通过 flags 精确指定哪些资源与父进程共享、哪些复制。

线程同步:futex是mutex的底层调用,核心是“快路径在用户态,慢路径才进内核”。无竞争时,mutex 只用一次用户态原子操作即可拿到锁,开销接近几十个时钟周期;有竞争时才调用 futex(FUTEX_WAIT) 挂起线程,由持有者用 FUTEX_WAKE 唤醒。所以无竞争的 mutex 很便宜,竞争激烈的 mutex 很贵,因为每次都要用户态和内核态来回切换。

线程模型对比

1:1:每个用户态线程对应一个内核线程。Linux 的 pthread 和 std::thread 就是这种模型。优点是简单,能真正利用多核并行,阻塞 I/O 不影响其他线程;缺点是创建和切换都要进内核,开销大,线程数量受栈和 TCB 限制。

N:1:多个用户态线程映射到一个内核线程。创建和调度全在用户态,轻量、切换快;但一个线程阻塞会卡住整个内核线程,且只能跑在一个核心上,没有真正并行。早期绿色线程就是这种。

M:N:M 个用户态线程映射到 N 个内核线程,通常 M » N。既轻量又能利用多核,Go 的 goroutine 是典型实现。但实现复杂,要处理抢占、系统调用包装和栈切换。

C++ 程序员需要清楚:std::thread 在所有主流平台都是 1:1 模型,即一个用户线程对应一个内核线程。它简单可靠,但创建、切换开销大,线程数量受限,不适合大量轻量并发任务。

少量计算并行用 std::thread,大量任务用线程池,海量轻量并发用协程。线程池和协程本质上都是在 1:1 模型之上构建 M:N 调度策略,只是调度逻辑由你或运行时库控制。

本文由作者按照 CC BY 4.0 进行授权