45、同步与松弛内存一致性模型详解

同步与松弛内存一致性模型详解

1. 硬件实现同步原语的缺陷

硬件实现的同步原语存在一些明显的缺陷:
- 可扩展性差 :除了内存之外,还需要共享资源,而共享资源的带宽限制了与之相连的处理器数量。
- 灵活性有限 :如果所需的同步资源(总线线路、触发器、寄存器)超过了可用的硬连线数量,那么当达到最大值时,某些线程必须停止执行,或者软件必须对用于同步的硬件资源进行多路复用(虚拟化),这比在共享内存中实现同步更加困难和低效。
- 复杂性高 :如今的处理器是多线程的,因此我们需要为每个线程提供同步支持,而不仅仅是为每个核心提供支持。

相比之下,共享内存为同步锁提供了丰富的共享内存位置,并且缓存机制有助于提高对每个内存位置(包括锁等共享同步数据)的访问可扩展性。

2. 基于软件的同步

历史上,同步主要通过共享内存和称为RMW(读 - 修改 - 写)的特殊共享内存指令来实现。RMW指令是除了加载和存储之外的一类新的内存访问指令,它从内存中读取一个值,对其进行修改,然后原子地将其写回内存。所有现代指令集架构(ISA)都有支持原子RMW操作的指令。

2.1 简单加载和存储实现锁的问题

我们先尝试使用简单的加载和存储指令来实现锁:

Lock:
    LW R2,lock
    BNEZ R2,Lock
    SW R1,lock  /R1 = 1
Unlock: SW R0,lock

这段代码中,锁会“忙等待”,直到返回的值为0,然后将锁设置为1。然而,由于指令之间缺乏原子性,这段锁代码存在问题。两个线程可能同时将锁的值0加载到它们的寄存器R2中,这将导致两个线程同时进入临界区,这是不希望出现的结果。

2.2 测试并设置(T&S)

原子RMW指令如测试并设置(T&S)可以解决锁的问题。该指令读取一个内存位置,原子地将其设置为1,并最终将读取的值返回在一个寄存器中:

T&S R1,lock

如果R1中返回的值为0,则表示成功获取锁;如果返回的值为1,则表示获取锁失败。使用T&S指令,可以在多处理器系统中正确实现锁:

Lock:
    T&S R1,lock
    BNEZ R1,Lock
Unlock: SW R0,lock

T&S可以在内存中执行(如果锁不可缓存),也可以在缓存中执行(如果锁可缓存)。如果在内存中执行,它会绕过缓存,内存控制器可以通过先执行加载,然后存储1,并返回加载的值来强制执行RMW周期的原子性。如果在缓存中执行,协议应该是失效协议,如MSI - 失效协议。

然而,上述锁代码会产生大量的内存总线流量。为了减少缓存间的流量,可以在忙等待循环中暂停后重试T&S。指数退避是一种常用的技术,每次T&S失败时,下一次尝试的延迟会呈指数增加。

还有一种利用缓存协议的“测试并测试&设置”锁:

Lock:
    LW R1,lock
    BNEZ R1,Lock
    T&S R1,lock
    BNEZ R1,Lock
Unlock: SW R0,lock

在这段代码中,忙等待循环执行常规加载,在MSI - 失效协议中这些加载会命中缓存。一旦加载返回0,就会执行T&S以尝试原子地获取锁。

2.3 其他RMW指令

除了测试并设置之外,还有其他RMW指令:
- 交换(Swap) :将锁设置为寄存器中存储的任何值:

SWAP Rx,lock

Rx和内存位置lock中的值会原子地交换。当寄存器中的值为1时,测试并设置是交换的一种特殊情况。交换也可以通过在交换中使用R0来解锁。
- 比较并交换(CAS) :只有在满足条件时才进行交换:

CAS Rx,Ry,lock

