[iOS] 线程锁 — synchronized & 各种Lock

1. 为什么多线程需要锁?

首先在多线程处理的时候我们经常会需要保证同步,这是为啥呢,看一下下面这个例子:

NSInteger count;
count = 50;

- (void)test {
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        for (NSInteger i = 0; i < 10; i++) {
            [self lockSection];
        }
    });
    
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        for (NSInteger i = 0; i < 10; i++) {
            [self lockSection];
        }
    });
}

- (void)lockSection {
    NSLog(@"before count: %ld", (long)count);
    count = count - 1;
    NSLog(@"after count: %ld", (long)count);
}

这种时候我们期待的输出大概就是按顺序,50、49、48……这种,但是实际上嘞:

2019-11-04 14:57:22.584024+0800 [14572:162956] before count: 50
2019-11-04 14:57:22.584022+0800 [14572:162947] before count: 50
2019-11-04 14:57:22.584123+0800 [14572:162956] after count: 49
2019-11-04 14:57:22.584186+0800 [14572:162956] before count: 49
2019-11-04 14:57:22.584206+0800 [14572:162947] after count: 48
2019-11-04 14:57:22.584254+0800 [14572:162956] after count: 47
2019-11-04 14:57:22.584270+0800 [14572:162947] before count: 47
2019-11-04 14:57:22.584323+0800 [14572:162956] before count: 47
2019-11-04 14:57:22.584535+0800 [14572:162947] after count: 46
2019-11-04 14:57:22.584621+0800 [14572:162956] after count: 45
2019-11-04 14:57:22.584934+0800 [14572:162956] before count: 45
2019-11-04 14:57:22.585000+0800 [14572:162947] before count: 45
2019-11-04 14:57:22.585289+0800 [14572:162956] after count: 44
2019-11-04 14:57:22.585544+0800 [14572:162956] before count: 43
2019-11-04 14:57:22.585499+0800 [14572:162947] after count: 43
2019-11-04 14:57:22.585783+0800 [14572:162956] after count: 42
2019-11-04 14:57:22.586086+0800 [14572:162956] before count: 42
2019-11-04 14:57:22.586100+0800 [14572:162947] before count: 42
2019-11-04 14:57:22.586301+0800 [14572:162956] after count: 41
2019-11-04 14:57:22.586618+0800 [14572:162947] after count: 40
2019-11-04 14:57:22.586651+0800 [14572:162956] before count: 40
2019-11-04 14:57:22.587034+0800 [14572:162956] after count: 39
2019-11-04 14:57:22.586989+0800 [14572:162947] before count: 40
……

感受一下这个bug,为啥已经count=50的时候进入了但是下次进入又是50呢,其实这个就是线程的问题了,系统在运行的时候每个线程都是干一会儿活就会让出时间片,让其他线程再干一会儿,交替执行。

于是可能线程1刚进入count=50的时候,下一步count=count-1还没执行就让出了时间片,于是进程2就进入了,这个时候由于count仍旧是50,所以打印的就会重复啦。

所以如果我们想保证不要出现多线程的冲突问题,就需要线程同步了。其实xcode提供检测多线程操作同一数据的工具非常方便,通过edit schema就可以啦:

[iOS] 线程锁 — synchronized & 各种Lock_第1张图片
edit schema

勾选这个选项,再次运行程序,会发现它在出现多线程访问同一数据并且对它进行操作的时候断点:


[iOS] 线程锁 — synchronized & 各种Lock_第2张图片
data race

2. 如何进行多线程同步

主要还是靠加锁。。iOS有很多锁,包括:NSLock、semaphore、OSSpinLock神马的。根据参考文章里面的test,他们加解锁的时间大概如下:


性能
2.1 @synchronized

先来看加锁最慢的一个,也是在Android中蛮常用到的一个~

如果改写为:

- (void)lockSection {
    @synchronized (self) {
        NSLog(@"before count: %ld", (long)count);
        count = count - 1;
        NSLog(@"after count: %ld", (long)count);
    }
}

输出就变为了:

