C++并发编程-13. 无锁并发队列
1. 简介
前文介绍了如何通过内存顺序实现内存模型,本文基于前文的基础,利用内存顺序和内存模型的知识,带着大家探索无锁并发的应用,主要是通过无锁队列的实现来让大家熟悉无锁并发的实现方式。
2. 环形队列
我们要实现无锁并发,经常会用到一种结构无锁队列,而无锁队列和我们经常使用的队列颇有不同,它采用的是环状的队列结构,为什么成环呢?主要有两个好处,一个是成环的队列大小是固定的,另外一个我们通过移动头和尾就能实现数据的插入和取出。
3. 用锁实现环形队列
我们可以用锁实现上述环形队列,在push和pop时分别加锁,并通过head和tail计算队列是否为满或者空。
#include <iostream>
#include <mutex>
#include <memory>
template<typename T, size_t Cap>
class CircularQueLk :private std::allocator<T> {
public:CircularQueLk() :_max_size(Cap + 1),_data(std::allocator<T>::allocate(_max_size)), _head(0), _tail(0) {}CircularQueLk(const CircularQueLk&) = delete;CircularQueLk& operator = (const CircularQueLk&) volatile = delete;CircularQueLk& operator = (const CircularQueLk&) = delete;~CircularQueLk() {//循环销毁std::lock_guard<std::mutex> lock(_mtx);//调用内部元素的析构函数while (_head != _tail) {std::allocator<T>::destroy(_data + _head);_head = (_head+1)%_max_size;}//调用回收操作std::allocator<T>::deallocate(_data, _max_size);}//先实现一个可变参数列表版本的插入函数最为基准函数template <typename ...Args>bool emplace(Args && ... args) {std::lock_guard<std::mutex> lock(_mtx);//判断队列是否满了if ((_tail + 1) % _max_size == _head) {std::cout << "circular que full ! " << std::endl;return false;}//在尾部位置构造一个T类型的对象,构造参数为args...std::allocator<T>::construct(_data + _tail, std::forward<Args>(args)...);//更新尾部元素位置_tail = (_tail + 1) % _max_size;return true;}//push 实现两个版本,一个接受左值引用,一个接受右值引用//接受左值引用版本bool push(const T& val) {std::cout << "called push const T& version" << std::endl;return emplace(val);}//接受右值引用版本,当然也可以接受左值引用,T&&为万能引用// 但是因为我们实现了const T&bool push(T&& val) {std::cout << "called push T&& version" << std::endl;return emplace(std::move(val));}//出队函数bool pop(T& val) {std::lock_guard<std::mutex> lock(_mtx);//判断头部和尾部指针是否重合,如果重合则队列为空if (_head == _tail) {std::cout << "circular que empty ! " << std::endl;return false;}//取出头部指针指向的数据val = std::move(_data[_head]);//更新头部指针_head = (_head + 1) % _max_size;return true;}
private:size_t _max_size;T* _data;std::mutex _mtx;size_t _head = 0;size_t _tail = 0;
};
测试也比较简单,我们写一个函数,初始化队列大小为5,测试队列push满的情况和pop直到为空的情况
void TestCircularQue() {//最大容量为10CircularQueLk<MyClass, 5> cq_lk;MyClass mc1(1);MyClass mc2(2);cq_lk.push(mc1);cq_lk.push(std::move(mc2));for (int i = 3; i <= 5; i++) {MyClass mc(i);auto res = cq_lk.push(mc);if (res == false) {break;}}cq_lk.push(mc2);for (int i = 0; i < 5; i++) {MyClass mc1;auto res = cq_lk.pop(mc1);if (!res) {break;}std::cout << "pop success, " << mc1 << std::endl;}auto res = cq_lk.pop(mc1);
}
4. 无锁队列
- 那如果我们用原子变量而不是用锁实现环形队列,那就是无锁并发的队列了。还记得我们之前提到的原子变量的读改写操作吗?
bool std::atomic<T>::compare_exchange_weak(T &expected, T desired);
bool std::atomic<T>::compare_exchange_strong(T &expected, T desired);
compare_exchange_strong会比较原子变量atomic的值和expected的值是否相等,如果相等则执行交换操作,将atomic的值换为desired并且返回true,否则将expected的值修改为bool变量的值,并且返回false.
template<typename T, size_t Cap>
class CircularQueSeq :private std::allocator<T>
{
public:CircularQueSeq() :_max_size(Cap + 1), _data(std::allocator<T>::allocate(_max_size)), _atomic_using(false), _head(0), _tail(0) {}CircularQueSeq(const CircularQueSeq&) = delete;CircularQueSeq& operator = (const CircularQueSeq&) volatile = delete;CircularQueSeq& operator = (const CircularQueSeq&) = delete;~CircularQueSeq() {//循环销毁bool use_expected = false;bool use_desired = true;do{use_expected = false;use_desired = true;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));//调用内部元素的析构函数while (_head != _tail) {std::allocator<T>::destroy(_data + _head);_head = (_head + 1) % _max_size;}//调用回收操作std::allocator<T>::deallocate(_data, _max_size);do{use_expected = true;use_desired = false;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));}//先实现一个可变参数列表版本的插入函数最为基准函数template <typename ...Args>bool emplace(Args && ... args) {bool use_expected = false;bool use_desired = true;do{use_expected = false;use_desired = true;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));//判断队列是否满了if ((_tail + 1) % _max_size == _head) {std::cout << "circular que full ! " << std::endl;do{use_expected = true;use_desired = false;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));return false;}//在尾部位置构造一个T类型的对象,构造参数为args...std::allocator<T>::construct(_data + _tail, std::forward<Args>(args)...);//更新尾部元素位置_tail = (_tail + 1) % _max_size;do{use_expected = true;use_desired = false;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));return true;}//push 实现两个版本,一个接受左值引用,一个接受右值引用//接受左值引用版本bool push(const T& val) {std::cout << "called push const T& version" << std::endl;return emplace(val);}//接受右值引用版本,当然也可以接受左值引用,T&&为万能引用// 但是因为我们实现了const T&bool push(T&& val) {std::cout << "called push T&& version" << std::endl;return emplace(std::move(val));}//出队函数bool pop(T& val) {bool use_expected = false;bool use_desired = true;do{use_desired = true;use_expected = false;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));//判断头部和尾部指针是否重合,如果重合则队列为空if (_head == _tail) {std::cout << "circular que empty ! " << std::endl;do{use_expected = true;use_desired = false;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));return false;}//取出头部指针指向的数据val = std::move(_data[_head]);//更新头部指针_head = (_head + 1) % _max_size;do{use_expected = true;use_desired = false;} while (!_atomic_using.compare_exchange_strong(use_expected, use_desired));return true;}
private:size_t _max_size;T* _data;std::atomic<bool> _atomic_using;size_t _head = 0;size_t _tail = 0;
};
我们可以写一个函数在单线程情况下下测试一下
void TestCircularQueSeq()
{CircularQueSeq<MyClass, 3> cq_seq;for(int i = 0; i < 4; i++){MyClass mc1(i);auto res = cq_seq.push(mc1);if(!res){break;}}for(int i = 0; i < 4; i++){MyClass mc1;auto res = cq_seq.pop(mc1);if(!res){break;}std::cout << "pop success, " << mc1 << std::endl;}for (int i = 0; i < 4; i++){MyClass mc1(i);auto res = cq_seq.push(mc1);if (!res){break;}}for (int i = 0; i < 4; i++){MyClass mc1;auto res = cq_seq.pop(mc1);if (!res){break;}std::cout << "pop success, " << mc1 << std::endl;}
}
多线程情况下也能保证安全是因为原子变量循环检测保证有且只有一个线程修改成功。读取也是这样。
5. 单一原子变量的弊端
- 上述空转检测
template<typename T, size_t Cap>
class CircularQueLight: private std::allocator<T>
{
public:CircularQueLight():_max_size(Cap + 1),_data(std::allocator<T>::allocate(_max_size)), _head(0), _tail(0) {}CircularQueLight(const CircularQueLight&) = delete;CircularQueLight& operator = (const CircularQueLight&) volatile = delete;CircularQueLight& operator = (const CircularQueLight&) = delete;
private:size_t _max_size;T* _data;std::atomic<size_t> _head;std::atomic<size_t> _tail;
};
我们将_head 和_tail 替换为原子变量。
接下来我们考虑pop逻辑
bool pop(T& val) {size_t h;do{h = _head.load(); //1 处//判断头部和尾部指针是否重合,如果重合则队列为空if(h == _tail.load()){return false;}val = _data[h]; // 2处 注意这里需要使用赋值,而不是std::move()// 因为可能两个线程同时执行这个操作,如果是move,线程1得到了,线程2得到一个控制,但是线程2在做while判断时成功了但是线程2得到是空值} while (!_head.compare_exchange_strong(h, (h+1)% _max_size)); //3 处return true;}
在pop逻辑里我们在1处load获取头部head的值,在2处采用了复制的方式将头部元素取出赋值给val,而不是通过std::move,因为多个线程同时pop最后只有一个线程成功执行3处代码退出,而失败的则需要继续循环,从更新后的head处pop元素。所以不能用std::move,否则会破坏原有的队列数据。
接下来我们来做push的函数逻辑
bool push(T& val){size_t t;do{t = _tail.load(); //1//判断队列是否满if( (t+1)%_max_size == _head.load()){return false;}_data[t] = val; //2} while (!_tail.compare_exchange_strong(t,(t + 1) % _max_size)); //3return true;}
1.1 线程1执行操作1
1.2 线程1执行操作2
线程2执行操作2会把线程1执行的操作2覆盖掉
bool push(T& val){size_t t;do{t = _tail.load(); //1//判断队列是否满if( (t+1)%_max_size == _head.load()){return false;}} while (!_tail.compare_exchange_strong(t,(t + 1) % _max_size)); //3_data[t] = val; //2return true;}
我们将2处的代码移动到循环之外,这样能保证多个线程push,仅有一个线程生效时,他写入的数据一定是本线程要写入到tail的数据,而此时tail被缓存在t里,那是一个线程本地变量,所以在这种情况下我们能确定即使多个线程运行到2处,他们的t值也是不同的,并不会产生线程安全问题。
毕竟多个线程push数据时对资源的竞争仅限tail。
但是这种push操作仍然会有安全问题
我们思考这种情况
bool push(const T& val){size_t t;do{t = _tail.load(); //1//判断队列是否满if( (t+1)%_max_size == _head.load()){return false;}} while (!_tail.compare_exchange_strong(t,(t + 1) % _max_size)); //3_data[t] = val; //2size_t tailup;do{tailup = t;} while (_tail_update.compare_exchange_strong(tailup, (tailup + 1) % _max_size));return true;}
再实现pop版本
bool pop(T& val) {size_t h;do{h = _head.load(); //1 处//判断头部和尾部指针是否重合,如果重合则队列为空if(h == _tail.load()){return false;}//判断如果此时要读取的数据和tail_update是否一致,如果一致说明尾部数据未更新完if(h == _tail_update.load()){return false;}val = _data[h]; // 2处} while (!_head.compare_exchange_strong(h, (h+1)% _max_size)); //3 处return true;}
6. 优化性能
我们用acquire和release模型优化上述代码,实现同步。
最简单的方式就是将load的地方变为memory_order_relaxed,compare_exchange_strong的地方变为memory_order_release
我们先看pop操作
bool pop(T& val) {size_t h;do{h = _head.load(std::memory_order_relaxed); //1 处//判断头部和尾部指针是否重合,如果重合则队列为空if (h == _tail.load(std::memory_order_acquire)) //2处{std::cout << "circular que empty ! " << std::endl;return false;}//判断如果此时要读取的数据和tail_update是否一致,如果一致说明尾部数据未更新完if (h == _tail_update.load(std::memory_order_acquire)) //3处{return false;}val = _data[h]; // 2处} while (!_head.compare_exchange_strong(h,(h + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //4 处std::cout << "pop data success, data is " << val << std::endl;return true;}
1 处为memory_order_relaxed是因为即使多个线程pop,每个线程获取的head可能不及时,这个没关系,因为我们有4处的while来重试。
2 compare_exchange_strong操作,在期望的条件匹配时采用memory_order_release, 期望的条件不匹配时memory_order_relaxed可以提升效率,毕竟还是要重试的。
我们再看push 操作
bool push(const T& val){size_t t;do{t = _tail.load(std::memory_order_relaxed); //5//判断队列是否满if ((t + 1) % _max_size == _head.load(std::memory_order_acquire)){std::cout << "circular que full ! " << std::endl;return false;}} while (!_tail.compare_exchange_strong(t,(t + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //6_data[t] = val; size_t tailup;do{tailup = t;} while (_tail_update.compare_exchange_strong(tailup,(tailup + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //7std::cout << "called push data success " << val << std::endl;return true;}
两个线程协同工作,一个线程先push,另一个线程后pop,那么对于tail部分和_tail_update,我们要保证push的结果_data[t] = val;先于pop的结果val = _data[h];
所以push线程中对于_tail_update的compare_exchange_strong操作采用memory_order_release方式。
pop线程对于_tail_update的load操作采用memory_order_acquire。
如果一个线程先pop,另一个线程先push,那么对于head部分,我们要保证pop的结果val = _data[h];先于pop的结果_data[t] = val;。
7. 思考
优势
无锁高并发. 虽然存在循环重试, 但是这只会在相同操作并发的时候出现. push 不会因为与 pop 并发而重试, 反之亦然.
缺陷
这样队列只应该存储标量, 如果存储类对象时,多个push线程只有一个线程push成功,而拷贝复制的开销很大,其他线程会循环重试,每次重试都会有开销。