将Rx中的值与内存位置lock中的值进行比较,如果它们相等,则将Ry和lock中的值原子地交换。CAS可用于管理队列的原子插入和删除。
- 获取并操作(F&OP) :返回内存位置的值,然后对该内存位置应用操作OP。最常见的形式是F&ADD:

F&ADD Rx,lock,Imm

内存位置lock中的值会返回在Rx中,然后lock会原子地增加立即值Imm。F&ADD可用于动态调度循环迭代。

2.4 加载锁定(LL)和存储条件(SC)

RMW指令是复杂的指令,不太适合RISC流水线(如5级流水线)或乱序处理器,因为它们需要原子地执行两个内存访问(一个加载和一个存储)。可以将RMW指令的加载和存储分开,但硬件必须确保在这两个指令的执行之间不违反原子性。使用LL和SC实现T&S锁的代码如下:

T&S(Rx,lock):
    ADDI R1,R0,1
    LL Rx,lock
    SC R1,lock
    BEQZ R1, T&S
    return

加载链接(LL)将内存位置lock加载到Rx中;存储条件(SC)只有在自LL以来没有对lock执行其他存储时才更新内存,否则它会中止内存更新并在寄存器R1中返回0。如果R1中返回的值为0,则重试LL。

LL/SC的实现依赖于缓存一致性协议。窥探器或网络接口支持一个LL位,当执行LL时设置该位。窥探器或网络接口监视传入信号,如果接收到失效或更新,则重置LL位,这会导致后续的SC失败。

以下是使用LL和SC实现CAS的代码示例:

CAS(Rx,Ry,X)
    ADD R2,Ry,R0  /save Ry in R2
    LL R1,X
    BNE Rx,R1,return
    SC R2,X  /attempt to store Ry
    BEQZ R2,CAS
    ADD Ry,R1,R0  /return X in Ry
return:
2.5 信号量

信号量是基于锁和解锁原语构建的系统级同步原语。对信号量有两个操作:P和V。P(sem)是一个内核调用,用于获取信号量(并进入临界区);V(sem)是一个内核调用,用于释放信号量(并退出临界区)。

布尔信号量是一个二进制变量,类似于锁。当它为1时,信号量是空闲的;当它为0时,信号量是忙碌的。布尔信号量控制对临界区的访问,这些临界区足够大,值得在稍后重新调度线程时进行抢占。

P(sem): if (sem>0) then sem--
        else <block calling thread and switch to another thread>;
V(sem): if <there is a thread waiting on sem> then
            <select waiting thread and wake it up>
        else sem++;

信号量初始化为1。如果在P操作时信号量忙碌,则线程被挂起,其描述符被插入到与信号量关联的等待队列中。然后激活另一个线程并让其运行。在V操作时,查找与信号量关联的等待队列。如果队列为空,则将信号量设置为空闲;否则,从等待队列中选择优先级最高的线程并激活它。

3. 同步事件的组成部分

任何基于软件的同步原语或线程操作(如屏障、线程创建或线程终止)都是基于锁强制执行的临界区构建的。锁必须先被获取,然后再被释放。获取是一种获取同步变量访问权限以通过同步事件的方法,释放则使其他线程能够获得通过同步的权利。释放可以通过简单的存储R0或与R0交换来实现,而获取则更复杂,必须包含一种等待方法。

常见的等待方法有:
- 忙等待 :如果锁忙碌,线程会不断测试它,直到其值改变。在此过程中,它会消耗处理器和内存带宽。线程调度器可以减轻这种开销。在硬件多线程核心中,等待线程可以被停用,或者其执行优先级可以降低。在某些情况下,锁可以挂起正在运行的线程(阻塞它),使其从核心的就绪线程列表中移除。
- 阻塞 :在操作系统实现的信号量中,P是一个阻塞操作。在获取时,如果信号量忙碌,线程会被取消调度并放入与信号量关联的等待队列中,然后另一个线程会在硬件线程上下文中被调度。这为其他线程释放了处理器。

