一些常见的多线程相关的面试题
进程和线程的区别
1 进程是操作系统分配资源的最小单元,拥有独立运行所需的全部资源.线程是CPU 分配资源和调度的最小单位
2 线程是进程的执行单元,进程的所有任务都在线程中执行
3 一个程序可以对应多个进程(多进程),一个进程中可有多个线程,但至少要有一条线程
4 同一个进程内的线程共享进程资源
iOS进程间的通信方式
1、URL scheme
这个是iOS APP通信最常用到的通信方式,APP1通过openURL的方法跳转到APP2,并且在URL中带上想要的参数,有点类似HTTP的get请求那样进行参数传递。这种方式是使用最多的最常见的,使用方法也很简单只需要源APP1在info.plist中配置LSApplicationQueriesSchemes,指定目标App2的scheme;然后再目标App2的info.plist 中配置好URLtypes,表示该App接受何种URL scheme的唤起。
2、Keychain
iOS 系统的keychain是一个安全的存储容器,它本质上就是一个sqlite数据库,它的位置存储在/private/var/Keychains/keychain-2.db,不过它所保存的所有数据都是经过加密的,可以用来为不同的APP保存敏感信息,比如用户名,密码等。iOS系统自己也用keychain来保存VPN凭证和WiFi密码。它是独立于每个APP的沙盒之外的,所以即使APP被删除之后,keychain里面的信息依然存在
3、UIPasteBoard
uipasteboard是剪切板功能,因为iOS 的原生控件UItextView,UItextfield,UIwebView ,我们在使用时如果长按,就回出现复制、剪切、选中、全选、粘贴等功能,这个就是利用系统剪切板功能来实现的。
4、UIDocumentInteractionController uidocumentinteractioncontroller 主要是用来实现同设备上APP之间的贡献文档,以及文档预览、打印、发邮件和复制等功能。
5、Local socket
原理:一个APP1在本地的端口port1234 进行TCP的bind 和 listen,另外一个APP2在同一个端口port1234发起TCP的connect连接,这样就可以简历正常的TCP连接,进行TCP通信了,然后想传什么数据就可以传什么数据了
6、AirDrop
通过 Airdrop实现不同设备的APP之间文档和数据的分享
7、UIActivityViewController
iOS SDK 中封装好的类在APP之间发送数据、分享数据和操作数据
8、APP Groups
APP group用于同一个开发团队开发的APP之间,包括APP和extension之间共享同一份读写空间,进行数据共享。同一个团队开发的多个应用之间如果能直接数据共享,大大提高用户体验
多线程的优缺点
优点:
1 能提高程序的执行效率
2 能提高资源利用率(CPU、内存的利用率)
缺点:
1 开启线程会占用一定的内存空间(默认情况下,主线程占用1M,子线程占用512K),如果开启大量子线程,会占用大量的内存空间,降低程序的性能
2 线程越多,CPU在调度线程上的开销就越大
3 程序设计更加复杂:比如线程之间的通信、多线程的数据共享与安全
什么是线程任务
任务就是线程执行的操作,也就是在线程中执行的代码,在 GCD 中是放在 block 中的。执行任务有两种 方式:同步执行(sync)和异步执行(async)
同步(Sync):同步添加任务到指定的队列中,在添加的任务执行结束之前,会一直等待,直到队列里面的 任务完成之后再继续执行,即会阻塞线程。只能在当前线程中执行任务(是当前线程,不一定是主线程), 不具备开启新线程的能力。
异步(Async):线程会立即返回,无需等待就会继续执行下面的任务,不阻塞当前线程。可以在新的线程中 执行任务,具备开启新线程的能力(并不一定开启新线程)。如果不是添加到主队列上,异步会在子线程中 执行任务。
什么是线程队列
队列(Dispatch Queue)是指执行任务的等待队列,即用来存放任务的队列。队列是一种特殊的线性表,采用FIFO(先进先出)的原则,即新任务总是被插入到队列的末尾,而读取任务的时候总是从 队列的头部开始读取。每读取一个任务,则从队列中释放一个任务
在 GCD 中有两种队列:串行队列和并发队列。两者都符合 FIFO(先进先出)的原则。两者的主要区别是: 执行顺序不同,以及开启线程数不同。
串行队列(Serial Dispatch Queue): 同一时间内,队列中只能执行一个任务,只有当前的任务执行完成之后,才能执行下一个任务。(只开启一个线程,一个任务执行完毕后,再执行下一个任务)。主队列是主线程上的一个串行队列,是系统自动为我们创建的
并发队列(Concurrent Dispatch Queue): 同时允许多个任务并发执行。(可以开启多个线程,并且同时执行任务)。并发队列的并发功能只有在异步(dispatch_async)函数下才有效
介绍下NSThread
NSThread:轻量级别的多线程技术,是我们自己手动开辟的子线程,如果使用的是初始化方式就需要我们自己启动,如果使用的是构造器方式,它就会自动启动。只要是我们手动开辟的线程,都需要我们自己管理该线程,不只是启动,还有该线程使
用完毕后的资源回收
1 | // 使用初始化创建出来的方法,需要主动启动,主动关闭 |
介绍下performSelector
performSelector…只要是NSObject的子类或者对象都可以在子线程和主线程调用这个方法。
需要注意的是:如果是带 afterDelay 的延时函数,会在内部创建一个 NSTimer,然后添加到当前线程的 Runloop 中。也就是如果当前线程没有开启 runloop,该方法会失效。在子线程中,需要启动 runloop(注 意调用顺序)
1 | [self performSelector:@selector(testThread) withObject:nil afterDelay:1]; |
而 performSelector:withObject:只是一个单纯的消息发送,和时间没有一点关系。所以不需要添加到子 线程的 Runloop 中也能执行
1 | - (void)viewDidLoad { |
运行结果如下:
1 | 2020-02-10 19:30:15.363854+0800 多线程测试[76105:2623507] <_NSMainThread: 0x6000019d80c0>{number = 1, name = main} |
GCD和NSOperation的关系
GCD 是面向底层的C语言的API,NSOpertaionQueue用GCD构建封装的,是GCD 的高级抽象。
1、GCD 执行效率更高,而且由于队列中执行的是由 block 构成的任务,这是一个轻量级的数据结构,写起 来更方便
2、GCD 只支持FIFO的队列,而NSOperationQueue可以通过设置最大并发数,设置优先级,添加依赖关系等调整执行顺序
3、NSOperationQueue甚至可以跨队列设置依赖关系,但是GCD只能通过设置串行队列,或者在队列内添 加barrier(dispatch_barrier_async)任务,才能控制执行顺序,较为复杂
4、NSOperationQueue因为面向对象,所以支持KVO,可以监测operation是否正在执行(isExecuted)、 是否结束(isFinished)、是否取消(isCanceld)
1 实际项目开发中,很多时候只是会用到异步操作,不会有特别复杂的线程关系管理,所以苹果推崇的且优化完善、运行快速的GCD是首选
2 如果考虑异步操作之间的事务性,顺序行,依赖关系,比如多线程并发下载,GCD需要自己写更多的代码来实现,而NSOperationQueue 已经内建了这些支持
3 不论是GCD还是NSOperationQueue,我们接触的都是任务和队列,都没有直接接触到线程,事实上线程管理也的确不需要我们操心,系统对于线程的创建,调度管理和释放都做得很好。而NSThread需要我们自己去管理线程的生命周期,还要考虑线程同步、加锁问题,造成一些性能上的开销
GCD的队列有哪几种
iOS中GCD的队列有三种类型:
1 main_queue:通过dispatch_get_main_queue()获得,这是一个与主线程相关的串行队列。
2 global queue:全局队列,是并发队列,由整个进程共享,存在着高、中、低三种优先级的全局队列,调用dispath_get_global_queue 并传入优先级来访问队列。
3 自定义队列:通过函数 dispatch_queue_create 创建的队列,有串并行之分
GCD dispatch_queue_t 作为属性用strong还是assign?
在AFNetWorking里有段代码,是dispatch_queue_t作为属性
1 | @property (readwrite, nonatomic, strong) dispatch_queue_t requestHeaderModificationQueue; |
为什么要用strong的方式来管理呢?
运行时打断点,就可以看到dispatch_queue_t的继承关系,最终的父类NSObject,所以要用strong的方式来管理。
死锁
死锁就是队列引起的循环等待,下面是几个常见的死锁例子:
1 主队列同步
1 | dispatch_sync(dispatch_get_main_queue(), ^{ |
在主线程中运用主队列同步,也就是把任务放到了主线程的队列中。 同步对于任务是立刻执行的,那么当把任务放进主队列时,它就会立马执行,只有执行完这个任务, viewDidLoad 才会继续向下执行。
而 viewDidLoad 和任务都是在主队列上的,由于队列的先进先出原则,任务又需等待 viewDidLoad 执行完 毕后才能继续执行,viewDidLoad 和这个任务就形成了相互循环等待,就造成了死锁。 想避免这种死锁,可以将同步改成异步 dispatch_async,或者将 dispatch_get_main_queue 换成其他串行 或并行队列,都可以解决。
2 同步队列里循环等待
1 | dispatch_queue_t serialQueue = dispatch_queue_create("测试的同步队列", DISPATCH_QUEUE_SERIAL); |
外面的函数无论是同步还是异步都会造成死锁。
这是因为里面的任务和外面的任务都在同一个 serialQueue 队列内,又是同步,这就和上边主队列同步的 例子一样造成了死锁
解决方法也和上边一样,将里面的同步改成异步 dispatch_async,或者将 serialQueue 换成其他串行或并 行队列,都可以解决
多线程常用案例
案例一:多个异步线程,转为有序同步线程
1 | dispatch_semaphore_t semaphore = dispatch_semaphore_create(1); |
案例二:用GCD实现多读单写
多读单写的意思就是:可以多个读者同时读取数据,而在读的时候,不能取写入数据。并且,在写的过程 中,不能有其他写者去写。即读者之间是并发的,写者与读者或其他写者是互斥的。这里的写处理就是通过栅栏的形式去写。 就可以用dispatch_barrier_sync(栅栏函数)去实现
dispatch_barrier_sync: Submits a barrier block object for execution and waits until that block completes.(提交一个栅栏函数在执行中,它会等待栅栏函数执行完)
dispatch_barrier_async: Submits a barrier block for asynchronous execution and returns immediately.(提交一个栅栏函数在异步执行中,它会立马返回)
实现代码如下:
1 | - (id)readDataForKey:(NSString *)key { |
案例三:多个网络请求并发,全部结束后刷新界面
1 | dispatch_queue_t concurrentQueue = dispatch_queue_create("测试的同步队列", DISPATCH_QUEUE_CONCURRENT); |
案例四:实现单例
1 | + (instancetype)shareInstance { |
GCD的信号量
GCD 中的信号量是指 Dispatch Semaphore,是持有计数的信号。 Dispatch Semaphore 提供了三个函数:
1.dispatch_semaphore_create:创建一个 Semaphore 并初始化信号的总量 2.dispatch_semaphore_signal:发送一个信号,让信号总量加 1 3.dispatch_semaphore_wait:可以使总信号量减 1,当信号总量为 0 时就会一直等待(阻塞所在线程),否 则就可以正常执行。
Dispatch Semaphore 在实际开发中主要有两个用途:
1 保持线程同步,将异步执行任务转换为同步执行任务
1 | dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); |
dispatch_semaphore_wait 加锁阻塞了当前线程,dispatch_semaphore_signal 解锁后当前线程继续执行
2 保证线程安全,为线程加锁
在线程安全中可以将 dispatch_semaphore_wait 看作加锁,而 dispatch_semaphore_signal 看作解锁 首先创建全局变量
注意到这里的初始化信号量是 1。
1 | _semaphore = dispatch_semaphore_create(1); |
异步任务
1 | - (void)asyncTask { |
在子线程中并发执行 asyncTask,那么第一个添加到并发队列里的,会将信号量减 1,此时信号量等于 0, 可以执行接下来的任务。而并发队列中其他任务,由于此时信号量不等于 0,必须等当前正在执行的任务 执行完毕后调用 dispatch_semaphore_signal 将信号量加 1,才可以继续执行接下来的任务,以此类推,从而 达到线程加锁的目的。
延时函数
dispatch_after 能让我们添加进队列的任务延时执行,该函数并不是在指定时间后执行处理,而只是在指 定时间追加处理到 dispatch_queue
由于其内部使用的是 dispatch_time_t 管理时间,而不是 NSTimer。 所以如果在子线程中调用,相比 performSelector:afterDelay,不用关心 runloop 是否开启
NSOperationQueue的优点
NSOperation、NSOperationQueue 是苹果提供给我们的一套多线程解决方案。实际上 NSOperation、 NSOperationQueue 是基于 GCD 更高一层的封装,完全面向对象。但是比 GCD 更简单易用、代码可读性 也更高。
1、可以添加任务依赖,方便控制执行顺序
2、可以设定操作执行的优先级 3、任务执行状态控制:isReady,isExecuting,isFinished,isCancelled
如果只是重写 NSOperation 的 main 方法,由底层控制变更任务执行及完成状态,以及任务退出 如果重写了 NSOperation 的 start 方法,自行控制任务状态
系统通过 KVO 的方式移除 isFinished==YES 的 NSOperation
3、可以设置最大并发量
自旋锁与互斥锁
自旋锁:
是一种用于保护多线程共享资源的锁,与一般互斥锁(mutex)不同之处在于当自旋锁尝试获取锁时以忙等 待(busy waiting)的形式不断地循环检查锁是否可用。当上一个线程的任务没有执行完毕的时候(被锁住), 那么下一个线程会一直等待(不会睡眠),当上一个线程的任务执行完毕,下一个线程会立即执行。
在多 CPU 的环境中,对持有锁较短的程序来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能。
互斥锁:
当上一个线程的任务没有执行完毕的时候(被锁住),那么下一个线程会进入睡眠状态等待任务执行完毕, 当上一个线程的任务执行完毕,下一个线程会自动唤醒然后执行任务。
总结:
自旋锁会忙等: 所谓忙等,即在访问被锁资源时,调用者线程不会休眠,而是不停循环在那里,直到被锁 资源释放锁。
互斥锁会休眠: 所谓休眠,即在访问被锁资源时,调用者线程会休眠,此时 cpu 可以调度其他线程工 作。直到被锁资源释放锁。此时会唤醒休眠线程。
优缺点:
自旋锁的优点在于,因为自旋锁不会引起调用者睡眠,所以不会进行线程调度,CPU 时间片轮转等耗时操 作。所有如果能在很短的时间内获得锁,自旋锁的效率远高于互斥锁。
缺点在于,自旋锁一直占用 CPU,他在未获得锁的情况下,一直运行–自旋,所以占用着 CPU,如果不 能在很短的时 间内获得锁,这无疑会使 CPU 效率降低。自旋锁不能实现递归调用。
自旋锁:atomic、OSSpinLock、dispatch_semaphore_t
互斥锁:pthread_mutex、@ synchronized、NSLock、NSConditionLock 、NSCondition、NSRecursiveLock十六、自旋锁与互斥锁
代码执行效果评估
第一段
1 | dispatch_queue_t serialQueue = dispatch_queue_create("测试的同步队列", DISPATCH_QUEUE_SERIAL); |
打印顺序是 13245
原因是:
首先先打印 1
接下来将任务 2 其添加至串行队列上,由于任务 2 是异步,不会阻塞线程,继续向下执行,打印 3 然后是任务 4,将任务 4 添加至串行队列上,因为任务 4 和任务 2 在同一串行队列,根据队列先进先出原则, 任务 4 必须等任务 2 执行后才能执行,又因为任务 4 是同步任务,会阻塞线程,只有执行完任务 4 才能继 续向下执行打印 5
所以最终顺序就是 13245。
这里的任务 4 在主线程中执行,而任务 2 在子线程中执行。
如果任务 4 是添加到另一个串行队列或者并行队列,则任务 2 和任务 4 无序执行(可以添加多个任务看效果)
第二段
1 | - (void)viewDidLoad { |
运行结果如下:
1 | 2020-02-10 15:55:54.273147+0800 多线程测试[60327:2437248] 程序开始 |
运行情况是,执行到第一个dispatch_async的时候,异步添加到同步队列里,会立即走后面的dispatch_sync,dispatch_sync在同步队列阻塞队列,知道轮到它执行,那么就会等第一个dispatch_async添加的操作执行完毕,也就是休眠五秒打印1,然后轮到第二个同步操作执行,休眠3秒打印2.最后才轮到主线程休眠1秒打印3.
第三段
1 | dispatch_async(dispatch_get_global_queue(0, 0), ^{ |
这里的test方法是不会去执行的,原因在于performSelector
这个方法中带延时的方法,要创建提交任务到runloop上的,而gcd底层创建的线程是默认没有开启对应runloop的,所有这个方法就会失效。
而如果将dispatch_get_global_queue改成主队列,由于主队列所在的主线程是默认开启了runloop的, 就会去执行(将 dispatch_async 无需改成同步,虽然同步是在当前线程执行,但这里的异步同步只是决定添加到主线程队列这个任务的操作是同步还是异步,这个无所谓。那么如果当前线程是主线程,test方法也是会去执行的)。
并发下线程池的最佳数量计算
如果是CPU密集型应用,则线程池大小设置为N+1;(对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。(即使当计算密集型的线程偶尔由于缺失故障或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费。摘自《Java Concurrency In Practise》)
如果是IO密集型应用,则线程池大小设置为2N+1
任务一般可分为:CPU密集型、IO密集型、混合型,对于不同类型的任务需要分配不同大小的线程池。CPU密集型任务 尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,只能增加上下文切换的次数,因此会带来额外的开销。IO密集型任务 可以使用稍大的线程池,一般为2乘以CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候去处理别的任务,充分利用CPU时间。混合型任务 可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。 因为如果划分之后两个任务执行时间相差甚远,那么先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。
这里还有一种计算方式:
一个系统最快的部分是CPU,所以决定一个系统吞吐量上限的是CPU。增强CPU处理能力,可以提高系统吞吐量上限。在考虑时需要把CPU吞吐量加进去。在IO优化文档中,有这样地公式:
最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目