Jerry's Blog

Less is more, Live and learning

Hello,I'm an iOS developer in China.


iOS中各种“锁”的理解及应用

前言

通常在一般的iOS应用开发中会很少碰到使用“锁”的业务逻辑,但是在需要使用多线程技术,解决大多数场景写的业务逻辑时,会使用到线程锁来保证临界数据的读写安全性。当然,“锁”的概念在计算机科学及应用中也是举足轻重的,对于要写出高质量、高性能、安全可靠的代码来说,也是非常重要的。

预备知识

  • 线程调度

    计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令.所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务.在运行池中,会有多个处于就绪状态的线程在等待CPU,JAVA虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配CPU的使用权.线程调度有两种调度模式:

    • 分时调度模型,分时调度模型是指让所有的线程轮流获得 CPU 的使用权,并且平均分配每个线程占用的 CPU 的时间片。
    • 抢占式调度模型,指优先让可运行池中优先级高的线程占用 CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用 CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU
  • 时间片轮转算法

    • 时间片:(timeslice)是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间(在抢占内核中是:从进程开始运行直到被抢占的时间)。现代操作系统(如:WindowsLinuxMac OS X等)允许同时运行多个进程 —— 例如,你可以在打开音乐播放器听音乐的同时用浏览器浏览网页并下载文件。事实上,由于一台计算机通常只有一个CPU,所以永远不可能真正地同时运行多个任务。这些进程“看起来像”同时运行的,实则是轮番穿插地运行,由于时间片通常很短(在Linux上为5ms-800ms),用户不会感觉到。– 维基百科

      时间片由操作系统内核的调度程序分配给每个进程。首先,内核会给每个进程分配相等的初始时间片,然后每个进程轮番地执行相应的时间,当所有进程都处于时间片耗尽的状态时,内核会重新为每个进程计算并分配时间片,如此往复。

    • 分配算法:现代操作系统再管理普通线程时,通常采用时间片轮转算法,每个线程会被分配一段时间片,通常在 10-100 毫秒左右,当线程使用完自己的时间片之后j就被会操作系统挂起,放入等待队列中,等待下一次被分配时间片。

  • 原子操作

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

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

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

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

    这段代码的作用是把 target 的值设置为 1,并返回原来的值。当然,在具体实现时,它通过一个原子性的指令来完成。 —— 参考bestswifter博客《深入理解iOS开发中的锁》

  • 自旋锁和互斥锁

    都属于CPU时间分片算法下的实现保护共享资源的一种机制。都实现互斥操作,加锁后仅允许一个访问者。 区别在于自旋锁不会使线程进入wait状态,而通过轮训不停查看是否该自旋锁的持有者已经释放的锁;对应的,互斥锁在出现锁已经被占用的情况会进入wait状态,CPU会当即切换时间片。

    参考资料《自旋锁和互斥锁的区别》

各种锁的应用及性能对比

下面讨论iOS开发中常见的几种锁。包括锁的简单介绍及使用,锁的特性及加解锁性能对比。业界大神YYKit的作者ibireme 已经在其博客中讨论了各种锁的性能,且做出一个速度由快至慢的排名进行对比。图示如下:

img1

需要说明的是,加解锁速度不表示锁的效率,只表示加解锁操作在执行时的复杂程度,下文会通过具体的例子来解释。

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 循环,他们都不会在这里卡住,而是继续执行,这样就无法保证锁的可靠性了。解决思路也很简单,只要确保申请锁的过程是原子操作即可。

至此,自旋锁的实现原理就很清楚了,在申请锁的过程中确保原子操作,代码如下:

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

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

自旋锁使用范例

// 需要导入的头文件

#import <libkern/OSAtomic.h>
#import <os/lock.h>
#import <AddressBook/AddressBook.h>

// 自旋锁 实现