等待方法的选择主要取决于预期的等待时间。忙等待几乎没有线程调度开销,但会消耗硬件资源,因此只应在预期等待时间非常短的情况下使用。当预期等待时间很长时,最好抢占等待线程并将硬件上下文分配给其他线程。在某些情况下,混合方法可能会有效:首先,线程尝试通过忙等待获取锁,经过几次不成功的尝试后,线程会被挂起在等待队列中。

在下面的mermaid流程图中展示了同步事件的基本流程:

graph TD;
    A[开始] --> B[获取锁];
    B --> C{锁是否可用};
    C -- 是 --> D[进入临界区];
    C -- 否 --> E{等待方法};
    E -- 忙等待 --> F[持续测试锁];
    F --> C;
    E -- 阻塞 --> G[放入等待队列];
    G --> H[等待唤醒];
    H --> B;
    D --> I[执行操作];
    I --> J[释放锁];
    J --> K[结束];
4. 松弛内存一致性模型

顺序一致性是程序员期望的最严格的内存一致性模型。在这个模型中,不同线程的各个内存指令按照它们的线程顺序交织在所有内存访问的全局一致顺序中。顺序一致性对硬件施加了严格的约束,因为它限制了内存访问的发出和全局执行方式和时间。特别是,在顺序一致的系统中,存储缓冲区无效,这使流水线面临存储的全部延迟。

编译器也可能违背程序员的意图,因为代码移动和修剪是在每个线程中单独进行的。例如,在以下代码中,编译器可能会将线程T1中FLAG的更新移到A的更新之上,因为这种代码移动不会导致局部危险。此外,一个好的编译器可能会检测到线程T2中的while循环是一个无限循环,并可能将其删除。

INIT: A=FLAG=0
T1
    A=1;
    FLAG=1;
T2
    while(FLAG==0);
    Print A;

为了防止这种情况发生,程序员必须将FLAG声明为特殊的共享变量,以便编译器极其谨慎地处理对它的访问。在这种情况下,硬件也可以通过使用特殊的加载和存储来区别对待对这些变量的访问。因此,在硬件层面上对所有内存位置全面追求顺序一致性可能是徒劳的。

其他模型可能会产生更高效的硬件。当一个内存一致性模型对硬件的要求比顺序一致性弱时,我们称之为松弛。松弛内存一致性模型可能依赖也可能不依赖于显式的同步原语,如T&S或CAS。

4.1 不依赖同步的松弛模型

不依赖同步的松弛内存模型可以用一个简单的硬件模型来描述,在这个模型中,共享内存系统是原子的,每个进程(线程)都有一个有或没有转发的存储缓冲区。

从硬件角度来看,内存访问的执行有四个需要强制执行的顺序:加载 - 加载、加载 - 存储、存储 - 加载和存储 - 存储。箭头表示第二个访问不能在所有前面的第一个类型的访问全局执行之前发出到内存。

以下是不同内存一致性模型对这些顺序的要求表格:
| 模型 | 加载 - 加载 | 加载 - 存储 | 存储 - 加载 | 存储 - 存储 |
| — | — | — | — | — |
| 顺序一致性 | 加载在内存(ME)阶段阻塞,直到返回值,期间后续加载和存储都被阻塞 | 加载在内存(ME)阶段阻塞,直到返回值,期间后续加载和存储都被阻塞 | 若地址不在本地存储流水线中,加载必须等待存储缓冲区中的所有先前存储在缓存中全局执行 | 存储必须按FIFO顺序从存储缓冲区逐个全局执行 |
| 无转发的存储 - 加载松弛 | 加载可以绕过本地存储流水线中具有不同地址的先前存储 | 加载可以绕过本地存储流水线中具有不同地址的先前存储 | 加载不返回本地存储流水线中的值,内存系统是存储原子的 | 存储必须按FIFO顺序从存储缓冲区逐个全局执行 |
| 有转发的存储 - 加载松弛 | 加载可以返回本地存储流水线中的值,打破存储原子性 | 加载可以返回本地存储流水线中的值,打破存储原子性 | 加载可以返回本地存储流水线中的值,打破存储原子性 | 存储必须按FIFO顺序从存储缓冲区逐个全局执行 |

