C++并发编程(六)并发数据结构的设计

目录

1.并发设计的内涵

1.1设计并发数据结构的要点

2基于锁的并发数据结构

2.1线程安全的栈(前面章节)

2.2线程安全的队列容器(锁和条件变量)

2.3精细粒度的锁和条件变量提高队列并发度

2.4等待数据弹出的安全栈

3.更复杂的基于锁的并发结构

3.1线程安全的查找表

3.2线程安全的链表

4.要点小结


如果多个线程访问同一数据结构,除了采用独立互斥和外部锁(第3、4章),我们还可以为并发访问专门设计数据结构以保护数据。

1.并发设计的内涵

线程安全的数据结构需要满足以下条件:

(1)多线程执行的操作无论异同,每个线程所见的数据结构自恰.

(2)数据不会丢失或破坏,不存在恶性数据竞争

并发设计的意义在于提供更高的并发程度,让各线程由更多机会按并发的方式访问数据结构。

互斥保护数据的方式是阻止真正的并发访问,实现串行化(serialization):每个线程轮流访问受互斥保护的数据,它们只能先后串行访问。保护的范围越小,并发程度越高。

1.1设计并发数据结构的要点

设计并发数据结构我们需要考虑:

(1)确保访问安全

构造函数完成以前和析构函数开始以后,访问不会发生。

我们需要明确哪些函数可以安全地跨线程调用。

还需要判断:这些函数与其它操作并发调用是否安全,函数是否要求以排他方式访问。

(2)实现真正的并发访问.

限制锁的作用域,让操作的某些部分在锁保护以外执行。

数据结构内部不同部分使用不同互斥。

不同操作使用不同程度的保护。

在不影响操作语义的情况下,提高数据结构并发度。

核心问题:保留必要的串行操作,最大限度实现并发访问。

2基于锁的并发数据结构

奥义:确保先锁住互斥,再访问数据,尽量缩短锁的持续时间。

2.1线程安全的栈(前面章节)

#include 
#include 
#include 
#include 
struct empty_stack : std::exception
{
	const char* what() const throw();
};

template
class threadsafe_stack
{
private:
	std::stack data;
	mutable std::mutex m;
public:
	threadsafe_stack(){}
	threadsafe_stack(const threadsafe_stack& other)
	{
		std::lock_guard lock(other.m);
		data = other.data;
	}

	threadsafe_stack& operator=(const threadsafe_stack&) = delete;//删除赋值运算操作

	void push(T new_val)
	{
		std::lock_guard lock(m);
		data.push(std::move(new_val));
	}

	std::shared_ptr pop()
	{
		std::lock_guard lock(m);
		if (data.empty()) throw empty_stack();
		const std::shared_ptr res(std::make_shared(data.top()));
		data.pop();
		return res
	}

	void pop(T& val)
	{
		std::lock_guard lock(m);
		if (data.empty()) throw empty_stack();
		val = data.top();
		data.pop();
	}

	bool empty()const
	{
		std::lock_guard lock(m);
		return data.empty();
	}
};

安全性:

栈容器所有成员都使用std::lock_guard<>保护数据。

在可能抛出异常的操作完成之前,底层栈容器数据并未改动,保证了安全抛出行为。

仅构造和析构函数不是安全的成员函数,若对象未完成构造或销毁,转去调用成员函数绝非正确之举,必须使用者自己保证。

不足:

虽然成员函数都使用锁保护,但线程会更激烈地争夺栈容器,迫使它们串行化。

未提供等待操作,若容器满载而线程又等待加入数据,pop()函数线程耗费算力(调用empty())检查容器是否为空。

2.2线程安全的队列容器(锁和条件变量)

#include
#include 
#include 
#include
template 
class thread_safe_queue
{
private:
	std::queue q;
	mutable std::mutex m;
	std::condition_variable cond;
public:
	//构造和复制构造
	thread_safe_queue() {}
	thread_safe_queue(const thread_safe_thread& other)
	{
		std::lock_guard lk(other.m);
		q = other.q;
	}

	void push(T data)
	{
		std::lock_guard lk(m);
		q.push(data);
		cond.notify_one();
	}

