44、内存一致性与同步机制解析

内存一致性与同步机制解析

1. 顺序一致性与内存访问定义

在内存系统中,顺序一致性是一个重要的概念。为了更好地理解和优化内存访问,有了新的内存访问执行定义。
- 存储操作的执行 :对于线程 i 而言,当线程 i 的处理器节点收到存储操作的通知(失效或更新)时,存储操作就相对于该线程执行了。当存储操作相对于所有线程都执行完毕,它就实现了全局执行。
- 加载操作的执行 :当加载操作的值被绑定且不可撤销时,加载操作执行。当加载操作执行,并且提供该值的存储操作也全局执行时,加载操作实现全局执行。

基于这些新定义,之前关于存储原子性和顺序一致性的充分条件仍然有效,并且能利用入站消息处理的优化。不过,新定义违反了原始的存储原子性定义。因为在存储操作全局执行时,某些处理器可能仍能读取到旧值,这从理论上暴露了存储的非原子性。但只要缓存收到通知,并且每个节点内仔细处理这些通知,软件就无法检测到这种原子性的缺失。

例如,在图 7.25(c) 中,P1 将 1 存储到 A 中,根据新定义该存储操作全局执行,但 P1 和 P2 的缓存中存在不同的 A 值,P2 加载 A 时仍可能返回 0。尽管如此,根据新的充分条件,执行仍然是顺序一致的。

执行时间的不可预测性推理能实现许多优化。比如在 MSI - 失效协议中,连续的入站失效消息可能会乱序处理,但这并不违反顺序一致性,因为观察失效数据需要发生缺失,这会触发处理入站缓冲区中的所有消息。

2. 存储同步

存储同步是从不同角度看待时间对存储原子性和顺序一致性属性的影响。
- 存储同步的定义 :如果对所有地址的所有存储操作都强制执行全局顺序,并且没有两个线程以不同顺序观察这些存储操作,那么该内存系统就是存储同步的。存储同步的内存系统与存储原子的内存系统在软件层面无法区分。
- 存储原子性的充要条件 :一个内存系统是存储原子的,当且仅当它的存储操作是同步的。

在硬件层面,存储原子性和存储同步的区别在于,存储同步中线程可以在任何时刻观察到同一位置的不同值,这违反了一些基本定义,但软件无法检测到这种违反。存储同步利用了存储操作可以以不同速度传播到所有处理器节点这一事实,软件编写应独立于实际时间,重要的是所有地址的存储操作的全局观察顺序,而非确切时间。

如果处理器按线程顺序一次执行一个内存系统中的加载和存储操作,存储原子系统就是顺序一致的,因为所有存储操作存在全局顺序,加载操作不会乱序观察这些存储操作,并且按线程顺序排列。

3. 转发存储缓冲区示例

考虑对所有内存地址的访问时,可以对转发存储缓冲区的内存访问进行调度,使存储操作对软件而言看起来是原子的,并且保证顺序一致性。
- 内存系统结构 :主内存系统结构与图 7.19 相同,假设内存系统是原子的,但加载操作可以从本地存储缓冲区返回值。与图 7.20 的加载和存储调度不同的是,现在考虑不同内存地址之间的交互,并跟踪所有地址的访问。
- 访问规则 :加载操作可能从本地存储缓冲区返回值,但如果在存储缓冲区中未找到值,存储缓冲区的内容必须传播到缓存,加载操作才能在缓存中执行。创建所有地址访问的总顺序与图 7.20 类似,只是涉及所有地址。当加载操作在本地存储缓冲区未命中时,存储缓冲区中之前执行过的所有访问(加载和存储)通过将本地存储传播到缓存,插入到所有地址的加载和存储的全局顺序中。由于这些存储操作仅被本地线程观察到,因此在存储缓冲区条目中本地执行的所有加载和存储操作可以作为一个组插入,以构建加载和存储的全局顺序。
- 顺序一致性保证 :如果内存访问按线程顺序提交到存储缓冲区,系统也是顺序一致的,因为加载和存储操作将按线程顺序出现在所有访问的全局顺序中,满足顺序一致性的条件。顺序一致系统中对加载操作的限制迫使包括存储缓冲区在内的内存系统具有存储原子性。