2019-11-18 16:29:58.503712+0800 Example1[45488:373901] before count: 50
2019-11-18 16:29:58.503825+0800 Example1[45488:373901] after count: 49
2019-11-18 16:29:58.503907+0800 Example1[45488:373901] before count: 49
2019-11-18 16:29:58.503986+0800 Example1[45488:373901] after count: 48
2019-11-18 16:29:58.504053+0800 Example1[45488:373901] before count: 48
2019-11-18 16:29:58.504115+0800 Example1[45488:373901] after count: 47
2019-11-18 16:29:58.504244+0800 Example1[45488:373901] before count: 47
2019-11-18 16:29:58.504304+0800 Example1[45488:373901] after count: 46
2019-11-18 16:29:58.504370+0800 Example1[45488:373901] before count: 46
2019-11-18 16:29:58.504534+0800 Example1[45488:373901] after count: 45
2019-11-18 16:29:58.504734+0800 Example1[45488:373901] before count: 45
2019-11-18 16:29:58.504951+0800 Example1[45488:373901] after count: 44

据说 @synchronized block 会变成 objc_sync_enter 和 objc_sync_exit 的成对儿调用。

@synchronized(obj) {
    //do work
}

转化成这样的东东:

@try {
    objc_sync_enter(obj);
    //do work
} @finally {
    objc_sync_exit(obj);    
}

查看objc-sync.h文件可以发现:

#ifndef __OBJC_SNYC_H_
#define __OBJC_SNYC_H_

#include 

/** 
 * Begin synchronizing on 'obj'.  
 * Allocates recursive pthread_mutex associated with 'obj' if needed.
 * 
 * @param obj The object to begin synchronizing on.
 * 
 * @return OBJC_SYNC_SUCCESS once lock is acquired.  
 */
OBJC_EXPORT int
objc_sync_enter(id _Nonnull obj)
    OBJC_AVAILABLE(10.3, 2.0, 9.0, 1.0, 2.0);

/** 
 * End synchronizing on 'obj'. 
 * 
 * @param obj The object to end synchronizing on.
 * 
 * @return OBJC_SYNC_SUCCESS or OBJC_SYNC_NOT_OWNING_THREAD_ERROR
 */
OBJC_EXPORT int
objc_sync_exit(id _Nonnull obj)
    OBJC_AVAILABLE(10.3, 2.0, 9.0, 1.0, 2.0);

enum {
    OBJC_SYNC_SUCCESS                 = 0,
    OBJC_SYNC_NOT_OWNING_THREAD_ERROR = -1
};


#endif // __OBJC_SYNC_H_

也就是说enter的时候其实是用pthread_mutex锁来实现了lock,根据源码https://opensource.apple.com/source/objc4/objc4-646/runtime/objc-sync.mm看一下到底做了些什么:

// Begin synchronizing on 'obj'. 
// Allocates recursive mutex associated with 'obj' if needed.
// Returns OBJC_SYNC_SUCCESS once lock is acquired.  
int objc_sync_enter(id obj)
{
    int result = OBJC_SYNC_SUCCESS;

    if (obj) {
        SyncData* data = id2data(obj, ACQUIRE);
        require_action_string(data != NULL, done, result = OBJC_SYNC_NOT_INITIALIZED, "id2data failed");
    
        result = recursive_mutex_lock(&data->mutex);
        require_noerr_string(result, done, "mutex_lock failed");
    } else {
        // @synchronized(nil) does nothing
        if (DebugNilSync) {
            _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
        }
        objc_sync_nil();
    }

done: 
    return result;
}


// End synchronizing on 'obj'. 
// Returns OBJC_SYNC_SUCCESS or OBJC_SYNC_NOT_OWNING_THREAD_ERROR
int objc_sync_exit(id obj)
{
    int result = OBJC_SYNC_SUCCESS;
    
    if (obj) {
        SyncData* data = id2data(obj, RELEASE); 
        require_action_string(data != NULL, done, result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR, "id2data failed");
        
        result = recursive_mutex_unlock(&data->mutex);
        require_noerr_string(result, done, "mutex_unlock failed");
    } else {
        // @synchronized(nil) does nothing
    }
    
done:
    if ( result == RECURSIVE_MUTEX_NOT_LOCKED )
         result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;

    return result;
}