	void wait_and_pop(T& val)
	{
		std::unique_lock lk(m);
		cond.wait(lk, [this] {return !q.empty(); });
		val = q.front();
		q.pop();
	}
	std::shared_ptr wait_and_pop()
	{
		std::unique_lock lk(m);
		cond.wait(lk, [this] {return !q.empty(); });
		std::shared_ptr res = std::make_shared(q.front());
		q.pop();
		return res;
	}
	bool try_pop(T& val)
	{
		std::lock_guard lk(m);
		if (q.empty()) return false;
		val = q.front();
		q.pop();
		return true;
	}
	std::shared_ptr try_pop()
	{
		std::lock_guard lk(m);
		if (q.empty()) return std::shared_ptr();
		std::shared_ptr res = std::make_shared(q.front());
		q.pop();
		return res;
	}

	bool empty() const
	{
		std::lock_guard lk(m);
		return q.empty();
	}
};

本例使用了条件变量进行通知-等待,新增了两个wait_and_pop()函数,等待弹出的线程不必连续调用empty(),改为wait_and_pop()。

觉醒的线程在执行wait_and_pop()时,可能抛出异常(如新指针std::shared_ptr<>构建时),导致没有线程被唤醒,我们有几种方法:

1、改为cond.notify_all()通知所有线程,但只有一个线程能弹出,其余继续休眠,开销增大。

2、异常抛出后再次调用cond.notify_one()通知其它线程。

3、修改底层队列容器存储std::shared_ptr<>,并将std::shared_ptr初始化语句移到push()调用处。

使用std::shared_ptr<>存储好处:在push中为新的std::shared_ptr实例分配内存可以脱离保护,增强性能:

	//原版
    void push(T data)
	{
		std::lock_guard lk(m);
		q.push(data);
		cond.notify_one();
	}
    //改进
    void push(T data)
	{
		std::shared_ptr data(std::make_shared(std::move(data)));
		std::lock_guard lk(m);
		q.push(data);
		cond.notify_one();
	}

2.3精细粒度的锁和条件变量提高队列并发度

采用std::unique_ptr管控节点,不再需要时会自动删除。

问题:我们可以给head和tail各设置一个互斥,但push函数可以同时修改两个指针,需要两个互斥。且push和pop可能并发访问同一节点的next指针,tail->next和old_head->next有可能为同一节点(容器仅有一项数据)。

解决:我们设置一个虚位节点(dummy node),确保至少存在一个节点,区别头尾两个节点的访问。若队列为空,则head和tail指向虚位节点,此时向队列增添数据,head->next和tail->next指向不同节点。

template
class queue
{
private:
	struct node
	{
		std::shared_ptr data;//避免重复内存分配
		std::unique_ptr next;
	};
	std::unique_ptr head;//不需要某个节点时,它和所包含的数据被自动删除
	node* tail;
public:
	queue() :head(new node), tail(head.get()) {}
	//删除复制构造和赋值
	queue(const queue& other) = delete;
	queue& operator=(const queue& other) = delete;

	std::shared_ptr try_pop()
	{
		if (head.get() == tail)//都指向虚位节点说明空 get函数从unique返回指针
		{
			return std::shared_ptr();
		}
		const std::shared_ptr res(head->data);
		std::unique_ptr old_head = std::move(head);
		head = std::move(old_head->next);
		//此处修改完毕可解锁
		return res;
	}
	void push(T new_value)
	{
		std::shared_ptr new_data(
			std::make_shared(std::move(new_value))
		);
		std::unique_ptr p(new node);//创建新节点
		tail->data = new_data;
		tail->next = std::move(p);
		const node* new_tail = p.get();//因为tail的类型为node*,next指针为unique_ptr,故创建新节点
		tail = new_tail;
	}
};

好处:push()只访问tail指针而不再触及head指针,pop()仅在最开始比较的访问tail指针,短暂持锁。虚位节点使得pop和push不再同时访问相同节点。

尝试加入互斥实现并行化:
 push()中新节点一旦创建完成,我们就锁住互斥,函数结束才释放。
 pop()的head指针需要一直持锁。一旦head改动完成就释放锁,返回操作无需持锁。
 pop()对tail的访问仅有一次,可以包装为一个函数。