4. 不同内存系统的比较

通过“值可观察性线”可以比较不同内存系统,包括严格一致性、存储同步、普通一致性和无序内存。
| 内存系统类型 | 值可观察性特点 |
| ---- | ---- |
| 严格一致性内存系统 | 值同时对所有线程可见 |
| 存储同步系统 | 值可能在不同时间对不同处理器可见,但值可观察性线不交叉,所有处理器按相同顺序观察值 |
| 普通一致性内存系统 | 不同处理器可以同时观察到同一字的不同值,存储操作的可观察性线可能交叉,不同线程观察不同字的存储操作顺序可能不同 |
| 无序内存 | 两个线程可以以不同顺序观察对同一内存位置的两个存储操作 |

下面是一个简单的 mermaid 流程图,展示存储同步和普通一致性的区别:

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px

    A([存储同步]):::startend --> B(值可观察性线不交叉):::process
    C([普通一致性]):::startend --> D(值可观察性线可能交叉):::process
5. 同步的必要性

在多任务程序中,线程或进程之间可靠的同步需求超越了一致性或内存一致性模型。即使系统是顺序一致的,同步仍然是必需的。同步是计算领域一个古老的问题,自多任务操作系统出现以来就存在,并且在多处理器系统出现之前,系统就要求正确执行同步原语。

从 20 世纪 60 年代起,大型主机计算机被多个用户共享,用户分时使用处理器、内存和 I/O 设备等计算机资源。分时操作系统需要确保这些资源的正确、可靠和公平共享,通过在时间上复用这些资源来提高吞吐量,同时保持合理的个体响应时间。在单处理器系统中,进程分时也会共享内存位置,需要同步来访问共享内存位置。当多个核心共享内存时,由于线程或进程并发运行,同步变得更加关键。

6. 基本同步原语
6.1 基本锁定问题

多个线程共享内存位置时,访问共享可写变量必须遵循互斥原则,即同一时间只有一个进程可以访问该变量。

例如,两个线程 T1 和 T2 都执行 A = A + 1 操作,程序员期望 A 最终增加 2,但由于程序语句不是原子执行的,实际情况可能并非如此。
- 单处理器情况 :T1 执行加载操作后时间片用完被抢占,T2 执行加载、加法和存储操作后也被抢占,T1 恢复执行剩余操作,最终 A 只增加了 1。
- 多处理器情况 :T1 和 T2 可能同时执行加载、加法和存储操作,同样导致 A 最终只增加了 1。

为了确保 A 最终增加 2,需要使用临界区。在单处理器软件多线程且单执行上下文的情况下,可以通过禁用中断来实现临界区代码。但在硬件多线程核心或多核系统中,禁用中断无效,需要使用锁。

锁通常是一个二进制标志,值为 0 时表示锁空闲,线程将其设置为 1 来获取锁。获取锁的线程可以执行临界区代码,其他线程则被锁定,直到锁被释放。锁可以使用简单的加载和存储操作在共享内存标志上实现,例如 Dekker 或 Peterson 算法,但该算法存在一些问题,如线程可能死锁、线程数量增加时代码复杂度增加、仅在顺序一致模型下工作等。因此,锁通常使用特殊的硬件支持,如原子 RMW(读 - 修改 - 写)指令、专用总线线路或同步寄存器来实现。

6.2 屏障

屏障是多个线程之间的同步协议,所有线程必须到达屏障后,任何线程才能继续执行。一个简单的双线程屏障代码如下:

INIT BAR = 0
T1
...
Lock(bar_lock);
BAR = BAR + 1;
Unlock(bar_lock);
while (BAR < 2);
...

T2
...
Lock(bar_lock);
BAR = BAR + 1;
Unlock(bar_lock);
while (BAR < 2);
...

每个线程在屏障处通过增加屏障计数(BAR)进行检查,然后等待 BAR 达到 2 后继续执行。需要注意的是,增加屏障计数必须在临界区中完成,但在 while 循环中读取 BAR 不需要在临界区中,因为 BAR 是单调递增的。

