目录
2. 写回(Write-back)与写通(Write-through)策略
1.概念
原子操作(Atomic Operations)和锁(Locks)都是在多线程编程中确保数据一致性和线程安全的技术,对共享变量的操作在执行时不会被干扰,从而避免竞争太条件。
1.1原子操作
原子操作是不可被其他线程中断的操作,即这些操作在开始执行后会连续运行,直到结束,不会出现在执行过程中被其他线程打断的情况。在多核处理器环境中,这是通过特定的机器指令来实现的。常见的原子操作包括原子加(atomic add)、原子减(atomic subtract)、原子读(atomic read)、原子写(atomic write)等。
其中一个特别重要的原子操作是“比较并交换”(Compare-And-Swap,CAS)。CAS 操作包含三个参数:内存位置、预期原值以及新值。操作过程中,CAS 会原子性地比较内存位置的值与预期原值是否相等,如果相等,就将该内存位置的值更新为新值,这整个过程是原子性的。
1.2原子性
这张图展示了CPU的缓存架构和缓存行的结构。
1.2.1CPU 缓存架构
- CPU核心:图中显示有一个CPU核心(core 0),其中包括寄存器(register)。寄存器是CPU内部的极小容量存储,用于存放指令、数据和地址等。
- L1缓存:速度最快的缓存,分为指令缓存和数据缓存,容量相对较小(32KB指令和48KB数据)。访问延迟大约是0.9纳秒。
- L2缓存:速度快,容量大于L1,存储更多的数据,延迟大约是2.8纳秒,容量为1.25MB。
- L3缓存:更大的共享缓存,延迟大约是12.9纳秒,容量为30MB。
1.2.2内存和缓存总线延迟
- 主存:存储容量最大,延迟最长(100纳秒),最大容量可以达到128GB。
1.2.3缓存行
- 缓存行结构:展示了一个64字节大小的缓存行,包括标记(tag),标志位(flag),以及数据部分(data)。缓存行是缓存和主存之间交换数据的最小单位。
1.2.4缓存一致性问题
缓存不一致问题通常发生在多核CPU的系统中,每个核心有自己的一级缓存(L1)和有时甚至有自己的二级缓存(L2),而三级缓存(L3)通常是多个核心共享的。当多个核心修改同一数据的副本时,就可能发生缓存不一致性问题。这里有几个关键因素导致这种问题:
1. 缓存层次的独立性
每个CPU核心的L1和L2缓存通常是私有的,这意味着核心可以独立地读取和写入它们的缓存,而不影响其他核心。如果一个核心更改了存储在其私有缓存中的数据,而这些数据的副本也存在于其他核心的缓存中,就会导致数据不一致。
2. 写回(Write-back)与写通(Write-through)策略
- 写回策略:在这种策略中,数据首先在缓存中修改,并在某个时间点之后才写回到主内存中。这种延迟写回可以增加缓存不一致的风险,因为其他核心的缓存可能在这段时间内还是旧的数据。
- 写通策略:在这种策略中,数据被同时写入缓存和主内存,这有助于保持一致性,但可能降低性能,因为每次写操作都要访问主内存。
3. 多核环境中的缓存一致性协议
为了解决这些一致性问题,现代多核处理器通常实现了缓存一致性协议,如MESI(修改、独占、共享、无效)协议,这是最常用的协议之一。这些协议通过在核心间传递信息来管理每个缓存行的状态,确保所有核心对共享数据的视图保持一致。例如:
状态:
- 修改(Modified):表示缓存行已被修改并且是唯一的副本,还未写回到主内存。
- 独占(Exclusive):表示缓存行未被修改,只有一个核心缓存了该行。
- 共享(Shared):表示缓存行未被修改,可能被多个核心缓存。
- 无效(Invalid):表示缓存行的数据是无效的,需要从主内存或其他缓存中重新加载。
通过这样的机制,系统可以维护多核环境中数据的一致性,从而确保程序的正确运行和性能的优化。
1.3内存序
内存序问题,也称作内存可见性和操作顺序问题,主要是由于现代计算机系统在编译器和处理器层面进行的优化造成的。这些优化包括指令重排和内存访问重排,目的是提高执行效率,但同时也可能导致多线程程序的执行结果不符合预期。
1.3.1内存序问题的根源
-
编译器优化重排: 编译器为了优化程序的性能,可能会改变指令的顺序。例如,编译器可能会重新排序不依赖于彼此的语句以填充流水线或减少依赖。
-
CPU指令优化重排: CPU为了更高效地利用执行单元和减少管道阻塞,可能会在保持程序语义的前提下对指令执行顺序进行优化。这种乱序执行可以提升处理器性能,但在多线程环境中可能导致内存访问的结果不一致。
1.3.2内存序的规定
内存序(Memory Order)规定了在多线程程序中,对共享内存的读写操作如何被排序和观察到,以保证数据一致性和正确性。内存模型尝试解决的核心问题是如何在多核处理器的不同核之间同步共享数据的状态。
1.3.36种不同的内存序
对同一时间的读写操作进行排序。在不同的 CPU 架构上,这些模型的具体实现方式可能不同,但是 C++11 帮你屏蔽了内部细节,不用考虑内存屏障,只要符合上面的使用规则,就能得到想要的效果。可能有时使用的模型粒度比较大,会损耗性能,当然还是使用各平台底层的内存屏障粒度更准确,效率也会更高,对程序员的功底要求也高。C++11标准中引入了几种不同的内存序选项,用于原子操作:
-
memory_order_relaxed: 放宽了内存序的要求。允许读写操作的乱序执行,但保证了原子性。不提供任何顺序保障。
-
memory_order_consume: 对于依赖于先前读取的数据的操作,保证了适当的顺序。但它的实用性和支持在实际实现中是有争议的,不建议使用。
-
memory_order_acquire: 防止之后的读或写操作被重排序到原子操作之前(加载操作)。
-
memory_order_release: 释放操作,在写入某原子对象时,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去,并且当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见;通常与 memory_order_acquire 或 memory_order_consume 配对使用;防止之前的读或写操作被重排序到原子操作之后(存储操作)。
-
memory_order_acq_rel: 结合了acquire和release,用于读-改-写操作,如原子的增加操作。获得释放操作,一个读‐修改‐写操作同时具有获得语义和释放语义,即它前后的任何读写操作都不允许重排,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见,当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见;
-
memory_order_seq_cst: 顺序一致性语义,对于读操作相当于获得,对于写操作相当于释放,对于读‐修改‐写操作相当于获得释放,是所有原子操作的默认内存序,并且会对所有使用此模型的原子操作建立一个全局顺序,保证了多个原子变量的操作在所有线程里观察到的操作顺序相同,当然它是最慢的同步模型。
1.5.4同步和顺序
内存序的选择影响了程序中线程间同步的行为和性能。正确的内存序可以防止数据竞争和条件竞争,确保程序的正确性。通过使用适当的内存序,程序员可以在性能和正确性之间做出权衡,如使用memory_order_seq_cst
来获得最直观的行为,或使用memory_order_relaxed
来获得可能的性能优势(在确保程序逻辑正确的前提下)。
1.4互斥锁
在多线程编程中,互斥锁是一种常用的同步机制,用于控制多个线程对共享资源的访问,确保在同一时刻只有一个线程可以访问特定的资源或执行特定的代码段。
1.4.1互斥锁(Mutex)
互斥锁提供了一种基本的同步手段,以防止两个线程同时执行临界区的代码。当一个线程获得互斥锁时,其他任何试图获得同一个锁的线程都会被阻塞,直到锁被释放。
1.4.2内存标记和线程ID
- 内存标记:在多线程环境中,操作系统或运行时可能会使用特定的内存标记来追踪资源的状态,如标记互斥锁是否被某个线程持有。
- 线程ID:每个线程拥有一个唯一的标识符(线程ID),用于识别和管理线程。在互斥锁的实现中,可能会记录哪个线程ID持有了锁,以实现锁的拥有权管理和错误检测。
1.4.3阻塞队列
阻塞队列是一种数据结构,用于线程间的同步,其中的线程执行依赖于队列中数据的状态。如果队列空,试图从中取数据的线程会被阻塞;如果队列满,试图存数据的线程也会被阻塞。
1.4.4硬件级原子操作
硬件级原子操作是由处理器直接支持的不可分割的操作,确保在执行过程中不会被其他线程或中断打断。常见的原子操作包括CAS(比较并交换)、原子加减操作等,它们是构建更高级同步机制(如互斥锁)的基础。
1.4.5屏蔽中断
在某些低级的同步操作中,如在内核开发或嵌入式系统中,可能会暂时屏蔽中断来防止在执行关键代码段时被中断,确保代码的原子性和一致性。
1.4.6行为模式
- 用户态自旋:一种轻量级的锁策略,线程在用户态中循环等待(自旋),尝试获取锁。这种方式避免了系统调用的开销,适用于锁持有时间极短的情况。
- 获取失败任务挂起:如果锁长时间不可用,线程可能会从自旋状态转入挂起状态,操作系统将其从CPU调度中移除,以减少资源消耗。
- 休眠和重试:线程在挂起一段时间后,可能会被重新唤醒(或根据某种策略自动重试),再次尝试获取锁。这种方式在锁竞争激烈的情况下可以有效地平衡CPU使用和响应时间。
以上各点展示了互斥锁的多方面特性和处理多线程环境中的同步复杂性的方法。这些机制和策略共同作用,帮助开发者构建既高效又安全的并发程序。
1.5其他锁
锁是另一种用于管理对共享资源的访问的机制,以避免多个线程在同一时间内对同一资源进行修改导致数据冲突。锁提供了一种方式,使得在任何时刻只有一个线程可以访问特定的数据或代码块。
锁的类型很多,包括:
- 自旋锁(Spinlock):线程在用户态进行忙等待,如果锁被其他线程持有,尝试获取锁的线程将在一个循环中不断检查锁的状态,直到锁被释放。
- 互斥锁(Mutex):在锁已被占用的情况下会使线程进入睡眠状态,即线程不会消耗CPU资源,而是等待操作系统的调度器再次将其唤醒。
- 读写锁(Read-Write Lock):允许多个读操作同时进行,但写操作会独占访问。在用户态,等待写锁的线程可能会进入睡眠状态,而读操作通常可以在没有太多上下文切换的情况下并行执行。
1.6原子操作 vs. 锁
选择合适的锁或使用原子操作通常依赖于应用程序的具体需求,如数据访问模式、锁的持有时间、资源的竞争程度以及对性能的影响。下面是一些常见情况下关于锁的选择以及原子操作的应用指南:
1.6.1使用原子操作的情况
- 简单的数值更新:如果只需要对单个变量进行基本的增加、减少、设置或比较和交换操作,原子操作通常是最快的选择。例如,计数器或标志的更新。
- 性能关键的小操作:在高性能计算或需要极低延迟的场景中,使用原子操作可以避免锁的开销,从而提高效率。
- 低冲突环境:如果资源冲突不频繁,原子操作因为简单快速,可以有效避免复杂的锁调度。
1.6.2使用自旋锁的情况
- 锁持有时间极短:如果预期锁的持有时间非常短,使用自旋锁可以避免操作系统进行线程上下文切换的开销。
- 多核系统:在多核处理器上,当其他核可能很快释放锁时,使用自旋锁可以减少等待时间。
- 实时系统:在需要确保响应时间的实时系统中,自旋锁可以避免由于线程阻塞和调度延迟带来的不确定性。
1.6.3使用互斥锁的情况
- 锁持有时间较长:当操作涉及到复杂处理或者调用多个操作时,使用互斥锁可以避免长时间的CPU空转,尤其是在锁竞争激烈的情况下。
- 保护复杂的数据结构:对于需要保护的数据结构复杂,如链表、树或者图这类的数据结构,互斥锁提供了一种简单的方式来确保数据一致性。
- 兼容性和简易性:在许多编程环境中,互斥锁提供了良好的库支持和易用性,使得它们在不追求极限性能的普通应用程序中非常实用。
1.6.4使用读写锁的情况
- 读多写少:当数据通常被多个线程频繁读取,但很少被修改时,读写锁是一个理想的选择。这种锁允许多个读操作并行执行,而写操作则需要独占访问。
1.6.5使用条件变量
- 等待特定条件:当线程需要在特定条件成立之前暂停执行时,条件变量可以与互斥锁一起使用来阻塞线程直到条件满足。