template
class queue
{
private:
	struct node
	{
		std::shared_ptr data;//避免重复内存分配
		std::unique_ptr next;
	};
	std::mutex head_mutex;
	std::unique_ptr head;//不需要某个节点时,它和所包含的数据被自动删除
	node* tail;
	//将互斥封装
	//获取尾部
	node* get_tail()
	{
		std::lock_guard tail_lock(tail_mutex);
		return tail;
	}
	//pop
	std::unique_ptr pop_head()
	{
		//此处head_mutex在tail_mutex之前,很重要,如若不然,可能其它线程先调用pop锁住了head_mutex,导致本线程的pop停滞不前。
		std::lock_guard head_lock(head_mutex);
		if (head.get() == get_tail()) return std::unique_ptr();//get_tail需要在head_mutex保护范围内,若在作用域外,可能tail和head已经改变
		std::unique_ptr old_head = std::move(head);
		head = std::move(old_head->next);
		return old_head;
	}
public:
	queue() :head(new node), tail(head.get()) {}

	//删除复制构造和赋值
	queue(const queue& other) = delete;
	queue& operator=(const queue& other) = delete;

	std::shared_ptr try_pop()
	{
		std::unique_ptr old_head = pop_head();
		return old_head ? old_head->data : std::shared_ptr();
	}
	void push(T new_value)
	{
		std::shared_ptr new_data(
			std::make_shared(std::move(new_value))
		);
		std::unique_ptr p(new node);//创建新尾部节点
		const node* new_tail = p.get();//因为tail的类型为node*,next指针为unique_ptr,故创建新节点
        std::lock_guard tail_lock(tail_mutex);
		tail->data = new_data;
		tail->next = std::move(p);
		tail = new_tail;
	}
};

异常处理安全分析
try_pop():只有在互斥加锁时才可能抛出异常,在获取锁后数据才会发生变动,异常安全。
push():在堆分配上创建了两个实例(T类型和node类型),因为对象是智能指针,万一有异常抛出,占用的内存会自动释放,获取锁后的操作不会抛出异常,异常安全。
并发度分析:相比以前的版本更精细的锁。
push():在没有持锁的状态下为新节点和数据分配内存,允许多个线程为新节点和数据并发分配内存。且每次仅有一个线程将新节点如队。
try_pop():仅在读取tail指针时短暂持锁,比较过程无需持锁,整个调用过程可以与push()并发执行。虽然每次允许一个线程调用pop_head但多个线程可以并发执行try_pop()其它部分。
另外,unique_ptr old_head析构函数开销高,在互斥保护外执行。

2.4等待数据弹出的安全栈

上述代码仅提供try_pop()重载,是否能借助精细的锁实现wait_and_pop()?答案是肯定的,将断言设置为head!=get_tail,只需持有互斥head_mutex,调用cond.wait()时重新锁住互斥head_mutex。

try_pop()的另一重载接收value引用参数,再用拷贝赋值操作赋予它old_head的值,因为拷贝赋值可能抛出异常,所以需要在互斥保护范围内。

empty()只需要锁住head_mutex互斥,然后检查head==get_tail()即可。

我们可以把pop相关操作中重复的代码封装为函数,以下是完整版:

template
class queue
{
private:
	struct node
	{
		std::shared_ptr data;//避免重复内存分配
		std::unique_ptr next;
	};
	std::mutex head_mutex;
	std::mutex tail_mutex;
	std::unique_ptr head;//不需要某个节点时,它和所包含的数据被自动删除
	node* tail;
	std::condition_variable cond;
	//-------------------辅助函数---------------------------
	node* get_tail()
	{
		std::lock_guard tail_lock(tail_mutex);
		return tail;
	}
	//pop返回old_head,需要再其它函数加锁
	std::unique_ptr pop_head()
	{
		std::unique_ptr old_head = std::move(head);
		head = std::move(old_head->next);
		return old_head;
	}
	//等待队列中有数据
	std::unique_lock wait_for_data()
	{
		std::unique_lock head_lock(head_mutex);
		cond.wait(head_lock, [&] {return head.get() != get_tail(); });
		return std::move(head_lock);//返回锁实例
	}
	//---------------------辅助函数---------------------------

