iOS底层原理探索 - 锁的基本使用

590

这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战

写在前面: iOS底层原理探究是本人在平时的开发和学习中不断积累的一段进阶之
路的。 记录我的不断探索之旅,希望能有帮助到各位读者朋友。

目录如下:

  1. iOS 底层原理探索 之 alloc
  2. iOS 底层原理探索 之 结构体内存对齐
  3. iOS 底层原理探索 之 对象的本质 & isa的底层实现
  4. iOS 底层原理探索 之 isa - 类的底层原理结构(上)
  5. iOS 底层原理探索 之 isa - 类的底层原理结构(中)
  6. iOS 底层原理探索 之 isa - 类的底层原理结构(下)
  7. iOS 底层原理探索 之 Runtime运行时&方法的本质
  8. iOS 底层原理探索 之 objc_msgSend
  9. iOS 底层原理探索 之 Runtime运行时慢速查找流程
  10. iOS 底层原理探索 之 动态方法决议
  11. iOS 底层原理探索 之 消息转发流程
  12. iOS 底层原理探索 之 应用程序加载原理dyld (上)
  13. iOS 底层原理探索 之 应用程序加载原理dyld (下)
  14. iOS 底层原理探索 之 类的加载
  15. iOS 底层原理探索 之 分类的加载
  16. iOS 底层原理探索 之 关联对象
  17. iOS底层原理探索 之 魔法师KVC
  18. iOS底层原理探索 之 KVO原理|8月更文挑战
  19. iOS底层原理探索 之 重写KVO|8月更文挑战
  20. iOS底层原理探索 之 多线程原理|8月更文挑战
  21. iOS底层原理探索 之 GCD函数和队列
  22. iOS底层原理探索 之 GCD原理(上)
  23. iOS底层 - 关于死锁,你了解多少?
  24. iOS底层 - 单例 销毁 可否 ?
  25. iOS底层 - Dispatch Source
  26. iOS底层 - 一个栅栏函 拦住了 数
  27. iOS底层 - 不见不散 的 信号量
  28. iOS底层 GCD - 一进一出 便成 调度组

以上内容的总结专栏


细枝末节整理


前言

锁是最常用的同步工具之一。可以使用锁来保护代码的关键部分,该部分代码段一次只能访问一个线程。例如,关键部分可能会操作特定数据结构或使用一次最多支持一个客户端的某些资源。通过关键部分枷锁,可以排除其他线程进行可能影响代码正确性的更改。

锁的分类

表4-1列出了程序员常用的一些锁。OS X和iOS为大多数这些锁类型提供了实现,但不是全部。对于不受支持的锁类型,描述列解释了这些锁没有直接在平台上实现的原因。

表4-1锁类型

锁定描述
互斥锁 (Mutex)相互排斥(或互斥)锁充当资源周围的保护屏障。互斥体是一种信号量,一次只授予对一个线程的访问权。如果一个互斥体正在使用,而另一个线程试图获取它,则该线程会阻塞,直到其原始持有人释放互斥体。如果多个线程竞争同一互斥体,一次只允许一个线程访问它。(例如:NSLock 、pthread_mutext、@synchronized )
递归锁 (Recursive lock)递归锁是互斥锁上的变体。递归锁允许单个线程在释放锁之前多次获取锁。其他线程一直被阻塞,直到锁的所有者以相同次数释放锁。递归锁主要用于递归迭代,但也可用于多个方法需要单独获取锁的情况。 (例如: NSRecursiveLock 、 pthread_mutex(recursive))
读写锁 (Read-write lock)读写锁也称为共享排他锁。这种类型的锁通常用于更大规模的操作,如果经常读取受保护的数据结构,并且偶尔修改数据结构,则可以显著提高性能。正常情况下,多个读取器可以同时访问该数据结构。但是,当一个线程想要写入结构时,它会阻塞,直到所有的读取者释放锁,然后它会获得锁并更新结构。当一个写线程正在等待锁时,新的读线程会阻塞,直到写线程完成。系统仅支持POSIX线程的读写锁。 
分布式锁 (Distributed lock)分布式锁在进程级别提供互斥访问。与真正的互斥体不同,分布式锁不会阻止进程或阻止其运行。它只是报告锁何时忙,让流程决定如何继续。
自旋锁 (Spin lock)自旋锁反复轮询其锁条件,直到该条件变为真。自旋锁最常用于多处理器系统,其中锁的预期等待时间很小。在这些情况下,轮询通常比阻塞线程更有效,这涉及上下文切换和线程数据结构的更新。由于自旋锁的轮询性质,系统不提供任何自旋锁的实现,但在特定情况下您可以轻松地实现它们。
双重检查锁 (Double-checked lock)双重检查锁是通过在取锁之前测试锁定条件来减少取锁的开销。由于双击锁可能不安全,系统不为其提供明确支持,因此不鼓励使用它们。
条件锁就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就是锁住了。当资源被分配到了,条件锁打开,进程继续运行。 (例如 : NSCondition、NSConditionLock)
信号量是一种更高级的同步机制,互斥锁可以说是 semaphore 在仅取值0/1时的特例。信号量可以有更多的取值空间,用来实现更加复杂的同步,而不单单是线程间互斥。 (例如:dispatch_semaphore )

