C++多线程之旅实战-自旋锁那点事

目录

      • 前言
      • 实现
      • 优缺点
        • 缺点
        • 优点
        • 适用场景
      • Ticket Lock
      • CLH Lock
      • MCS Lock
      • 总结

前言

前一篇文章讲的是带锁的并发数据结构,而且讲到了如果不带锁将会面临什么样的问题。这一部分我将为大家带来一个全新的数据结构-自旋锁。这是一种不使用阻塞库的数据结构,我们将不使用阻塞库的结构称为非阻塞,但是不是所有的非阻塞数据结构都是无锁的。

阻塞函数就是当这个函数不执行完,函数所在线程就一直停止在这里不动。

比如最常见的在多线程里面的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实现了互斥锁的功能,虽然没有调用任何阻塞函数但是依然不是无锁数据结构。

所有的代码围绕着循环“旋转”,这也是自旋锁的由来。而且这样也会像使用阻塞函数一样,占用内存资源。

优缺点

缺点

  1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。

  2. 上面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 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 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 Lock

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;
        }
    }

总结

  1. 自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
  2. 自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。
  3. 自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。
  4. 自旋锁本身无法保证公平性,同时也无法保证可重入性。

那么为了保证公平性又引出了TicketLock ,TicketLock 是采用排队叫号的机制来实现的一种公平锁,但是它每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。

所以我们又引出了CLHLock和MCSLock,CLHLock和MCSLock通过链表的方式避免了减少了处理器缓存同步,极大的提高了性能,区别在于CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态。

你可能感兴趣的:(C++多线程)