上一篇:03 重修C++之并发实战5
【设计基于锁的并发数据结构】
在最基本的层面,为并发设计数据结构意味着多个线程可以同时使用此数据结构,执行相同或不同的操作,并且每个线程都有数据结构的一致性视图。不会丢失或破坏数据,维持所有不变量,并且没有不确定的竞争条件,此种数据结构就被称为线程安全的。通常,只有在特定的并发存取下,一种数据结构才是安全的。很有可能出现这种情况,就是多个线程执行不同的操作,它们并发地存取某个数据结构是安全地。然而多个线程执行相同的操作,它们并发地存取某个数据结构可能会有问题。
实际上并发设计远远不只是为多个线程提供存取数据结构地并发机会。本质上,互斥元提供的互斥,一次只允许一个线程获取互斥元的锁。一个互斥元通过明确阻止对它所保护的数据进行并发存取来保护数据结构。这被称为序列化(serialization):多个线程轮流存取互斥元保护的数据,它们必须线性地非并发地存取数据。
使得数据结构线程安全地基本原理。
- 保证当前数据结构不变性被别的线程破坏地状态不被任何别的线程看到。
- 注意避免数据结构接口出现固有地竞争现象,通过为完整操作提供函数,而不是提供操作步骤。
- 注意当出现例外时,数据结构时怎样来保证不变性不被破坏地。
- 当使用数据结构时,通过限制锁的范围和避免使用嵌套锁,来降低生产死锁的机会。
在考虑这些问题之前还要再思考一个问题,如果一个函数通过特殊函数使用数据结构,那么其他线程调用那个函数是不是安全的?因为大多数构造函数和析构函数需要以独占的方式访问数据结构,但是需要使用者保证它们再构造函数完成前或者析构函数开始后没有被使用。如果数据结构支持赋值、swap()、或复制构造,那么作为数据结构的设计者,就需要决定这些操作与别的操作同时被调用时是否安全,或者是否需要使用者保证互斥问,即使操作数据结构的大部分函数可能被多线程同时访问时没问题。其次还需要考虑的问题是实现真正的并发存取:
- 锁的范围能否被限定,使得一个操作的一部分可以在锁外被执行。
- 数据结构的不同部分能否被不同的互斥元保护。
- 是否所有操作需要相同级别的保护。
- 数据结构的一个小改变能否再不影响操作语义的情况下提高并发的机会。
简单总结上述思想:在必然序列化的情况下最小化锁的范围,并且实现最大限度地并发。
设计基于锁的变法数据结构关键是要确保存取数据时要锁住正确的互斥元,并且要确保将锁的时间最小化。只用一个互斥元保护一个数据结构是很困难的。你要确保此数据在锁保护区域之外不会被存取,并且不会发生接口竞争现象。如果使用独立的互斥元来保护数据结构的独立部分,问题会变得更加复杂,并且如果数据结构上的操作需要多个互斥元被锁住就有可能产生死锁。因此设计有多个互斥元的数据结构时,需要比设计只有一个互斥元的数据结构考虑得更细致。
下面得实现是采用了std::shared_ptr
共享指针来持有数据。
#ifndef _QUEUE_P_H_
#define _QUEUE_p_H_
#include
#include
#include
template <typename T>
class threadeafe_queue
{
private:
mutable std::mutex mut;
std::queue<std::shared_ptr<T> > data_queue;
std::condition_variable data_cond;
public:
threadeafe_queue() {}
threadeafe_queue(threadeafe_queue &) = delete;
threadeafe_queue(threadeafe_queue &&) = delete;
threadeafe_queue &operator=(threadeafe_queue &) = delete;
threadeafe_queue &operator=(threadeafe_queue &&) = delete;
virtual ~threadeafe_queue() {}
void wait_and_pop(T& value)
{
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk, [this](){return !data_queue.empty();});
value = std::move(*data_queue.front()); // [1]
data_queue.pop();
}
int try_pop(T& value)
{
std::lock_guard<std::mutex> lk(mut);
if (data_queue.empty())
return -1;
value = std::move(*data_queue.front()); // [2]
data_queue.pop();
return 0;
}
std::shared_ptr<T> wait_and_pop()
{
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk, [this](){return !data_queue.empty();});
std::shared_ptr<T> res = data_queue.front(); //[3]
data_queue.pop();
return res;
}
std::shared_ptr<T> try_pop()
{
std::lock_guard<std::mutex> lk(mut);
if (data_queue.empty())
return std::shared_ptr<T>();
std::shared_ptr<T> res = data_queue.front(); // [4]
data_queue.pop();
return res;
}
void push(T new_value)
{
std::shared_ptr<T> data(
std::make_shared<T>(std::move(new_value))); // [5]
std::lock_guard<std::mutex> lk(mut);
data_queue.push(data);
data_cond.notify_one();
}
bool empty()
{
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};
#endif //_QUEUE_P_H_
通过std::shared_ptr
共享指针来持有数据的基本效果是直观的:接受一个对变量的引用来获取新值的pop函数,现在必须解引用所存储的指针[1]、[2],返回一个std::shared_ptr
实例的pop函数可以在返回调用前从队列中取得这个数[3]、[4]。
如果数据由std::shared_ptr
共享指针来持有,那么还有一个额外的好处:可以在push()的锁外完成新实例的分配[5],否则就需要在pop()持有锁的情况下才能这么做。因为内存分配通常是很昂贵的操作,这种方式就有助于提高队列的性能,因为它减少了持有互斥元的时间,允许其它线程同时在队列上执行操作。
实现队列最简单的数据结构为单链表,队列包含一个头(head)指针,指向链表的第一项,并且每一项都指向下一项。将数据从队列中移走时,首先将头指针指向下一个数据项,然后返回以前头指针指向的数据项。数据项被添加到队列的另一端。为了实现这一点,队列还包含一个尾(tail)指针,指向链表的最后一项,可以实现添加结点通过将最后一项的 next 指向新节点,然后将尾指针更新为指向新的一项。当链表为空时,头指针和尾指针均为NULL。
但是在多线程环境下试图使用细粒度锁,就会带来问题。假定有两个数据项(head 和 tail),原则上可以使用两个互斥元,一个用来保护 head ,另一个用来保护 tail,但是这样会存在一些问题。最明显的问题就是,push()可以修改 head 和 tail,因此它就需要锁住这两个互斥元。虽然倒霉,但也不是什么大问题,因为锁住两个互斥元是可能的。关键问题是,push() 和 pop() 都要访问某个结点的 next 指针。push() 更新 tail->next
,pop() 读取 head->next
。如果队列只有一个节点那么两个next指针就指向的是同一个对象,因而需要保护。因为还没读取 head 和 tail时,无法分辨它们是否为同一个对象,所以 push() 和 pop() 就必须锁定同一个互斥元。
为了解决这个问题,可以通过分离数据允许并发,可以通过预先分配一个存储数据的傀儡结点,以保证队列中总是至少会有一个结点,将头尾两个访问分开,来解决这个问题。对于一个空队列,head 和 tail 都指向这个傀儡节点,而不是 NULL。这样就没问题了,因为如果当队列为空,pop() 不会访问 head->next
。当一个新节点加入队列(于是就有一个真正的结点),head 和 tail 就指向不同节点,因此就不存在竞争。缺点是,必须添加一个额外的间接层,通过指针存储数据,以便允许假结点。
下面是使用傀儡结点的线程安全队列:
#ifndef _TS_QUEUE_PUPPET_H_
#define _TS_QUEUE_PUPPET_H_
#include
#include
#include
template<typename T>
class ThreadSafeQueue
{
private:
struct node //结点数据结构
{
std::shared_ptr<T> data;
std::unique_ptr<node> next;
};
std::mutex head_mutex; //头部锁
/* 头指针使用unique_ptr为避免内存泄漏 */
std::unique_ptr<node> head; //头部指针
std::mutex tail_mutex; //尾部锁
node* tail; //尾指针
/* 条件变量 */
std::condition_variable data_cond;
private: //内部私有方法
node* get_tail()
{ /* 获取尾锁 返回尾指针 */
std::lock_guard<std::mutex> tail_lock(tail_mutex);
return tail;
}
std::unique_ptr<node> pop_head()
{ /* 出队 返回旧的头指针 */
std::unique_ptr<node> old_head = std::move(head);
head = std::move(old_head->next);
return old_head;
}
std::unique_lock<std::mutex> wait_for_data()
{ /* 等待数据 取头锁 等待数据通知 转移锁的所有权 */
std::unique_lock<std::mutex> head_lock(head_mutex);
data_cond.wait(head_lock, [&] { return head.get() != get_tail(); }); // [1]
return std::move(head_lock);
}
std::unique_ptr<node> try_pop_head()
{ /* 非阻塞 pop head */
std::lock_guard<std::mutex> head_lock(head_mutex); //取头锁
if (head.get() == get_tail()) // [2]
{ // 如果 head == tail 就视为空
return nullptr; //返回空指针
}
return pop_head(); //非空 出队 返回旧的头指针
}
std::unique_ptr<node> wait_pop_head()
{ /* 阻塞 pop head */
std::unique_lock<std::mutex> head_lock(wait_for_data()); //等待数据 并获取锁
return pop_head(); //出队 返回旧的头指针
}
public: //共有方法
ThreadSafeQueue():head(new node()), tail(head.get())
{ }
ThreadSafeQueue(ThreadSafeQueue &&) = default;
ThreadSafeQueue(const ThreadSafeQueue &) = delete;
ThreadSafeQueue &operator=(ThreadSafeQueue &&) = default;
ThreadSafeQueue &operator=(const ThreadSafeQueue &) = delete;
virtual ~ThreadSafeQueue()
{ }
void push(T new_value)
{ //构造数据的 shared_ptr
std::shared_ptr<T> new_data(std::make_shared<T>(std::move(new_value)));
//构造新的傀儡结点 并获取其unique_ptr
std::unique_ptr<node> p(new node());
{ //内部代码块 数据填充
std::lock_guard<std::mutex> tail_lock(tail_mutex); //获取锁
node* const new_tail = p.get(); //获取傀儡结点指针
tail->data = new_data; //数据
tail->next = std::move(p); //将新傀儡节点接入
tail = new_tail; //尾指针后移
} //代码块结束 自动释放锁
data_cond.notify_one(); //通知条件变量
}
std::shared_ptr<T> try_pop()
{ //调用内部方法出队
std::unique_ptr<node> old_head = try_pop_head();
//返回
return old_head ? old_head->data : std::shared_ptr<T> ();
}
std::shared_ptr<T> wait_and_pop()
{ //调用内部方法出队
std::shared_ptr<node> const old_head = wait_pop_head();
//返回
return old_head->data;
}
bool empty()
{
std::lock_guard<std::mutex> head_lock(head_mutex);
return (head.get() == get_tail());
}
};
#endif // _TS_QUEUE_PUPPET_H_
其中大部分细节已经添加注释,现需要注意的一点是[1]和[2]处隐含了获取锁的顺序,一定是先获取到头指针的锁再获取尾指针的锁。这样是为了保证数据结构的安全,如果反过来获取锁,有可能再多线程中造成再头指针锁外获取旧的尾指针,有可能误判队列是否为空,导致数据混乱甚至数据结构损坏。
查找表或字典将一种类型(键类型)的值与另外一种相同或不同类型(映射类型)的值联系起来。一般来说,这种数据结构的目的是使代码可以用一个给定的键值来查询相关的数据。在C++标准库中,是通过使用关联容器来实现这种功能的,例如,std::map<>
std::multimap<>
std::unordered_map<>
以及 std::unordered_multimap<>
。从并发的角度来说,std::map<>
接口的最大问题就是迭代器。尽管当别的线程访问(以及修改)容器时,拥有一个能够安全访问容器的迭代器也是有可能的,但是这很棘手。正确把握迭代器要求处理以下的问题,例如另一个线程正在删除迭代器引用的元素,这很麻烦。作为现场安全查找表要砍掉的第一个接口,应该是迭代器。std::map<>
(以及标准库中其他的关联容器)的接口很大程度上基于迭代器,所以重新设计接口可能是值得的。
查找表的一些基本操作:
- 添加新的键-值对;
- 改变给定的键相关联的值;
- 删除键及其关联的值;
- 获得与给定键相关联的值,如果有的话。
还有一些容器范围的操作也是有用的,例如检查容器是否为空,键的完整列表的快照,或是键-值对的完整集合的快照。一旦确定了接口,那么(假设没有接口竞争条件)可以通过在每个成员函数中使用一个互斥元和一个简单锁来保护下层数据结构,以保证线程安全。然而,这会浪费通过独立的函数来读取数据结构并修改它所提供的并发可能性。一种方法是使用一个支持多个读线程或一个写线程的互斥元。尽管这种方法可以提供高并发访问的可能性,但是每次只有一个线程能够修改数据结构。
为了允许细粒度锁,如同前面实现的线程安全队列一样,需要我们自己考虑结构的细节,通常由以下三种方法来实现一个类似于查找表的关联容器。
- 二叉树,例如红黑树;
- 已排序数组;
- 哈希表。
二叉树不能为 扩大并发提供太多空间,每次查找或修改必须从访问的根节点开始,因此根节点必须被锁定。尽管当前线程沿着数树向下移动的时候会释放这个锁,但也不比锁定整个数据结构好多少。已排序数组就更糟了,因为无法事先得知一个给定数据结构在数组的那个位置,所以就需要一次锁定整个数组。
只剩下哈希表了,假设有一定数量的桶,一个键属于那个桶完全取决于这个键以及哈希函数的特性。这就意味着可以安全地在每个桶上有一个独立地的锁。如果再次使用支持多重读或单一写的互斥元,你就将并发机会增加了N重,这里的N是桶的数量。其缺点是为键找一个好的哈希函数。C++标准库提供了std::hash<>
模板,可以用来实现这一目的。它已经为int等基本类型以及std::string这样的常见库类型进行了特化,而且使用者也可以轻易地为别的键类型将其特化。如果效仿标准的无序容器,以及在进行哈希计算时将函数对象类型作为参数模板参数,那么使用者可以选择是否为键类型特化std::hash<>
,或是提供一个单独的哈希函数。
【实现】
#ifndef _THREADSAFE_LOOKUP_TABLE_H_
#define _THREADSAFE_LOOKUP_TABLE_H_
#include
#include
#include
#include
#include
#include
template<typename Key, typename Value, typename Hash = std::hash<Key> >
class threadsafe_lookup_table
{
private:
//===========================================内部类=====================================================开始
class bucket_type //内部类 桶数据结构
{
private:
typedef std::pair<Key, Value> bucket_value; //桶内数据键值对 类型
typedef std::list<bucket_value> bucket_data; //桶内存放键值对的list 类型
typedef typename bucket_data::iterator bucket_iterator; //对应list的迭代器 类型
bucket_data data; //存放数据的list
//每个桶都被都被自己的 shared_mutex 保护起来
mutable boost::shared_mutex mutex; // 【1】
//在桶里找存放对应key的键值对,返回迭代器
bucket_iterator find_entry_for(Key const& key) // const (原文此处有const修饰) 【2】
{
return std::find_if(data.begin(), data.end(),
[&](bucket_value const& item)
{ return item.first == key;});
}
public:
//返回对应键的值,没有键返回默认值
Value value_for(Key const& key, Value const& default_value) // const (原文此处有const修饰)
{
boost::shared_lock<boost::shared_mutex> lock(mutex); // 【3】读共享锁
bucket_iterator const found_entry = find_entry_for(key);
return (found_entry == data.end()) ? default_value : found_entry->second;
}
//添加键值对或更新
void add_or_update_mapping(Key const& key, Value const& value)
{
std::unique_lock<boost::shared_mutex> lock(mutex); // 【4】写独占锁
bucket_iterator const found_entry = find_entry_for(key);
if (found_entry == data.end()) //没有键->添加
{
data.push_back(bucket_value(key, value));
}
else //有键->更新
{
found_entry->second = value;
}
}
//删除键值对
void remove_mapping(Key const& key)
{
std::unique_lock<boost::shared_mutex> lock(mutex); //【5】写独占锁
bucket_iterator const found_entry = find_entry_for(key);
if (found_entry == data.end())
{
data.erase(found_entry);
}
}
};
//===========================================内部类=====================================================结束
// 该实现用 buckets 来持有桶,允许在构造函数中指定桶的数量(这里默认值是19)
// 哈希表和质数数量的桶合作最好。每个桶都可以允许很多个并发读,或单个调用一个修改函数。
std::vector<std::unique_ptr<bucket_type> > buckets; //【6】
// 哈希函数实例
Hash hasher;
//返回对应key的数据所在的桶
bucket_type& get_bucket(Key const& key) const //【7】 因为桶的数量固定,所以调用get_bucket时不用加锁
{
std::size_t const bucket_index = hasher(key) % buckets.size();
return *buckets[bucket_index];
}
public:
typedef Key key_type;
typedef Value mapped_type;
typedef Hash hash_type;
//构造函数 默认初始化19个桶,默认std::hash类
threadsafe_lookup_table(unsigned num_buckets = 19, Hash const& hasher_ = Hash()) :
buckets(num_buckets), hasher(hasher_)
{
for (unsigned i = 0; i < num_buckets; ++i)
{
buckets[i].reset(new bucket_type);
}
}
threadsafe_lookup_table(threadsafe_lookup_table &&) = default;
threadsafe_lookup_table(const threadsafe_lookup_table &) = delete;
threadsafe_lookup_table &operator=(threadsafe_lookup_table &&) = default;
threadsafe_lookup_table &operator=(const threadsafe_lookup_table &) = delete;
~threadsafe_lookup_table() { }
//对外的公有接口
Value Value_for(Key const& key, Value const& default_value = Value()) const
{
return get_bucket(key).value_for(key, default_value);
}
void add_or_update_mapping(Key const& key, Value const& value)
{
get_bucket(key).add_or_update_mapping(key, value);
}
void remove_mapping(Key const& key)
{
get_bucket(key).remove_mapping(key);
}
};
#endif // !_THREADSAFE_LOOKUP_TABLE_H_
添加、删除、修改每个桶中的数据都需要锁定桶的锁(共享或独占),而各个桶本身又是独立的所以上面查找表具有比较高的并发性能。特别注意:上述代码有几处**”const (原文此处有const修饰)的注释“**,不去掉const的话会导致编译错误,这里说明一下:
报错内容:迭代器类型不匹配。
原因:标准库中的
std::list
中的begin和end是有两个重载的,具体形式如下:iterator begin() noexcept; //(C++11 起) const_iterator begin() const noexcept; //(C++11 起) const_iterator cbegin() const noexcept; //(C++11 起) iterator end() noexcept; //(C++11 起) const_iterator end() const noexcept; //(C++11 起) const_iterator cend() const noexcept; //(C++11 起)
如果按书上原文所写的代码在函数名后加
const
修饰,会导致默认整个花括号中的数据类型都会以const
为优先,所以有的 begin() 这种都会返回 const_iterator 类型而不是 iterator,导致类型不匹配,或者后续通过迭代器无法修改内容。
链表是一种最基本的数据结构,因此它应该能被直接写成线程安全的,不是吗?那么,这取决于你追求什么样的功能,并且需要提供迭代器支持,这是这本书一直避免将迭代器加入到基础图中的东西,因为它太复杂了。STL风格的迭代器支持,指的是迭代器必须持有某种对容器内部数据结构的引用。如果容器可以被另外一个线程修改,这个引用必须仍然有效,这就从根本上要求迭代器在部分数据结构上持有锁。考虑到STL风格的迭代器的生成期时完全不受容器控制的,这就不是一个好主意。
另一种方式是提供类似于for_each
这样的迭代器函数作为容器本身的一部分。这就让容器完全负责迭代器和锁,但是这与第三章中提到的避免死锁的原则是冲突的。为了使得for_each
做一些有用的操作,它就必须在持有内部锁的时候调用用户提供的代码。不仅如此,为了使用户提供的代码能用于数据项,它必须对每个数据项的引用传递给用户提供的代码。你可以通过向用户提供的代码传递每个数据项的副本来避免这点,但是当数据项很大时,这种方式就很耗费资源。
因此目前我们把他留给用户,让它们确保不会在用户提供的操作中因获取锁而导致死锁,并且通过在锁外的访问中存储引用以避免导致数据竞争。就查找表所使用的链表来说,它完全时安全的,因为不会做任何不恰当的操作。
下面是一些链表需要的操作清单
- 向链表添加新项目;
- 从链表中删除满足一定条件的项目;
- 在链表中查找满足一定条件的项目;
- 更新满足一定条件的项目;
- 复制链表中每个项目到另一个容器中。
#ifndef _THREADSAFE_LIST_H_
#define _THREADSAFE_LIST_H_
#include
#include
#include
template <typename T>
class threadsafe_list
{
private:
struct node
{
std::mutex m;
std::shared_ptr<T> data;
std::unique_ptr<node> next;
node() : next(nullptr) { }
node(T const& value) : data(std::make_shared<T>(value)){ }
node(node && old_node): data(std::move(old_node.data)), next(std::move(old_node.next)) { }
};
node head;
public:
threadsafe_list()
{
head = nullptr; //初始化
}
threadsafe_list(threadsafe_list &&) = default;
threadsafe_list(const threadsafe_list &) = delete;
threadsafe_list &operator=(threadsafe_list &&) = default;
threadsafe_list &operator=(const threadsafe_list &) = delete;
~threadsafe_list()
{
remove_if([](node const&){return true;});
}
void push_front(T const& value)
{
std::unique_ptr<node> new_node(new node(value));
if (head == nullptr) //这里加判空就好了
{
head = std::move(new_node);
head.next = nullptr;
}
else
{
std::lock_guard<std::mutex> lock(head.m);
new_node->next = std::move(head.next);
head.next = std::move(new_node); //这里原来有问题会出段错误,原因未初始化和判空
}
}
template<typename Function>
void for_each(Function f)
{
node *current = &head;
std::unique_lock<std::mutex> lock(head.m);
while (node* const next = current->next.get())
{
std::unique_lock<std::mutex> next_lock(next->m);
lock.unlock();
f(*next->data);
current = next;
lock = std::move(next_lock);
}
}
template<typename Predicate>
std::shared_ptr<T> find_first_if(Predicate p)
{
node *current = &head;
std::unique_lock<std::mutex> lock(head.m);
while (node* const next = current->next.get())
{
std::unique_lock<std::mutex> next_lock(next->m);
lock.unlock();
if(p(*next->data))
{
return next->data;
}
lock = std::move(next_lock);
}
return std::shared_ptr<T>();
}
template<typename Predicate>
void remove_if(Predicate p)
{
node *current = &head;
std::unique_lock<std::mutex> lock(head.m);
while (node* const next = current->next.get())
{
std::unique_lock<std::mutex> next_lock(next->m);
lock.unlock();
if (p(*next->data))
{
std::unique_ptr<node> old_next = std::move(next->next);
next_lock.unlock();
}
else
{
lock.unlock();
current = next;
lock = std::move(next_lock);
}
}
}
};
#endif // !_THREADSAFE_LIST_H_
报错提示:(注:我链接pthread的动态库了)
(gdb) bt
#0 threadsafe_list, std::allocator > >::push_front (this=0x7fffffffdbf0, value="a")
at /home/wangs7/VSCode/Concurrency/6th_chapter/threadsafelist/threadsafe_list.hpp:40
#1 0x0000000000408325 in main (argc=1, argv=0x7fffffffdd58) at /home/wangs7/VSCode/Concurrency/6th_chapter/threadsafelist/main.cpp:11
(gdb) c
Continuing.
Breakpoint 2, threadsafe_list, std::allocator > >::push_front (this=0x7fffffffdbf0, value="a")
at /home/wangs7/VSCode/Concurrency/6th_chapter/threadsafelist/threadsafe_list.hpp:41
41 new_node->next = std::move(head.next);
(gdb) c
Continuing.
Breakpoint 3, threadsafe_list, std::allocator > >::push_front (this=0x7fffffffdbf0, value="a")
at /home/wangs7/VSCode/Concurrency/6th_chapter/threadsafelist/threadsafe_list.hpp:42
42 head.next = std::move(new_node);
(gdb) c
Continuing.
terminate called after throwing an instance of 'std::system_error'
what(): Operation not permitted
Program received signal SIGABRT, Aborted.
__GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:50
50 return ret;
(gdb) q
A debugging session is active.
Inferior 1 [process 9419] will be killed.
Q:这部分就先这样吧,这个问题回头有时间再看看,或者那个大佬知道问题可以指点我一下qwq。
A:原因找到了,是由于没有判断头指针是否为空照成的,需要在push的时候判断一下。
【2022/01/14】改进问题
下一篇:03 重修C++之并发实战7