	//wait_and_pop的重载
	std::unique_ptr wait_pop_head()
	{
		std::unique_lock head_lock(wait_for_data());//利用wait_for_data()的锁,保证头节点弹出的全过程都持有同一个锁
		return pop_head();
	}
	std::unique_ptr wait_pop_head(T& value)
	{
		std::unique_lock head_lock(wait_for_data());
		value = std::move(head.get()->data);//*head->data
		return pop_head();
	}
	//try_pop_head的重载
	std::unique_ptr try_pop_head()
	{
		std::lock_guard lk(head_mutex);//因为没有wait所以不调用wait_for_data()
		if (head.get() == get_tail())
		{
			return std::unique_ptr();
		}
		return pop_head();
	}
	std::unique_ptr try_pop_head(T& value)
	{
		std::lock_guard lk(head_mutex);
		if (head.get() == get_tail())
		{
			return std::unique_ptr();
		}
		value = std::move(head.get()->data);
		return pop_head();
	}

public:
	queue() :head(new node), tail(head.get()) {}

	//删除复制构造和赋值
	queue(const queue& other) = delete;
	queue& operator=(const queue& other) = delete;

	//try_pop函数
	std::shared_ptr try_pop();
	bool try_pop(T& value);

	//wait_and_pop函数
	std::shared_ptr wait_and_pop();
	void wait_and_pop(T& value);

	void push(T new_value);

	bool empty();
};

push()函数增加条件变量的通知:

void queue::push(T new_value)
{
	std::shared_ptr new_data(std::make_shared(std::move(new_value)));
	std::unique_ptr p(new node);
	const node* new_tail = p.get();
	{
		std::lock_guard tail_lock(tail_mutex);
		tail->data = new_data;
		tail->next = std::move(p);
		tail = new_tail;
	}
	cond.notify_one();
}

wait_and_pop()函数调用wait_pop_head(),而后者调用公用函数pop_head返回old_head:

//wait_and_pop
template
std::shared_ptr queue::wait_and_pop()
{
	std::unique_ptr const old_head = wait_pop_head();
	return old_head->data;
}

template
void queue::wait_and_pop(T& value)
{
	std::unique_ptr const old_head = wait_pop_head(value);
}

try_pop()同理调用try_pop_head:

template
std::shared_ptr queue::try_pop()
{
	std::unique_ptr old_head = try_pop_head();
	return old_head ? old_head->data : std::shared_ptr ();
}

template
bool queue::try_pop(T& value)
{
	const std::unique_ptr old_head = wait_pop_head(value);
	return old_head;
}

empty():

template
bool queue::empty()
{
	std::lock_guard lk(head_mutex);
	return (head.get()==get_tail());
}

以上例子是无限队列,只要存在空闲内存就能一直压入数据,通常我们需要限制容器中元素的数量,因为某些线程向队列添加任务的速度可能远超线程从队列领取任务的速度,可以在push函数中加以限制。 

3.更复杂的基于锁的并发结构

3.1线程安全的查找表

查找表又称“字典”,其元素由两部分组成:键值(key type)和值(mapped type)。标准库中的:std::map<>,std::multimap<>,std::unordered_map<>,std::unordered_multimap<>。

查找表的几项基本操作:

 增加配对的键值对。

 根据给定键值改变关联值。

 移除某个键及其关联的值。

 根据给定的键获取关联值。(若键值存在)

暂且搁置迭代器这一复杂的部分,还有一些有用的整体操作:复制整个容器,检查是否为空等。

注意的地方:
两个线程分别加入键值相同而值不同的元素,则先添加的操作成功,后添加的失败。

依据键值获取值的前提是“若键值存在”,我们可以准许用户设定默认值,若键值不存在则函数返回默认值充当结果,如:mapped type get_value(const key_type& key, mapped_type default)。我们也可以返回智能指针,指向查找目标的关联值,若指针为NULL说明查找失败,无值返回。

精细粒度锁操作的map数据结构

有三种方法可以实现关联容器:
二叉树(红黑树),有序数组,散列表

·二叉树,每次查找都要从根节点开始访问,根节点加锁,随着访问线程逐层向下移动,根节点上的锁随之释放。

