因此,您的计算机现在具有四个CPU内核; 并行计算是最新的流行语,因此您渴望参与其中。 但是并行计算不仅仅是在随机函数和方法中使用互斥量和条件变量。 C++
开发人员必须拥有的关键工具之一就是设计并发数据结构的能力。 本文是由两部分组成的系列文章的第一篇,讨论了多线程环境中并发数据结构的设计。 对于本文,您将使用POSIX Threads库(也称为Pthreads; 有关链接,请参阅参考资料),但是也可以使用Boost Threads之类的实现( 有关链接,请参阅参考资料 )。
本文假定您具有基本数据结构的基本知识,并且对POSIX Threads库有所了解。 您还应该对线程创建,互斥锁和条件变量有基本的了解。 在Pthreads稳定版中,在整个示例中,您将大量使用pthread_mutex_lock
, pthread_mutex_unlock
, pthread_cond_wait
, pthread_cond_signal
和pthread_cond_broadcast
。
设计并发队列
首先,扩展最基本的数据结构之一:队列。 您的队列基于链接列表; 基础列表的接口基于标准模板库(STL;请参阅参考资料 )。 多个控制线程可以同时尝试将数据推送到队列或删除数据,因此您需要一个互斥对象来管理同步。 队列类的构造函数和析构函数负责创建和销毁互斥体,如清单1所示。
清单1.链接列表和基于互斥的并发队列
#include <pthread.h> #include <list.h> // you could use std::list or your implementation namespace concurrent { template <typename T> class Queue { public: Queue( ) { pthread_mutex_init(&_lock, NULL); } ~Queue( ) { pthread_mutex_destroy(&_lock); } void push(const T& data); T pop( ); private: list<T> _list; pthread_mutex_t _lock; } };
将数据插入并发队列中或从中删除数据
显然,将数据推入队列类似于将数据追加到列表,并且此操作必须由互斥锁保护。 但是,如果有多个线程打算将数据追加到队列,会发生什么? 第一个线程锁定互斥锁并将数据追加到队列,而其他线程等待轮到他们。 一旦第一个线程解锁/释放互斥锁,操作系统将决定哪个线程在队列中添加下一个数据。 通常,在没有实时优先级线程的Linux®系统中,等待时间最长的线程是下一个唤醒,获取锁并将数据附加到队列的线程。 清单2显示了此代码的第一个工作版本。
清单2.将数据推送到队列
void Queue<T>::push(const T& value ) { pthread_mutex_lock(&_lock); _list.push_back(value); pthread_mutex_unlock(&_lock); }
弹出数据的代码类似清单3所示。
清单3.从队列中弹出数据
T Queue<T>::pop( ) { if (_list.empty( )) { throw ”element not found”; } pthread_mutex_lock(&_lock); T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; }
公平地说, 清单2和清单3中的代码可以正常工作。 但是请考虑这种情况:您的队列很长(可能超过100,000个元素),并且从代码中读取数据的线程要比在代码执行过程中某个时刻追加数据的线程多得多。 因为您要为推和弹出操作共享相同的互斥锁,所以在写入线程访问锁时,数据读取速度会有所降低。 使用两个锁怎么办? 一个用于读操作,另一个用于写操作应该可以解决问题。 清单4显示了修改后的Queue
类。
清单4.并发队列带有用于读取和写入操作的独立互斥体
template <typename T> class Queue { public: Queue( ) { pthread_mutex_init(&_rlock, NULL); pthread_mutex_init(&_wlock, NULL); } ~Queue( ) { pthread_mutex_destroy(&_rlock); pthread_mutex_destroy(&_wlock); } void push(const T& data); T pop( ); private: list<T> _list; pthread_mutex_t _rlock, _wlock; }
清单5显示了push / pop方法定义。
清单5.具有独立互斥锁的并发队列Push / Pop操作
void Queue<T>::push(const T& value ) { pthread_mutex_lock(&_wlock); _list.push_back(value); pthread_mutex_unlock(&_wlock); } T Queue<T>::pop( ) { if (_list.empty( )) { throw ”element not found”; } pthread_mutex_lock(&_rlock); T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_rlock); return _temp; }
设计并发阻塞队列
到目前为止,如果读取器线程想要从没有数据的队列中读取数据,则只需抛出一个异常并继续。 但是,这可能并非始终是理想的方法,并且读取器线程可能希望等待或阻塞自身,直到时间数据可用为止。 这种队列称为阻塞队列 。 一旦发现队列为空,读者将如何继续等待? 一种选择是定期轮询队列。 但是,由于该方法不能保证队列中数据的可用性,因此可能导致浪费大量CPU周期。 推荐的方法是使用条件变量,即pthread_cond_t
类型的变量。 在深入研究语义之前,让我们看一下修改后的队列定义,如清单6所示。
清单6.使用条件变量的并发阻塞队列
template <typename T> class BlockingQueue { public: BlockingQueue ( ) { pthread_mutex_init(&_lock, NULL); pthread_cond_init(&_cond, NULL); } ~BlockingQueue ( ) { pthread_mutex_destroy(&_lock); pthread_cond_destroy(&_cond); } void push(const T& data); T pop( ); private: list<T> _list; pthread_mutex_t _lock; pthread_cond_t _cond; }
清单7显示了阻塞队列的pop操作的修改版本。
清单7.从队列中弹出数据
T BlockingQueue<T>::pop( ) { pthread_mutex_lock(&_lock); if (_list.empty( )) { pthread_cond_wait(&_cond, &_lock) ; } T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; }
现在,读取器线程不再在队列为空时引发异常,而是在条件变量上自行阻塞。 隐式地, pthread_cond_wait
也将释放mutex _lock
。 现在,考虑这种情况:有两个读取器线程和一个空队列。 第一个读取器线程锁定了互斥锁,意识到队列为空,并在_cond
自身_cond
,这隐式释放了互斥锁。 第二个阅读器线程进行了重新演说。 因此,到最后,您现在有了两个读取器线程,它们都在等待条件变量,并且互斥锁已解锁。
现在,查看清单8所示的push()
方法的定义。
清单8.将数据推送到阻塞队列中
void BlockingQueue <T>::push(const T& value ) { pthread_mutex_lock(&_lock); const bool was_empty = _list.empty( ); _list.push_back(value); pthread_mutex_unlock(&_lock); if (was_empty) pthread_cond_broadcast(&_cond); }
如果列表最初为空,则调用pthread_cond_broadcast
将推送数据发布到列表中。 这样做会唤醒所有正在等待条件变量_cond
的读取器线程; 读取器线程现在在释放互斥锁时隐式竞争。 操作系统调度程序确定下一个线程将控制下一个互斥锁,通常是等待时间最长的读取器线程首先读取数据。
这是并发阻塞队列设计的两个较优方面:
- 而不是
pthread_cond_broadcast
,你也可以使用pthread_cond_signal
。 但是,pthread_cond_signal
解除阻塞等待条件变量的至少一个线程,而不必阻塞等待时间最长的读取器线程。 尽管阻塞队列的功能不会因该选择而受到影响,但是使用pthread_cond_signal
可能会导致某些读取器线程的等待时间不可接受。 - 线程的虚假唤醒是可能的。 因此,在唤醒阅读器线程之后,请确认列表不为空,然后继续。 清单9显示了
pop()
方法的稍作修改的版本,强烈建议您使用基于while
循环的pop()
版本。
清单9.从队列中弹出数据,并容忍虚假唤醒
T BlockingQueue<T>::pop( ) { pthread_cond_wait(&_lock) ; //need writer(s) to acquire and pend on the condition while(_list.empty( )) { pthread_cond_wait(&_cond,&_lock) ; } T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; }
设计具有超时的并发阻塞队列
有很多系统,如果它们在一定时间内无法处理新数据,则根本不会处理数据。 一个很好的例子是新闻频道的报价器,显示来自金融交易所的实时股票价格,每隔n秒就会收到新数据。 如果在n秒内无法处理某些先前的数据,则丢弃该数据并显示最新信息是很有意义的。 基于此想法,让我们看一下并发队列的概念,在该队列中,推送和弹出操作都带有超时。 这意味着,如果系统无法在您指定的时限内执行推入或弹出操作,则该操作将根本不执行。 清单10显示了该接口。
清单10.带有时间限制的推送和弹出操作的并发队列
template <typename T> class TimedBlockingQueue { public: TimedBlockingQueue ( ); ~TimedBlockingQueue ( ); bool push(const T& data, const int seconds); T pop(const int seconds); private: list<T> _list; pthread_mutex_t _lock; pthread_cond_t _cond; }
让我们从定时的push()
方法开始。 现在, push()
方法不再依赖任何条件变量,因此无需额外等待。 延迟的唯一原因可能是写程序线程太多,并且在获取锁之前已经经过了足够的时间。 那么,为什么不增加writer线程优先级呢? 原因是,如果所有写入器线程的优先级都增加了,那么增加写入器线程优先级将无法解决问题。 相反,可以考虑创建一些具有较高调度优先级的编写器线程,并将数据移交给那些应始终推入队列的线程。 清单11显示了代码。
清单11.带超时将数据推送到阻塞队列中
bool TimedBlockingQueue <T>::push(const T& data, const int seconds) { struct timespec ts1, ts2; const bool was_empty = _list.empty( ); clock_gettime(CLOCK_REALTIME, &ts1); pthread_mutex_lock(&_lock); clock_gettime(CLOCK_REALTIME, &ts2); if ((ts2.tv_sec – ts1.tv_sec) <seconds) { was_empty = _list.empty( ); _list.push_back(value); { pthread_mutex_unlock(&_lock); if (was_empty) pthread_cond_broadcast(&_cond); }
该clock_gettime
结构中的例行程序返回timespec
的纪元以来所经过的时间量(更多相关信息,请参见相关主题 )。 在获取互斥锁之前和之后,将两次调用此例程,以根据经过的时间确定是否需要进一步处理。
超时弹出数据比推送更复杂。 请注意,读取器线程正在等待条件变量。 第一个检查类似于push()
。 如果在读取器线程获取互斥之前发生了超时,则无需进行任何处理。 接下来,读取器线程需要确保(这是您执行的第二次检查),它不会在条件变量上等待的时间不超过指定的超时时间。 如果没有唤醒,则在超时结束时,阅读器需要自行唤醒并释放互斥锁。
在这种背景下,让我们看一下用于第二次检查的函数pthread_cond_timedwait
。 该函数与pthread_cond_wait
相似,不同之处在于第三个参数是绝对时间值,读者线程愿意在该绝对时间之前放弃它。 如果读取器线程在超时之前被唤醒,则pthread_cond_timedwait
的返回值将为0
。 清单12显示了代码。
清单12.从超时队列中弹出数据
T TimedBlockingQueue <T>::pop(const int seconds) { struct timespec ts1, ts2; clock_gettime(CLOCK_REALTIME, &ts1); pthread_mutex_lock(&_lock); clock_gettime(CLOCK_REALTIME, &ts2); // First Check if ((ts1.tv_sec – ts2.tv_sec) < seconds) { ts2.tv_sec += seconds; // specify wake up time while(_list.empty( ) && (result == 0)) { result = pthread_cond_timedwait(&_cond, &_lock, &ts2) ; } if (result == 0) { // Second Check T _temp = _list.front( ); _list.pop_front( ); pthread_mutex_unlock(&_lock); return _temp; } } pthread_mutex_unlock(&lock); throw “timeout happened”; }
清单12中的while
循环确保正确处理了虚假唤醒。 最后,在某些Linux系统上, clock_gettime
可能是librt.so的一部分,您可能需要在编译器命令行后附加–lrt
开关。
使用pthread_mutex_timedlock API
清单11和清单12中的痛处之一是,当线程最终确实设法访问该锁时,可能已经存在超时。 因此,它所要做的就是释放锁定。 如果系统支持,可以使用pthread_mutex_timedlock
API进一步优化这种情况(请参阅参考资料 )。 该例程接受两个参数,第二个参数是时间的绝对值,如果无法获取锁定,该时间将以非零状态返回。 因此,使用此例程可以减少系统中等待线程的数量。 这是例行声明:
int pthread_mutex_timedlock(pthread_mutex_t *mutex, const struct timespec *abs_timeout);
设计并发阻塞有界队列
让我们以并发阻塞边界队列的讨论结束。 此队列类型类似于并发阻塞队列,除了队列的大小是有界的。 在许多嵌入式系统中,内存是有限的,并且确实需要大小有限的队列。
在阻塞队列中,当队列中没有数据时,只有读取器线程需要等待。 在有界阻塞队列中,如果队列已满,则编写器线程也需要等待。 外部接口类似于阻塞队列的接口,如清单13中的代码所示。 (请注意,选择的是向量而不是列表。您可以使用基本的C/C++
数组,并根据需要使用大小对其进行初始化。)
清单13.并发有界阻塞队列
template <typename T> class BoundedBlockingQueue { public: BoundedBlockingQueue (int size) : maxSize(size) { pthread_mutex_init(&_lock, NULL); pthread_cond_init(&_rcond, NULL); pthread_cond_init(&_wcond, NULL); _array.reserve(maxSize); } ~BoundedBlockingQueue ( ) { pthread_mutex_destroy(&_lock); pthread_cond_destroy(&_rcond); pthread_cond_destroy(&_wcond); } void push(const T& data); T pop( ); private: vector<T> _array; // or T* _array if you so prefer int maxSize; pthread_mutex_t _lock; pthread_cond_t _rcond, _wcond; }
但是,在解释push操作之前,请看一下清单14中的代码。
清单14.将数据推送到有界的阻塞队列
void BoundedBlockingQueue <T>::push(const T& value ) { pthread_mutex_lock(&_lock); const bool was_empty = _array.empty( ); while (_array.size( ) == maxSize) { pthread_cond_wait(&_wcond, &_lock); } _ array.push_back(value); pthread_mutex_unlock(&_lock); if (was_empty) pthread_cond_broadcast(&_rcond); }
清单13和清单14中的第一件事是,有两个条件变量,而不是阻塞队列具有的条件变量。 如果队列已满,则_wcond
线程将等待_wcond
条件变量; 阅读器线程在使用队列中的数据后将需要向所有线程发出通知。 同样,如果队列为空,则读取器线程将等待_rcond
变量,而写入器线程将数据插入队列后,将广播发送到所有等待_rcond
线程。 当没有线程正在等待_wcond
或_rcond
而是广播通知时会发生什么? 好消息是什么都没有发生。 系统只是忽略这些消息。 还要注意,两个条件变量都使用相同的互斥量。 清单15显示了有界阻塞队列中pop()
方法的代码。
清单15.从有界阻塞队列中弹出数据
T BoundedBlockingQueue<T>::pop( ) { pthread_mutex_lock(&_lock); const bool was_full = (_array.size( ) == maxSize); while(_array.empty( )) { pthread_cond_wait(&_rcond, &_lock) ; } T _temp = _array.front( ); _array.erase( _array.begin( )); pthread_mutex_unlock(&_lock); if (was_full) pthread_cond_broadcast(&_wcond); return _temp; }
请注意,释放互斥锁后,您已调用pthread_cond_broadcast
。 这是一个好习惯,因为唤醒后减少了读取器线程的等待时间。
结论
本期文章讨论了许多类型的并发队列及其实现。 实际上,进一步的变化是可能的。 例如,考虑一个队列,其中读取器线程仅在插入后一定时间延迟后才被允许使用数据。 确保检查“ 相关主题”部分以获取有关POSIX线程和并发队列算法的详细信息。
翻译自: https://www.ibm.com/developerworks/aix/library/au-multithreaded_structures1/index.html