屏障在迭代算法中广泛使用,例如 Jacobi 迭代算法。每个迭代有两个步骤,不同步骤对 Xi 和 Yi 值的访问有不同限制,这些限制由屏障强制执行。但简单的屏障代码需要修改,以便在每次完成后可以重置屏障,这是一个非平凡的问题,因为简单的屏障代码依赖于 BAR 单调递增的特性。

6.3 点对点(生产者/消费者)同步

有时一个线程(生产者)需要向另一个线程(消费者)发出信号,表明它已经到达执行的某个点。这可以通过内存中的一个简单共享标志来实现:

INIT A = FLAG = 0
T1
...
while (FLAG == 0);
print A
...

T2
...
A = 1;
FLAG = 1;
...

在这个例子中,T2 是生产者,T1 是消费者,T1 必须打印 A 的值为 1。对 FLAG 的访问不需要临界区保护,因为只有一个线程可以修改 FLAG。但这种代码依赖于内存一致性模型,在某些内存一致性模型下可能无法正常工作。

7. 基于硬件的同步

锁和屏障可以通过专用硬件资源实现,如总线线路、寄存器或触发器。
- 屏障的硬件实现 :可以使用开集电极连接的专用总线线路实现屏障。线路初始为高电平(空闲),每个线程在屏障处尝试将开集电极拉低,只有当所有线程都完成此操作时,所有线程才能越过屏障。
- 其他硬件实现 :共享同步寄存器可以实现与屏障相同的功能,但寄存器可以跨时钟存储值,与仅在当前时钟保持值的总线不同。共享触发器可以实现锁,共享计数寄存器可以实现屏障。

下面是一个 mermaid 流程图,展示基于硬件的同步方式:

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px

    A([基于硬件的同步]):::startend --> B(专用总线线路):::process
    A --> C(共享同步寄存器):::process
    A --> D(共享触发器):::process
    A --> E(共享计数寄存器):::process
    B --> F(实现屏障):::process
    C --> F
    D --> G(实现锁):::process
    E --> F

综上所述,内存一致性和同步机制在多任务和多处理器系统中至关重要。通过合理的内存访问定义、存储同步策略以及有效的同步原语和硬件实现,可以确保系统的正确性和高效性。

内存一致性与同步机制解析(续)

8. 不同同步机制的综合应用案例

在实际的多线程编程场景中,常常需要综合运用各种同步机制来确保程序的正确性和高效性。以下通过一个具体的案例来说明不同同步机制是如何协同工作的。

假设我们有一个多线程的图像渲染程序,多个线程负责不同区域的图像渲染,最后将各个区域的渲染结果合并成完整的图像。

  • 线程间的分工与协作 :每个线程负责渲染图像的一个特定区域,这就相当于多个生产者线程,它们各自完成自己的任务后,需要通知一个消费者线程来进行图像的合并操作。这里可以使用点对点(生产者/消费者)同步机制,每个生产者线程在完成渲染后,通过设置一个共享标志来通知消费者线程。
INIT RENDER_FLAG = 0
// 多个生产者线程(负责不同区域渲染)
ProducerThread1
...
RenderRegion1();
RENDER_FLAG = RENDER_FLAG | 0x01; // 表示区域 1 渲染完成
...

ProducerThread2
...
RenderRegion2();
RENDER_FLAG = RENDER_FLAG | 0x02; // 表示区域 2 渲染完成
...

// 消费者线程
ConsumerThread
...
while (RENDER_FLAG != 0x03); // 等待所有区域渲染完成
MergeRegions();
...
  • 临界区的使用 :在图像合并过程中,可能会涉及到对共享资源的访问,比如共享的图像缓冲区。为了确保同一时间只有一个线程可以访问该缓冲区,需要使用锁机制。
// 假设使用一个锁对象 LockImgBuffer
ConsumerThread
...
Lock(LockImgBuffer);
MergeRegions();
Unlock(LockImgBuffer);
...
  • 屏障的应用 :如果在渲染过程中,每个线程在完成一定阶段的工作后需要等待其他线程也完成该阶段,才能进入下一阶段,那么可以使用屏障机制。例如,在进行图像的预处理阶段,每个线程需要对自己负责的区域进行一些计算,只有当所有线程都完成预处理后,才能开始正式的渲染。