- (void)OSSpinLock {
    if (@available(iOS 10.0, *)) {  // iOS 10以后解决了优先级反转问题
        
        os_unfair_lock_t unfairLock = &(OS_UNFAIR_LOCK_INIT);
        NSLog(@"线程1 准备上锁");
        os_unfair_lock_lock(unfairLock);
        sleep(4);
        NSLog(@"线程1执行");
        os_unfair_lock_unlock(unfairLock);
        NSLog(@"线程1 解锁成功");

    } else { // 会造成优先级反转,不建议使用
        __block OSSpinLock oslock = OS_SPINLOCK_INIT;
        
        //线程2
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
            NSLog(@"线程2 befor lock");
            OSSpinLockLock(&oslock);
            NSLog(@"线程2执行");
            sleep(3);
            OSSpinLockUnlock(&oslock);
            NSLog(@"线程2 unlock");
        });
        
        //线程1
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            NSLog(@"线程1 befor lock");
            OSSpinLockLock(&oslock);
            NSLog(@"线程1 sleep");
            sleep(3);
            NSLog(@"线程1执行");
            OSSpinLockUnlock(&oslock);
            NSLog(@"线程1 unlock");
        });
        
        // 可以看出不同的队列优先级,执行的顺序不同,优先级越高,越早被执行
    }
}

dispatch_semaphore_t(信号量)

信号量在GCD多线程技术中是一个重要的角色,在保证性能的情况下,可实现多线程同步功能,也可以通过初始化value=1,来实现加锁。

在实现加锁的过程中,如果线程1已经获取了锁,并在执行任务过程中,其他线程会被阻塞,直到线程1任务完成释放锁。

YY大神推荐使用信号量dispatch_semaphore作为自旋锁的替代方案。

实现原理

信号量 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 微秒左右,而且至少需要两次切换。如果等待时间很短,比如只有几个微秒,忙等就比线程睡眠更高效。

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

应用示例

// 创建sem ,value 设置为1
dispatch_semaphore_t signal = dispatch_semaphore_create(1);
// 设置超时时间5s,当超过5s会自动释放锁
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 5.0f * NSEC_PER_SEC);
    
//线程1
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
   NSLog(@"线程1 holding");
   dispatch_semaphore_wait(signal, timeout); //signal 值 -1
   NSLog(@"线程1 sleep");
   sleep(4);
   NSLog(@"线程1");
   dispatch_semaphore_signal(signal); //signal 值 +1
   NSLog(@"线程1 post singal");
});
    
//线程2
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
   NSLog(@"线程2 holding");
   dispatch_semaphore_wait(signal, timeout);
   NSLog(@"线程2 sleep");
   sleep(4);
   NSLog(@"线程2");
   dispatch_semaphore_signal(signal);
   NSLog(@"线程2 post signal");
});

// 代码执行结果:
class:AppDelegate+AppService.m line:162 msg:线程2 holding
class:AppDelegate+AppService.m line:151 msg:线程1 holding
class:AppDelegate+AppService.m line:153 msg:线程1 sleep
class:AppDelegate+AppService.m line:155 msg:线程1
class:AppDelegate+AppService.m line:164 msg:线程2 sleep
class:AppDelegate+AppService.m line:157 msg:线程1 post singal

Pthread_mutex(互斥锁)

pthread 表示 POSIX thread,定义了一组跨平台的线程相关的 API,pthread_mutex 表示互斥锁。互斥锁的实现原理与信号量非常相似,不是使用忙等,而是阻塞线程并睡眠,需要进行上下文切换,性能不及信号量。

实现原理

互斥锁在申请锁时,调用了 pthread_mutex_lock 方法,它在不同的系统上实现各有不同,有时候它的内部是使用信号量来实现,即使不用信号量,也会调用到 lll_futex_wait 函数,从而导致线程休眠。

上文说到如果临界区很短,忙等的效率也许更高,所以在有些版本的实现中,会首先尝试一定次数(比如 1000 次)的 testandtest,这样可以在错误使用互斥锁时提高性能。

另外,由于 pthread_mutex 有多种类型,可以支持递归锁等,因此在申请加锁时,需要对锁的类型加以判断,这也就是为什么它和信号量的实现类似,但效率略低的原因。

常见用法

互斥锁的常见用法如下:

#import <pthread.h>   // 需要导入头文件

pthread_mutexattr_t attr;  
pthread_mutexattr_init(&attr);  
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);  // 定义锁的属性

pthread_mutex_t mutex;  
pthread_mutex_init(&mutex, &attr) // 创建锁

pthread_mutex_lock(&mutex); // 申请锁  
    // 临界区
pthread_mutex_unlock(&mutex); // 释放锁  

对于 pthread_mutex 来说,它的用法和之前没有太大的改变,比较重要的是锁的类型,可以有 PTHREAD_MUTEX_NORMALPTHREAD_MUTEX_ERRORCHECKPTHREAD_MUTEX_RECURSIVE 等等,具体的特性就不做解释了,网上有很多相关资料。

