基于锁的并发数据结构之线程安全的栈和队列

920 阅读6分钟

参考C++并发编程(中文版)(C++ Concurrency In Action)

std::lock_guardstd::unique_lock

锁管理器在C++ 11中有两种: 用于简单的std::lock_guard,以及用于高级用例的std::unique_lock。

{
  std::mutex m,
  std::lock_guard<std::mutex> lockGuard(m);
  sharedVariable= getVar();
}

std::lock_guard生命周期只在这{}里面有效。 也就是说,当生命周期离开临界区时,它的生命周期就结束了。 确切地说,在那个时间点,std::lock_guard的析构函数被调用,互斥体被释放了。 过程是全自动的, 此外,如果getVar()sharedVariable = getVar()抛出异常时也会发生。 lock_guard类是 non-copyable的。 boost::noncopyable 的工作原理就是禁止访问它的复制构造函数和赋值操作符,然后使用它作为基类。

RAII手法管理mutex的std::lock_guard其功能是在对象构造时将mutex加锁,析构时对mutex解锁,这样一个栈对象保证了在异常情形下mutex可以在lock_guard对象析构被解锁,lock_guard拥有mutex的所有权。

unique_lock内部持有mutex的状态:locked,unlocked。unique_lock比lock_guard占用空间和速度慢一些,因为其要维护mutex的状态。

提供了更好的上锁和解锁控制,尤其是在程序抛出异常后先前已被上锁的 Mutex 对象可以正确进行解锁操作,极大地简化了程序员编写与 Mutex 相关的异常处理代码****。

互斥锁保证了线程间的同步, 但是却将并行操作变成了串行操作, 这对性能有很大的影响, 所以我们要尽可能的减小锁定的区域, 也就是使用细粒度锁。 这一点lock_guard做的不好,不够灵活, lock_guard只能保证在析构的时候执行解锁操作, lock_guard本身并没有提供加锁和解锁的接口。 unique_lock提供了lock()unlock()接口。 unique_locklock_guard都不能复制,lock_guard不能移动,但是unique_lock可以:

// unique_lock 可以移动,不能复制
std::unique_lock<std::mutex> guard1(_mu);
std::unique_lock<std::mutex> guard2 = guard1;  // error
std::unique_lock<std::mutex> guard2 = std::move(guard1); // ok

// lock_guard 不能移动,不能复制
std::lock_guard<std::mutex> guard1(_mu);
std::lock_guard<std::mutex> guard2 = guard1;  // error
std::lock_guard<std::mutex> guard2 = std::move(guard1); // error

shared_ptr<T>

std::shared_ptr<int> p3 = std::make_shared<int>(10);
//调用拷贝构造函数
std::shared_ptr<int> p4(p3);//或者 std::shared_ptr<int> p4 = p3;
//调用移动构造函数
std::shared_ptr<int> p5(std::move(p4)); //或者 std::shared_ptr<int> p5 = std::move(p4);

如上所示,p3p4 都是 shared_ptr 类型的智能指针,因此可以用 p3 来初始化 p4,由于 p3 是左值,因此会调用拷贝构造函数。需要注意的是,如果 p3 为空智能指针,则 p4 也为空智能指针,其引用计数初始值为 0;反之,则表明 p4p3 指向同一块堆内存,同时该堆空间的引用计数会加 1。

而对于 std::move(p4) 来说,该函数会强制将 p4 转换成对应的右值,因此初始化 p5 调用的是移动构造函数。另外和调用拷贝构造函数不同,用 std::move(p4) 初始化 p5,会使得 p5 拥有了 p4 的堆内存,而 p4 则变成了空智能指针。

移动语义与std::move()

参考第14课 移动语义(std::move)

C++11 move()函数:将左值强制转换为右值

移动构造函数的调用时机是:用同类的右值对象初始化新对象。那么,用当前类的左值对象(有名称,能获取其存储地址的实例对象)初始化同类对象时,是否就无法调用移动构造函数了呢?当然不是,C++11 标准中已经给出了解决方案,即调用 move() 函数。

move 本意为 "移动",但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。

  1. std::move的本质就强制类型转换,它无条件地将实参转为右值引用类型(匿名对象,是个右值),继而用于移动语义。

  2. 该函数只是将实参转为右值,除此之外并没有真正的move任何东西。实际上,它在运行期没任何作为,编译器也不会为它生成任何的可执行代码,连一个字节都没有。

  3. 如果要对某个对象执行移动操作时,则不要将其声明为常量。因为针对常量对象执行移动操作将变成复制操作。

线程安全栈——使用锁

/*
线程安全栈——使用锁
*/

#include <iostream>       // std::cout
#include <thread>         // std::thread
#include <mutex>          // std::mutex, std::lock_guard
#include <stdexcept>      // std::logic_error
#include <exception>