也就是在enter和exit的时候都用了SyncData,然后通过recursive_mutex_lock和unlock操作data中的mutex。

那么SyncData是什么呢?

typedef struct SyncData {
    id object;
    recursive_mutex_t mutex;
    struct SyncData* nextData;
    int threadCount;
} SyncData;

typedef struct SyncList {
    SyncData *data;
    spinlock_t lock;
} SyncList;

// Use multiple parallel lists to decrease contention among unrelated objects.
#define COUNT 16
#define HASH(obj) ((((uintptr_t)(obj)) >> 5) & (COUNT - 1))
#define LOCK_FOR_OBJ(obj) sDataLists[HASH(obj)].lock
#define LIST_FOR_OBJ(obj) sDataLists[HASH(obj)].data
static SyncList sDataLists[COUNT];

也就是说,有一个容量为16的SyncList数组,通过把obj的内存地址转为无符号整型右移5位然后和15的二进制表示做与运算,可以得到数组的下标。

拿到对应的SyncList以后,就可以拿到SyncData,然后通过data的mutex来上锁;spinlock_t是用来Spinlock prevents multiple threads from creating multiple locks for the same new object.

SyncData结构体包含:

  • 一个 object(嗯就是我们给 @synchronized 传入的那个对象)。
  • 一个有关联的 recursive_mutex_t,它就是那个跟 object 关联在一起的锁。
  • 一个指向另一个 SyncData 对象的指针,叫做 nextData,所以你可以把每个 SyncData 结构体看做是链表中的一个元素。
  • 一个 threadCount,这个 SyncData 对象中的锁会被一些线程使用或等待,threadCount 就是此时这些线程的数量。它很有用处,因为 SyncData 结构体会被缓存,threadCount==0 就暗示了这个 SyncData 实例可以被复用。

你可以把 SyncData 当做是链表中的节点。每个 SyncList 结构体都有个指向 SyncData 节点链表头部的指针,也有一个用于防止多个线程对此列表做并发修改的锁spinlock_t。

这里特别看下链表是啥:

// malloc a new SyncData and add to list.
// XXX calling malloc with a global lock held is bad practice,
// might be worth releasing the lock, mallocing, and searching again.
// But since we never free these guys we won't be stuck in malloc very often.
result = (SyncData*)calloc(sizeof(SyncData), 1);
result->object = object;
result->threadCount = 1;
recursive_mutex_init(&result->mutex);
result->nextData = *listp;
*listp = result;

在找obj对应的SyncData的时候,会先通过obj内存地址转换成下标以后找的sync list,然后从list的sync data开始找,不断地next来找链表上是不是已经存在了obj对应的sync data。如果没有就创建一个SyncData,然后让这个新建的SyncData的next指向现在list的头data,并且将新建的data设为新的list头。

https://blog.csdn.net/TuGeLe/article/details/88399115里面的流程图总结的很好~


通过clang转为源码据说是酱紫的:

