这段时间的研究内容的是锁
,因为实际开发中用到的比较少,文中难免会有错误,希望能够多多指正。
这篇博客的第一部分是一些计算机的基础知识,然后介绍一些常见的锁以及它们的工作原理,最后部分是 GCD 相关的一些内容。
一些基础知识
下面是一些计算机知识,比较枯燥。你可以跳过这一部分,直接看后面的内容,等看到一些不懂的概念的时候再跳回来看这部分内容。
时间片
时间片
又称为“量子”或者“处理器片”,是分时操作系统分配给每个正在运行的进程微观上的一段 CPU 时间。现代操作系统(例如 Windows,Mac OS X)允许同时运行多个进程。例如,在打开音乐播放器的同时用浏览器浏览网页并下载文件。由于有些计算机只有一个CPU,所以不可能真正地同时运行多个任务。这些进程“看起来像”同时运行,实则是轮番运行,由于时间片通常很短(在Linux上为5ms-800ms),用户不会感觉到。
时间片由操作系统内核的调度程序分配给每个进程。首先,内核会给每个进程分配相等的初始时间片,然后每个进程轮番地执行相应的时间,当所有进程都处于时间片耗尽的状态时,内核会重新为每个进程计算并分配时间片,如此往复。
通常状况下,一个系统所有的进程被分配到的时间片长短并不相等,尽管初始时间片基本相等,系统通过测量进程的阻塞
和执行
状态的时间长短来计算每个进程的交互性。交互性和每个进程预设的静态优先级(Nice值)的叠加即是动态优先级,动态优先级按比例缩放就是要分配给那个进程时间片的长短。一般的,为了获得较快的响应速度,交互性强的进程(即趋向于IO消耗型)被分配到的时间片要长于交互性弱的进程。
进程基本状态
进程有以下几种状态:
- new:创建状态。进程正在被创建,仅仅在堆上分配内存,尚未进入就绪态
- ready:就绪态。进程已处于准备运行的状态,即进程已获得除了 CPU 之外的所需资源,一旦分配到 CPU 时间片即可进入运行状态
- run:运行态。进程正在运行,占用 CPU 资源,执行代码。任意时间点,处于运行状态的进程(线程)的总数,不会超过 CPU 的总核数
- wait:阻塞态。进程处于等待某一事件而放弃 CPU,暂停运行。阻塞状态分3种:
- 阻塞在对象等待池:当进程在运行时执行
wait()
方法,将线程放入等待池 - 阻塞在对象锁池:当对象在运行时企图获取已经被其它进程占用的同步锁时,会把线程放入锁池
- 其它阻塞状态:当进程在运行时执行
sleep()
方法,或调用其它进程的join()
方法,或发出I/O请求时,进入阻塞状态
- 阻塞在对象等待池:当进程在运行时执行
- dead:死亡态。进程正在被结束,这可能是进程正常结束或其它原因中断运行。进程结束运行前,系统必须置进程为dead态,再处理资源释放和回收等工作
在特定的情况下,这三种状态可以相互转换
- ready -> run: 就绪态的进程获得 CPU 时间片,进入运行态
- run -> ready: 运行态的进程在时间片用完后,必须出让 CPU,进入就绪态
- run -> wait: 当进程请求资源的使用权或等待事件发生(如I/O完成)时,由运行态转换为阻塞态
- wait -> ready: 当进程已经获取所需资源的使用权或者等待时间已完成时,中断处理程序必须把相应进程的状态由阻塞态转为就绪态
进程以及线程的关系
进程是资源分配的最小单位
进程(Process)
是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
每个进程都占用一个进程表项
,该表项包含了操作系统对进程进行描述和控制的全部信息,包括程序计数器,堆栈指针,内存分配状况,打开文件的状态,账号和调度信息
线程是”轻量级的进程“,是 CPU 调度的最小单位
一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
线程是程序中一个单一的顺序控制流程。进程内一个相对独立的、可调度的执行单元,是系统独立调度和分派CPU的基本单位指运行中的程序的调度单位。在单个程序中同时运行多个线程完成不同的工作,称为多线程。
进程和线程的区别有以下几点:
- 调度:在多线程os中,线程是调度和分配的基本单位,进程是资源分配的最小单位。在同一进程中,线程的切换不会引起进程的切换。线程上下文切换比进程上下文切换要快很多
- 资源:进程是拥有资源的一个基本单位,他可以拥有自己的资源,一般地说,线程不拥有系统资源(只有一些必不可少的资源),但它可以访问其隶属进程的资源
- 系统开销:在创建和销毁进程时,系统都要为之分配和回收资源,因此,操作系统所付出的开销显著的大于创建或销毁线程的开销
- 通信:进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信
- 在多线程os中,进程不是一个可执行的实体
对比维度 | 进程 | 线程 |
---|---|---|
数据共享、同步 | 数据共享复杂,需要用IPC;数据是分开的,同步简单 | 因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂 |
内存、CPU | 占用内存多,切换复杂,CPU利用率低 | 内存占用少,切换简单,CPU利用率高 |
创建销毁、切换 | 创建销毁、切换复杂,速度慢 | 创建销毁、切换简单,速度很快 |
编程、调试 | 编程简单,调试简单 | 编程复杂,调试复杂 |
可靠性 | 进程间不会互相影响 | 线程可能会引起进程异常 |
分布式 | 适用于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 | 适用于多核分布式 |
哈希表
哈希表(Hash table, 也叫散列表),是根据 key 而直接访问在内存存储位置的数据结构。哈希表本质是一个数组, 通过哈希函数(散列函数)将 key 转换成 index,根据 index 在数组中找到相应的数据。
举个例子:为了在电话本中查找某人的号码,可以创建一个按照人名首字母顺序排列的表,在首字母为“W”的表中查找“王”姓的电话号码,显然比直接查找就要快的多。这里使用人名作为 key,“取首字母“就是这个例子中的哈希函数 F(),存放首字母的表对应哈希表。
不管哈希函数设计的如何完美,都可能出现不同的 key 经过哈希函数处理后得到相同的 hash 值。解决哈希冲突的方法,常见的有下面两种:
- 开放定址法:使用两个大小为N的数组(一个存放keys,一个存放values)。使用数组中的空位解决碰撞,当碰撞发生时,直接 hash 值+1,如果此时对应下标的位置仍被占用,则 hash 值继续+1;如果位置为空,则将 key 存放在此位置中。举个例子:
将关键字为{89, 18, 49, 48, 69}插入到一个散列表中。假定取关键字除以10的余数为哈希函数法则。
散列地址 | 空表 | 插入89 | 插入18 | 插入49 | 插入58 | 插入69 |
---|---|---|---|---|---|---|
0 | 49 | 49 | 49 | |||
1 | 58 | 58 | ||||
2 | 69 | |||||
3 | ||||||
4 | ||||||
5 | ||||||
6 | ||||||
7 | ||||||
8 | 18 | 18 | 18 | 18 | ||
9 | 89 | 89 | 89 | 89 | 89 |
第一次冲突发生在填装49的时候。地址为9的单元已经填装了89这个关键字,所以取49的哈希值并+1,得到10,也就是0,发现该地址为空,所以将49填装在地址为0的空单元。第二次冲突则发生在58上,取哈希值为8,因为位置9和0都已经占用,往下查找3个单位,将58填装在地址为1的空单元。69同理。
- 拉链法:将哈希表同一个存储位置的所有元素保存在一个链表中。实现时,一种策略是散列表同一位置的所有冲突结果都是用栈存放的,即新元素被插入到链表头中。简单讲就是
数组+链表
iOS 关联对象及 weak 对象均以该方法储存。
原子性
原子指化学反应中的基本为例,原子在化学反应中不可分割。
计算机中所谓原子性是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何的上下文切换(context switch)。
临界区
临界区指的是一个访问共享资源的代码片段,并非一种机制或是算法。一个程序可以拥有多个临界区域。
当有一个线程在访问临界区,那么其它线程将被挂起。临界区被释放后,其它线程可继续抢占该临界区
锁
在计算机科学中,锁是一种同步机制,用于限制多线程环境中对临界区的访问,你可以理解锁是用于排除并发的一种的策略。
但如果使用不当,可能会引起死锁,锁封护(lock convoying,多个同优先级的线程重复竞争同一把锁,此时大量虽然被唤醒而得不到锁的线程被迫进行调度切换)等不良影响。
互斥锁
互斥锁是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。
互斥锁是排它的,当锁被某个线程获取之后,其它访问临界区的线程进入阻塞态。只有当获取了锁的线程释放这个锁,被阻塞的线程才会被唤醒进入运行态。
举个例子:一段代码(甲)正在分步修改一块数据。这时,另一条线程(乙)由于一些原因被唤醒。如果乙此时去读取甲正在修改的数据,而甲碰巧还没有完成整个修改过程,这个时候这块数据的状态就处在极大的不确定状态中,读取到的数据当然也是有问题的。更严重的情况是乙也往这块地方写数据,这样的一来,后果将变得不可收拾。因此,多个线程间共享的数据必须被保护。达到这个目的的方法,就是确保同一时间只有一个临界区域处于运行状态,而其他的临界区域,无论是读是写,都必须被挂起并且不能获得运行机会。
互斥锁在申请锁时,调用了pthread_mutex_lock
方法,它在不同的系统上实现各有不同,有时候它的内部是使用信号量来实现,即使不用信号量,也会调用到lll_futex_wait
函数,从而导致线程休眠。
上文说到如果临界区很短,忙等的效率也许更高,所以在有些版本的实现中,会首先尝试一定次数(比如 1000 次)的 test-and-test,这样可以在错误使用互斥锁时提高性能。
另外,由于pthread_mutex
有多种类型,可以支持递归锁等,因此在申请加锁时,需要对锁的类型加以判断,这也就是为什么它和信号量的实现类似,但效率略低的原因。
@synchronized
使用方式:
@synchronized (obj) {
// do something...
}
@synchronized(id obj)
关键字锁,使用的时候需要添加一个OC对象。在很多情况下,@synchronized 的可读性更高,使用更方便。
开始的时候,我一直不敢用这个锁。因为我不知道 obj 需要什么样子的变量才可以,局部变量有用吗,还是一定需要全局变量什么的?
但是看了关于 @synchronized,这儿比你想知道的还要多这篇博客之后,理解了它的实现原理,才明白,obj只要是个oc对象就行,当然,在实际使用的时候,你输入的这个对象最好不可以被外界所修改。
现在简单讲解一下关于 @synchronized,这儿比你想知道的还要多这篇博客里面 @synchronized 的工作原理:
@synchronized 使用哈希链表的方式存储锁SyncData
。SyncData 是链表上的元素,每个SyncData
都有一个递归互斥锁recursive_mutex_t mutex
,一个id object
(传入的obj),一个int threadCount
(使用或等待的线程数量,等于0代表这个锁可以被复用)以及下一个节点struct SyncData* nextData
。结构体SyncList
的成员变量SyncData *data
用来记录链表上的头节点,spinlock_t lock
用来防止多线程并发对链表进行修改
当你调用 @synchronized(obj) 时,首先会根据 obj 的内存地址计算出其哈希值,然后在哈希表上找到相应的SyncList
实例,接下来根据 obj 来查找有没有未被使用(threadCount == 0)的SyncData
实例,如果有则使用这个锁;如果没有则新建一个SyncData
锁实例,将锁插入到链表的SyncList
的头结点中(这样查找会快一点,因为新建的锁往往使用的频繁一点)。上面的查找和新建过程都是加锁的,结束后解锁。
使用时可能有两种特殊情况
-
输入 nil
此时 @synchronized 不起作用,即锁不生效 -
输入的 obj 在 @synchronized 的 block 里面被释放掉了
对 @synchronized 的使用没有影响。你可以使用clang -rewrite-objc xx.m
将代码转换成 C++ 实现
// 代码
- (void)foo {
NSObject *object = [NSObject new];
@synchronized (object) {
NSLog(@"测试@synchronized");
}
}
// c++实现
static void _I_MyObject_foo(MyObject * self, SEL _cmd) {
NSObject *object = ((NSObject *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("NSObject"), sel_registerName("new"));
{ id _rethrow = 0; id _sync_obj = (id)object; 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_h0_ybj03b8d0mj52n8dx82v2br40000gn_T_MyObject_807aa3_mi_1);
} 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 _rethrow = 0; id _sync_obj = (id)object; objc_sync_enter(_sync_obj);
,在内部会将 obj 的值复制一份,所以即使你将 obj 置为 nil,还是能够正常使用。
pthread_mutex
pthread_mutex 的常见用法如下:
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 支持递归锁,也就是允许一个线程递归的申请锁,只要把 attr 的类型改成 PTHREAD_MUTEX_RECURSIVE 即可。
NSLock 和 NSRecursiveLock
使用方式:
- (void)foo {
NSLock *lock = [NSLock new];
[lock lock];
// do something...
[lock unlock];
NSRecursiveLock *recursiveLock = [NSRecursiveLock new];
[recursiveLock lock];
// do something...
[recursiveLock unlock];
}
NSLock 和 NSRecursiveLock 是 Objective-C 以对象的形式暴露给开发者的一种锁,它们的内部实现都是使用的pthread_mutex
,属性为PTHREAD_MUTEX_ERRORCHECK
,它会损失一定新能换来错误提示。理论上来说,NSLock 和 pthread_mutex 拥有相同的运行效率,实际由于封装的原因会略慢一点。由于有缓存存在,相差不会很多。
NSRecursiveLock 与 NSLock 的区别在于内部封装的 pthread_mutex_t 对象的类型不同,NSRecursiveLock 的类型为 PTHREAD_MUTEX_RECURSIVE。
自旋锁
自旋锁是计算机科学用于多线程同步的一种锁,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。
自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。因此操作系统的实现在很多地方往往用自旋锁。Windows操作系统提供的轻型读写锁(SRW Lock)内部就用了自旋锁。显然,单核CPU不适于使用自旋锁,这里的单核CPU指的是单核单线程的CPU,因为,在同一时间只有一个线程是处在运行状态,假设运行线程A发现无法获取锁,只能等待解锁,但因为A自身不挂起,所以那个持有锁的线程B没有办法进入运行状态,只能等到操作系统分给A的时间片用完,才能有机会被调度。这种情况下使用自旋锁的代价很高。
获取、释放自旋锁,实际上是读写自旋锁的存储内存或寄存器。因此这种读写操作必须是原子的。通常用test-and-set(TLS 检查并设置)等原子操作来实现。
OSSpinLock
使用方式:
#import <libkern/OSAtomic.h>
- (void)foo {
__block OSSpinLock osLock = OS_SPINLOCK_INIT;
// 线程1
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
NSLog(@"线程1 准备上锁");
OSSpinLockLock(&osLock);
NSLog(@"线程1");
OSSpinLockUnlock(&osLock);
NSLog(@"线程1 解锁完成");
});
// 线程2
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
NSLog(@"线程2 准备上锁");
OSSpinLockLock(&osLock);
NSLog(@"线程2");
OSSpinLockUnlock(&osLock);
NSLog(@"线程2 解锁完成");
});
}
由于使用OSSpinLock
的使用中可能会出现优先级反转的问题,苹果在 iOS10 发布之后,将 OSSpinLock 比较为了 Deprecated,并且提供了新的 os_unfair_lock 作为代替。
优先级翻转:有高优先级任务 a,低优先级任务 b,资源 y。b 获得锁并在访问 y,a 在等待。此时由于自旋锁,所以 a 处于忙等状态而占用大量 CPU,此时 b 无法获得时间片,而一直无法完成任务,释放掉锁。详细可以看这篇博客不再安全的 OSSpinLock
os_unfair_lock
使用方式:
#include <os/lock.h>
// 初始化
os_unfair_lock_t lock;
lock = &(OS_UNFAIR_LOCK_INIT);
// 加锁
os_unfair_lock_lock(lock);
// 临界区
// 解锁
os_unfair_lock_unlock(lock);
自旋锁和互斥锁的对比
相同点:
- 都能保证同一时间只有一个线程访问共享资源。都能保证线程安全
不同点:
- 互斥锁:如果共享数据已经有其他线程加锁了,线程会进入休眠状态等待锁。一旦被访问的资源被解锁,则等待资源的线程会被唤醒
- 自旋锁:如果共享数据已经有其他线程加锁了,线程会以死循环的方式等待锁,一旦被访问的资源被解锁,则等待资源的线程会立即执行
- 自旋锁的效率高于互斥锁,因为没有切换线程的消耗
信号量
信号量(英语:semaphore)又称为信号标,是一个同步对象,用于保持在0至指定最大值之间的一个计数值。当线程完成一次对该 semaphore 对象的等待(wait)时,该计数值减一;当线程完成一次对 semaphore 对象的释放(release)时,计数值加一。当计数值为0,则线程等待该 semaphore 对象不再能成功直至该 semaphore 对象变成 signaled 状态。semaphore 对象的计数值大于0,为 signaled 状态;计数值等于0,为 nonsignaled 状态.
semaphore 对象适用于控制一个仅支持有限个用户的共享资源,是一种不需要使用忙碌等待(busy waiting)的方法。
信号量的概念是由荷兰计算机科学家艾兹赫尔·戴克斯特拉(Edsger W. Dijkstra)发明的,广泛的应用于不同的操作系统中。在系统中,给予每一个进程一个信号量,代表每个进程当前的状态,未得到控制权的进程会在特定地方被强迫停下来,等待可以继续进行的信号到来。如果信号量是一个任意的整数,通常被称为计数信号量(Counting semaphore),或一般信号量(general semaphore);如果信号量只有二进制的0或1,称为二进制信号量(binary semaphore)。在linux系统中,二进制信号量(binary semaphore)又称互斥锁(Mutex)。
dispatch_semaphore_t
使用方式:
// 创建一个信号量5的锁
dispatch_semaphore_t lock = dispatch_semaphore_create(5);
// 如果信号量的值 > 0,就让信号量的值减1,然后继续往下执行代码
// 如果信号量的值 <= 0,就会休眠等待,直到信号量的值变成>0,就让信号量的值减1,然后继续往下执行代码
dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
// 让信号量的值+1
dispatch_semaphore_signal(lock);
注意,正常的使用顺序是先降低(wait)然后再提高(signal),这两个函数通常成对使用。
信号量是允许并发访问的,也就是说,允许多个线程同时执行多个任务。信号量可以由一个线程获取,然后由不同的线程释放。
在dispatch_semaphore_wait()
函数中,第二个参数超时时间我们可以选择DISPATCH_TIME_NOW
或者DISPATCH_TIME_FOREVER
。根据这个值,信号量最终会表现为互斥或者自旋的方式实现,这也是为什么评测中信号量性能总是优于互斥低于自旋。虽然信号量的性能不是最优,但是这种结合方案保证了它的作用范围更大。
条件锁
在线程间的同步中,有这样一种情况:
线程 A 需要等条件 C 成立,才能继续往下执行.现在这个条件不成立,线程 A 就阻塞等待。而线程 B 在执行过程中,使条件 C 成立了,就唤醒线程 A 继续执行。
对于上述情况,可以使用条件变量来操作。
条件变量,类似信号量,提供线程阻塞与信号机制,可以用来阻塞某个线程,等待某个数据就绪后,随后唤醒线程。
一个条件变量总是和一个互斥量搭配使用。
NSCodition
它通常用于表明共享资源是可被访问或者确保一系列任务能按照指定的执行顺序执行。如果一个线程视图访问一个共享资源,而正在访问该资源的线程将其条件设置为不可访问,那么该线程会被阻塞,直到正在访问该资源的线程将访问条件更改为可访问状态或者说给被阻塞的线程发送信号后,被阻塞的线程才能正常访问这个资源。
NSCondition 的底层是通过条件变量(condition variable) pthread_cond_t 来实现的。条件变量有点像信号量,提供了线程阻塞与信号机制,因此可以用来阻塞某个线程,并等待某个数据就绪,随后唤醒线程,比如常见的生产者-消费者模式。
@property (nonatomic, strong) NSCondition *lock;
- (void)customer {
[_lock lock];
while (!data) {
[lock wait];
}
// 消费者消费数据
[_lock unlock];
}
- (void)producer {
[_lock lock];
// 生产数据
[_lock signal];
[_lock unlock];
}
NSCodition 可以给每个每个线程加锁,加锁后线程仍旧能够进入临界区
。所以 NSCodition 使用 wait 并加锁之后,并不能真正的保证线程安全。当一个 broadcast 操作发出后,如果有两个线程都在做消费者操作,那同时都会消耗掉资源,可能会引发错误。所以我们在方法customer
中,使用while (!data)
来判断资源是否存在,而不是if (!data)
。
注意:signal
只能唤醒单个 race 竞太,而broadcast
是广播,唤醒所有
NSCoditionLock
NSConditionLock 称为条件锁,只有 condition 参数与初始化时候的 condition 相等,lock 才能正确进行加锁操作。
这里分清两个概念:
-
unlockWithCondition:
它是先解锁,再修改 condition 参数的值。 并不是当 condition 符合某个件值去解锁。 -
lockWhenCondition:
它与unlockWithCondition:
不一样,不会修改 condition 参数的值,而是符合 condition 的值再上锁。
在这里可以利用 NSConditionLock 实现任务之间的依赖.
条件变量和信号量的区别
每个信号量都有一个与之关联的值,signal 时+1,wait 时-1,任何线程都可以发出一个信号,即使没有线程在等待该信号量的值。
可是对于条件变量,例如 signal 发出信号后,没有任何线程阻塞在wait 上,那这个条件变量上的信号会直接丢失掉。条件变量 NSCodition 可以使用方法broadcast
唤醒所有阻塞的线程。
读写锁
读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU数。写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),但不能同时既有读者又有写者。
pthread_rwlock
使用方式:
#import <pthread.h>
//初始化锁
pthread_rwlock_t lock;
pthread_rwlock_init(&lock, NULL);
//读加锁
pthread_rwlock_rdlock(&lock);
//读尝试加锁
pthread_rwlock_trywrlock(&lock);
//写加锁
pthread_rwlock_wrlock(&lock);
//写尝试加锁
pthread_rwlock_trywrlock(&lock);
//解锁
pthread_rwlock_unlock(&lock);
//销毁
pthread_rwlock_destroy(&lock);
- 同一时间,只能有1个线程进行写操作
- 同一时间,允许多个线程进行读操作
- 同一时间,不允许同时有读和写操作
具体使用:
#import <pthread.h>
@property (nonatomic, assign) pthread_rwlock_t lock;
- (void)foo {
pthread_rwlock_init(&_lock, NULL);
dispatch_queue_global_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
for (int i = 0; i < 100; i++) {
dispatch_async(queue, ^{
[self read];
});
dispatch_async(queue, ^{
[self write];
});
}
}
- (void)read {
pthread_rwlock_rdlock(&_lock);
sleep(1);
NSLog(@"%s", __func__);
pthread_rwlock_unlock(&_lock);
}
- (void)write {
pthread_rwlock_wrlock(&_lock);
sleep(1);
NSLog(@"%s", __func__);
pthread_rwlock_unlock(&_lock);
}
barrier
dispatch_barrier_async
和dispatch_barrier_sync
的共同点:
- 会先完成在它们前面插入的任务,然后再执行自己的任务
- 执行完自己的任务,再执行后面插入的任务
不同点:
-
dispatch_barrier_sync
会先将自己的任务执行完,在会插入后面的任务 -
dispatch_barrier_sync
不会等自己的任务执行完,就会把后面的任务插入队列,然后等待自己的任务结束在执行后面的任务
注意:
-
dispatch_barrier_async
和dispatch_barrier_sync
指定的队列必须是自己创建的并发队列,如果是串行队列或者全局并发队列,那么这两个方法的行为就会类似dispatch_async
和dispatch_sync
- dispatch_barrier_sync 如果指定当前队列可能会引起死锁
具体使用:
- (void)foo {
dispatch_queue_t queue = dispatch_queue_create(NULL, DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
NSLog(@"test1");
});
dispatch_async(queue, ^{
NSLog(@"test2");
});
dispatch_async(queue, ^{
NSLog(@"test3");
});
dispatch_barrier_async(queue, ^{
for (int i = 0; i <= 50000000; i++) {
if (5000 == i) {
NSLog(@"point1");
}else if (6000 == i) {
NSLog(@"point2");
}else if (7000 == i) {
NSLog(@"point3");
}
}
NSLog(@"barrier");
});
NSLog(@"aaa");
dispatch_async(queue, ^{
NSLog(@"test4");
});
dispatch_async(queue, ^{
NSLog(@"test5");
});
dispatch_async(queue, ^{
NSLog(@"test6");
});
}
GCD
串行和并发队列
dispatch queue
分发队列,我喜欢叫它任务分发队列,而任务就是我们在 block 中写的代码。
任务队列有两种:
- 串行:当一个任务执行完,才能执行下一个任务。
- 并行:当一个任务刚提交,不需要等它结束,就开始执行下一个任务。并发队列支持障碍任务(barrier block)
这两种队列均遵循 FIFO 原则,即先提交的任务先执行,举个简单的例子:
有三个任务,三个任务的输出分别是1,2,3。串行队列输出的结果是1,2,3;而并行队列的输出结果就不一定了。
虽然并行队列可以同时执行多个任务,但还是需要当前系统的状态来。如果当前系统最多只能处理2个任务,那么1、2就会排在前面先执行,等其中一个任务结束了,再执行任务3。
同步和异步
同步和异步针对的是线程。
同步任务:
- 同步任务会阻塞
当前线程
,必须等待任务执行完毕返回,才能继续执行下一个任务 - 作为一个优化,可能会在
当前线程
执行同步任务,因为切换线程需要消耗较多资源。但如果是在主队列中,则该任务会在主线程中执行 - 在当前队列中执行同步任务会导致死锁。
异步任务:
- 提交完任务后就立即返回,不会等待任务执行完毕。不会阻塞当前线程,会开启新的线程
这里结合队列举几个例子
例子1:
- (void)foo {
dispatch_queue_t queue = dispatch_queue_create(NULL, DISPATCH_QUEUE_SERIAL);
NSLog(@"任务0");
dispatch_sync(queue, ^{
sleep(1);
NSLog(@"任务1 %@", [NSThread currentThread]);
});
dispatch_async(queue, ^{
sleep(10);
NSLog(@"任务2 %@", [NSThread currentThread]);
});
dispatch_sync(queue, ^{
NSLog(@"任务3 %@", [NSThread currentThread]);
});
NSLog(@"任务4");
}
输出结果如下:
2019-08-23 11:18:06.165116+0800 190611[6192:1600283] 任务0
2019-08-23 11:18:07.166471+0800 190611[6192:1600283] 任务1 <NSThread: 0x280a95dc0>{number = 1, name = main}
2019-08-23 11:18:17.167838+0800 190611[6192:1600318] 任务2 <NSThread: 0x280414100>{number = 3, name = (null)}
2019-08-23 11:18:17.168202+0800 190611[6192:1600283] 任务3 <NSThread: 0x280a95dc0>{number = 1, name = main}
2019-08-23 11:18:17.168329+0800 190611[6192:1600283] 任务4
分析一下代码的执行流程:
- 创建一个串行队列。串行队列里的任务是顺序执行的,且需要当前任务执行返回,才能执行下一个任务
- 任务1是同步任务,会阻塞当前线程(主线程)。任务1在主线程中执行
- 等任务1执行后,执行任务2。任务2是一个异步任务,所以在一个新线程中执行。当任务2执行完毕(过了10秒),执行任务3
- 任务3是一个同步任务,阻塞当前线程。任务3在主线程中执行
- 任务3执行完毕后,主线程不再被阻塞,执行任务4
通过上面的例子,我们可以得出这样的结论:
- 串行中的任务遵循先提交新执行的原则,且是按顺序一个一个执行的。不管任务是同步的还是异步的
- 同步任务会在当前线程中执行,并阻塞当前线程。异步任务会在一个新的线程中执行
例子2:
- (void)foo {
dispatch_queue_t queue = dispatch_queue_create(NULL, DISPATCH_QUEUE_CONCURRENT);
NSLog(@"任务0");
dispatch_sync(queue, ^{
sleep(1);
NSLog(@"任务1 %@", [NSThread currentThread]);
});
dispatch_async(queue, ^{
sleep(10);
NSLog(@"任务2 %@", [NSThread currentThread]);
});
dispatch_sync(queue, ^{
NSLog(@"任务3 %@", [NSThread currentThread]);
});
NSLog(@"任务4");
}
输出结果如下:
2019-08-23 11:26:58.546591+0800 190611[6206:1601450] 任务0
2019-08-23 11:26:59.547963+0800 190611[6206:1601450] 任务1 <NSThread: 0x280283c40>{number = 1, name = main}
2019-08-23 11:26:59.548243+0800 190611[6206:1601450] 任务3 <NSThread: 0x280283c40>{number = 1, name = main}
2019-08-23 11:26:59.548320+0800 190611[6206:1601450] 任务4
2019-08-23 11:27:09.553363+0800 190611[6206:1601490] 任务2 <NSThread: 0x280c060c0>{number = 3, name = (null)}
分析一下代码的执行流程:
- 创建一个并行队列。并行队列里的任务也是顺序执行的,但不需要当前任务结束返回就能执行下一个任务
- 任务1是一个同步任务,会阻塞当前线程(主线程),并且执行任务(耗时1秒)。结束后,释放线程。执行任务3
- 任务3也是一个同步任务,会阻塞当前线程(主线程)。结束后,执行任务4
- 任务2是一个异步任务,虽然这个任务的提交时间跟任务1差不多,但它需要执行(10秒),所以它是最后才完成的
通过上面的例子,我们可以得出这样的结论:
- 并发队列虽然是"并发",但仍是按照任务提交顺序来执行任务的,只不过它不需要等待任务结束返回就可以开始执行下一个任务,所以表现起来像是并发的。
- 在上面的例子中,任务1和任务3同样都是同步任务。同步任务会阻塞当前线程,任务1因为先提交所以先执行,然后阻塞主线程。任务3虽然在任务1执行后也跟着执行,但是因为主线程被任务1阻塞了,所以必须等待任务1执行完毕释放线程才能接着执行任务
下面我提几个问题,因为我在我好多博客里都看到了错误的结论
- 在主队列中使用同步任务是否会造成死锁?
如果你直接在主线程中使用主队列提交一个同步任务是会造成死锁的。但下面这种情况就不会
例子3:
- (void)foo {
dispatch_queue_t queue = dispatch_queue_create(NULL, DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"任务1");
});
});
NSLog(@"任务0");
}
输出结果是
2019-08-23 14:35:44.580059+0800 190611[6310:1614276] 任务0
2019-08-23 14:35:44.587546+0800 190611[6310:1614276] 任务1
为什么呢?同步任务往往会阻塞当前线程,任务1在一个异步任务中提交,而异步任务中会创建一个新线程。所以,任务1仅仅是阻塞了这个新的线程。主队列是一个串行队列,任务0是可以算作提交的第一个任务,任务1是后面提交的任务,所以先执行任务1,再执行任务1.
还有很多说在并行队列中同步任务是顺序执行的
- 在并行队列中的同步任务是否是顺序执行的?
在上面的例子2中是顺序执行,但下面这个例子里的就不会
例子4:
dispatch_queue_t queue1 = dispatch_queue_create(NULL, DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t queue2 = dispatch_queue_create(NULL, DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue2, ^{
dispatch_sync(queue1, ^{
sleep(3);
NSLog(@"任务1");
});
});
dispatch_sync(queue1, ^{
NSLog(@"任务2");
});
NSLog(@"任务0");
输出结果:
2019-08-23 14:56:55.999218+0800 190611[6346:1617197] 任务2
2019-08-23 14:56:55.999259+0800 190611[6346:1617197] 任务0
2019-08-23 14:56:59.008903+0800 190611[6346:1617213] 任务1
在任务2顺序执行的原因是在第一个同步任务阻塞了当前的线程。但在这个例子中,第一个同步任务,阻塞的是异步任务中创建的新线程,而不是主线程,所以第二个同步任务任务2会率先完成。
所以不要去背别人写好的规则,要学会自己去分析。下面是几点总结,类似于数学中的”公理“,能帮助你分析
- 串行和并行队列,都是先执行先提交的任务。串行会等这个任务结束再执行下一个任务,而并行队列不会等它结束就执行下一个任务
- 同步任务会在当前线程中执行,除了主队列提交的同步任务会在主线程中执行
- 异步任务中会创建一个新的线程
更难的案例分析
案例1
- (void)foo {
NSLog(@"任务1");
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
NSLog(@"任务2");
});
NSLog(@"任务3");
}
输出结果:
2019-08-23 15:07:03.149869+0800 190611[6361:1618423] 任务1
2019-08-23 15:07:03.149920+0800 190611[6361:1618423] 任务2
2019-08-23 15:07:03.149937+0800 190611[6361:1618423] 任务3
这里谈一下我的理解
- 首先我会将 foo() 这个方法当做在主队列中的第一个同步任务,把它叫做任务0好了
- 任务0在主线程中执行。任务2是一个同步任务,所以它会阻塞主线程。任务2提交到了一个并发队列中,而不是主队列,所以不会造成死锁。
- 任务2完成,释放主线程,执行任务3
如果把任务2中的队列替换成主队列,就会造成死锁。
- (void)foo {
NSLog(@"任务1");
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"任务2");
});
NSLog(@"任务3");
}
原因是主队列是一个串行队列,任务按顺序执行。所以先要执行任务0,而任务2需要任务0执行完毕才能执行,但是任务2会阻塞主线程,导致两个任务谁都无法完成,造成死锁。
案例2
- (void)foo {
dispatch_queue_t queue = dispatch_queue_create(NULL, DISPATCH_QUEUE_SERIAL);
NSLog(@"任务1");
dispatch_async(queue, ^{
NSLog(@"任务2");
dispatch_sync(queue, ^{
NSLog(@"任务3");
});
NSLog(@"任务4");
});
NSLog(@"任务5");
}
输出结果是 1,5,2 或者是 1,2,5
分析流程:
- 首先执行任务1
- 创建一个串行队列,并添加一个异步任务。由于异步任务和任务5不知道哪个会先执行,所以输出结果可能是 1,5,2 或者是 1,2,5
- 在异步任务中,首先执行任务2.然后在当前串行队列中使用了同步任务,造成死锁
案例3
- (void)foo {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSLog(@"任务1");
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"任务2");
});
NSLog(@"任务3");
});
NSLog(@"任务4");
while (1) {
}
NSLog(@"任务5");
}
输出结果是 1,4 或者是 4,1
分析流程:
- 首先在并行队列中添加一个异步任务,所以不确定任务1跟任务4哪个先执行。所以输出结果是 1,4 或者是 4,1
- 在异步任务中,先执行任务1。然后碰到一个主队列的同步任务,由于是同步任务,所以会阻塞当前线程。该同步任务会等待主队列中的任务5执行完成然后再执行,但是任务5前面有一个死循环,所以任务5永远不会完成,也就是任务2永远无法完成,于是会一直卡着线程
总结
写了很多,写的也很杂,但如果你想了解锁,了解多线程的话,这些知识都是少不了的。
为了让硬件得到充分利用,我们会使用 GCD 来使用多线程,而为了多线程安全,我们又会使用锁。学习本身也是一个递归的过程,希望大家看完能有所收货~
网友评论