4.2 顺序一致性

在顺序一致性中,所有线程顺序必须在所有内存访问的全局顺序中得到执行。可以通过以下方式强制执行所有顺序:
- 加载 - 存储和加载 - 加载 :加载在内存(ME)阶段阻塞,直到返回值。当一个加载在ME阶段阻塞时,所有后续的加载和存储都会被阻塞。
- 存储 - 加载 :加载值可以从本地存储流水线返回。如果地址不在本地存储流水线中,加载必须等待存储缓冲区中的所有先前存储在缓存中全局执行。
- 存储 - 存储 :存储必须按FIFO顺序从存储缓冲区逐个全局执行。由于存储 - 存储顺序,存储缓冲区中的合并是不允许的,除非合并的两个存储之间没有对不同位置的中间存储。

顺序一致性对存储 - 加载顺序的限制使得存储缓冲区几乎无效。由于有效使用存储缓冲区对流水线处理器的性能至关重要,许多重要的内存一致性模型放宽了存储和后续加载之间的顺序。

4.3 无转发的存储 - 加载松弛

在这个模型中,加载不能对非全局执行(GP)的值进行操作。基本规则如下:
- 加载可以绕过本地存储流水线中具有不同地址的先前存储。
- 加载不返回本地存储流水线中的值(无转发)。
- 内存系统(在本地存储流水线之后)是存储原子的。

这个内存一致性模型是存储原子的,但对不同位置的内存访问在缓存中会按线程顺序之外的顺序处理,这是与顺序一致性的主要区别之一。这个模型已被IBM机器(如IBM370 ISA)采用。

例如,在下面的代码中:

INIT: A=B=0
T1
    S1(A)1
    L1(B)0
T2
    S2(B)1
    L2(A)0

在顺序一致性模型中可能存在循环,但在无转发的存储 - 加载松弛模型中,由于加载可以绕过具有不同地址的存储,循环被移除,所有访问可以以一致的方式排序。

4.4 有转发的存储 - 加载松弛

有转发的松弛模型与无转发的松弛模型的区别在于,有转发时,加载可以在存储未全局执行时从线程的存储流水线返回值,从而打破存储原子性。整体系统是一致的,但不是存储原子的。

这个模型已被Sun Microsystems的SPARC ISA采用,通常被称为总存储顺序(TSO)。例如,在以下代码中:

INIT: A=B=C=0
T1
    S1(A)1
    S1(C)1
    L1(C)1
    L1(B)0
T2
    S2(B)1
    S2(C)2
    L2(C)2
    L2(A)0

有存储转发时,两个线程中C的存储和加载之间的顺序被移除,循环被打破,因此这个执行在有存储转发时是有效的,但在无存储转发时是无效的。

有存储转发的模型整体仍然是(普通)一致的,但由于无法找到所有内存访问的全局一致顺序,形式验证和人类验证都更加复杂。

4.5 Sun Microsystems松弛内存顺序

在松弛内存顺序(RMO)中,只强制执行线程内的一致性,就像在所有单处理器中一样;RMO不强制线程之间的任何访问顺序。然而,ISA通过MEMBAR指令向程序员或编译器暴露内存访问并行性,该指令用于在软件控制下强制执行全局内存顺序。

MEMBAR指令充当线程发出的内存访问的栅栏,它有一个4位操作数,每位对应一个顺序:加载 - 加载、加载 - 存储、存储 - 存储或存储 - 加载。通过在每对内存访问之间插入操作数为1111的MEMBAR指令,编译器可以强制硬件按线程顺序全局执行每个内存访问,从而以代码扩展为代价强制执行顺序一致性。RMO是一个非常灵活的模型,在这个模型下,内存访问的排序以及实际的内存一致性模型完全由软件控制。