INIT PREPROCESS_BAR = 0
ProducerThread1
...
Lock(bar_lock);
PREPROCESS_BAR = PREPROCESS_BAR + 1;
Unlock(bar_lock);
while (PREPROCESS_BAR < NUM_THREADS);
RenderRegion1();
...

ProducerThread2
...
Lock(bar_lock);
PREPROCESS_BAR = PREPROCESS_BAR + 1;
Unlock(bar_lock);
while (PREPROCESS_BAR < NUM_THREADS);
RenderRegion2();
...
9. 同步机制的性能考量

在使用同步机制时,性能是一个需要重点考虑的因素。不同的同步机制在不同的场景下可能会有不同的性能表现。

同步机制 优点 缺点 适用场景
实现简单,能有效保证互斥访问 可能导致线程阻塞,产生上下文切换开销 对共享资源的互斥访问,如临界区代码
屏障 确保所有线程在某个点同步,便于协调多线程工作 可能会造成线程等待,降低并发度 多线程在某个阶段需要同步后再继续执行的场景
点对点同步 简单高效,适用于生产者/消费者模型 依赖内存一致性模型,可能存在不可靠性 一个线程需要通知另一个线程某个事件发生的场景

为了提高性能,可以采取以下一些优化策略:
- 减少锁的持有时间 :尽量缩短线程持有锁的时间,避免在锁内执行耗时的操作。例如,将一些不必要的计算放在锁外进行。
- 使用细粒度锁 :将大的临界区拆分成多个小的临界区,使用多个细粒度的锁,这样可以减少线程之间的竞争,提高并发度。
- 避免不必要的同步 :在某些情况下,如果可以通过其他方式保证数据的一致性,就尽量避免使用同步机制,减少同步带来的开销。

10. 内存一致性模型对同步的影响

不同的内存一致性模型会对同步机制的实现和效果产生影响。

  • 顺序一致性模型 :在顺序一致性模型下,所有线程看到的内存操作顺序是一致的,这使得同步机制的实现相对简单。例如,Dekker 或 Peterson 算法在顺序一致性模型下可以正常工作,确保线程之间的互斥访问。但顺序一致性模型可能会限制系统的性能,因为它要求严格的顺序执行。
  • 弱一致性模型 :在弱一致性模型下,线程看到的内存操作顺序可能不同,这会给同步机制的实现带来挑战。例如,在点对点同步中,由于内存操作的顺序可能不一致,消费者线程可能无法及时看到生产者线程设置的标志,导致同步失败。在这种情况下,可能需要使用更复杂的同步机制或特殊的硬件支持来保证同步的正确性。

下面是一个 mermaid 流程图,展示不同内存一致性模型对同步的影响:

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px

    A([内存一致性模型]):::startend --> B(顺序一致性模型):::process
    A --> C(弱一致性模型):::process
    B --> D(同步实现相对简单):::process
    B --> E(可能限制系统性能):::process
    C --> F(同步实现挑战大):::process
    C --> G(可能需要特殊支持):::process
11. 未来发展趋势

随着计算机技术的不断发展,内存一致性和同步机制也在不断演进。

  • 硬件层面的优化 :未来的硬件可能会提供更强大的同步支持,例如更高效的原子操作指令、更智能的缓存一致性协议等,以减少同步开销,提高系统的并发性能。
  • 软件层面的创新 :新的编程语言和编程模型可能会引入更简洁、更安全的同步机制,降低程序员的编程难度,同时提高程序的可靠性和性能。例如,一些函数式编程语言通过不可变数据和纯函数的特性,减少了对显式同步的需求。
  • 混合架构的应用 :未来的系统可能会结合不同类型的处理器和内存架构,如 CPU、GPU、FPGA 等,这就需要更灵活的内存一致性和同步机制来协调不同组件之间的工作。

总之,内存一致性和同步机制是多线程和多处理器系统中不可或缺的部分。深入理解这些概念,并根据具体的应用场景选择合适的同步机制和优化策略,对于开发高效、可靠的软件系统至关重要。在未来,我们需要不断关注技术的发展趋势,以适应不断变化的计算环境。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值