·有序数组,无法预知所需查找目标的位置,唯有对整个数组使用单一的锁。

·散列表,散列表中每个键属于一个桶,桶内链表装有相同散列值,我们可以使用标准库提供的函数模版std::hash<>设置散列函数。

散列表是比较适合的底层容器,可以为每个桶设置独立的锁,若采用共享锁,则支持多个读线程或一个写线程,能有效提高并发度。

template>//标准库提供的函数模版
class lookup_table 
{
private:

	class bucket_type {
		typedef std::pair bucket_value;
		typedef std::list bucket_data;//桶内部存储键值对的链表,每个链表装有键值(键值除以总size的余数)相同的键值对
		typedef typename bucket_data::itrator bucket_iterator;
		bucket_data data;
		mutable std::shared_mutex mutex;//共享锁

		bucket_iterator find_entry_for(const Key& key) const//找到键值对应的桶的开始位置
		{
			return std::find_if(data.begin(), data.end(),//遍历数据链表,数据链表中的每个元素为bucket_value
				[&](const bucket_value& item) {return item.first == key; })
		}
	public:
		Value value_for(const Key& key, const Value& default_value)const//根据key查找值
		{
			std::shared_lock lock(mutex);//共享方式加锁(只读)
			const bucket_iterator found_entry = find_entry_for(key);
			return (found_entry == data.end()) ? default_value : found_entry->second;
		}
		void add_or_update_mapping(const Key& key, const Value& value)
		{
			std::unique_lock lock(mutex);//独占方式加锁(读/写)
			const bucket_iterator found_entry = find_entry_for(key);
			if (found_entry == data.end())//新的键值数据则直接push_back
			{
				data.push_back(bucket_value(key, value));
			}
			else //找到则更改键值对应的关联值
			{
				found_entry->second = value;
			}
		}
		void remove_mapping(const Key& key)
		{
			std::unique_lock lock(mutex);
			const bucket_iterator found_entry = find_entry_for(key);
			if (found_entry != data.end())
			{
				data.erase(found_entry);
			}
		}
	};
	
	std::vector> buckets;
	Hash hasher;
	bucket_type& get_bucket(const Key& key) const//找键值对应的桶,此函数无须用锁保护(buckets的size固定)
	{
		const std::size_t bucket_index = hasher(key) % buckets.size();//通过哈希函数找到桶向量中的索引
		return *buckets[bucket_index];//返回键值对应桶
	}

public:
	typedef Key key_value;
	typedef Value mapped_type;
	typedef Hash hash_type;
	//构造函数
	lookup_table(unsigned num_buckets = 19, const Hash& hasher_ = Hash()):buckets(num_buckets), hasher(hasher_)
	{
		for (unsigned i = 0; i < num_buckets; ++i)
		{
			buckets[i].reset(new bucket_type);
		}
	}

	lookup_table(const lookup_table& other) = delete;//删除复制构造
	//接口通过调用对应元素对象的函数实现
	Value value_for(const Key& key, const Value& default_value = Value())const
	{
		return get_bucket(key).value_for(key, default_value);
	}

	void add_or_update_mapping(const Key& key, const Value& value)
	{
		return get_bucket(key).add_or_update_mapping(key, value);
	}

	void remove_mapping(const Key& key)
	{
		return get_bucket(key).remove_mapping(key);
	}
};

异常安全分析:

value_for()函数不涉及改动,抛出异常也不会影响数据结构,所以没问题。

remove_mapping()通过调用erase()改动链表,不抛出异常。

add_or_update_mapping()含有if语句,有两个分支流程,条件成立则push_back(),异常安全操作;条件不成立(原已有该键值对),则赋值操作替换原有值,赋值操作抛出异常只能期望原有值没有改变,由使用者提供的类型负责。

加入“数据快照”功能

数据快照,以快照形式取得查找表的当前状态,如保存为std::map<>。该功能要求锁住整个数据结构,所有桶被锁住(唯一一项锁住整体的操作),这样做确保获取的副本数据与查找表的状态保持一致。

