使用互斥元、条件变量以及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
template<typename T>
class lock_free_stack
{
private:
struct node
{
T data;
node *next;
node(const T &data_):data(data_){}
};
std::atomic<node*> 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
template<typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data;
node *next;
node(const T &data_):data(std::make_shared<T>(data_)){}
};
std::atomic<node*> head;
public:
// 使用CAS
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
}
std::shared_ptr<T> pop()
{
node *old_head = head.load();
// 要判断old_head是否为空,如果为空,old_head->next是未定义行为。
while(old_head && !head.compare_exchange_weak(old_head,old_head->next));
// 返回智能指针,不会有异常,或者返回空指针,但是还是没有释放内存。
return old_head ? old_head->data:std::make_shared<T>();
}
};
但是没有回收垃圾,不用的头节点并没有回收。
可以用:
// 使用两个引用计数的无锁栈中入栈结点
#include
#include
template<typename T>
class lock_free_stack
{
private:
struct node;
struct counted_node_ptr
{
int external_count; // 外部引用计数,保证直到你访问时指针仍然是有效的,不会变成悬空指针
node *ptr; // 结点指针
};
struct node
{
std::shared_ptr<T> data; // 真正的数据
std::atomic<int> internal_count; // 内部引用计数
counted_node_ptr next;
node(const T &data_):data(std::make_shared<T>(data_)),internal_count(0){}
};
std::atomic<counted_node_ptr> 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; // 新创建的结点外部只有一个引用,就是head
new_node.ptr->next = head.load();
while(!head.compare_exchange_weak(new_node.ptr->next,new_node));
}
std::shared_ptr<T> 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<T>();
}
// 再次查看该线程拿到的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<T> res;
res.swap(ptr->data); // 先把值保存下来
// 从列表移除结点-1,当前线程不再使用该结点,再-1
const int count_increase = old_head.external_count - 2;
// 如果当前引用的值为0
if(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
template<typename T>
class lock_free_stack
{
private:
struct node;
struct counted_node_ptr
{
int external_count; // 外部引用计数,保证直到你访问时指针仍然是有效的,不会变成悬空指针
node *ptr; // 结点指针
};
struct node
{
std::shared_ptr<T> data; // 真正的数据
std::atomic<int> internal_count; // 内部引用计数
counted_node_ptr next;
node(const T &data_):data(std::make_shared<T>(data_)),internal_count(0){}
};
std::atomic<counted_node_ptr> 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; // 新创建的结点外部只有一个引用,就是head
new_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<T> 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<T>();
}
// 再次查看该线程拿到的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<T> res;
res.swap(ptr->data); // 先把值保存下来
// 从列表移除结点-1,当前线程不再使用该结点,再-1
const int count_increase = old_head.external_count - 2;
// 如果当前引用的值为0
if(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;
}
}
}
};
最先设计的时候,使用最强顺序作为原型,因为最强顺序就是代码的顺序,容易理解,在保证代码正确之后,再放松顺序。
无锁代码最大的问题之一就是管理内存。
三种方法:
在所有的情况下,关键的想法就是使用一些方法来记录有多少线程在访问一个特定的对象,并且只删除不再被引用的对象。有很多方法可以回收无锁数据结构的内存。例如,使用垃圾回收器是很理想的方案。当你不再使用结点的时候,垃圾回收期可以释放结点。在这种情况下写程序就简单一些。
另一个方法就是回收结点,并且当数据结构被销毁的时候才完全释放它们。因为结点是重复使用的,内存永远不会失效。这样避免未定义行为的困难就不存在了。缺点就是另一个问题变得更常见。这就是所谓的ABA问题。
ABA问题时任何基于CAS算法都必须提防的问题。
问题描述:
写无锁程序很容易遇到这样的问题,最常用的避免方法就是在变量x上使用一个ABA计数器。每次修改值的时候,与值绑定的计数器就会+1;所以即使值是一样的,如果计数器不一样,也会CAS失败。