static void _I_CustomObject_testSynchronized(CustomObject * self, SEL _cmd) {
{
    id _rethrow = 0;
    id _sync_obj = (id)self;
    objc_sync_enter(_sync_obj);
    try {
        struct _SYNC_EXIT { _SYNC_EXIT(id arg) : sync_exit(arg) {}
            ~_SYNC_EXIT() {objc_sync_exit(sync_exit);}
            id sync_exit;
        } _sync_exit(_sync_obj);
        
        NSLog((NSString *)&__NSConstantStringImpl__var_folders_p3_pyrv2p4j0gn_yqv6994w1ryr0000gn_T_CustomObject_77509d_mi_0);
    } catch (id e) {
        _rethrow = e;
        
    }
    
    { struct _FIN { _FIN(id reth) : rethrow(reth) {}
        ~_FIN() { if (rethrow) objc_exception_throw(rethrow); }
        id rethrow;
    } _fin_force_rethow(_rethrow);}
}

需要注意的是一开始就id _sync_obj = (id)self;保存了self也就是synchronized的object,即使之后这个object被置为了nil,由于_sync_obj还拿着引用计数,就不会被清掉内存。

但这个也说明了一个事情,就是被用于synchronized的只能是object。

Q: 那么为什么需要_sync_obj呢?

从 objc_sync_enter和 objc_sync_exit实现可知,当加锁条件为nil时,临界区代码正常执行,但无法加锁解锁,不能保证临界区代码在线程中的安全。

首先objc_sync_enter和objc_sync_exit如果传nil的话,其实代码里面什么都没做,只有obj不为空才会lock和unlock。

如果objc_sync_enter的时候obj不为空,lock了mutex锁,然后exit的时候传入了nil,那么这个mutex锁不会被unlock,就会导致死锁。

所以其实_sync_obj的作用就是keep住传入的object,确保它的引用计数不会为0,也就不会被清掉,所以lock和unlock是成对的。

Q: 既然@synchronized对加锁条件进行了强引用保护,那么是否可以在临界区代码中对加锁条件进行更改?

不建议在临界区代码中对加锁条件进行更改的操作。

原因在于若在临界区代码中对加锁条件进行更改,那么此时如果再次对该加锁条件进行加锁,此时获取的 SyncData为不同对象对应的值,虽说也能成功加锁,但是无法保证与第一次加锁线程互斥,可能造成业务逻辑的错误。

Q: 是否可以对所有需要锁的操作都使用同一个加锁条件?

不建议对所有需要锁的操作使用同一个加锁条件。

原因在于当某个操作对加锁条件进行加锁后,若其他与该操作无关的操作再对加锁条件进行加锁时,需等到前一个操作执行完毕,这可能造成无关操作多余无用的等待时间,造成程序效率低下。

所以建议对涉及共同资源的操作使用同一个加锁条件进行加锁,相互无关的操作使用不同的加锁条件加锁。


2.2 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 互斥锁的类型不同。通过宏定义,可以简化方法的定义。

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

Q: 插一个之前面试的时候一个帅气冷漠小哥哥问我的问题,如何用NSLock实现让任务A和B执行以后再执行C?

A: 后来问了另一个帅气不冷漠小哥哥,他提醒我可以用两把锁...感觉自己智商拙计了~


2.3 NSRecursiveLock

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

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


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

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


2.5 OSSpinLock自旋锁
#import 
OSSpinLock *lock;

lock = OS_SPINLOCK_INIT;

- (void)lockSection {
    OSSpinLockLock(&lock);
    NSLog(@"before count: %ld", (long)count);
    count = count - 1;
    NSLog(@"after count: %ld", (long)count);
    OSSpinLockUnlock(&lock);
}

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

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

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

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

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

※ 自旋锁的忙等

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

OSSpinLock编译会报警告已经废弃了,大家也已经不再用它了,因为它在某一些场景下已经不安全了,可以参考不再安全的 OSSpinLock。

新版 iOS 中,系统维护了 5 个不同的线程优先级/QoS: background,utility,default,user-initiated,user-interactive。高优先级线程始终会在低优先级线程前执行,一个线程不会受到比它更低优先级线程的干扰。这种线程调度算法会产生潜在的优先级反转问题,从而破坏了 spin lock。

具体来说,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。这并不只是理论上的问题,libobjc 已经遇到了很多次这个问题了,于是苹果的工程师停用了 OSSpinLock。


2.6 os_unfair_lock
#import 
os_unfair_lock lock;
lock = OS_UNFAIR_LOCK_INIT;

- (void)lockSection {
    os_unfair_lock_lock(&lock);
    NSLog(@"before count: %ld", (long)count);
    count = count - 1;
    NSLog(@"after count: %ld", (long)count);
    os_unfair_lock_unlock(&lock);
}

os_unfair_lock 是苹果官方推荐的替换OSSpinLock的方案,但是它在iOS10.0以上的系统才可以调用。os_unfair_lock是一种互斥锁,它不会向自旋锁那样忙等,而是等待线程会休眠。


2.7 pthread_mutex

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

互斥锁的常见用法如下:

#include 

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_NORMAL、PTHREAD_MUTEX_ERRORCHECK、PTHREAD_MUTEX_RECURSIVE 等等。

  • PTHREAD_MUTEX_NORMAL 普通锁

  • PTHREAD_MUTEX_RECURSIVE 嵌套锁
    允许同一个线程对同一个锁成功获得多次,并通过多次unlock解锁。如果是不同线程请求,则在加锁线程解锁时重新竞争。

  • PTHREAD_MUTEX_ERRORCHECK 检错锁
    如果同一个线程请求同一个锁,则返回EDEADLK,否则与PTHREAD_MUTEX_NORMAL类型动作相同。这样就保证当不允许多次加锁时不会出现最简单情况下的死锁。

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

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


2.8 pthread_rwlock

读写锁与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么是不加锁状态,而且一次只有一个线程对其加锁。

读写锁可以有三种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可用同时占有读模式的读写锁。

读写锁也叫做共享-独占锁,当读写锁以读模式锁住时,它是以共享模式锁住的,当它以写模式锁住时,它是以独占模式锁住的。

常用的接口有:

1、pthread_rwlock_init,初始化锁

2、pthread_rwlock_rdlock,阻断性的读锁定读写锁

3、pthread_rwlock_tryrdlock,非阻断性的读锁定读写锁

4、pthread_rwlock_wrlock,阻断性的写锁定读写锁

5、pthread_rwlock_trywrlock,非阻断性的写锁定读写锁

6、pthread_rwlock_unlock,解锁

7、pthread_rwlock_destroy,销毁锁释放

使用:

pthread_rwlock_t rwLock;

pthread_rwlock_init(&rwLock, NULL);

pthread_rwlock_wrlock(&rwLock);
// 写临界区
pthread_rwlock_unlock(&rwLock);

这里我感觉如果不用的时候最好能把锁销毁掉,否则可能会浪费内存吧。


2.9 NSCondition

The NSCondition class implements a condition variable whose semantics follow those used for POSIX-style conditions. A condition object acts as both a lock and a checkpoint in a given thread. The lock protects your code while it tests the condition and performs the task triggered by the condition. The checkpoint behavior requires that the condition be true before the thread proceeds with its task. While the condition is not true, the thread blocks. It remains blocked until another thread signals the condition object.

NSCondition 的对象实际上作为一个锁和一个线程检查器:锁主要为了当检测条件时保护数据源,执行条件引发的任务;线程检查器主要是根据条件决定是否继续运行线程,即线程是否被阻塞。

使用:

NSConditon *condition =[ [NSCondition alloc]]init;

[condition lock];//一般用于多线程同时访问、修改同一个数据源,保证在同一时间内数据源只被访问、修改一次,其他线程的命令需要在lock 外等待,只到unlock ,才可访问

[condition unlock];//与lock 同时使用

[condition wait];//让当前线程处于等待状态

[condition signal];//CPU发信号告诉线程不用在等待,可以继续执行
  • wait:阻塞住当前线程,线程会停在-wait方法中不会返回,直到被其他线程的-signal方法唤醒。
  • waitUntilDate:与-wait方法类似,不过这里多了一个时间参数表示最长阻塞到此时间为止
  • signal:调用此方法可以唤醒一个被-wait方法阻塞着的线程
  • broadcast:与-signal类似,不过这个方法可以唤醒所有被-wait方法阻塞着的线程

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

举个例子,生产者and消费者:

生产者:
-(void)produce{
    self.shouldProduce = YES;
    while (self.shouldProduce) {
        [self.condition lock];
        if (self.collector.count > 0 ) {
            [self.condition wait];
        }
        [self.collector addObject:@"iPhone"];
        NSLog(@"生产:iPhone");
        [self.condition signal];
        [self.condition unlock];
    }
}

消费者:
-(void)consumer{
    self.shouldConsumer = YES;
    while (self.shouldConsumer) {
        [self.condition lock];
        if (self.collector.count == 0 ) {
            [self.condition wait];
        }
        
        NSString *item = [self.collector objectAtIndex:0];
        NSLog(@"买入:%@",item);
        [self.collector removeObjectAtIndex:0];
        [self.condition signal];
        [self.condition unlock];
    }
}

wait做了什么?如何被唤醒?
当线程在wait一个condition时,condition对象会解锁它的lock,并阻塞线程。当condition被signaled,系统会唤醒线程。condition在wait()或者wait(until:)方法返回之前,会再次尝试获取到它的lock(如果获取lock不成功会继续保持阻塞状态)。因此,从线程的角度来看,就好像它总是held the lock

线程从-wait返回继续执行下面代码的前提是:此线程被其他signal方法唤醒 & 当前的condition不再被lock

一般在使用-wait时会使用一个谓词的修饰来做条件判断,这是因为:

  1. 根据苹果官方文档,-signal方法本身就不完全保证是准确的,会存在其他线程没有调用-signal方法,但是被wait的线程依然被唤醒的情况。

  2. 就算被wait的线程的唤醒时机没有问题,但是在被wait的线程被唤醒到执行后面代码期间,程序状态可能会发生变化,这也是一个风险项。所以在要执行wait后面代码时,都要重新判断当前程序状态是不是自己期望的。

如果有多个线程处在wait状态,那么它们被唤醒的顺序为先入先出,即先进入wait状态的线程先被唤醒。


一个错误:if or while来判断condition?

上面的例子中我们用了:

消费者:
if (self.collector.count == 0 ) {
  [self.condition wait];
}

并且在之后做了[self.collector removeObjectAtIndex:0];这个事儿。

但是呢注意如果想要从wait继续向下走,需要两个条件:一个是被signal唤醒,另一个是还需要获取lock。如果另外一个生产者在生产一个商品以后发了signal,然后又清空了产品,最后释放了锁。

那么,消费者在wait返回时,其实产品池还是空的,如果此时用if来判断self.collector.count == 0,那么其实走到下面的remove就会crash,但如果时用while,那么wait返回以后其实会再次判断count,如果产品池是空的,会再次wait不会直接跳出到后面的remove。

故而,在NSCondition的状况中,应该多用while来判断,而非if来判断需要wait的状况。


2.10 NSConditionLock

NSCondition与NSConditionLock非常的像,他们都需要3个元素:互斥锁,条件变量,条件探测变量。

不同点是NSCondition需要一个外部共享变量,来探测条件是否满足;而NSConditionLock不需要,条件锁自带一个探测条件,是否满足。

使用:

@interface NSConditionLock : NSObject  {
@private
    void *_priv;
}
//初始化一个NSConditionLock对象
- (instancetype)initWithCondition:(NSInteger)condition NS_DESIGNATED_INITIALIZER;

@property (readonly) NSInteger condition;  //锁的条件

//满足条件时加锁
- (void)lockWhenCondition:(NSInteger)condition;


- (BOOL)tryLock;
//如果接收对象的condition与给定的condition相等,则尝试获取锁,不阻塞线程
- (BOOL)tryLockWhenCondition:(NSInteger)condition;

//解锁后,重置锁的条件
- (void)unlockWithCondition:(NSInteger)condition;

- (BOOL)lockBeforeDate:(NSDate *)limit;

//在指定时间前尝试获取锁,若成功则返回YES 否则返回NO
- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;

@property (nullable, copy) NSString *name API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));

@end

NSConditionLock与NSCondition大体相同,但是NSConditionLock可以设置锁条件condition,而NSCondition确只是无脑的通知信号。

如果像下面这么用,让condition值永远是一样的话,其实就是NSLock:

lock = [[NSConditionLock alloc] initWithCondition:0];

[lock lockWhenCondition:0];
// 临界区
[lock unlockWithCondition:0];

如果维持其他的不变,将上锁改成[lock lockWhenCondition:1];那么其实lock之后的临界区就怎么也进不去了,因为condition初始化为0后从来也没被修改为1,所以始终wait无法被唤醒。

参考:
https://juejin.im/post/57f6e9f85bbb50005b126e5f#heading-1
https://www.jianshu.com/p/b1edc6b0937a
http://yulingtianxia.com/blog/2015/11/01/More-than-you-want-to-know-about-synchronized/
https://blog.csdn.net/TuGeLe/article/details/88399115
https://blog.csdn.net/chenyong05314/article/details/54598948
https://www.jianshu.com/p/5d20c15ae690
https://www.jianshu.com/p/25b00ce874c6

你可能感兴趣的:([iOS] 线程锁 — synchronized & 各种Lock)