科学模拟和多媒体处理应用中大量使用了矩阵数据结构,因而上述并行编程技术对这类应用非常有效。
本章将介绍针对非科学应用的并行编程技术,特别是大量使用链式数据结构Linked Data Structure,LDS。LDS包括所有使用一组节点并通过指针链接在一起的数据结构,如链表、树、图、散列表等。
链式数据结构的访问往往含有大量的循环传递依赖,而本书介绍的循环并行化技术很难成功应用到链式数据结构上,因此针对链式数据结构需要不同的并行化技术。
本章帮助读者理解锁机制如何应用在基于链式数据结构的非科学应用中,锁粒度与并行度的关系,以及编程复杂度。
4.1 LDS并行化所面临的的挑战
所有链式数据结构的共同特点是都包含一组节点并且节点之间通过指针相互链接。
虽然不同链式数据结构之间存在差异,然而链式数据结构的遍历都具有一个相同的特征,即在遍历过程中需要读取当前节点中的指针以发现该指针指向的下一个节点,并以此方法访问所有节点。LDS在遍历过程中需要读取到当前节点的指针数据才能获得下一个节点的地址。这样的模式导致链式数据结构的遍历过程存在循环传递依赖。
循环级并行的不足
typedef struct tagIntListNode {
int key;
int data;
struct tagIntListNode* next;
}IntListNode;
typedef IntListNode* pIntListNode;
typedef struct{
pIntListNode;
}IntList;
typedef IniList* pIntList;
void AddValue(pIntList pList, int key, int x)
{
pIntListNode p = pList->head;
while(p != NULL){
if (p->key == key)
p->data = p->data + x;
p = p->next;
}
}
循环传递依赖不仅仅存在于遍历语句中,这是LDS并行化面临的另一个问题。例如,LDS本身包含环路,常见于图中,但也有可能发生在链表中(循环链表)。如果在遍历过程中,同一个节点在被第二次访问时,代码读取到的值可能是上一次迭代过程修改过的值,这会造成额外的循环传递依赖。
LDS并行化面临的另一个问题则来自递归遍历。例如,在树的遍历过程中,往往包含递归。递归遍历并不是并行化的主要障碍。例如,在遍历树时,可以创建两个线程分别遍历左子树和右子树。这样,就能够从某种程度上将树的递归并行化。
4.2 LDS并行化技术
4.2.1 计算并行化与遍历
一个简单的并行化LDS的方法是将计算部分并行化(而非遍历过程)。假设程序需要遍历链表并在每个节点上执行计算操作,循环传递依赖只会影响节点的遍历而非计算操作。因此,可以在保持遍历过程串行执行的基础上,将每个节点上的计算操作分配到不同的任务上并行执行。
while (p != NULL){
compute(p);
p = p->next;
}
#pragma omp parallel
{
#pragma omp single
{
while (p != NULL){
#pragma omp task firstprivate(p) untied
{
compute(p);
}
p = p->next;
}
}
}
其中一个线程串行遍历LDS,并将每个节点上的计算操作分配给不同的任务并行执行。这些任务可以动态地分配给可用的程序并执行。
如果考虑到该方法的执行效率,预取操作可以进一步提升该方法的性能。如果多个线程共享处理器上的高速缓存,主线程在遍历LDS的过程中会将该节点上的数据预取到共享高速缓存中,这样便可以减少程序运行过程中从线程的缓存缺失次数。
然而相较于执行遍历和任务管理所耗费的时间比重,真正影响该方法性能的因素是执行节点计算操作的时间。当需要处理大量的任务管理的开销就会显著增加。假设执行计算操作的时间等于或小于遍历时间与任务管理时间之和,则该方法能获取的最大加速比为2.
对于树和图,节点的遍历和计算过程甚至可以并行计算。
4.2.2 针对数据结构的操作并行化
另一个LDS并行化方法是对LDS的操作并行化。从算法层面上看,可以将LDS当作支持一系列基本操作的数据结构,比如插入节点、删除节点、搜索节点以及修改节点等操作。对于某些LDS,可能还有其他基本操作,比如对数LDS进行平衡操作。本小节将讨论如何在数据结构层面发掘并行。
可串行性概念定义为:一组并行执行的操作或者原语是可串行的,如果产生的结果与某串行执行情况所产生的结果相同。
确保LDS的操作能正确并行执行的关键是并行执行的结果永远与串行执行的结果一致。
// 插入节点
void Insert(pIntList pList, int key)
{
pIntListNode prev = NULL, p = NULL;
newNode = IntListNode_creat(key);
if (pList->head == NULL){
pList->head = newNode;
return;
}
p = pList->head;
prev = NULL;
while (p != NULL && p->key < newNode->key){
prev = p;
p = p->next;
}
newNode->next = p;
if (prev->next = NULL){
prev->next = newNode;
else
pList->head = newNode;
}
}
// 删除节点
void Delete(pIntList pList, int key)
{
pIntListNode prev = NULL, p = NULL;
if(pIntList->head == NULL)
return;
p = pIntListNode->head;
while (p != NULL && p->key != key){
prev = p;
p = p->next;
}
if (p == NULL)
return;
if (prev == NULL)
pList->head = p->next;
else
prev->next = p->next;
free(p);
}
// 搜索节点
int Search(pIntList pList, int key)
{
pIntListNode p = NULL;
if (pList->head == NULL)
return 0;
p = pList->head;
while (p != NULL && p->key != key){
p = p->next;
}
if (p == NULL)
return 0;
else
return 1;
}
在并行执行插入和删除节点时,同样可能会出现以上不可串行化的情况。无论在一个将要删除的节点前后插入一个节点,都会导致不可串行化的结果。
一般来说,当两个修改链表的操作并行执行,并且操作的节点相互接近时,就有可能发生异常结果。
总结以上案例,可以得到以下几个观察结果:
1. 当针对同一个节点的两个操作并行执行时,如果其中有至少一个操作会修改值,就会产生冲突并导致不可串行化结果。值得注意的是,如果两个操作影响的完全不同的节点集合,那么将不会导致冲突。
2. 在要点1出现冲突的情况下,某些时候仍然可能出现可串行结果。
3. 在LDS操作与内存管理函数之间也会发生冲突(如内存回收和分配)。
4.3 针对链表的并行化技术
在LDS并行中,并行度越高,其编程复杂度也就越高。
4.3.1 读操作之间的并行
发掘并行性最简单的方法是只允许只读操作并行执行,而不允许只读和读/写操作并行执行。
链表中的基础操作,如操作节点、删除节点和修改节点都会修改链表中的节点。然而,搜索节点并不改变链表。
为了实现该方法,需要确保读/写操作和只读操作之间的互斥执行,但在两个只读操作之间不需要互斥。为了实现这一策略,定义了两种锁:读锁ead lock和写锁write lock。只读操作执行之前需要获取到读锁,并在执行完后释放该锁。读/写操作执行执行需要获取写锁,同样地需要在执行完成后释放锁。如果该锁已被另一个操作占用,那么可以申请别的读锁,但是写锁只有在当前操作完成之后才能分配给下一个操作。另外,如果写锁已经分配给一个操作,那么读锁和写锁都只有在当前写操作完成并释放写锁之后才能够获得。
一种方法是使用传统的锁保护的普通数据结构来实现读/写锁。另一种实现读/写锁的方式是使用单个计数器。比如读取并相加。假设提前知道线程数n,获取读锁时将计数器加1,释放锁时将计数器减1。请求写操作时将计数器减n,相应地,释放写锁后将计数器加n。如果计数器之前的值为负,说明当前有写操作正在进行,那么读申请就会失败。如果计数器的值不为0,说明当前有写操作(值为负)或多个读操作(值为正)正在进行,对于写锁的申请就会失败。
为了实现这种方案,每一个操作都可以被封装到封装函数里面,通过调用该函数并根据操作的类型来申请读或写锁,并在操作完成之后释放相应锁。这些封装函数会为插入和删除操作获取读写锁,并为搜索操作获取读锁。将锁命名为global,以表示LDS的所有操作都依赖该全局变量。
void Insert(pIntList pList, int key)
{
setLock(global, WRITE);
OrigInsert(pList, key);
unsetLock(global);
}
void Delete(pList pList, int key)
{
setLock(global, WRITE);
OrigDelete(pList, key);
unsetLock(global);
}
int Search(pList pList, int key)
{
setLock(global, READ);
int result = OrigSearch(pList, key);
unsetLock(global);
return result;
}
在数据库管理系统的事务处理中,另一锁类型是升级锁,用于避免死锁问题,特别是当存在多个事务同时持有同一个对象的读锁,并且都希望将该锁升级为写锁时。除此之外,还有意向读或写锁,用于实现嵌套锁机制。
4.3.2 LDS遍历中的并行
如果想在读/写操作和其余操作之间也允许并行的话,会得到更高的并行度。在这种情况下,至少有两种方法能够实现并行。一种细粒度的方法是将链表中的每个节点都关联一个锁变量,这样对于每个节点的操作可以用锁分别保护起来。另一个更简单的方法是使用一个全局锁来保护整个链表。细粒度锁的方法增加了管理锁的复杂度,如需要避免处理死锁和活锁。使用全局锁的方法就避免了死锁和活锁的情况,由于只有一个全局锁,因此在锁获取过程中没有环形依赖。
在读/写操作之间实现并行的关键在于要将操作分解为对链表只读和对链表更新的操作。
逻辑上,链表的删除和插入操作都包含了遍历过程以便找到相应的位置来进行操作。一旦定位到相应的位置,就执行修改。由于遍历过程只读链表,因此可以将多个遍历操作并行执行。只有当操作需要修改链表的时候,才会申请锁以便执行修改链表操作。
然而在遍历完成后到成功申请到写锁这段时间会发生什么改变呢?比如,prev指针指向的节点可能会被删除,或者p指针指向的节点被删除。在这种情况下,插入一个新的节点并让其next指针指向一个被删除了的节点,或者让一个被删除的节点连接一个新的节点之间完成节点的插入。为了检测这种情形,代码会测试prev->next是否仍然等于p。如果以上情形发生了,新的节点就不能被插入,并且需要重新开始遍历过程。这些检测解释了为什么当一个节点被删除后不能立即回收内存,否则检测过程会访问到已经被回收的节点从而可能会导致段错误。
typedef struct tagIntListNode{
int key;
int data;
struct tagIntListNode;
int deleted;
}IntListNode;
typedef IntListNode* pIntListNode;
void Insert(pIntList pList, int key)
{
int success;
do{
success = TryInsert(pList, key);
}
while(!success);
}
int TryInset(pIntList pList, int key)
{
int success = 1;
...
p = pList->head; prev = NULL;
while(p != NULL && p->key < newNode->key){
prep = p;
p = p->next;
}
// 为了简洁,只展示prev和p不是NULL的情况
setLock(gloabl, WRITE);
if (prev->deleted || p->deleted || prev->next != p){ // 检查假设
success = 0;
}
else{
newNode->next = p;
prev->next = newNode;
}
unsetLock(global);
return success;
}
那么何时能够安全删除节点并回收其内存呢?最低要求是,当前没有任何操作会使用该节点,即没有任何线程的活跃指针指向该节点。然而,若保存这种信息需要对每个节点设置一个引用计数器,这会产生很大的内存开销。一个简单的标记方法是,等所有待执行的操作都完成后,再调度垃圾回收程序来回收所有被标记已删除的节点的内存。
无论对于什么类型的LDS,全局锁的方法都相对容易实现一些。因为在只有一个锁的情况下,就不用处理死锁和活锁的情况。应用全局锁的方法需要将针对LDS的每一个操作中的遍历和修改操作分离开来。
通过全局锁机制可以获得并行度有多少?假设遍历到特定节点的时间为,修改节点的时间为。当数据结构大小增加时,则会相对变小,通过全局锁机制并行化优势(以及潜在的加速比)就会增加。然而,当线程数量增加时,就会变成限制加速比的重要因素。
4.3.3 细粒度锁
尽管全局锁的方式允许多个操作并行执行,但是该方法仍然存在限制,如一次只允许一个线程修改链表。
针对链表中不同部分进行修改的操作是如何并行执行的。需要更细粒度的锁。可以将每个节点都分别与一个锁绑定,而不是使用一个全局锁。这里的基本原则是,当一个操作需要修改一个节点时,它就锁住该节点从而别的操作不能修改或读取该节点,但是其余修改或读取节点的操作就可以无冲突地并行执行。
现在的问题是如何确定每个操作需要锁住的节点。为了解决这个问题,首先需要区分操作中会被修改的节点,以及那些只读但一定要保持有效,以便操作能正确完成的节点。处理该问题的核心是:将要被修改的节点需要获取写锁,被读取并需要保持有效性的节点则需要获取读锁。需要注意的是,过度地(例如使用写锁)对这两种节点使用锁也是不必要的,因为这会使得并发度降低。然而,过于宽松(例如使用读锁)地使用锁有会影响结果的正确性。
就节点插入而言,prev指向的节点会被修改指向新的节点,因此该操作获得写锁。p指向的节点不会被修改,但要保持有效以便操作能正确执行,如在完成操作之前p指向的节点不能够被删除,因此需要读锁。
就节点删除操作而言,prev指向的节点的next会被删除,因此需要获得写锁。p指向的节点会被删除,因此也需要获得写锁。需要注意的是,后续节点(p的next指针指向的节点)一定要在删除操作完成之前保持有效,因此需要获得读锁。这样做的原因在于,如果p指向的节点的下一个节点被删除了,那么在删除操作的最后,prev节点的next指针会指向一个删除一个节点,而该结果是错误的。
两个不能并行执行的操作。第一个插入操作一定要获取节点3的写锁,节点4的写锁以及节点6的读锁。第二个插入操作需要获取节点3的写锁、节点5的写锁以及节点6的读锁。因此,这两个会发生冲突,插入操作只能串行执行。
在所有相关节点的锁都正确获取之后,以及对节点进行修改之前还需要再次测试节点的有效性,检测方式与使用全局锁的时候类似。这是因为在遍历节点和第一次获取锁期间,以及不同节点获取锁期间,链表有可能会被其余的线程修改。
在所有相关节点的正确获取之后,以及对节点进行修改之前还需要再次测试节点的有效性,检测方式与使用全局锁的时候类似。
在插入时,需要申请prev指向的节点的写锁,因为代码会修改该节点;以及p指向的节点的读锁,因为需要保证该节点在删除操作执行过程中不会被修改。然后,通过检查prev指向的节点是否被删除、p指向的节点是否被删除,以及prev->next是否与p相等来检测节点的有效性。如果其中一个条件无法满足,那么函数返回0,则插入失败。
在删除节点时,执行遍历之后,需要申请prev指向的节点的写锁,因为代码会修改该节点;以及p指向的节点的写锁,因为代码会删除该节点;还需要p->next节点的读锁,因为需要保证该节点在代码执行过程中不被修改,如p->next节点不能在执行过程中被删除。然后,通过检查prev指向的节点是否被删除,和p指向的节点是否被删除,且prev->next是否与p相等来检测节点的有效性。
在实现细粒度锁方法时,需要注意如果没有以正确的顺序来获取锁,那么可能会出现死锁的情况。在展示的方法中,获取锁的顺序总是从最左边的节点开始。如果插入节点的操作从最左边的节点开始获取锁,但是删除节点的操作从最右边的节点开始获取锁,那么插入节点和删除节点获取锁的方式就会产生环形依赖进而造成死锁。另一种方式是通过以节点地址的升序或降序的方式获取锁,以确保所有线程以统一顺序来获取锁。这种方法在数据结构本身没有顺序信息的时候会很有用,如在图结构中。
typedef struct tagIntListNode{
int key;
int data;
struct tagIntListNode* next;
int deleted;
lock_t lock;
}IntListNode;
typedef IntListNode* pIntListNode;
void Delete(pIntList pList, int key)
{
int success;
do{
success = TryDelete(pList, key);
}
while(!success)
}
int TryDelete(pIntList head, int x)
{
int success = 1;
p = pList->head;
prev = NULL;
while(p != NULL && p->key < newNode->key){
prev = p;
p = p->next;
}
// 为简洁起见,只展示prev和p不为NULL情况
setLock(prev, WRITE);
setLock(p, WRITE);
setLock(p->next, READ);
if (prev ->next != p !! prev->deleted || p->deleted)
success = 0;
else{
prev->next = p->next;
p->deleted = 1;
}
unsetLock(p->next);
unsetLock(p);
unsetLock(prev);
return success;
}
void Insert(pIntList pList, int key)
{
int success;
do{
success = TryInsert(pList, key);
}
while(!success)
}
int TryInsert(pIntList head, int x)
{
int success = 1;
p = pList->head;
prev = NULL;
while(p != NULL && p->key < newNode->key){
prev = p;
p = p->next;
}
// 为简洁起见,只展示prev和p不为NULL情况
setLock(prev, WRITE);
setLock(p, READ);
if (prev ->next != p !! prev->deleted || p->deleted)
success = 0;
else{
newNode->next = p;
prev->next = newNode;
}
unsetLock(p);
unsetLock(prev);
return success;
}
另一个细小的问题是,Insert和Delete避免了因同时修改邻居节点而导致违反可串行性的问题,但它们无法避免在插入或者删除操作之间的遍历过程而违反可串行问题。因为遍历过程可能会访问一个刚插入或刚删除的节点,但是相应的插入或者删除操作还没有彻底完成:在插入过程中,链表中刚插入了一个新的节点,但是该节点的next指针还没有指向链表的后续部分;在删除操作中,链表中刚删除的节点的next指针为NULL,而不是指向链表中后续部分,那么遍历过程就会误以为已到达链表的末尾而不会遍历链表的剩余部分。有两种可行操作来解决这个问题。第一个方法是谨慎地编写代码。在插入一个新的节点时,程序员需要确保正确的顺序:
1)新插入节点的next指针一定要指向链表中相应的正确位置;
2)新插入节点的前驱节点的next指针要指向新插入的节点。
另一方面,删除节点的操作要遵循以下要求:
1)前一个节点的next指针一定要等于当前指针指向的节点的next指针值;
2)被删除节点的next指针不能被覆盖;
3)被删除节点的内存不能被回收。
还有一种方法,即在遍历的时候也使用锁,从而保证被遍历的节点是被锁住的。该方法保证了当前被遍历的节点不被其余的操作修改。但该方法的缺陷是在遍历过程中需要不断地获取和释放节点的锁,这会增加处理时间。这种方法被称为蜘蛛锁spider locking,因为这些操作就像蜘蛛一样在遍历过程中沿着数据结构往前并逐个获取锁。
将细粒度锁和全局锁对比,对于单链表而言,二者的编程复杂度完全不同。由于链表数据结构的规则性,可以很轻松地将以下三个步骤分开:
1)遍历;
2)锁住将被修改或者需要依赖其有效性的节点;
3)修改节点。
就更复杂类型的LDS来说,要想实现细粒度锁方法就会更加难,因为对于以上三个步骤就没有那么容易区分开。比如在树LDS中,执行树平衡操作算法在执行操作之前无法知道到底有多少节点会被写或读。另外,节点的修改和锁可能会被混淆,除非在算法设计过程中已将其区分开。确定节点锁的获取顺序以避免死锁同样是非常困难的,由于算法本身的要求,某些操作需要先访问树结构中的低层节点,然而另一些则需要先访问高层节点。在这些情况下,则需要将算法分成两部分:一部分用于确定哪些节点需要被锁住和修改;一部分执行锁定节点的操作和修改操作。
然而,在LDS原语级别实现并行就需要对相关算法和源码做较多的修改。
// 蜘蛛锁实现并行搜索节点
int Search(pIntList pList, int key)
{
pIntListNode p, prev;
if (pList->head == NULL)
return 0;
p = pList->head; prev = NULL;
setLock(p, READ);
while (p != NULL && p->key != key){
prep = p;
if (p->next != NULL)
setLock(p->next);
p = p->next;
unsetLock(prev);
}
unsetLock(p);
if (p == NULL)
return 0;
else
return 1;
}
4.4 事务内存
事务内存Transactional memory, TM可以在某种程度上简化LDS并行编程。使用TM最简单的方法就是将一个LDS操作封装在一个事务中。比如atomic(Insert)。
但事务内存仍有几点不足。随着LDS结构大小的增加,每一个操作都需要更长的时间才能完成,且大部分时间都在遍历LDS。更长的操作时间会降低两个事务发生冲突的可能性,导致操作回滚且至少有一个事务需要重新执行,这样就降低了性能。在TM中,当检测到冲突时就会触发回滚,当一个事务的写操作和另一事务的读/写操作重叠时就会检测到冲突。这种冲突也被称为假冲突false conflict。
类似于其他乐观并发技术,TM的性能依赖于低冲突率。高冲突率会导致过量的事务冲突、终止和失败。最后一些支持TM的硬件限制了可以用事务方法处理的数据规模。任何处理超过最大投机缓冲区大小的事务都会被终止,即使没有任何事务之间发生冲突。
void Delete(pIntList pList, int key)
{
int success;
do{
success = TryDelete(pList, key);
}
while(!success)
}
int TryDelete(pIntList head, int x)
{
int success = 1;
p = pList->head;
prev = NULL;
while(p != NULL && p->key != key){
prev = p;
p = p->next;
}
// 为简洁起见,只展示prev和p不为NULL情况
atomic{
if (prev ->next != p !! prev->deleted || p->deleted)
success = 0;
else{
prev->next = p->next;
p->deleted = 1;
}
}
return success;
}
不管使用细粒度锁还是TM方法来实现对LDS的并行编程,程序员都需要仔细考虑并发性以及粒度问题。与使用锁编程类似,事务粒度越细,就会有越多的竞争,同时编程也更复杂,然而,一旦确定了锁的粒度,使用TM则不需要担心维护锁以及数据结构和使用锁的风险,因而TM可以简化使用细粒度锁的编程。对于粗粒度事务,由于事务被终止的可能性很大,仍然可能需要用到锁,因此需要考虑。