同步与松弛内存一致性模型详解(下半部分)

5. 不同内存一致性模型的对比总结

为了更清晰地对比不同内存一致性模型的特点,我们将前面介绍的几种模型的关键特性整理成如下表格:
| 模型名称 | 顺序约束 | 存储缓冲区有效性 | 执行特点 | 应用示例 |
| — | — | — | — | — |
| 顺序一致性 | 严格遵循所有线程顺序,加载 - 存储、加载 - 加载、存储 - 加载、存储 - 存储顺序都需严格执行 | 几乎无效,因存储 - 加载顺序限制 | 内存访问按全局一致顺序执行,硬件约束大 | 对内存访问顺序要求极高的场景,但性能受限 |
| 无转发的存储 - 加载松弛 | 加载可绕过不同地址的先前存储,无转发,内存系统存储原子 | 有效,加载可在存储未全局执行时进行 | 存储原子,但不同位置内存访问可乱序 | IBM370 ISA |
| 有转发的存储 - 加载松弛(TSO) | 加载可返回本地存储流水线值,打破存储原子性 | 有效,可提前返回值 | 整体一致但非存储原子,难以找到全局一致顺序 | Sun Microsystems的SPARC ISA |
| 松弛内存顺序(RMO) | 仅线程内一致,通过MEMBAR指令控制全局顺序 | 灵活,由软件控制顺序 | 软件控制内存访问排序,可按需调整一致性 | 可根据具体应用灵活调整内存一致性 |

从这个表格中可以看出,不同的内存一致性模型在顺序约束、存储缓冲区的使用以及执行特点上有很大的差异,这也决定了它们在不同场景下的适用性。

6. 内存一致性模型对程序的影响

不同的内存一致性模型会对程序的执行结果和性能产生显著影响。

6.1 顺序一致性模型下的程序

在顺序一致性模型下,程序的执行结果是可预测的,因为所有内存访问都按照全局一致的顺序进行。例如,在下面的代码中:

INIT: A=FLAG=0
T1
    A=1;
    FLAG=1;
T2
    while(FLAG==0);
    Print A;

在顺序一致性模型中,线程T2会在FLAG被设置为1后才会打印A的值,并且打印的A值一定是1。但这种严格的顺序约束会导致存储缓冲区无效,增加了存储操作的延迟,从而影响程序的性能。

6.2 松弛内存一致性模型下的程序

在松弛内存一致性模型下,程序的执行结果可能会变得不可预测。例如,在有转发的存储 - 加载松弛模型(TSO)中,由于加载可以返回本地存储流水线中的值,打破了存储原子性,可能会出现线程T2在FLAG还未被全局更新时就读取到本地存储流水线中的FLAG值,从而提前打印A,而此时A的值可能还未被正确更新。

然而,松弛内存一致性模型可以提高程序的性能。因为它放宽了存储和加载之间的顺序限制,使得存储缓冲区可以更有效地工作,减少了存储操作的延迟。例如,在无转发的存储 - 加载松弛模型中,加载可以绕过不同地址的先前存储,使得程序可以在存储未全局执行时继续进行其他操作,提高了并行性。

7. 同步原语与内存一致性模型的结合使用

同步原语(如锁、信号量等)和内存一致性模型是相互关联的,它们共同影响着多线程程序的正确性和性能。

7.1 锁与内存一致性模型

锁是一种常用的同步原语,用于保护临界区。在不同的内存一致性模型下,锁的实现和使用方式可能会有所不同。
- 在顺序一致性模型下,锁的实现相对简单,因为所有内存访问都按照全局一致的顺序进行,锁的操作可以保证临界区的互斥访问。
- 在松弛内存一致性模型下,由于内存访问顺序的放宽,锁的实现需要更加谨慎。例如,在有转发的存储 - 加载松弛模型中,需要确保锁的操作不会因为加载提前返回本地存储流水线中的值而导致错误的临界区访问。