注意: 大多数类型的锁还包含内存屏障,以确保在进入关键部分之前完成之前的任何加载和存储指令。


其实基本的锁就包括了三类 自旋锁 互斥锁 读写锁, 其他的比如条件锁,递归锁,信号量都是上层的封装和实现!


锁的使用

锁是线程编程的基本同步工具。锁使你能够轻松保护大部分代码,以确保该代码的正确性。 OSX和iOS为所有应用程序类提供了基本的互斥锁,Foundation框架为特殊情况定义了互斥锁的一些额外变体。下面我们看一下如何使用其中几种类型的锁。

使用POSIX互斥锁

POSIX互斥锁非常容易从任何应用程序中使用。要创建互斥锁,请声明并初始化 pthread_mutex_t 结构。要锁定和解锁互斥锁,请使用 pthread_mutex_lockpthread_mutex_unlock 函数。 列表 4-2 显示了初始化和使用POSIX线程互斥锁所需的基本代码。完成锁后,只需调用 pthread_mutex_destroy 即可释放锁结构数据。

清单4-2使用互斥锁

pthread_mutex_t mutex;
void MyInitFunction()
{
    pthread_mutex_init(&mutex, NULL);
}
 
void MyLockingFunction()
{
    pthread_mutex_lock(&mutex);
    // 干事情
    pthread_mutex_unlock(&mutex);
}

使用NSLock类

NSLock对象为Cocoa应用程序实现了一个基本的互斥锁。所有锁(包括NSLock)的接口实际上是由NSLock协议定义的,它定义了锁和解锁方法。我们可以使用这些方法来获取和释放锁,就像使用任何互斥锁一样。

除了标准的锁定行为,NSLock类还添加了 tryLocklockBeforeDate: 方法。

  • tryLock 方法尝试获取锁,但在锁不可用时不会阻塞;相反,该方法只是返回NO。
  • lockBeforeDate: 方法尝试获取锁,但如果在指定的时间限制内没有获得锁,则解除线程阻塞(并返回NO)。 以下示例演示如何使用NSLock对象来协调可视化显示器的更新,该显示器的数据由多个线程计算。如果线程无法立即获取锁,它只需继续计算,直到它能够获取锁并更新显示器。
BOOL moreToDo = YES;
NSLock *theLock = [[NSLock alloc] init];
...
while (moreToDo) {
    /* 做另一个增量的计算 */
    /* 直到无事可做 */
    if ([theLock tryLock]) {
        /* 更新所有线程使用的显示 */
        [theLock unlock];
    }
}

使用 @synchronized

@synchronized指令是在Objective-C代码中实时创建互斥锁的便捷方式。@synchronized指令可以做任何其他互斥锁的作用——它阻止不同的线程同时获取相同的锁。然而,在这种情况下,您不必直接创建互斥体或锁定对象。相反,您只需将任何Objective-C对象用作锁令牌,如下例所示:

- (void)myMethod:(id)anObj
{
    @synchronized(anObj)
    {
        // 大括号之间的所有内容都受到@synchronized指令的保护
    }
}

传递给@synchronized指令的对象是用来区分受保护的块的唯一标识符。如果在两个不同的线程中执行前面的方法,在每个线程中为anObj参数传递一个不同的对象,每个线程都将获得自己的锁并继续处理,而不会被另一个线程阻塞。但是,如果在两种情况下传递相同的对象,其中一个线程将首先获得锁,另一个线程将阻塞,直到第一个线程完成临界区。 作为预防措施,@synchronized块隐式向受保护的代码添加了异常处理程序。如果抛出异常,此处理程序会自动释放互斥体。这意味着,为了使用@synchronized指令,您还必须在代码中启用Objective-C异常处理。如果您不希望隐式异常处理程序引起的额外开销,应考虑使用锁类。

使用NSRecursiveLock

NSRecursiveLock类定义了一个锁,该锁可以被同一线程多次获取,而不会导致线程死锁。递归锁会记录它成功获得的次数。每次成功获取锁必须通过相应的解锁锁的调用来平衡。只有当所有锁和解锁调用都平衡时,锁才会真正释放,以便其他线程获得它。

顾名思义,这种类型的锁通常用于递归函数内部,以防止递归阻塞线程。在非递归情况下,您也可以同样使用它来调用其语义要求它们也接受锁的函数。这里有一个简单的递归函数的例子,它通过递归获取锁。如果您没有为此代码使用NSRecursiveLock对象,则当再次调用函数时,线程将死锁。

