前言
有这么一个经典的案例,某一趟车次现在还有20张余票,火车站有4个售票窗口,人们开始排队买票。我们来模拟一下这个场景。
@property (nonatomic, assign) NSUInteger ticketCount;
- (void)saleAllTickets {
self.ticketCount = 20;
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 5; i++) {
[self showTicketState];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 5; i++) {
[self showTicketState];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 3; i++) {
[self showTicketState];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
for (int i = 0; i < 10; i++) {
[self showTicketState];
}
});
}
- (void)showTicketState {
if (self.ticketCount > 0) {
self.ticketCount--;
sleep(0.1);
NSLog(@"当前余票还剩:%ld张",self.ticketCount);
} else {
NSLog(@"当前车票已售罄");
}
}
咋一看程序没有问题,运行程序:
image可以看出,程序的运行结果并不是我们想要的,因为每个窗口的卖票都是独立且同时进行的,所以showTicketState
这个方法同时可能被多个线程访问,这样线程的不安全导致了数据的异常。解决这个问题就引入了我们今天要探究的主题---锁,当我们用锁锁住showTicketState
这个方法,保证其线程安全,程序才能正常运行。
基本概念
那么在iOS
开发中锁是什么呢?顾名思义,锁是保证线程安全常见的同步工具。锁是一种非强制的机制,每一个线程在访问数据或者资源前,要先获取(acquire)
锁,并在访问结束之后释放(release)
锁。如果锁已经被占用,其它试图获取锁的线程会等待,直到锁重新可用。
多线程编程时,当多个线程同时对一块内存发生读和写的操作,可能就会出现程序执行的顺序会被打乱,造成提前释放一个变量,计算结果错误等情况。所以我们需要使用线程安全工具---也就是锁将线程不安全的代码“锁”起来,保证一段代码或者多段代码操作的原子性,保证多个线程对同一个数据的同步(synchronization)
访问 。
借用锁的功能,我们就可以修改上面的代码,让其正运行,比如使用@synchronized
,或者是使用信号量dispatch_semaphore_t
将showTicketState
方法锁住,让其在多线程访问的时候依然同步执行。
锁的分类
根据锁的状态,锁的特性等我们可以对锁进行不同的分类,很多锁之间其实并不是并列的关系,而是一种锁的不同实现。
iOS
中的锁大致可以分为互斥锁、自旋锁、信号量这三种。
互斥锁
互斥锁,顾名思义,互相排斥。是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区而达成线程安全的目的。当一个线程进行访问的时候,该线程获得锁,其他线程进行访问的时候,将被操作系统挂起,直到该线程释放锁,其他线程才能对其进行访问,从而却确保了线程安全。
互斥锁由POSIX
定义了一个宏PTHREAD_MUTEX_INITIALIZER
来静态初始化的。
- 静态初始化互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 动态初始化互斥量
int pthread_mutex_init(pthread_mutex_t *mutex, pthread_mutexattr_t *attr)
- 注销毁互斥量
int pthread_mutex_destory(pthread_mutex_t *mutex)
- 获取锁(加锁)
当另一个线程来获取这个锁的时候,发现这个锁已经加锁,那么这个线程就会进入休眠状态,直到这个互斥量被解锁,线程才会重新被唤醒。
int pthread_mutex_lock(pthread_mutex_t *mutex)
- 尝试获取锁
当互斥量已经被锁住时调用该函数将返回错误代码EBUSY
,如果当前互斥量没有被锁,则会正常加锁。
int pthread_mutex_trylock(pthread_mutex_t *mutex)
- 释放锁(解锁)
int pthread_mutex_unlock(pthread_mutex_t *mutex)
同一条线程如果连续锁定两次或者多次(递归),就会造成死锁问题。那如果想在递归中使用锁,那要怎么办呢,这就用到了NSRecursiveLock
递归锁,而递归锁也是互斥锁的一种。递归锁可以被一个线程多次获得,而不会引起死锁。它记录了成功获得锁的次数,每一次成功的获得锁,必须有一个配套的释放锁和其对应,这样才不会引起死锁。只有当所有的锁被释放之后,其他线程才可以获得锁。
互斥锁分为递归锁和非递归锁。常见的递归锁有NSRecursiveLock
;对象锁@synchronized
。常见的非递归锁有pthread_mutex
;NSLock
;条件锁NSCondition
、NSConditionLock
。
对象锁@synchronized
,是基于recursive_mutex_t
的上层封装,属于递归锁。当我们对其传入nil
的时候,它不会做任何事情,可以用来防止死递归。让@synchronized
具备处理递归能力的是lockCount
,让其能够处理多线程的是threadCount
。在日常开发中,要慎用@synchronized(self)
,直接将self
传入@synchronized
确实是很简单粗暴的方法,但是这样容易导致死锁的出现。
条件锁NSCondition
、NSConditionLock
就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就是被锁住了。当资源被分配到了,条件锁打开,进程继续运行。NSCondition
是基于对pthread_mutex
的封装,而NSConditionLock
是对NSCondition
做了一层封装。
自旋锁
线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。
简单的说,线程1获取到锁,在释放锁之前,线程2又来获取锁,此时是获取不到的,线程2会不断的进入循环,一直检查锁是否已被释放,如果释放,则能获取到锁。
OSSpinLock
之前是一种很具有代表性的自旋锁,后来因为安全性问题已经被苹果废弃,此处就不再多做赘述。
我们常说的读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成“读者”和“写者”,“读者”只对共享资源进行读访问,“写者”则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU
数。写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU
数相关),但不能同时既有读者又有写者。在读写锁保持期间也是抢占失效的。
如果读写锁当前没有读者,也没有写者,那么写者可以立刻获得读写锁,否则它必须自旋在那里,直到没有任何写者或读者。如果读写锁没有写者,那么读者可以立即获得该读写锁,否则读者必须自旋在那里,直到写者释放该读写锁。
一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁,也就是我们常说的多读单写。正是因为这个特性,当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁,它必须直到所有的线程释放锁。读写锁适合于对数据结构的读次数比写次数多得多的情况。因为,读模式锁定时可以共享,以写模式锁住时意味着独占,所以读写锁又叫共享-独占锁。
鉴于读写锁的特性:
- 同一时间,只能有1个线程进行写的操作
- 同一时间,允许有多个线程进行读的操作
- 同一时间,不允许既有写操作又有读操作
我们可以模拟一下读写做的处理,依据第一条,我们可以使用dispatch_barrier
实现写方法;依据第二条,我们可以不对读操作进行任何处理;依据第三条,我们需要读写都是同步操作。
读的时候:
dispatch_sync(concurrentQueue, ^{
}
写的时候:
dispatch_barrier_sync(concurrentQueue, ^{
});
其实自旋锁,也是互斥锁的一种实现,而两者都是为了解决某项资源的互斥使用,在任何时刻只能有一个保持者。区别在于调度机制上有所不同。大多数情况下,自旋锁看起来是比较耗费cpu
的,然而在互斥临界区计算量较小的场景下,它的效率远高于其它的锁。
互斥锁和自旋锁的主要区别如下:
- 互斥锁:当线程获取锁但没有获取到时,线程会进入休眠状态,等锁被释放时,线程会被唤醒,同时获取到锁,继续执行任务,互斥锁会改变线程的状态
- 自旋锁:当线程获取锁但没获取到时,不会进入休眠,而是一直循环,线程始终处于活跃状态,不会改变线程状态。递归调用自旋锁一定会死锁。由于自旋锁在线程等待个过程中是活跃的,在一定时间内是个死循环,会消耗较多的
cpu
资源,因此自旋锁适合用于短时间内的轻量级锁定,mac
应用开发比较实用。
信号量(dispatch_semaphore)
是一种更高级的同步机制,互斥锁可以说是
dispatch_semaphore
在仅取值0/1时的特例。信号量可以有更多的取值空间,用来实现更加复杂的同步,而不仅仅是线程间互斥。
dispatch_semaphore_wait
的参数为0的时候会堵塞线程,起到锁的作用。它能够通过创建信号量的值控制最大并发数,是性能最高的锁。
信号量和互斥锁的区别:
- 信号量是允许并发访问的,也就是说,允许多个线程同时执行多个任务。信号量可以由一个线程获取,然后由不同的线程释放。
- 互斥量只允许一个线程同时执行一个任务。也就是同一个程获取,同一个线程释放。
此处附上网上一张性能图对比作为结束:
image具体锁的使用请见下几篇文章。
网友评论