std::map lookup_table::get_map() const
	{
		std::vector> locks;
		for (unisgned i = 0; i < buckets.size(); ++i)
		{
			locks.push_back(std::unique_lock(buckets[i].mutex));//使用std::shared_mutex允许对桶进行并发读操作
		}
		std::map res;
		for (unsigned i = 0; i < buckets.size(); ++i)
		{
			for (bucket_iterator it = buckets[i].data.begin(); it != buckets[i].data.end(); ++it)
			{
				res.insert(*it);//把list中的键值对插入到map中
			}
		}
		return res;
	}

3.2线程安全的链表

链表需要对外提供的功能:
向链表加入数据;根据条件从链表移除数据;在链表中查找数据;更新符合条件的数据;复制另一链表数据;按位置插入数据(此处实例忽略)。

为了实现精细粒度的所操作,可以给每个节点一个互斥,每个操作仅锁住目标节点,操作转移到下一节点时,原来的锁解开,实现真正的并发操作。

template
class threadsafe_list
{
	struct node
	{
		std::mutex m;
		std::shared_ptr data;
		std::unique_ptr next;
		node():next(){}
		node(const T& value) :data(std::make_shared(value)) {}
	};
	node head;//虚节点
public:
	threadsafe_list(){}
	~threadsafe_list()
	{
		remove_if([](const& node) {return true; });
	}
	//删除复制构造和赋值函数
	threadsafe_list(const threadsafe_list& other) = delete;
	threadsafe_list& operator=(const thread_list& other) = delete;

	void push_front(T& value)
	{
		std::unique_ptr new_node(new node(value));//堆上分配内存
		std::lock_guard lk(head.m);
		new_node->next = std::move(head.next);
		head.next = std::move(new_node);
	}
	//--------------迭代函数
	template
	void for_each(Function f)//每项数据都传入用户给定的函数
	{
		node* current = &head;
		std::unique_lock lk(current.m);
		while (node* const next = current->next.get())//因为next为unique_ptr,直接赋值会夺走归属权
		{
			std::unique_lock new_lk(next->m);

			lk.unlock();//先锁住新互斥再解锁,保证next指向的内容不被改变(const仅保证指向的地址不变)
			
			f(*(next->data));
			current = next;
			lk = std::move(new_lk);//转移归属权
		}
	}
	template
	std::shared_ptr find_first_if(Predicate p)
	{
		node* current = &head;
		std::unique_lock lk(current.m);
		while (node* const next = current->next.get())
		{
			std::unique_lock new_lk(next->m);
			lk.unlock();
			if (p(*(next->data)))//满足条件则返回
			{
				return next->data;
			}
			current = next;
			lk = std::move(next_lk);
		}
		return std::shared_ptr();
	}
	template
	void remove_if(Predicate p)
	{
		node* current = &head;
		std::unique_lock lk(current.m);
		while (node* const next = current->next.get())
		{
			std::unique_lock n_lk(next.m);
			if (p(*(next->data)))
			{
				std::unique_ptr old_next = std::move(next);//智能指针指向next,if运行完成自动销毁
				current->next = std::move(next->next);
				n_lk.unlock();//current不需要改变
			}
			else
			{
				lk.unlock();
				current = next;
				lk = std::move(n_lk);
			}
		}
	}
};

迭代操作单向进行,都是先锁住下一节的互斥,再解锁当前节点的互斥,确保锁操作顺序不可逆。

条件竞争的可能:remove_if中智能指针销毁发生在解锁之后,因为销毁已锁互斥使未定义行为。此时current持有锁,没有线程能越过当前节点,故操作安全。

4.要点小结

1.使用虚头节点避免push(tail互斥)和pop(短暂tail互斥,head互斥)的数据竞争。

2.容器保存std::shared_ptr<>指向底层数据。优点:避免重复分配内存;可在锁住互斥前完成新数据的初始化,提升性能。


3.使用unique_ptr<>管控节点。不需要时自动销毁,普通指针指向时需要调用get(),否则夺走归属权。


4.设计辅助函数返回锁,在接口中可以直接利用辅助函数的锁。                                                如:std::unique_lock head_lock(wait_for_data());


5.pop在返回弹出元素时可释放锁。


6.迭代函数中,在释放前一个节点的互斥前应锁住下一个节点的互斥。

你可能感兴趣的:(C++并发,数据结构,c++)