定义和结果 使用互斥元、条件变量以及future 来同步数据的算法和数据结构被称为阻塞的算法和数据结构.调用库函数的应用会中断一个线程的执行,直到另一个线程执行一个动作.这种库函数调用被称为阻塞调用,因为直到阻塞被释放时线程才能继续执行下去.通常,操作系统会完全阻塞一个线程(并且将这个线程的时间片分配给另一个线程),直到另一个线程执行了适当的动作将其解锁,可以是解锁互斥元、通知条件变量或者使得future就绪.
不使用阻塞库函数的数据结构和算法被称为非阻塞的.但是,并不是所有这样的数据结构都是无锁的,因此我们来看一看非阻塞数据结构的各种类型.
即非阻塞不一定就是不适用锁 。
例子:使用std::atomic_flag作为自旋锁的基本互斥元,实现自旋锁:
// 使用std::atomic_flag的自选锁互斥元的实现#include class spinlock_mutex{private:std::atomic_flag flag;public:spinlock_mutex():flag(ATOMIC_FLAG_INIT){}void lock(){while(flag.test_and_set(std::memory_order_acquire));}void unlock(){flag.clear(std::memory_order_release);}}; 该代码不调用任何阻塞函数,因此,使用这个互斥元的操作也将是非阻塞的,但它并非是无锁的 。
即自旋锁不阻塞 。
互斥锁会阻塞等待,挂起线程,放弃cpu;
自旋锁会循环等待,占用cpu;
互斥锁不占用cpu资源,但是有上下文的切换代价;自旋锁占用cpu资源,但是没有上下文切换的代价 。
当只有一个核心的时候,自旋锁就是浪费;
当有多个核心的时候,如果占用锁的时间很长,切换的代价小于占用cpu的代价,就可以选择互斥锁,反之可以选择自旋锁 。
无锁数据结构 使用无锁的数据结构的最大原因就是实现最大程序的并发 。对于基于锁的容器,总是有那么一个线程或这多个线程会阻塞 。而无锁的线程就不再需要等待结果,继续执行 。
另外,无锁的结构更具有健壮性,当一个持有锁的线程异常终止的时候,可能会因为它正拿着锁的原因导致整 。
无锁结果不仅意味着要注意加于操作上的顺序限制和线程可见性,无锁比有锁难得多 。
既然要使用无锁,就要依赖于原子操作,以及和原子操作有关的内存顺序 。
刚开始的例子使用最简单的内存顺序:memory_order_seq_cst,它是默认的,也是最耗资源的,也是最简单的 。
在所有原子操作里面,只有atomic_flag保证是无锁的,其他的可能自己内部封装了锁,这是C++做的封装,如果不想用可以用系统调用 。
编写不用锁的线程安全栈 实现不使用锁的线程安全push()
// 实现不是用锁的线程安全push#include templateclass lock_free_stack{private:struct node{T data;node *next;node(const T &data_):data(data_){}};std::atomic head;public:void push(const T &data){// 创建一个新结点// 唯一可能引发异常的点,不会修改链表,所以是异常安全的const node *new_node = new node(data);// 唯一的可能引发异常的点new_node->next = head.load();// A// 如果head的值和new_node->next的值是是一样的,就把head赋值为new_node// 如果不相同,说明有别的线程修改了head,所以要将当前结点的next重新指向新的head,// 然后再循环比较 。while(!head.compare_exchange_weak(new_node->next,new_node));// B}}; pop()实现:
为了防止异常,用共享指针保存data
// 实现不是用锁的线程安全push#include #include 但是没有回收垃圾,不用的头节点并没有回收 。
在无锁数据结构中管理内存 可以用:
- 计数操作:统计每一个结点的线程数,当没有线程操作这个节点的时候,这个节点被删除,但是这个很无语,因为一般不会出现线程没操作的情况 。
- 风险指针:需要申请专利,而且应用也很难,也是利用了引用计数
- 智能指针(共享指针):好像共享指针可以很好的实现,但是共享指针内部的操作不一定是无锁的,可能会有效率上的损耗 。
- 双层引用计数:使用双层引用计数,是书中提到的比较不错的例子,但也是比较烧脑 。
// 使用两个引用计数的无锁栈中入栈结点#include #include templateclass lock_free_stack{private:struct node;struct counted_node_ptr{int external_count; // 外部引用计数,保证直到你访问时指针仍然是有效的,不会变成悬空指针node *ptr;// 结点指针};struct node{std::shared_ptr data;// 真正的数据std::atomic internal_count;// 内部引用计数counted_node_ptr next;node(const T &data_):data(std::make_shared(data_)),internal_count(0){}};std::atomic head;void increase_head_count(counted_node_ptr &old_counter){counted_node_ptr new_counter;do{new_counter = old_counter;++new_counter.external_count;// 外部引用计数加1} while (!head.compare_exchange_strong(old_counter,new_counter));old_counter.external_count = new_counter.external_count;}public:~lock_free_stack(){while(pop());}void push(const T &data){counted_node_ptr new_node;new_node.ptr = new node(data);new_node.external_count = 1;// 新创建的结点外部只有一个引用,就是headnew_node.ptr->next = head.load();while(!head.compare_exchange_weak(new_node.ptr->next,new_node));}std::shared_ptr pop(){counted_node_ptr old_head = head.load();for(;;){// 因为引用了head,所以要增加外部引用+1,// 避免别的线程在这个时间点把head干掉,导致当前线程变成悬空指针increase_head_count(old_head);// 拿到head结点内部的数据指针const node *ptr = old_head.ptr;if(!ptr)// 如果数据是空的,直接弹出{return std::shared_ptr();}// 再次查看该线程拿到的head是否还是刚刚的head,// 如果不是,说明有别的线程干掉了head,该线程需要重新寻找head// 找到之后,该线程就把head放到后面去了,自己使用old_head,不影响别的线程继续操作// 如果链表不为空,使用CAS来移动指针// 如果false,就重新那ptr,所以能保证进入的ptr就是old_head的内部数据if(head.compare_exchange_strong(old_head,ptr->next)){std::shared_ptr res;res.swap(ptr->data);// 先把值保存下来// 从列表移除结点-1,当前线程不再使用该结点,再-1const int count_increase = old_head.external_count - 2;// 如果当前引用的值为0if(ptr->internal_count.fetch_add(count_increase) == -count_increase){delete ptr;}return res;}// 如果ptr的内部引用计数只有1,也就是只有当前线程,那么可以直接干掉// 但是不能return,因为既然是1,说明肯定已经被别的线程干掉了,否则应该是2// 所以直接干掉ptr内存就可以了// 不需要再返回指针了 。else if(ptr->internal_count.fetch_sub(1) == 1){delete ptr;}}}}; 将内存模型应用至无锁栈 在改变内存顺序前,你需要检查操作以及它们之间的关系 。然后就可以寻找提供这些关系的最小内存顺序 。为了实现这一点,就必须在不同场景下从线程角度考虑情况 。最简单的场景就是一个线程入栈一个数据项,并且稍后另一个线程将那个数据项出栈,我们先考虑这种情况 。使用引用计数和放松原子操作的无锁栈
// 使用引用计数和放松原子操作的无锁栈#include #include templateclass lock_free_stack{private:struct node;struct counted_node_ptr{int external_count; // 外部引用计数,保证直到你访问时指针仍然是有效的,不会变成悬空指针node *ptr;// 结点指针};struct node{std::shared_ptr data;// 真正的数据std::atomic internal_count;// 内部引用计数counted_node_ptr next;node(const T &data_):data(std::make_shared(data_)),internal_count(0){}};std::atomic head;void increase_head_count(counted_node_ptr &old_counter){counted_node_ptr new_counter;do{new_counter = old_counter;++new_counter.external_count;// 外部引用计数加1} while (!head.compare_exchange_strong(old_counter,new_counter,std::memory_order_acquire,std::memory_order_relaxed));//old_counter.external_count = new_counter.external_count;}public:~lock_free_stack(){while(pop());}void push(const T &data){counted_node_ptr new_node;new_node.ptr = new node(data);new_node.external_count = 1;// 新创建的结点外部只有一个引用,就是headnew_node.ptr->next = head.load(std::memory_order_relaxed);// 这一句是唯一的原子操作,上面的load虽然是原子的,但是顺序没有强制,// 所以head.load可以是最松散顺序 。// 但是下面的CAS,如果在成功的情况下,最小限制应该是release的 。// 保证前面的语句不会在CAS后面执行 。// 如果CAS失败,那么就可以是relaxed的,因为不会做任何改变 。while(!head.compare_exchange_weak(new_node.ptr->next,new_node,std::memory_order_release,std::memory_order_relaxed));}std::shared_ptr pop(){counted_node_ptr old_head = head.load(std::memory_order_relaxed);for(;;){// 因为引用了head,所以要增加外部引用+1,// 避免别的线程在这个时间点把head干掉,导致当前线程变成悬空指针increase_head_count(old_head);// 拿到head结点内部的数据指针const node *ptr = old_head.ptr;if(!ptr)// 如果数据是空的,直接弹出{return std::shared_ptr();}// 再次查看该线程拿到的head是否还是刚刚的head,// 如果不是,说明有别的线程干掉了head,该线程需要重新寻找head// 找到之后,该线程就把head放到后面去了,自己使用old_head,不影响别的线程继续操作// 如果链表不为空,使用CAS来移动指针// 如果false,就重新那ptr,所以能保证进入的ptr就是old_head的内部数据if(head.compare_exchange_strong(old_head,ptr->next,std::memory_order_relaxed)){std::shared_ptr res;res.swap(ptr->data);// 先把值保存下来// 从列表移除结点-1,当前线程不再使用该结点,再-1const int count_increase = old_head.external_count - 2;// 如果当前引用的值为0if(ptr->internal_count.fetch_add(count_increase,std::memory_order_release) == -count_increase){delete ptr;}return res;}// 如果ptr的内部引用计数只有1,也就是只有当前线程,那么可以直接干掉// 但是不能return,因为既然是1,说明肯定已经被别的线程干掉了,否则应该是2// 所以直接干掉ptr内存就可以了// 不需要再返回指针了 。else if(ptr->internal_count.fetch_sub(1,std::memory_order_relaxed) == 1){ptr->internal_count.load(std::memory_order_acquire);delete ptr;}}}}; 设计无锁数据结构的准则 使用std::memory_order_seq_cst作为原型 最先设计的时候,使用最强顺序作为原型,因为最强顺序就是代码的顺序,容易理解,在保证代码正确之后,再放松顺序 。使用无锁内存回收模式 无锁代码最大的问题之一就是管理内存 。
三种方法:
- 等待直到没有线程访问该数据结构,并且删除所有等待删除的对象
- 使用风险指针来确定线程正在访问一个特定的对象
- 引用计数对象,只有直到没有显著引用时才删除它们
另一个方法就是回收结点,并且当数据结构被销毁的时候才完全释放它们 。因为结点是重复使用的,内存永远不会失效 。这样避免未定义行为的困难就不存在了 。缺点就是另一个问题变得更常见 。这就是所谓的ABA问题 。
当心ABA问题 ABA问题时任何基于CAS算法都必须提防的问题 。
问题描述:
- 线程1读取一个原子变量x,并且发现它的值为A
- 线程1基于这个值执行了一些操作,例如解引用它或者做一些查找操作
- 线程1被系统阻塞了
- 另一个线程在x上执行了一些操作,将它的值改为B
- 第三个线程更改了于值A相关的值,因此线程1持有的数值就不再有效了
- 第三个线程基于新值将x的值改回A,如果这是一个指针,那么就可能是一个新的对象,此对象刚好与先前的对象使用了相同的地址
- 线程1重新取得x,并在x上执行CAS,与A进行比较,CAS操作成功(因为值确实是A),但是这个A的值是错误的 。第二个步中读取的值不再有效,但是线程1不知道,并且将破坏数据结构 。
- 春季老年人吃什么养肝?土豆、米饭换着吃
- 三八妇女节节日祝福分享 三八妇女节节日语录
- 老人谨慎!选好你的“第三只脚”
- 校方进行了深刻的反思 青岛一大学生坠亡校方整改校规
- 脸皮厚的人长寿!有这特征的老人最长寿
- 长寿秘诀:记住这10大妙招 100%增寿
- 春季老年人心血管病高发 3条保命要诀
- 眼睛花不花要看四十八 老年人怎样延缓老花眼
- 香槟然能防治老年痴呆症? 一天三杯它人到90不痴呆
- 老人手抖的原因 为什么老人手会抖
