前一篇文章讲的是带锁的并发数据结构,而且讲到了如果不带锁将会面临什么样的问题。这一部分我将为大家带来一个全新的数据结构-自旋锁。这是一种不使用阻塞库的数据结构,我们将不使用阻塞库的结构称为非阻塞,但是不是所有的非阻塞数据结构都是无锁的。
阻塞函数就是当这个函数不执行完,函数所在线程就一直停止在这里不动。
比如最常见的在多线程里面的thread
库基本上都是阻塞函数。
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
class spinlock {
atomic_flag flag;
public:
spinlock(): flag(ATOMIC_FLAG_INIT){}
void lock() {
while (flag.test_and_set(memory_order_acquire));
}
void unlock() {
flag.clear(memory_order_release);
}
};
spinlock sp;
void fun_1(){
sp.lock();
cout << "fun_1 is lock(); " << endl;
sp.unlock();
cout << "fun_1 is unlock();" << endl;
}
void fun_2() {
sp.lock();
cout << "fun_2 is lock(); " << endl;
sp.unlock();
cout << "fun_2 is unlock();" << endl;
}
int main() {
thread t1(fun_1);
thread t2(fun_2);
t1.join();
t2.join();
}
在这里面使用atomic
实现了互斥锁的功能,虽然没有调用任何阻塞函数但是依然不是无锁数据结构。
所有的代码围绕着循环“旋转”,这也是自旋锁的由来。而且这样也会像使用阻塞函数一样,占用内存资源。
如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
上面C++实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)。
这个例子在前一篇文章中有所体现,代码见下,就是在唤醒线程时,由于时间原因导致最终唤醒失败。
mutex mut;
condition_variable cond;
void fun_3(){
int count = 0;
while(true){
unique_lock<mutex> lk(mut);
cout << "做包子" << endl;
this_thread::sleep_for(chrono::seconds(1));
cout << "做好了" << endl;
lk.unlock();
cond.notify_one();
this_thread::sleep_for(chrono::microseconds(1));
count++;
}
}
void fun_4(){
while (true){
unique_lock<mutex> lk(mut);
cout << "买包子" << endl;
cond.wait(lk);
cout << "买到包子" <<endl;
}
}
基于自旋锁的各种优缺点,可以得出自旋锁适合于对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
如果锁竞争激烈或者需要长时间占用锁,这种情况就不适合自旋锁,因为自旋锁会一直占用CPU做无用功。
但是简单的自旋锁无法保证多线程竞争的公平性,即谁最先将flag
设置为true
谁就最先获得锁,这会导致线程饥饿!所以需要对自旋锁进行改进,避免出现线程饥饿现象。
为了提供公平,有人发明了Ticket Lock
线程想要竞争某个锁,需要先领一张ticket,然后监听flag,发现flag被更新为手上的ticket的值了,才能去占领锁
就像是在医院看病一样,医生就是临界区,病人就是线程,病人挂了号领一张单子,单子上写了一个独一无二的号码,病人等的时候就看屏幕,屏幕上显示到自己的号码了,才能进去找医生。
AtomicInteger ticket = new AtomicInteger(0);
volatile int flag = 0;
void lock() {
int my_ticket = ticket.getAndIncrement();//发号必须是一个原子操作,不能多个线程拿到同一个ticket
while (my_ticket != flag) {
}
}
void unlock() {
flag++;
}
TicketLock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量queueNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
为了减少缓存一致性带来的开销,CLH Lock被发明了。CLH锁的核心思想是,1. 竞争线程排队 2. 监听变量拆分
CLH 是一种基于链表的可扩展,高性能,公平的自旋锁,申请线程只能在本地变量上自旋,它会不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
public class CLHLock {
public static class CLHNode{
private volatile boolean isLocked = true;
}
// 尾部节点
private volatile CLHNode tail;
private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<>();
private static final AtomicReferenceFieldUpdater<CLHLock,CLHNode> UPDATER =
AtomicReferenceFieldUpdater.newUpdater(CLHLock.class,CLHNode.class,"tail");
public void lock(){
// 新建节点并将节点与当前线程保存起来
CLHNode node = new CLHNode();
LOCAL.set(node);
// 将新建的节点设置为尾部节点,并返回旧的节点(原子操作),这里旧的节点实际上就是当前节点的前驱节点
CLHNode preNode = UPDATER.getAndSet(this,node);
if(preNode != null){
// 前驱节点不为null表示当锁被其他线程占用,通过不断轮询判断前驱节点的锁标志位等待前驱节点释放锁
while (preNode.isLocked){
}
preNode = null;
LOCAL.set(node);
}
// 如果不存在前驱节点,表示该锁没有被其他线程占用,则当前线程获得锁
}
public void unlock() {
// 获取当前线程对应的节点
CLHNode node = LOCAL.get();
// 如果tail节点等于node,则将tail节点更新为null,同时将node的lock状态职位false,表示当前线程释放了锁
if (!UPDATER.compareAndSet(this, node, null)) {
node.isLocked = false;
}
node = null;
}
}
初始时需要定义一个dummy节点(dummpy.flag == true, dummy.prev == null),head == tail == dummy。当有线程想要获取锁时,先创建一个链表节点node,然后将node挂载在waitingList的尾部(尝试cas(tail, oldTail, node),如果成功将node.prev更新为oldTail,失败则重试)。然后这个线程就监听node.prev.flag,什么时候node.prev.flag == false了,说明node的前一个节点对应的线程已经释放了锁,本线程此时可以安全的占有锁了。释放锁的时候,将对应的node.flag修改为false即可。
CLH锁并不是完美的,因为每个线程都是在前驱节点的locked字段上自旋,而在NUMA体系中,有可能多个线程工作在多个不同的socket上的core里。如果前驱节点的内存跟监听线程的core距离过远,会有性能问题。
MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。
MCS与CLH最大的不同在于:CLH是在前驱节点的locked域上自旋,MCS是在自己节点上的locked域上自旋。
具体的实现是,前驱节点在释放锁之后,会主动将后继节点的locked域更新。
也就是把多次对远端内存的监听 + 一次对本地内存的更新,简化成了多次对本地内存的监听 + 一次对远端内存的更新。
public class MCSLock {
volatile Node head, tail;//waitingList
public MCSLock() {
head = tail = null;
}
public Node lock() {
//lock-free的将node添加到waitingList的尾部
Node node = new Node(true, null);
Node oldTail = tail;
while (!cas(tail, oldTail, node)) {
oldTail = tail;
}
if (null == oldTail) {//如果等待列表为空,那么获取锁成功,直接返回
return node;
}
oldTail.setNext(node);
while (node.isLocked()) {//监听当前节点的locked变量
}
return node;
}
public void unlock(Node node) {
if (node.getNext() == null) {
if (cas(tail, node, null)) {//即使当前节点的后继为null,也要用cas看一下队列是否真的为空
return;
}
while (node.getNext() != null) {//cas失败,说明有后继节点,只是还没更新前驱节点的next域,等前驱节点看到后继节点后,即可安全更新后继节点的locked域
}
}
node.getNext().setLocked(false);
}
static class Node {
public Node(boolean locked, Node next) {
this.locked = locked;
this.next = next;
}
volatile boolean locked;//true:当前线程正在试图占有锁或者已经占有锁,false:当前线程已经释放锁,下一个线程可以占有锁了
Node next;//后继节点
public boolean isLocked() {
return locked;
}
public void setLocked(boolean locked) {
this.locked = locked;
}
public Node getNext() {
return next;
}
public void setNext(Node next) {
this.next = next;
}
}
那么为了保证公平性又引出了TicketLock ,TicketLock 是采用排队叫号的机制来实现的一种公平锁,但是它每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
所以我们又引出了CLHLock和MCSLock,CLHLock和MCSLock通过链表的方式避免了减少了处理器缓存同步,极大的提高了性能,区别在于CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态。