struct empty_stack : std::exception
{
	const char* what() const throw();
};
template<typename T>
class threadsafe_stack
{
private:
	std::stack<T> data;
	mutable std::mutex m; // 互斥量m能保证基本的线程安全,那就是对每个成员函数进行加锁保护。
public:
	threadsafe_stack() {}
	threadsafe_stack(const threadsafe_stack& other)
	{
		std::lock_guard<std::mutex> lock(other.m);
		data = other.data;
	} // other.m is automatically released when lock goes out of scope
	threadsafe_stack& operator=(const threadsafe_stack&) = delete;
	void push(T new_value)
	{
		std::lock_guard<std::mutex> lock(m);
		data.push(std::move(new_value));  // 1
		/*
		对data.push()①的调用可能会抛出一个异常,
		不是拷贝/移动数据值时,就是内存不足的时候。
		不管是哪种,std::stack<>都能保证其实安全的,
		所以这里也没有问题。
		*/
	}
	std::shared_ptr<T> pop()
	{
		std::lock_guard<std::mutex> lock(m);
		if (data.empty()) throw empty_stack();  // 2
		/*
		在empty()和pop()成员函数之间会存在潜在的竞争,
		不过代码会在pop()函数上锁时,
		显式的查询栈是否为空,所以这里的竞争是非恶性的。
		*/
		std::shared_ptr<T> const res(
			std::make_shared<T>(std::move(data.top())));  // 3
		// 为什么要用move呢?
		data.pop();  // 4
		return res;
	}
	void pop(T& value)
	{
		std::lock_guard<std::mutex> lock(m);
		if (data.empty()) throw empty_stack();
		value = std::move(data.top());  // 5
		data.pop();  // 6
	}
	bool empty() const
	{
		std::lock_guard<std::mutex> lock(m);
		return data.empty();
	}
};

线程安全队列——使用锁和条件变量

template<typename T>
class threadsafe_queue
{
private:
	mutable std::mutex mut;
	std::queue<T> data_queue;
	std::condition_variable data_cond;
public:
	threadsafe_queue()
	{}
	void push(T new_value)
	{
		std::lock_guard<std::mutex> lk(mut);
		data_queue.push(std::move(data));
		data_cond.notify_one();  // 1
		// notify_one()(随机唤醒一个等待的线程)和notify_all()(唤醒所有等待的线程)
	}
	void wait_and_pop(T& value)  // 2
	{
		std::unique_lock<std::mutex> lk(mut);
		data_cond.wait(lk, [this] {return !data_queue.empty(); });
		value = std::move(data_queue.front());
		data_queue.pop();
	}
	std::shared_ptr<T> wait_and_pop()  // 3
	{
		std::unique_lock<std::mutex> lk(mut);
		data_cond.wait(lk, [this] {return !data_queue.empty(); });  // 4
		std::shared_ptr<T> res(
			std::make_shared<T>(std::move(data_queue.front())));
		data_queue.pop();
		return res;
	}
	bool try_pop(T& value)
	{
		std::lock_guard<std::mutex> lk(mut);
		if (data_queue.empty())
			return false;
		value = std::move(data_queue.front());
		data_queue.pop();
		return true;
	}
	std::shared_ptr<T> try_pop()
	{
		std::lock_guard<std::mutex> lk(mut);
		if (data_queue.empty())
			return std::shared_ptr<T>();  // 5
		std::shared_ptr<T> res(
			std::make_shared<T>(std::move(data_queue.front())));
		data_queue.pop();
		return res;
	}
	bool empty() const
	{
		std::lock_guard<std::mutex> lk(mut);
		return data_queue.empty();
	}
};

持有std::shared_ptr<>实例的线程安全队列

template<typename T>
class threadsafe_queue
{
private:
	mutable std::mutex mut;
	std::queue<std::shared_ptr<T> > data_queue;
	std::condition_variable data_cond;
public:
	threadsafe_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();
	}
	bool try_pop(T& value)
	{
		std::lock_guard<std::mutex> lk(mut);
		if (data_queue.empty())
			return false;
		value = std::move(*data_queue.front());  // 2
		data_queue.pop();
		return true;
	}
	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
		// 这里为什么不是move
		// 因为下面pop会减1吗
		data_queue.pop();
		return res;
	}
	void push(T new_value)
	{
		std::shared_ptr<T> data(
			std::make_shared<T>(std::move(new_value)));  // 5
		// 用move是因为?:一旦new_value被move了,就成了空,然后不会再push多次
		std::lock_guard<std::mutex> lk(mut);
		data_queue.push(data);
		data_cond.notify_one();
	}
	bool empty() const
	{
		std::lock_guard<std::mutex> lk(mut);
		return data_queue.empty();
	}
};

std::shared_ptr<>持有数据的好处:新的实例分配结束时,不会被锁在push()⑤当中(而在清单6.2中,只能在pop()持有锁时完成)。因为内存分配操作的需要在性能上付出很高的代价(性能较低),所以使用std::shared_ptr<>的方式对队列的性能有很大的提升,其减少了互斥量持有的时间,允许其他线程在分配内存的同时,对队列进行其他的操作。

如同栈的例子,使用互斥量保护整个数据结构,不过会限制队列对并发的支持;虽然,多线程可能被队列中的各种成员函数所阻塞,但是仍有一个线程能在任意时间内进行工作。不过,这种限制的部分来源是因为在实现中使用了std::queue<>;因为使用标准容器的原因,数据处于保护中。要对数据结构实现进行具体的控制,需要提供更多细粒度锁,来完成更高级的并发。