以下是一个使用锁的示例代码:

Lock:
    T&S R1,lock
    BNEZ R1,Lock
    // 进入临界区
    // 执行临界区操作
    SW R0,lock  // 释放锁

在不同的内存一致性模型下,这个锁的实现可能需要根据具体的模型特点进行调整,以确保其正确性。

7.2 信号量与内存一致性模型

信号量也是一种重要的同步原语,用于控制对共享资源的访问。在内存一致性模型的影响下,信号量的操作也需要考虑内存访问顺序的问题。
- 在顺序一致性模型下,信号量的操作可以按照预定的顺序执行,保证了资源访问的正确性。
- 在松弛内存一致性模型下,信号量的操作可能会受到内存访问乱序的影响。例如,在有转发的存储 - 加载松弛模型中,信号量的P和V操作可能会因为加载提前返回值而导致错误的资源分配。

信号量的操作代码如下:

P(sem): if (sem>0) then sem--
        else <block calling thread and switch to another thread>;
V(sem): if <there is a thread waiting on sem> then
            <select waiting thread and wake it up>
        else sem++;

在不同的内存一致性模型下,需要确保信号量的操作能够正确地反映资源的状态,避免出现资源竞争和死锁等问题。

8. 实际应用中的选择与权衡

在实际应用中,选择合适的内存一致性模型和同步原语需要综合考虑多个因素,如程序的性能需求、正确性要求、硬件平台等。

8.1 性能需求

如果程序对性能要求较高,需要充分利用硬件资源,那么可以选择松弛内存一致性模型。例如,在一些并行计算任务中,使用有转发的存储 - 加载松弛模型(TSO)可以提高内存访问的并行性,减少存储延迟,从而提高程序的执行速度。

8.2 正确性要求

如果程序对内存访问顺序有严格的要求,需要保证数据的一致性和正确性,那么顺序一致性模型可能是更好的选择。例如,在一些金融交易系统中,对数据的一致性要求极高,任何内存访问的乱序都可能导致交易错误,因此需要使用顺序一致性模型。

8.3 硬件平台

不同的硬件平台支持不同的内存一致性模型和同步原语。在选择时,需要考虑硬件平台的特性。例如,IBM370 ISA支持无转发的存储 - 加载松弛模型,而Sun Microsystems的SPARC ISA支持有转发的存储 - 加载松弛模型(TSO)。

以下是一个选择内存一致性模型和同步原语的决策流程图:

graph TD;
    A[开始] --> B{性能需求高?};
    B -- 是 --> C{对内存访问顺序要求低?};
    C -- 是 --> D[选择松弛内存一致性模型];
    C -- 否 --> E[选择顺序一致性模型];
    B -- 否 --> F{对内存访问顺序要求高?};
    F -- 是 --> E;
    F -- 否 --> G[根据硬件平台选择合适模型];
    D --> H[选择合适同步原语];
    E --> H;
    G --> H;
    H --> I[应用到程序中];
    I --> J[结束];
9. 总结

本文详细介绍了硬件实现同步原语的缺陷,以及基于软件的同步方法,包括RMW指令、锁、信号量等同步原语的实现和应用。同时,深入探讨了松弛内存一致性模型,包括无转发和有转发的存储 - 加载松弛模型、Sun Microsystems松弛内存顺序等,分析了它们与顺序一致性模型的区别和特点。

不同的内存一致性模型和同步原语在多线程程序中起着重要的作用,它们相互关联,共同影响着程序的正确性和性能。在实际应用中,需要根据程序的性能需求、正确性要求和硬件平台等因素,综合选择合适的内存一致性模型和同步原语,以达到最佳的效果。通过合理运用这些技术,可以提高多线程程序的并行性和效率,同时保证程序的正确性和稳定性。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值