NSRecursiveLock *theLock = [[NSRecursiveLock alloc] init];
 
void MyRecursiveFunction(int value)
{
    [theLock lock];
    if (value != 0)
    {
        --value;
        MyRecursiveFunction(value);
    }
    [theLock unlock];
}
 
MyRecursiveFunction(5);

注意: 由于递归锁直到所有锁调用与解锁调用平衡后才会释放,因此您应该仔细权衡使用性能锁的决定和潜在的性能影响。长时间持有任何锁可能会导致其他线程阻塞,直到递归完成。如果您可以重写代码以消除递归或消除使用递归锁的必要性,您可能会获得更好的性能。

使用NSConditionLock

NSConditionLock对象定义了一个互斥锁,该锁可以使用特定值锁定和解锁。您不应将此类锁与条件混淆(请参阅条件)。这种行为与条件有些相似,但实现方式非常不同。

通常,当线程需要按特定顺序执行任务时,例如当一个线程生成另一个线程消耗的数据时,您将使用NSConditionLock对象。当生产者执行时,消费者使用特定于程序的条件获取锁。(条件本身只是您定义的整数值。)当生产者完成后,它会解锁锁,并将锁条件设置为适当的整数值,以唤醒消费者线程,然后消费者线程继续处理数据。

NSConditionLock对象响应的锁定和解锁方法可以在任何组合中使用。例如,可以将锁定消息与unlockWithCondition:配对,或将lockWhenCondition:消息与unlock配对。当然,后一种组合会解锁 锁,但可能不会释放等待特定条件值的任何线程。

以下示例演示了如何使用条件锁处理生产者-消费者问题。想象一下,一个应用程序包含一个数据队列。生产者线程向队列添加数据,消费者线程从队列中提取数据。生产者不需要等待特定条件,但必须等待锁可用,以便安全地将数据添加到队列中。

id condLock = [[NSConditionLock alloc] initWithCondition:NO_DATA];
 
while(true)
{
    [condLock lock];
    /* 向队列添加数据 */
    [condLock unlockWithCondition:HAS_DATA];
}

由于锁的初始条件设置为NO_DATA,生产者线程最初获取锁应该不会有困难。它用数据填充队列,并将条件设置为HAS_DATA。在后续迭代中,生产者线程可以在到达时添加新数据,无论队列是空的还是仍然有一些数据。它阻止的唯一时间是消费者线程从队列中提取数据。

因为消费线程必须有数据要处理,所以使用特定条件在队列上等待。当生产者将数据放在队列中时,消费者线程会醒来并获取其锁。然后,它可以从队列中提取一些数据并更新队列状态。以下示例显示了消费者线程处理循环的基本结构。

while (true)
{
    [condLock lockWhenCondition:HAS_DATA];
    /* 从队列中删除数据 */
    [condLock unlockWithCondition:(isEmpty ? NO_DATA : HAS_DATA)];
 
    // 本地处理数据
}

使用NSDistributedLock

NSDistributedLock类可以被多个主机上的多个应用程序使用,以限制对某些共享资源(如文件)的访问。锁本身实际上是一个互斥锁,使用文件系统项(例如文件或目录)实现。为了使NSDistributedLock对象可用,该锁必须由所有使用它的应用程序可写。这通常意味着将其放在一个文件系统中,该文件系统可以被运行应用程序的所有计算机访问。

与其他类型的锁不同,NSDistributedLock不符合NSLocking协议,因此没有锁方法。锁方法会阻塞线程的执行,并要求系统以预定的速率轮询锁。NSDistributedLock提供了一个tryLock方法,让您决定是否轮询,而不是对代码施加这种惩罚。

由于它是使用文件系统实现的,除非所有者显式释放NSDistributedLock对象,否则不会释放它。如果您的应用程序在持有分布式锁时崩溃,其他客户端将无法访问受保护的资源。在这种情况下,您可以使用breakLock方法打破现有锁,以便获得它。不过,除非您确定拥有过程已死亡,无法释放锁,否则通常应避免断开锁。

与其他类型的锁一样,当您使用NSDistributedLock对象完成时,您可以通过调用unlock方法释放它。

使用条件

条件是一种特殊类型的锁,可用于同步操作必须进行的顺序。它们与互斥锁在微妙方面有所不同。等待条件的线程一直被阻止,直到该条件被另一个线程显式发出信号。

由于实现操作系统所涉及的微妙之处,条件锁可以以虚假的成功返回,即使它们实际上没有被您的代码发出信号。为了避免这些虚假信号引起的问题,您应该始终使用与条件锁结合的谓词。谓词是确定线程继续安全是否安全的更具体的方法。该条件只是保持线程睡眠,直到信令线程可以设置谓词。

