线程安全(二)

作者: sindri的小巢 | 来源:发表于2018-08-20 21:47 被阅读229次

    原文链接

    之前写过一篇线程安全,简单介绍了保护数据安全的多种方式,以及其中一部分方式的原理。基于此基础,本文将介绍如何避免锁的性能浪费,以及如何实现无锁安全结构

    避免锁的性能浪费

    为了避免多个线程对数据的破坏,在使用锁保障线程安全的情况下,存在几个影响锁性能的重要因素:

    • 上下文切换
    • 临界区资源耗时

    如果能够减少这些因素的损耗,就能有效的提高锁的性能

    自旋锁

    通常来说,当一个线程获取锁失败后,会被添加到一个等待队列的末尾,然后休眠。直到锁被释放后,依次唤醒访问临界资源。休眠时会发生线程的上下文切换,当前线程的寄存器信息会被保存到磁盘上,考虑到这些情况,能做的有两点:

    1. 换一个更快的磁盘
    2. 改用自旋锁

    自旋锁采用死循环等待锁释放来替代线程的休眠和唤醒,避免了上下文切换的代价。当临界的代码足够短,使用自旋锁对于性能的提升是立竿见影的

    锁粒度

    粒度是指颗粒的大小

    对于锁来说,锁的粒度大小取决于锁保护的临界区的大小。锁的粒度越小,临界区的操作就越小,反之亦然,由于临界区执行代码时间导致的损耗问题我称作粒度锁问题。举个例子,假如某个修改元素的方法包括三个操作:查找缓存->查找容器->修改元素

    - (void)modifyName: (NSString *)name withId: (id)usrId {
        lock();
        User *usr = [self.caches findUsr: usrId];
        if (!usr) {
            usr = [self.collection findUsr: usrId];
        }
        if (!usr) {
            unlock();
            return;
        }
        usr.name = name;
        unlock();
    }
    

    实际上整个修改操作之中,只有最后的修改元素存在安全问题需要加锁,并且如果加锁的临界区代码执行时间过长可能导致有更多的线程需要等待锁,增加了锁使用的损耗。因此加锁代码应当尽量的短小简单:

    - (void)modifyName: (NSString *)name withId: (id)usrId {
        User *usr = [self.caches findUsr: usrId];
        if (!usr) {
            usr = [self.collection findUsr: usrId];
        }
        if (!usr) {
            return;
        }
        
        lock();
        usr.name = name;
        unlock();
    }
    

    大段代码改为小段代码加锁是一种常见的减少锁性能损耗的做法,因此不再多提。但接下来要说的是另一种常见但因为锁粒度造成损耗的问题:设想一下这个场景,在改良后的代码使用中,线程A对第三个元素进行修改,线程B对第四个元素进行修改:

    在两个线程修改user的过程中,实际上双方的操作是不冲突,但是线程B必须等待A完成修改工作,造成这个现象的原因是虽然看起来是对usr.name进行了加锁,但实际上是锁住了collectioncaches的操作,所以避免这种隐藏的粒度锁问题的方案是以容器元素单位构建锁:包括全局锁独立锁两种:

    • 全局锁

      构建一个global lock的集合,用hash的手段为修改元素对应一个锁:

        id l = [SLGlobalLocks getLock: hash(usr)];
        lock(l);
        usr.name = name;
        unlock(l);
      

      使用全局锁的好处包括可以在设计上可以懒加载生成锁,限制bucketCount来避免创建过多的锁实例,基于hash的映射关系,锁可以被多个对象获取,提高复用率。但缺点也是明显的,匹配锁的额外损耗,hash映射可能导致多个锁围观一个锁工作等。事实上@synchronized就是已存在的全局锁方案

    • 独立锁

      这个方案的名字是随便起的,从设计上要求容器的每个元素拥有自己的独立锁:

        @interface SLLockItem: NSObject
        
        @property (nonatomic, strong) id item;
        @property (nonatomic, strong) NSLock *lock;
        
        @end
        
        SLLockItem *item = [self.collection findUser: usrId];
        [item.lock lock];
        User *usr = item.item;
        usr.name = name;
        [item.lock unlock];
      

      独立锁保证了不同元素之间的加锁是绝对独立的,粒度完全可控,但锁难以复用,容器越长,需要创建的锁实例就越多也是致命的缺点。并且在链式结构中,增删操作的加锁是要和previous节点的修改操作发生竞争的,在实现上更加麻烦

    无锁安全结构

    无锁化是完全抛弃加锁工具,实现多线程访问安全的方案。无锁化需要去分解操作流程,找出真正需要保证安全的操作,举个例子:存在链表A -> B -> C,删除B的代码如下:

    Node *cur = list;
    while (cur.next != B && cur.next != nil) {
        cur = cur.next;
    }
    
    if (cur.next == nil) {
        return;
    }
    
    cur.next = cur.next.next;
    

    只要A.next的修改是不受多线程干扰的,那么就能保证删除元素的安全

    CAS

    compare and swap是计算机硬件提供的一种原子操作,它会比较两个值是否相等,然后决定下一步的执行指令,iOS对于这种操作的支持需要导入<libkern/OSAtomic.h>文件。

    bool    OSAtomicCompareAndSwapPtrBarrier( void *oldVal, void *newVal, void * volatile *theVal )
    

    函数会在oldValtheVal相同的情况下将oldVal存储的值修改为newVal,因此删除B的代码只要保证在A.next变成nil之前是一致的就可以保证安全:

    Node *cur = list;
    while (cur.next != B && cur.next != nil) {
        cur = cur.next;
    }
    
    if (cur.next == nil) {
        return;
    }
    
    while (true) {
        void *oldValue = cur.next;
        if (OSAtomicCompareAndSwapPtrBarrier(oldValue, nil, &cur.next)) {
            break;
        } else {
            continue;
        } 
    }
    

    基于上面删除B的例子,同一时间存在其他线程在A节点后追加D节点:

    由于CPU可能在任务执行过程中切换线程,如果D节点的修改工作正好在删除任务的中间完成,最终可能导致的是D节点的误删:

    所以上面的CAS还需要考虑A.next是否发生了改变:

    Node *cur = list;
    while (cur.next.value != B && cur.next != nil) {
        cur = cur.next;
    }
    
    if (cur.next == nil) {
        return;
    }
    
    while (true) {
        void *oldValue = cur.next;
        
        // next已经不再指向B
        if (!OSAtomicCompareAndSwapPtrBarrier(B, B, &cur.next.value)) {
            break;
        }
        
        if (OSAtomicCompareAndSwapPtrBarrier(oldValue, nil, &cur.next)) {
            break;
        } else {
            continue;
        } 
    }
    

    题外话

    OSAtomicCompareAndSwapPtrBarrier除了保证修改操作的原子性,还带有barrier的作用。在现在CPU的设计上,会考虑打乱代码的执行顺序来获取更快的执行速度,比如说:

    /// 线程1执行
    A.next = D;
    D.next = C;
    
    /// 线程2执行
    while (D.next != C) {}
    NSLog(@"%@", A.next);
    

    由于执行顺序会被打乱,执行的时候变成:

    D.next = C;
    A.next = D;
    
    while (D.next != C) {}
    NSLog(@"%@", A.next);
    

    输出的结果可能并不是D,而只要在D.next = C前面插入一句barrier函数,就能保证在这句代码前的指令不会被打乱执行,保证正确的代码顺序

    最后

    很方,这个月想了很多想写的内容,然后发现别人都写过,尴尬的一笔。果然还是自己太鶸了,最后随便赶工了一篇全是水货的文章,瑟瑟发抖

    关注我的公众号获取更新信息

    相关文章

      网友评论

      • ShawnFoo:"在现在CPU的设计上,会考虑打乱代码的执行顺序来获取更快的执行速度"

        请问楼主这句话是从什么资料上得出的结论?
        ShawnFoo:@sindri的小巢 :+1: 3Q, 涨知识了
        sindri的小巢:https://zh.wikipedia.org/wiki/乱序执行
        代码执行互不影响的时候,就有可能被乱序执行
      • hewking:厉害额,哈哈。有哪些要写被别人写的呢?看你的blog 写的很好 分享下学习心得吧
        sindri的小巢:@tooyoungt 比如weak的访问判断呀,通过寄存器信息定位野指针调用方法,采用类似taggedpoint机制用指针存储类名来定位野指针等等这些

      本文标题:线程安全(二)

      本文链接:https://www.haomeiwen.com/subject/krzliftx.html