并行编程模型中的事务内存技术解析
在并行编程领域,我们常常会面临各种挑战,例如如何有效地管理线程同步、避免死锁以及提高程序性能等。本文将深入探讨OpenMP编程模型以及事务内存(Transactional Memory,TM)这一重要的并行编程范式。
1. OpenMP编程模型基础
OpenMP为每个线程提供函数调用的相同起始地址,但通过函数调用参数传递不同的循环起始和结束索引。当线程完成分配的任务后,会返回到创建该线程的OpenMP库调用处。库函数中的屏障同步原语会阻止线程越过循环,直到所有线程都到达屏障,运行时环境会暂停所有线程,然后由一个线程执行并行循环之后的指令。
OpenMP还提供了大量的指令,用于在创建并行线程之前在运行时检查条件。例如,若循环中的
n
值过小,并行化该循环可能并不明智。用户可以指定
n
的最小值,只有当
n
的值大于用户指定的限制时,并行化才会生效,这一检查是在运行时进行的。虽然OpenMP旨在将创建线程和生成适当线程同步代码的负担从程序员转移到运行时系统,但它仍然依赖程序员在代码中明确指出并行化的机会以及相关的各种限制。
2. 传统线程并行化的瓶颈
OpenMP、Pthreads等并行编程范式虽然能帮助应用开发者快速编写并行代码,但在这些显式线程并行化方法中,开发并行代码的根本瓶颈在于依赖程序员识别并行代码段,并使用适当的同步原语来管理线程间通信。判断并行代码的行为以及决定何时使用同步和互斥是代码并行化中最具挑战性的任务。
例如,当锁和临界区嵌套时,就可能出现死锁问题。而且,任何并行代码的性能,无论是由编译器自动生成还是由程序员手动编写,都取决于消除不必要的线程同步。当多个线程想要更新共享数据结构时,通常需要使用锁或屏障同步来同步对整个共享数据结构的访问。线程间对数据结构的共享可分为两类:
-
真共享
:两个线程修改或读取数据结构中的同一字段。
-
假共享
:线程可能访问相同的数据结构,但修改或读取的数据结构中的不同字段。
如果两个线程总是访问数据结构中的不同字段,那么保护该数据结构的锁或屏障就可以移除,因为不需要进行序列化。然而,在实践中,很难在程序编写或编译时静态地确定共享类型,特别是当线程间的共享情况动态变化时。虽然可以采用比整个数据结构更细粒度的锁,但细粒度锁会导致锁的数量激增,难以跟踪和管理,并且存在死锁的风险。此外,基于锁的同步在线程可能被中断和抢占的环境中会引发严重问题。
3. 锁同步优化的困难示例
下面通过一个简单的代码段来说明使用锁优化线程同步的困难:
{
struct Shared {
int item1;
int item2;
} myShared;
}
{
void Producer() {
Begin_Transaction();
Lock(l);
if (cond1) {
myShared.item1++;
} else {
myShared.item2++;
}
End_Transaction();
Unlock(l);
}
}
{
void Consumer() {
Begin_Transaction();
Lock(l);
if ((cond2) && (myShared.item1 > 0)) {
myShared.item1--;
}
if ((!cond2) && (myShared.item2 > 0)) {
myShared.item2--;
}
End_Transaction();
Unlock(l);
}
}
在这个例子中,根据
cond1
和
cond2
的值,生产者和消费者线程之间的共享情况会发生变化。如果
cond1
和
cond2
都为真或都为假,那么两个线程会修改同一个项目,临界区是必要的。但如果
cond1
为真而
cond2
为假(或反之),那么两个线程会更新不同的项目,临界区就不需要了。由于
cond1
和
cond2
的值在编译时无法确定,并且可能动态变化,应用程序员必须决定最佳的锁粒度。因此,程序员可能会倾向于在临界区内执行生产者和消费者函数,以降低在细粒度共享下分析和调试程序的复杂性,但这样做实际上会在所有情况下对代码进行序列化。
4. 事务内存的概念与优势
事务内存(TM)是一种并发编程范式,旨在消除选择最佳锁粒度的负担,并消除加锁和强制互斥的开销。它通过支持无锁数据结构,提高了许多并行程序的可编程性和性能。TM是一种乐观的方法,它假设共享对象中的项目很少会被并发地读写,因此访问这些项目的代码可以在不加锁的情况下执行。在执行过程中,如果检测到对数据结构中同一项目的并发读写访问(即竞争条件),部分执行会被回滚。
与临界区不同,只要事务之间不存在读写竞争,它们就可以并发执行。读写竞争的检测由硬件自动执行,如果动态检测到竞争,同步代码会自动重新执行。事务内存适用于事务回滚概率较低的情况,例如多个线程同时搜索和修改大型图的情况。而对于线程使用屏障进行同步的数值应用程序,由于回滚概率较高,可能不太适合进行事务执行。
TM编程的可操作性优于基本的基于锁的共享内存范式,因为并行程序员只需要在每个线程中指定事务边界,而无需考虑复杂锁机制的正确性。但在TM编程中,主要的关注点是创建尽可能小的事务,以最小化回滚的概率和每次回滚时的重新执行量。此外,不能回滚的操作(如对物理设备的访问)不能包含在事务中,一般来说,线程中唯一可以回滚的效果是内存修改。
5. 事务内存的特性
事务内存的概念借鉴自数据库事务,具有以下三个必要特性:
-
原子性
:事务是一系列作为原子块执行的指令,即事务中的所有指令要么全部提交,要么全部中止,事务不能部分执行。如果事务因任何原因未能完成,必须重新执行。
-
隔离性
:隔离性防止其他事务在事务整体提交之前观察到该事务的任何活动,通常被视为原子性的一部分。
-
可串行化
:所有线程必须以相同的顺序观察已提交事务的结果。
原子性意味着事务可以对共享内存位置进行多次读写,但其他事务在事务提交之前无法观察到修改后的值。当事务提交时,其所有内存更新对所有其他线程可见,就好像所有更改是瞬间完成的。如果事务中止,则任何更改对其他线程都不可见。如果线程在事务执行过程中被抢占,它会首先中止事务,从而让其他线程可以自由访问共享变量。
6. 事务内存机制
为了检查和维护任何TM架构中的原子性和隔离性,除了能够回滚和重新启动事务外,还必须存在以下三个基本机制:
-
事务冲突检测机制
:当两个或多个事务访问同一内存位置,且至少有一个访问是存储操作(仅加载操作无害)时,会检测到事务之间的冲突,这违反了隔离性。
-
推测性内存数据管理机制
:未提交事务中的存储操作的推测性值必须与已提交事务的存储值分开保存,这通常被称为版本管理。
-
并发控制机制
:当检测到事务冲突时,该机制必须决定哪些事务中止,哪些事务提交。
从开始到提交,事务必须在本地跟踪其读集(即已加载的地址)和写集(即已修改的地址),以检测事务冲突。为了强制执行事务的隔离性,其他事务不应读写事务写集中的地址,也不应写事务读集中的地址,否则一个事务必须中止。
冲突检测和版本管理可以是急切的或懒惰的:
| 类型 | 冲突检测 | 版本管理 |
| ---- | ---- | ---- |
| 急切的 | 冲突发生时立即检测 | 事务开始时有效的值保存在某些内存中,新的未提交值直接存储在内存中 |
| 懒惰的 | 冲突发生后再检测,最晚在事务提交时检测 | 未提交的值存储在缓冲区中,在事务提交时传播到内存 |
一般来说,急切的检测和版本管理更可取。急切检测可以尽早执行中止和回滚,避免进一步的无用工作;急切版本管理使提交速度快(因为所有新值已经在内存中,只需刷新包含初始值的缓冲区),而中止速度慢(需要从缓冲区恢复内存值)。由于我们希望提交比中止更频繁,因此通常更倾向于急切版本管理。
根据这些机制的实现方式,TM系统可分为硬件事务内存(HTM)、软件事务内存(STM)和混合事务内存系统。接下来我们将重点关注硬件事务内存。
7. 硬件事务内存系统基础
为了简化讨论,我们不考虑嵌套事务,而是专注于事务中访问的共享内存地址集。事务外访问的地址通常与非事务系统中的处理方式相同。
我们向指令集架构(ISA)中添加了两个新指令
TBegin
和
TEnd
,用于开始和结束事务。执行
TBegin
指令会使处理器进入事务状态,执行
TEnd
指令则会使处理器返回非事务状态。这两个指令会设置和重置处理器中的一个状态位,用于跟踪处理器是否正在执行事务,这个状态位称为事务活动位。
当事务开始时,运行线程的状态必须进行检查点保存,包括寄存器和内存。由于寄存器值是线程私有的,检查点保存相对简单,例如可以使用影子寄存器文件来保存寄存器文件的状态。当执行
TBegin
时,影子寄存器文件中的所有条目都会失效。在事务期间,新的寄存器值会存储在影子寄存器文件中。寄存器值首先在影子寄存器文件中查找,如果影子寄存器条目无效,则从主寄存器文件中获取值。如果事务在
TEnd
时提交,则影子寄存器文件中的有效值会复制到常规寄存器文件中。
检查线程的内存状态则更具挑战性,因为内存值可以被其他线程共享。我们基于事务缓存状态展示了一种简单的机制。当事务中首次更新内存块时,内存系统中必须维护该块的两个副本:首次存储之前的块副本(已提交值)和包含未提交值的新块副本。每个核心的一级缓存可以保存新的未提交块副本,而下一级内存(共享L2缓存或主内存)可以保存事务开始时有效的(已提交)块副本。
在事务期间,所有加载和存储操作都被视为特殊的TM访问指令。在事务中首次存储到内存块时,需要对当前块副本进行检查点保存,并创建一个新的事务副本,具体分为以下两种情况:
graph TD;
A[首次存储到内存块] --> B{块是否在L1缓存中};
B -->|否| C[缓存获取修改后的块副本,更新内存或L2,将修改后的副本以事务状态加载到缓存,共享L2或主内存副本作为检查点副本];
B -->|是| D{块是否为修改后的副本};
D -->|是| E[将块写回(检查点副本),获取修改后的副本并以事务状态加载到L1缓存];
D -->|否| F[无需写回,获取修改后的副本并以事务状态加载到L1缓存];
- 块不在L1缓存中 :缓存必须首先获取该块的修改后副本,并更新内存或L2。修改后的副本以事务状态加载到缓存中,共享L2或主内存副本成为检查点副本。
- 块在L1缓存中 :如果是修改后的副本,必须先写回(作为检查点副本);如果是共享副本,则无需写回,因为下一级的副本是最新的。然后获取修改后的副本并以事务状态加载到L1缓存中。
在事务期间,对该块的每次存储都会更新本地L1缓存中的事务副本,并且该块必须在事务的剩余时间内保持在事务状态。在事务中对普通块进行加载时,通过设置与每行关联的事务读位,将缓存行标记为已读,从那时起,该块必须保留在缓存中直到事务结束。
事务的写集由事务块副本维护,读集由事务读位维护,所有这些都在本地L1缓存中。冲突检测使用事务状态和事务读位,多个线程之间的冲突检测通过缓存一致性协议实现。为了支持TM,MSI失效协议进行了如下修改:对普通块或非事务处理器缓存的总线请求处理方式与基本MSI失效协议相同。当处理器处于事务中(由事务活动位指示),并且在事务块上收到
BusRd
或
BusRdX
请求时,会检测到冲突;此外,如果请求是
BusRdX
,并且缓存副本是普通副本且事务读位已设置,也会检测到冲突。当检测到冲突时,缓存会向请求者发送忙碌响应,以强制执行隔离性。请求处理器收到忙碌响应后,必须稍后重试请求。如果请求线程本身正在执行事务,为了避免事务之间的死锁,明智的做法是中止并回滚事务,而不是忙等待。如果请求线程不在事务中,则可以暂停并等待,直到远程事务提交后再重试。
一旦事务中止或提交,事务活动位会重置,所有L1缓存行中的事务读位也会重置。如果事务中止,L1缓存中的所有事务条目会通过刷新其有效位被丢弃,同时影子寄存器内容也会被丢弃。如果事务提交,则影子寄存器文件的内容会转移到主寄存器文件中,L1缓存中的所有事务块会通过刷新缓存中的所有事务位切换为普通块。需要注意的是,事务提交必须是原子操作,例如通过同时刷新缓存中所有行的事务位,或者在事务块顺序切换为普通块时拒绝任何传入的一致性请求。为了实现这一点,缓存中的所有事务块在整个事务期间必须保持在修改状态,以便事务提交成为本地操作,无需与全局内存系统进行交互。
8. 事务缓存
目前我们忽略了一个主要问题,即由于缓存容量有限和缓存映射冲突导致的缓存替换。随着事务中存储操作的增加,缓存最终会在某些缓存集中溢出。当一个集合中的所有行都被事务块或设置了事务读位的普通块占用,并且该集合发生缺失时,就会发生溢出。
为了缓解这些问题,可以将事务块分配到一个与主缓存分离的全关联事务缓存中。这种方法将事务块的维护与主缓存的管理分开,主缓存和事务缓存会并行访问,并且两个缓存是互斥的,即一个内存块不能在两个缓存中都有副本。当事务提交时,事务缓存中的所有块都会变为普通块。因此,事务缓存包含无效块、普通块和事务块。当需要分配新的事务块时,会在事务缓存中选择一个无效或普通块作为牺牲品,为新的事务块腾出空间。
一旦全关联事务缓存中没有无效或普通块可供牺牲,事务就无法继续执行。在这种情况下,必须有一个后备机制来原子地完成事务的执行。通常,事务缓存可以溢出到主(物理)内存甚至线程的虚拟内存空间中。这种方法可以有效地处理短事务,而长事务则会面临较大的软件开销。需要注意的是,对于设置了事务读位的缓存块,也存在溢出问题,不过在这种情况下,缓存只需要跟踪哪些块地址被读取过,而不需要跟踪块的实际内容。
9. 事务内存提交和中止示例
下面通过具体示例来说明事务内存的提交和中止过程。假设有一个使用TM语义实现生产者和消费者函数的程序,在一个CMP中有两个核心:Core0运行生产者代码,Core1运行消费者代码,每个核心都有一个私有的L1缓存,并且每个缓存行都增加了额外的位
XactMod
(事务位)和
XactRead
(事务读位)。
9.1 事务提交情况
当生产者中的
cond1
为真,消费者中的
cond2
为假时,生产者和消费者分别处理不同的数据项
item1
和
item2
。当两个核心执行事务开始指令时,
XactActive
位(事务活动位)会在两个核心中都被设置。两个核心都会读取变量
cond1
和
cond2
,假设对这两个变量的访问在各自核心的本地缓存中命中,并且它们都处于共享状态,因此L2缓存也包含
cond1
和
cond2
的最新副本。Core0会为包含
cond1
的缓存行设置
XactRead
位,Core1会为包含
cond2
的缓存行设置
XactRead
位,通过设置
XactRead
位,每个核心可以跟踪当前活动事务已经读取了哪些缓存行。
接下来,Core0执行
myShared.item1++
语句,这是一个先读取后修改
myShared.item1
变量的操作。首先,Core0会发送一个
BusRd
请求以获取包含
item1
的块,一旦块副本加载到缓存中,其
XactRead
位会设置为1。在修改
myShared.item1
之前,Core0会先发送一个
BusRdX
请求,Core1会根据一致性协议进行响应,如果Core1有共享副本,会简单地使自己的副本无效,然后Core0可以自由更新
item1
。需要注意的是,只有当Core0的活动事务提交时,这个修改才会可见,如果事务中止,更新会被取消。除了设置其
M
位,Core0还会设置
XactMod
位,表明该缓存行已在事务中被修改。
Core1执行
myShared.item2--
的过程与Core0类似,它会先发送
BusRd
请求,然后发送
BusRdX
请求,同时设置其缓存行的
XactRead
、
XactMod
和
M
位,表明该块处于修改状态,并且在本地事务中已被读取和修改。此时,L2缓存包含
item1
和
item2
的检查点版本,即事务开始前的版本,而L1缓存包含最新副本,但由于
XactRead
和
XactMod
位被设置,它们仍然是推测性的。在当前场景中,当
cond1 = true
且
cond2 = false
时,两个事务不会发生冲突,因此Core0和Core1会执行事务提交。此时,两个L1缓存中的
XactRead
位都会重置为零,两个核心也会重置其
XactMod
位,
M
位设置为1表示
item1
和
item2
的最新值现在在L1缓存中,而L2缓存中的副本已过时。最后,Core0和Core1会重置其
XactActive
位,表明它们已退出事务模式。
9.2 事务中止情况
当
cond1
和
cond2
都为真时,假设Core0首先进入事务并增加
item1
,其L1缓存的状态与前面的情况相同。Core1稍微落后于Core0,最终也需要减少
item1
,它会像之前一样先发送
BusRd
请求,但不同的是,这次它发送的是针对
item1
的
BusRd
请求。当Core0收到
BusRd
请求时,它会根据其
XactMod
位识别出另一个核心试图读取它已经修改的数据项,这就是MSI协议在正常操作和事务操作中的区别。此时,Core0不会发送
item1
的最新版本,而是发送一个
XBusy
响应,Core1会收到这个忙碌响应,根据情况可能会选择中止并回滚事务,以避免死锁。
通过以上对OpenMP编程模型和事务内存的详细介绍,我们可以看到事务内存为并行编程提供了一种新的思路和方法,能够在一定程度上解决传统基于锁的并行编程所面临的问题,但在实际应用中也需要根据具体情况进行合理选择和优化。
并行编程模型中的事务内存技术解析
10. 事务内存技术的优势总结
事务内存技术在并行编程中展现出了多方面的显著优势,下面我们通过表格形式进行总结:
|优势|具体描述|
| ---- | ---- |
|消除锁粒度选择负担|无需程序员手动选择最优锁粒度,避免了细粒度锁带来的管理难题和死锁风险|
|降低加锁和互斥开销|支持无锁数据结构,减少了加锁和强制互斥操作所带来的性能损耗|
|提高可编程性|程序员只需指定事务边界,无需考虑复杂锁机制的正确性,降低了编程难度|
|支持并发执行|只要事务之间不存在读写竞争,就可以并发执行,提高了程序的并行度|
|自动处理竞争|硬件自动检测读写竞争,并在检测到竞争时自动重新执行同步代码|
11. 事务内存技术的挑战与限制
尽管事务内存技术具有诸多优势,但也面临一些挑战和限制:
-
事务回滚问题
:对于某些场景,如线程使用屏障进行同步的数值应用程序,事务回滚概率较高,会导致性能下降。
-
不可回滚操作限制
:不能回滚的操作(如对物理设备的访问、I/O 操作)不能包含在事务中,限制了事务的应用范围。
-
事务大小控制
:需要创建尽可能小的事务以降低回滚概率和重新执行量,但这在实际编程中需要仔细权衡。
-
缓存溢出问题
:事务缓存可能会因为容量有限和映射冲突而溢出,长事务可能会面临较大的软件开销。
12. 事务内存技术的应用场景分析
事务内存技术适用于多种不同的应用场景,以下是一些常见的应用场景及其适用性分析:
|应用场景|适用性分析|
| ---- | ---- |
|多线程操作大型图|多个线程同时搜索和修改大型图时,事务回滚概率较低,适合使用事务内存技术|
|数据结构动态共享|如生产者 - 消费者模型中数据结构的动态共享场景,事务内存可以根据实际情况灵活处理共享问题|
|并行搜索算法|在并行搜索算法中,事务内存可以提高搜索效率,减少同步开销|
|数值计算(部分情况)|对于一些数值计算,如果线程之间的依赖关系较弱,事务回滚概率低,也可以考虑使用事务内存技术|
13. 事务内存技术与传统锁机制的对比
为了更清晰地了解事务内存技术的特点,我们将其与传统锁机制进行对比:
|对比项|事务内存技术|传统锁机制|
| ---- | ---- | ---- |
|编程难度|只需指定事务边界,无需考虑复杂锁机制|需要程序员手动管理锁,处理锁的粒度、嵌套和死锁问题|
|性能表现|在事务回滚概率低的场景下性能较好,减少了不必要的同步开销|可能存在锁竞争和死锁问题,导致性能下降|
|并发执行能力|只要无读写竞争,事务可并发执行|需要通过锁来控制并发,可能会限制并发度|
|错误处理|硬件自动检测和处理竞争,自动回滚和重新执行|需要程序员手动处理锁的异常情况|
14. 事务内存技术的未来发展趋势
随着并行计算需求的不断增长,事务内存技术也在不断发展和完善,以下是一些可能的未来发展趋势:
-
硬件优化
:进一步优化硬件事务内存的设计,提高冲突检测和处理的效率,减少事务回滚的开销。
-
软件与硬件结合
:混合事务内存系统将软件和硬件的优势相结合,未来可能会得到更广泛的应用和发展。
-
应用拓展
:探索事务内存技术在更多领域的应用,如人工智能、大数据处理等,以满足这些领域对并行计算的需求。
-
标准和规范制定
:制定统一的事务内存标准和规范,提高不同系统之间的兼容性和互操作性。
15. 总结与建议
事务内存技术为并行编程提供了一种新的解决方案,它在提高可编程性、降低同步开销和支持并发执行等方面具有显著优势。然而,它也面临一些挑战和限制,在实际应用中需要根据具体情况进行合理选择。
以下是一些使用事务内存技术的建议:
-
评估应用场景
:在决定是否使用事务内存技术之前,需要评估应用场景的特点,如事务回滚概率、是否存在不可回滚操作等。
-
控制事务大小
:尽量创建小的事务,以降低回滚概率和重新执行量。
-
结合传统锁机制
:在某些情况下,可以将事务内存技术与传统锁机制结合使用,以充分发挥两者的优势。
-
持续关注技术发展
:事务内存技术仍在不断发展,需要持续关注其最新进展,以便在合适的时候应用到实际项目中。
通过对事务内存技术的深入了解和合理应用,我们可以更好地应对并行编程中的挑战,提高程序的性能和可维护性。在未来的并行计算领域,事务内存技术有望发挥更加重要的作用。
超级会员免费看
11

被折叠的 条评论
为什么被折叠?