以下部分向您展示了如何使用代码中的条件。

使用NSCondition类

NSCondition类提供与POSIX条件相同的语义,但将所需的锁和条件数据结构都封装在一个对象中。结果是,您可以像互斥体一样锁定对象,然后像条件一样等待。

清单4-3显示了一个代码片段,演示等待一个NSCondition对象的事件序列。cocoaCondition变量包含一个NSCondition对象,timeToDoWork变量是一个整数,在发出条件信号之前从另一个线程递增。

清单4-3使用可可条件

    [cocoaCondition lock];
    while (timeToDoWork <= 0)
    [cocoaCondition wait];
 
    timeToDoWork--;
 
    // 在这里做真正的工作。
 
    [cocoaCondition unlock];

清单4-4显示了用于向可可条件发出信号和增加谓词变量的代码。在发出信号之前,您应该始终锁定状态。

清单4-4发出可可条件的信号

[cocoaCondition lock];
timeToDoWork++;
[cocoaCondition signal];
[cocoaCondition unlock];

使用POSIX条件

POSIX线程条件锁需要同时使用条件数据结构和互斥体。虽然两个锁结构是分开的,但互斥锁在运行时与条件结构紧密相连。等待信号的线程应始终使用相同的互斥锁和条件结构。更改配对可能会导致错误。

清单4-5显示了条件和谓词的基本初始化和使用。在初始化条件和互斥锁后,等待线程使用ready_to_go变量作为谓词进入while循环。只有当谓词设置好,条件随后发出信号时,等待线程才会醒来并开始工作。

清单4-5使用POSIX条件

pthread_mutex_t mutex;
pthread_cond_t condition;
Boolean     ready_to_go = true;
 
void MyCondInitFunction()
{
    pthread_mutex_init(&mutex);
    pthread_cond_init(&condition, NULL);
}
 
void MyWaitOnConditionFunction()
{
    // 锁定互斥对象。
    pthread_mutex_lock(&mutex);
 
    // 如果已经设置了谓词,则绕过while循环;
    // 否则,线程将休眠,直到设置谓词为止.
    while(ready_to_go == false)
    {
        pthread_cond_wait(&condition, &mutex);
    }
 
    // 所做的工作。(互斥锁应该保持锁定状态。)
 
    // 重置谓词并释放互斥锁
    ready_to_go = false;
    pthread_mutex_unlock(&mutex);
}

信令线程既负责设置谓词,也负责将信号发送到条件锁。清单4-6显示了实现此行为的代码。在本示例中,该条件在互斥体内部发出信号,以防止等待该条件的线程之间发生竞速条件。

清单4-6 信号条件锁

void SignalThreadUsingCondition()
{
    // 此时,应该有其他线程要做的工作
    pthread_mutex_lock(&mutex);
    ready_to_go = true;
 
    // 通知另一个线程开始工作
    pthread_cond_signal(&condition);
 
    pthread_mutex_unlock(&mutex);
}

注意: 前面的代码是一个简化的例子,旨在展示POSIX线程条件函数的基本用法。您自己的代码应该检查这些函数返回的错误代码,并对其进行适当处理。

锁的性能分析

下面我们通过测试,看下现在iOS开发中常用的锁的性能:

iOS13.7, iPad mini 5

OSSpinLock: 13.396025 ms
dispatch_semaphore_t: 16.867995 ms
os_unfair_lock_lock: 15.706062 ms
pthread_mutex_t: 17.253995 ms
NSlock: 18.406034 ms
NSCondition: 18.399954 ms
PTHREAD_MUTEX_RECURSIVE: 24.248958 ms
NSRecursiveLock: 28.659940 ms
NSConditionLock: 53.735018 ms
@synchronized: 87.185979 ms

iOS14.2,iPhone8

OSSpinLock: 13.522983 ms
dispatch_semaphore_t: 29.148102 ms
os_unfair_lock_lock: 20.449996 ms
pthread_mutex_t: 32.608032 ms
NSlock: 32.607913 ms
NSCondition: 31.080008 ms
PTHREAD_MUTEX_RECURSIVE: 35.853982 ms
NSRecursiveLock: 38.555026 ms
NSConditionLock: 83.202004 ms
@synchronized: 135.954976 ms

我们用图标更直观的对比下性能

锁的性能分析 - iPad.001.jpeg

锁的性能分析 - iPhone.001.jpeg

锁的性能分析 - iPad_副本-2.001.jpeg

总结

今天我们简单对锁的概念有了一个基本的认识,再iOS中常用的锁基本使用也有了了解,接着,我们也是对常用的锁做了一个简单的Demo来做了下各种锁的性能分析。 这是一个开篇,接下来的篇章,我们将对开发中使用的锁进行详细的讲解和分析。 大家,加油!!

最后 :