美文网首页
iOS 开发中的锁

iOS 开发中的锁

作者: CoderLF | 来源:发表于2020-06-19 16:56 被阅读0次

    如何深入理解 iOS 开发中的锁?

    18569867-b138e26aac0802f1.png

    本文会按照从上至下(速度由快至慢)的顺序分析每个锁的实现原理。需要说明的是,加解锁速度不表示锁的效率,只表示加解锁操作在执行时的复杂程度,下文会通过具体的例子来解释。

    OSSpinLock

    上述文章中已经介绍了 OSSpinLock 不再安全,主要原因发生在低优先级线程拿到锁时,高优先级线程进入忙等(busy-wait)状态,消耗大量 CPU 时间,从而导致低优先级线程拿不到 CPU 时间,也就无法完成任务并释放锁。这种问题被称为优先级反转。

    为什么忙等会导致低优先级线程拿不到时间片?这还得从操作系统的线程调度说起。

    现代操作系统在管理普通线程时,通常采用时间片轮转算法(Round Robin,简称 RR)。每个线程会被分配一段时间片(quantum),通常在 10-100 毫秒左右。当线程用完属于自己的时间片以后,就会被操作系统挂起,放入等待队列中,直到下一次被分配时间片。

    自旋锁的实现原理

    自旋锁的目的是为了确保临界区只有一个线程可以访问,它的使用可以用下面这段伪代码来描述:

    do {  
        Acquire Lock
            Critical section  // 临界区
        Release Lock
            Reminder section // 不需要锁保护的代码
    }
    

    在 Acquire Lock 这一步,我们申请加锁,目的是为了保护临界区(Critical Section) 中的代码不会被多个线程执行。

    自旋锁的实现思路很简单,理论上来说只要定义一个全局变量,用来表示锁的可用情况即可,伪代码如下:

    bool lock = false; // 一开始没有锁上,任何线程都可以申请锁  
    do {  
        while(lock); // 如果 lock 为 true 就一直死循环,相当于申请锁
        lock = true; // 挂上锁,这样别的线程就无法获得锁
            Critical section  // 临界区
        lock = false; // 相当于释放锁,这样别的线程可以进入临界区
            Reminder section // 不需要锁保护的代码        
    }
    

    注释写得很清楚,就不再逐行分析了。可惜这段代码存在一个问题: 如果一开始有多个线程同时执行 while 循环,他们都不会在这里卡住,而是继续执行,这样就无法保证锁的可靠性了。解决思路也很简单,只要确保申请锁的过程是原子操作即可。

    原子操作

    狭义上的原子操作表示一条不可打断的操作,也就是说线程在执行操作过程中,不会被操作系统挂起,而是一定会执行完。在单处理器环境下,一条汇编指令显然是原子操作,因为中断也要通过指令来实现。

    然而在多处理器的情况下,能够被多个处理器同时执行的操作任然算不上原子操作。因此,真正的原子操作必须由硬件提供支持,比如 x86 平台上如果在指令前面加上 “LOCK” 前缀,对应的机器码在执行时会把总线锁住,使得其他 CPU不能再执行相同操作,从而从硬件层面确保了操作的原子性。

    这些非常底层的概念无需完全掌握,我们只要知道上述申请锁的过程,可以用一个原子性操作 test_and_set 来完成,它用伪代码可以这样表示:

    bool test_and_set (bool *target) {  
        bool rv = *target; 
        *target = TRUE; 
        return rv;
    }
    

    这段代码的作用是把 target 的值设置为 1,并返回原来的值。当然,在具体实现时,它通过一个原子性的指令来完成。

    自旋锁的总结

    至此,自旋锁的实现原理就很清楚了:

    bool lock = false; // 一开始没有锁上,任何线程都可以申请锁  
    do {  
        while(test_and_set(&lock); // test_and_set 是一个原子操作
            Critical section  // 临界区
        lock = false; // 相当于释放锁,这样别的线程可以进入临界区
            Reminder section // 不需要锁保护的代码        
    }
    
    

    如果临界区的执行时间过长,使用自旋锁不是个好主意。之前我们介绍过时间片轮转算法,线程在多种情况下会退出自己的时间片。其中一种是用完了时间片的时间,被操作系统强制抢占。除此以外,当线程进行 I/O 操作,或进入睡眠状态时,都会主动让出时间片。显然在 while 循环中,线程处于忙等状态,白白浪费 CPU 时间,最终因为超时被操作系统抢占时间片。如果临界区执行时间较长,比如是文件读写,这种忙等是毫无必要的。

    信号量

    之前我在 介绍 GCD 底层实现的文章 中简单描述了信号量 dispatch_semaphore_t 的实现原理,它最终会调用到 sem_wait 方法,这个方法在 glibc 中被实现如下:

    int sem_wait (sem_t *sem) {  
      int *futex = (int *) sem;
      if (atomic_decrement_if_positive (futex) > 0)
        return 0;
      int err = lll_futex_wait (futex, 0);
        return -1;
    )
    

    首先会把信号量的值减一,并判断是否大于零。如果大于零,说明不用等待,所以立刻返回。具体的等待操作在 lll_futex_wait 函数中实现,lll 是 low level lock 的简称。这个函数通过汇编代码实现,调用到 SYS_futex 这个系统调用,使线程进入睡眠状态,主动让出时间片,这个函数在互斥锁的实现中,也有可能被用到。

    主动让出时间片并不总是代表效率高。让出时间片会导致操作系统切换到另一个线程,这种上下文切换通常需要 10 微秒左右,而且至少需要两次切换。如果等待时间很短,比如只有几个微秒,忙等就比线程睡眠更高效。

    可以看到,自旋锁和信号量的实现都非常简单,这也是两者的加解锁耗时分别排在第一和第二的原因。再次强调,加解锁耗时不能准确反应出锁的效率(比如时间片切换就无法发生),它只能从一定程度上衡量锁的实现复杂程度。

    相关文章

      网友评论

          本文标题:iOS 开发中的锁

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