java 并发互斥_设计没有互斥量的并发数据结构

本文(本系列的最后一部分)讨论了两件事:用于实现基于互斥锁的并发列表和设计没有互斥锁的并发数据结构的设计选择。 对于后一个主题,我选择实现并发堆栈,并重点介绍设计这种数据结构时的一些问题。 在C++中设计独立于平台的无互斥数据结构尚未实现,因此我选择了GCC版本4.3.4作为编译器,并在代码中使用了特定于GCC的__sync_*函数。 如果您是WIndows®C C++开发人员,请考虑Interlocked *函数组来完成类似的工作。

并发单链接列表中的设计选择

清单1显示了最基本的并发,单链接列表接口。 明显缺少什么吗?

清单1.一个并发的单链接列表接口
template <typename T> class SList { private: typedef struct Node { T data; Node *next; Node(T& data) : value(data), next(NULL) { } } Node; pthread_mutex_t _lock; Node *head, *tail; public: void push_back(T& value); void insert_after(T& previous, T& value); // insert data after previous void remove(const T& value); bool find(const T& value); // return true on success SList( ); ~SList( ); };

对于预期的行, 清单2显示了push_back方法定义。

清单2.将数据推送到并发链接列表
void SList<T>::push_back(T& data) { pthread_mutex_lock(&_lock); if (head == NULL) { head = new Node(data); tail = head; } else { tail->next = new Node(data); tail = tail->next; } pthread_mutex_unlock(&_lock); }

现在,考虑一个线程,尝试通过调用push_back将n个整数快速推入此列表。 接口本身要求您获取和释放互斥锁n次,即使在首次获取锁之前就知道要插入的所有数据。 更好的方法是定义另一种方法,该方法接受整数列表,并且仅获取和释放互斥锁一次。 清单3显示了方法定义。

清单3.智能地添加到链表
void SList<T>::push_back(T* data, int count) // or use C++ iterators { Node *begin = new Node(data[0]); Node *temp = begin; for (int i=1; i<count; ++i) { temp->next = new Node(data[i]); temp = temp->next; } pthread_mutex_lock(&_lock); if (head == NULL) { head = begin; tail = head; } else { tail->next = begin; tail = temp; } pthread_mutex_unlock(&_lock); }

优化搜索元素

现在,让我们继续优化列表中的搜索元素-即find方法。 以下是一些可能发生的潜在情况:

  • 当一些线程遍历列表时,插入或删除请求就会进入。
  • 某些线程正在迭代列表时,将发出迭代请求。
  • 某些线程正在向列表中插入数据或从列表中删除数据时,将发出迭代请求。

显然,您应该能够同时服务多个迭代请求。 对于插入/删除率最小且主要活动包括搜索的系统,使用单个基于锁的方法远低于标准方法。 在这种情况下,了解读写锁或pthread_rwlock_t 。 在本文的示例中,您将在SList使用pthread_rwlock_t而不是pthread_mutex_t 。 这样做允许多个线程同时搜索列表。 插入和删除仍将锁定整个列表,这还是可以的。 清单4显示了一些带有pthread_rwlock_t的列表实现,后跟find的代码。

清单4.使用读写锁的并发,单链接列表
template <typename T> class SList { private: typedef struct Node { // … same as before } Node; pthread_rwlock_t _rwlock; // Not pthread_mutex_t any more! Node *head, *tail; public: // … other API remain as-is SList( ) : head(NULL), tail(NULL) { pthread_rwlock_init(&_rwlock, NULL); } ~SList( ) { pthread_rwlock_destroy(&_rwlock); // … now cleanup nodes } };

清单5显示了列表搜索的代码。

清单5.使用读写锁搜索链接列表
bool SList<T>::find(const T& value) { pthread_rwlock_rdlock (&_rwlock); Node* temp = head; while (temp) { if (temp->value == data) { status = true; break; } temp = temp->next; } pthread_rwlock_unlock(&_rwlock); return status; }

清单6显示了使用读写锁的push_back

清单6.使用读写锁将数据推入并发链接列表
void SList<T>::push_back(T& data) { pthread_setschedprio(pthread_self( ), SCHED_FIFO); pthread_rwlock_wrlock(&_rwlock); // … All the code here is same as Listing 2 pthread_rwlock_unlock(&_rwlock); }

让我们盘点一下。 您已经使用了两个锁定函数调用( pthread_rwlock_rdlockpthread_rwlock_wrlock同步,并使用了pthread_setschedprio调用来设置写程序线程的优先级。 如果没有任何写程序线程在此锁上被阻止(换句话说,没有插入/删除请求),则请求列表搜索的多个阅读器线程可以同时运行,因为在这种情况下一个阅读器线程不会阻止另一个阅读器线程。 如果编写器线程正在等待此锁,则当然不允许新的读取器线程来获取该锁,并且线程将等到现有读取器线程完成之后再写入器线程。 如果您在使用pthread_setschedprio区分写程序线程的优先级时不遵循这种方法,那么鉴于读写锁定的性质,很容易看出写程序线程可能会饿死。

使用此方法需要记住以下几点:

  • 如果超出了锁的最大读取锁(定义的实现)数量,则pthread_rwlock_rdlock可能会失败。
  • 如果该锁有n个并发读锁,请小心调用n次pthread_rwlock_unlock

允许并发插入

您应该学习的最后一个方法是insert_after 。 再一次,预期的使用模式将决定您调整数据结构的决定。 如果应用程序以一个预先提供的链表开头,该链表具有几乎相等的插入和搜索次数,但删除次数却很少,那么在插入过程中锁定整个列表并不明智。 在这种情况下,允许在列表中的不相交点同时插入是个好主意,并且您再次使用基于读写锁的方法。 这是构造列表的方式:

  • 锁定发生在两个级别上(请参见清单7 ):该列表具有读写锁定,而单个节点包含一个互斥体。 如果您要寻找节省空间的方法,那么考虑共享互斥锁的计划-可能维护节点与互斥锁的映射。
  • 在插入过程中,编写器线程对列表进行读锁定并继续。 要在其上添加新数据的单个节点在插入之前被锁定,在插入之后被释放,然后释放读写锁。
  • 删除将在列表上创建写锁定。 无需获取特定于节点的锁。
  • 可以像以前一样同时进行搜索。
清单7.具有两级锁定的并发单链接列表
template <typename T> class SList { private: typedef struct Node { pthread_mutex_lock lock; T data; Node *next; Node(T& data) : value(data), next(NULL) { pthread_mutex_init(&lock, NULL); } ~Node( ) { pthread_mutex_destroy(&lock); } } Node; pthread_rwlock_t _rwlock; // 2 level locking Node *head, *tail; public: // … all external API remain as-is } };

清单8显示了将数据插入列表的代码。

清单8.通过双重锁定将数据插入列表
void SList<T>:: insert_after(T& previous, T& value) { pthread_rwlock_rdlock (&_rwlock); Node* temp = head; while (temp) { if (temp->value == previous) { break; } temp = temp->next; } Node* newNode = new Node(value); pthread_mutex_lock(&temp->lock); newNode->next = temp->next; temp->next = newNode; pthread_mutex_unlock(&temp->lock); pthread_rwlock_unlock(&_rwlock); return status; }

基于互斥锁的方法存在的问题

到目前为止,您已经使用了一个或多个互斥锁作为数据结构的一部分进行同步。 但是,这种方法并非没有问题。 请考虑以下情况:

  • 等待互斥体会浪费宝贵的时间,有时会浪费很多时间。 此延迟对系统可伸缩性具有负面影响。
  • 较低优先级的线程可以获取一个互斥锁,从而停止需要相同互斥锁才能继续进行的较高优先级的线程。 此问题称为优先级倒置 ( 有关更多信息的链接,请参阅参考资料 )。
  • 持有互斥锁的线程可以取消调度,这可能是因为它的时间片已结束。 对于等待相同互斥量的其他线程,这会产生负面影响,因为等待时间现在甚至更长。 这个问题称为锁护送 (见相关主题的更多信息的链接)。

互斥锁的问题不会在这里结束。 最近,出现了不使用互斥锁的解决方案。 就是说,尽管互斥锁使用起来很棘手,但是如果您要寻求更好的性能,它们绝对值得您注意。

比较和交换指令

在继续讨论不涉及互斥锁的解决方案之前,让我们暂停片刻,并研究以80486开始的所有Intel®处理器上可用的CMPXCHG汇编指令。从概念上讲, 清单9显示了该指令的作用。

清单9.比较和交换指令行为
int compare_and_swap ( int *memory_location, int expected_value, int new_value) { int old_value = *memory_location; if (old_value == expected_value) *memory_location = new_value; return old_value; }

这里发生的事情是该指令正在检查内存位置是否具有预期值。 如果是这样,则将新值复制到该位置。 从汇编语言的角度来看, 清单10提供了伪代码。

清单10.比较和交换指令汇编伪代码
CMPXCHG OP1, OP2 if ({AL or AX or EAX} = OP1) zero = 1 ;Set the zero flag in the flag register OP1 = OP2 else zero := 0 ;Clear the zero flag in the flag register {AL or AX or EAX}= OP1

CPU根据操作数的宽度(8位,16位或32位)选择AL,AX或EAX寄存器。 如果AL / AX / EAX寄存器的内容与操作数1的内容匹配,则将操作数2的内容复制到第一个操作数; 否则,将使用操作数2的值更新AL / AX / EAX寄存器。IntelPentium®64位处理器具有类似的名为CMPXCHG8B的指令,支持64位比较和交换。 请注意,CMPXCHG指令是atomic ,这意味着在该指令完成之前,系统没有中间可见状态。 它已完全执行或尚未启动。 在其他平台上也可以使用等效指令-例如,摩托罗拉MC68030处理器具有名为“ 比较和交换 (CAS)”的指令,该指令具有类似的语义。

为什么我们对CMPXCHG感兴趣? 这是否意味着我会汇编代码?

您需要很好地了解CMPXCHG和诸如CMPXCHG8B之类的相关指令,因为它们构成了无锁解决方案的症结所在。 但是,您可以不进行汇编编码。 幸运的是,GCC(从4.1版开始的GNU编译器集合)提供了原子内建组件(请参阅参考资料 ),可用于为x86和x86-64平台实现CAS操作。 此支持不需要包含头文件。 在本文中,您将在无锁数据结构的实现中使用GCC内置函数。 看一下内置函数:

bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...) type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...)

内置的__sync_bool_compare_and_swapoldval*ptr进行比较。 如果它们匹配,它将newval复制到*ptr, newval 如果oldval*ptr匹配,则返回值为True,否则为False。 内置__sync_val_compare_and_swap的行为类似,不同之处在于它始终返回旧值。 清单11提供了一个示例用法。

清单11. GCC CAS内置示例用法
#include <iostream> using namespace std; int main() { bool lock(false); bool old_value = __sync_val_compare_and_swap( &lock, false, true); cout >> lock >> endl; // prints 0x1 cout >> old_value >> endl; // prints 0x0 }

设计无锁并发堆栈

现在您已经对CAS有一些了解,让我们设计一个并发堆栈。 不包括锁; 这种无锁的并发数据结构也称为非阻塞数据结构 。 清单12提供了代码接口。

清单12.基于链表的非阻塞堆栈的实现
template <typename T> class Stack { typedef struct Node { T data; Node* next; Node(const T& d) : data(d), next(0) { } } Node; Node *top; public: Stack( ) : top(0) { } void push(const T& data); T pop( ) throw (…); };

清单13显示了Push操作。

清单13.在非阻塞堆栈中推送数据
void Stack<T>::push(const T& data) { Node *n = new Node(data); while (1) { n->next = top; if (__sync_bool_compare_and_swap(&top, n->next, n)) { // CAS break; } } }

推操作发生了什么? 从单个线程的角度来看,将创建一个新节点,其下一个指针指向堆栈的顶部。 接下来,调用CAS并将新节点复制到顶部。

从多个线程的角度来看,两个或多个线程同时尝试将数据推入堆栈中完全是可能的。 假设您有线程A试图将20推入,线程B试图将30推入堆栈,并且线程A首先获得了时间片。 在指令n->next = top完成后,线程A也被调度了。 现在,线程B(还有一个幸运的线程)开始起作用,能够完成CAS,并通过将30推入堆栈来完成。 接下来,线程A继续执行,并且显然*topn->next与该线程不匹配,因为线程B修改了top位置的内容。 因此,代码循环返回,指向正确的顶部指针(由于线程B而更改了该顶部指针),调用CAS,并通过将20压入堆栈来完成。 所有这些都没有任何锁。

现在开始流行操作

清单14显示了将元素弹出堆栈的代码。

清单14.从非阻塞堆栈弹出数据
T Stack<T>::pop( ) { if (top == NULL) throw std::string(“Cannot pop from empty stack”); while (1) { Node* next = top->next; if (__sync_bool_compare_and_swap(&top, top, next)) { // CAS return top->data; } } }

您可以按照类似的方式定义Pop操作的语义来进行push 。 堆栈的顶部存储在result ,您可以使用CAS使用top-<next更新顶部位置并返回适当的数据。 如果在CAS之前有线程抢占,则在恢复线程之后,CAS将失败,并且循环将继续进行,直到获得有效数据为止。

一切顺利,一切顺利

不幸的是,堆栈的pop实现存在问题-显而易见的和不显而易见的变化。 显而易见的问题是NULL检查必须是while循环的一部分。 如果线程P和线程Q都试图从仅剩一个元素的堆栈中弹出数据,并且在CAS之前重新安排了线程P的调度,那么到重新获得控制权时,就没有弹出的内容了。 因为top将为NULL,所以访问&top是崩溃的必经之路-显然是可以避免的错误。 在处理并行数据结构时,此问题还突出了基本设计原则之一:永远不要假设任何代码都按顺序执行。

清单15显示了带有明显错误修复的代码。

清单15.从非阻塞堆栈弹出数据
T Stack<T>::pop( ) { while (1) { if (top == NULL) throw std::string(“Cannot pop from empty stack”); Node* next = top->next; if (top && __sync_bool_compare_and_swap(&top, top, next)) { // CAS return top->data; } } }

下一个问题有些复杂,但是如果您了解内存管理器是如何工作的(请参阅参考资料 ,以获得更多信息的链接),这应该不太困难。 清单16显示了该问题。

清单16.回收内存可能导致CAS出现严重问题
T* ptr1 = new T(8, 18); T* old = ptr1; // .. do stuff with ptr1 delete ptr1; T* ptr2 = new T(0, 1); // We can't guarantee that the operating system will not recycle memory // Custom memory managers recycle memory often if (old1 == ptr2) { … }

在此代码中,您不能保证oldptr2将具有不同的值。 根据操作系统和自定义应用程序内存管理系统的不同,删除的内存完全有可能被回收利用-也就是说,删除的内存存储在专用池中,供应用程序在需要时重用,而不返回给系统。 显然,这可以提高性能,因为您不需要通过系统调用来请求额外的内存。 现在,尽管通常这是一件好事,但让我们看一下为什么对非阻塞堆栈而言,它不是一个好消息。

假设您有两个线程-A和B。一个名为pop线程在CAS之前被调度。 然后B调用pop并推入数据,其中一部分来自早期Pop操作的回收内存。 清单17显示了伪代码。

清单17.序列图
Thread A tries to pop Stack Contents: 5 10 14 9 100 2 result = pointer to node containing 5 Thread A now de-scheduled Thread B gains control Stack Contents: 5 10 14 9 100 2 Thread B pops 5 Thread B pushes 8 16 24 of which 8 was from the same memory that earlier stored 5 Stack Contents: 8 16 24 10 14 9 100 2 Thread A gains control At this time, result is still a valid pointer and *result = 8 But next points to 10, skipping 16 and 24!!!

修复非常简单:不要存储下一个节点。 清单18显示了代码。

清单18.从非阻塞堆栈弹出数据
T Stack<T>::pop( ) { while (1) { Node* result = top; if (result == NULL) throw std::string(“Cannot pop from empty stack”); if (top && __sync_bool_compare_and_swap(&top, result, result->next)) { // CAS return top->data; } } }

通过这种安排,即使在线程A尝试弹出时线程B已经修改了顶部,您也可以确保堆栈中没有任何元素被跳过。

摘要

本系列文章为设计适合于并发访问的数据结构的世界提供了见解。 您已经看到设计选择可能是基于互斥或无锁的。 无论哪种方式,两者都需要超越这些数据结构的传统功能的思维方式-特别是,您始终需要牢记先发优势以及重新安排线程时线程如何恢复。 目前,解决方案(特别是在无锁方面)是特定于平台/编译器的。 考虑研究Boost库中线程和锁的实现,以及John Valois关于无锁链表的论文(请参阅参考资料中的链接)。 C++0x标准提供了std::thread类,但是迄今为止,对它的支持仅限于完全不存在的完全编译器中。


翻译自: https://www.ibm.com/developerworks/aix/library/au-multithreaded_structures2/index.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值