一般情况下,一个线程只能申请一次锁,也只能在获得锁的情况下才能释放锁,多次申请锁或释放未获得的锁都会导致崩溃。假设在已经获得锁的情况下再次申请锁,线程会因为等待锁的释放而进入睡眠状态,因此就不可能再释放锁,从而导致死锁。

然而这种情况经常会发生,比如某个函数申请了锁,在临界区内又递归调用了自己。辛运的是 pthread_mutex 支持递归锁,也就是允许一个线程递归的申请锁,只要把 attr 的类型改成 PTHREAD_MUTEX_RECURSIVE 即可。

YTKNetworking 中就有使用这种锁方式:

pthread_mutex_t lock;    // 创建 lock

// 宏定义,加解锁方法
#define Lock() pthread_mutex_lock(&_lock)        
#define Unlock() pthread_mutex_unlock(&_lock)

/* 使用范例 */

//添加 request 到字典
- (void)addRequestToRecord:(YTKBaseRequest *)request {
    Lock();
    _requestsRecord[@(request.requestTask.taskIdentifier)] = request;
    Unlock();
}

//从字典中移除指定的 request
- (void)removeRequestFromRecord:(YTKBaseRequest *)request {
    Lock();
    [_requestsRecord removeObjectForKey:@(request.requestTask.taskIdentifier)];
    YTKLog(@"Request queue size = %zd", [_requestsRecord count]);
    Unlock();
}

NSLock

NSLock 是 Objective-C 以对象的形式暴露给开发者的一种锁,它的实现非常简单,通过宏,定义了 lock 方法:

#define    MLOCK \
- (void) lock\
{\
  int err = pthread_mutex_lock(&_mutex);\
  // 错误处理 ……
}

NSLock 只是在内部封装了一个 pthread_mutex,属性为 PTHREAD_MUTEX_ERRORCHECK,它会损失一定性能换来错误提示。

这里使用宏定义的原因是,OC 内部还有其他几种锁,他们的 lock 方法都是一模一样,仅仅是内部 pthread_mutex 互斥锁的类型不同。通过宏定义,可以简化方法的定义。

NSLockpthread_mutex 略慢的原因在于它需要经过方法调用,同时由于缓存的存在,多次方法调用不会对性能产生太大的影响。

应用范例

AFNetworking 中就是用的NSLock方式:

@property (readwrite, nonatomic, strong) NSLock *lock;  // 声明锁

// 初始化锁
self.lock = [[NSLock alloc] init];
self.lock.name = AFURLSessionManagerLockName;
// 应用
- (void)removeDelegateForTask:(NSURLSessionTask *)task {
    NSParameterAssert(task);

    AFURLSessionManagerTaskDelegate *delegate = [self delegateForTask:task];
    [self.lock lock];
    [delegate cleanUpProgressForTask:task];
    [self removeNotificationObserverForTask:task];
    [self.mutableTaskDelegatesKeyedByTaskIdentifier removeObjectForKey:@(task.taskIdentifier)];
    [self.lock unlock];
}

NSCondition

NSCondition 的底层是通过条件变量(condition variable) pthread_cond_t 来实现的。条件变量有点像信号量,提供了线程阻塞与信号机制,因此可以用来阻塞某个线程,并等待某个数据就绪,随后唤醒线程,比如常见的生产者-消费者模式。

实现原理

NSCondition 其实是封装了一个互斥锁和条件变量, 它把前者的 lock 方法和后者的 wait/signal 统一在 NSCondition 对象中,暴露给使用者:

- (void) signal {
  pthread_cond_signal(&_condition);
}

// 其实这个函数是通过宏来定义的,展开后就是这样
- (void) lock {
  int err = pthread_mutex_lock(&_mutex);
}

它的加解锁过程与 NSLock 几乎一致,理论上来说耗时也应该一样(实际测试也是如此)。在图中显示它耗时略长,我猜测有可能是测试者在每次加解锁的前后还附带了变量的初始化和销毁操作。

条件锁使用

很多介绍 pthread_cond_t 的文章都会提到,它需要与互斥锁配合使用:

void consumer () { // 消费者  
    pthread_mutex_lock(&mutex);
    while (data == NULL) {
        pthread_cond_wait(&condition_variable_signal, &mutex); // 等待数据
    }
    // --- 有新的数据,以下代码负责处理 ↓↓↓↓↓↓
    // temp = data;
    // --- 有新的数据,以上代码负责处理 ↑↑↑↑↑↑
    pthread_mutex_unlock(&mutex);
}

void producer () {  
    pthread_mutex_lock(&mutex);
    // 生产数据
    pthread_cond_signal(&condition_variable_signal); // 发出信号给消费者,告诉他们有了新的数据
    pthread_mutex_unlock(&mutex);
}

自然我们会有疑问:“如果不用互斥锁,只用条件变量会有什么问题呢?”。问题在于,temp = data; 这段代码不是线程安全的,也许在你把 data 读出来以前,已经有别的线程修改了数据。因此我们需要保证消费者拿到的数据是线程安全的。

wait 方法除了会被 signal 方法唤醒,有时还会被虚假唤醒,所以需要这里 while 循环中的判断来做二次确认。

NSConditionLock(条件锁)

NSConditionLock 借助 NSCondition 来实现,它的本质就是一个生产者-消费者模型。“条件被满足”可以理解为生产者提供了新的内容。NSConditionLock 的内部持有一个 NSCondition 对象,以及 _condition_value 属性,在初始化时就会对这个属性进行赋值:

// 简化版代码
- (id) initWithCondition: (NSInteger)value {
    if (nil != (self = [super init])) {
        _condition = [NSCondition new]
        _condition_value = value;
    }
    return self;
}

它的 lockWhenCondition 方法其实就是消费者方法:

- (void) lockWhenCondition: (NSInteger)value {
    [_condition lock];
    while (value != _condition_value) {
        [_condition wait];
    }
}

对应的 unlockWhenCondition 方法则是生产者,使用了 broadcast 方法通知了所有的消费者:

- (void) unlockWithCondition: (NSInteger)value {
    _condition_value = value;
    [_condition broadcast];
    [_condition unlock];
}

NSRecursiveLock(递归锁)

上文已经说过,递归锁也是通过 pthread_mutex_lock 函数来实现,在函数内部会判断锁的类型,如果显示是递归锁,就允许递归调用,仅仅将一个计数器加一,锁的释放过程也是同理。

NSRecursiveLockNSLock 的区别在于内部封装的 pthread_mutex_t 对象的类型不同,前者的类型为 PTHREAD_MUTEX_RECURSIVE

@synchronized(同步锁)

这其实是一个 OC 层面的锁, 主要是通过牺牲性能换来语法上的简洁与可读。

我们知道 @synchronized 后面需要紧跟一个 OC 对象,它实际上是把这个对象当做锁来使用。这是通过一个哈希表来实现的,OC 在底层使用了一个互斥锁的数组(你可以理解为锁池),通过对对象去哈希值来得到对应的互斥锁。

在 SDWebImage 中用的就是这种方式:

- (void)cancel {
    @synchronized (self) {
        [self cancelInternal];
    }
}

总结

文章梳理了在iOS开发中用到的各种锁,简单介绍了其实现原理、不同锁的应用场景和注意事项。希望对看到这篇文章的小伙伴有所帮助。

参考资料

https://blog.ibireme.com/2016/01/16/spinlock_is_unsafe_in_ios/

https://blog.csdn.net/susidian/article/details/51068858

最近的文章

离屏渲染知多少?

预备知识OpenGL中,GPU屏幕渲染有两种方式: On-Screen Rendering (当前屏幕渲染):指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区进行。 Off-Screen Rendering (离屏渲染):指的是在GPU在当前屏幕缓冲区以外开辟一个缓冲区进行渲染操作。当前屏幕渲染不需要额外创建新的缓存,也不需要开启新的上下文,相对于离屏渲染性能更好。但是受当前屏幕渲染的局限因素限制(只有自身上下文、屏幕缓存有限等),当前屏幕渲染有些情况下的渲染解决不了的,就使用到离屏...…

iOS继续阅读
更早的文章

对象、类、元类、isa指针之间的爱恨情仇

前言在iOS开发中,对象、类的使用可以说是无处不在,伴随着整个项目的开发周期,也是程序的重要组成部分。但是在日常开发中,很难直观的见到元类、isa指针,那么它们究竟是谁,在开发中起着什么作用呢?下面我们就分别介绍下这几位亲兄弟的结构体定义,以及之间的关联关系。类(class)的结构体定义类对象(Class)是由程序员定义并在运行时有编译器创建的,他没有自己的实例变量,这里需要注意的是类的成员变量和实例方法列表是属于实例对象的,但其存储于类对象当中。下图指出类的结构体定义:// An opa...…

iOS继续阅读