并发编程:API 及挑战
线程
线程(thread)是组成进程的子单元,操作系统的调度器可以对线程进行单独的调度。实际上,所有的并发编程 API 都是构建于线程之上的 —— 包括 GCD 和操作队列(operation queues)。
多线程可以在单核 CPU 上同时(或者至少看作同时)运行。操作系统将小的时间片分配给每一个线程,这样就能够让用户感觉到有多个任务在同时进行。如果 CPU 是多核的,那么线程就可以真正的以并发方式被执行,从而减少了完成某项操作所需要的总时间。 -- 记得有张图很好!!!Grand Central Dispatch 基础教程:Part 1/2
需要重点关注的是,你无法控制你的代码在什么地方以及什么时候被调度,以及无法控制执行多长时间后将被暂停,以便轮换执行别的任务。这种线程调度是非常强大的一种技术。
(1)pthread太复杂
(2)NSThread 是 Objective-C 对 pthread 的一个封装。通过封装,在 Cocoa 环境中,可以让代码看起来更加亲切。例如,开发者可以利用 NSThread 的一个子类来定义一个线程,在这个子类的中封装需要在后台线程运行的代码。- start,- isFinished
两个基于队列的并发编程 API :GCD 和 operation queue 。它们集中管理一个被大家协同使用的线程池。
(3)GCD
通过 GCD,开发者不用再直接跟线程打交道了,只需要向队列中添加代码块即可,GCD 在后端管理着一个线程池。GCD 不仅决定着你的代码块将在哪个线程被执行,它还根据可用的系统资源对这些线程进行管理。这样可以将开发者从线程管理的工作中解放出来,通过集中的管理线程,来缓解大量线程被创建的问题。
GCD 带来的另一个重要改变是,作为开发者可以将工作考虑为一个队列,而不是一堆线程,这种并行的抽象模型更容易掌握和使用。
GCD 公开有 5 个不同的队列:<1> 运行在主线程中的 main queue,<2> 3 个不同优先级的后台队列,以及<3> 一个优先级更低的后台队列(用于 I/O)。
可以创建自定义队列:串行或者并行队列。在自定义队列中被调度的所有 block 最终都将被放入到系统的全局队列中和线程池中。
(4)Operation Queues
操作队列(operation queue)是由 GCD 提供的一个队列模型的 Cocoa 抽象。GCD 提供了更加底层的控制,而操作队列则在 GCD 之上实现了一些方便的功能,这些功能对于 app 的开发者来说通常是最好最安全的选择。
NSOperationQueue有两种不同类型的队列:主队列和自定义队列。主队列运行在主线程之上,而自定义队列在后台执行。在两种类型中,这些队列所处理的任务都使用NSOperation的子类来表述。
你可以通过重写main或者start(拥有更多的控制权,在操作中可以执行异步任务 - isExecuting,isFinished,isCancelled)方法 来定义自己的operations。
为了让操作队列能够捕获到操作的改变,需要将状态的属性以配合 KVO 的方式进行实现。
addOperation: -- addOperationWithBlock:
比 GCD完善的功能:= 最大并发 + 队列优先级 + 依赖关系
(1)maxConcurrentOperationCount属性来控制一个特定队列中可以有多少个操作参与并发执行。将其设置为 1 的话,你将得到一个串行队列,这在以隔离为目的的时候会很有用。
(2)根据队列中operation的优先级对其进行排序,这不同于 GCD 的队列优先级,它只影响当前队列中所有被调度的 operation 的执行先后。超越5个标准优先级之外的op执行顺序控制,可以在op之间指定依赖关系:
[intermediateOperation addDependency:operation1];
操作队列的性能比 GCD 要低那么一点,操作队列是并发编程的首选工具。
Run Loops
在主 dispatch/operation 队列中, run loop 将直接配合任务的执行,它提供了一种异步执行代码的机制。
一个 run loop 总是绑定到某个特定的线程中。main run loop 是与主线程相关的,在每一个 Cocoa 和 CocoaTouch 程序中,这个 main run loop 都扮演了一个核心角色,它负责处理 UI 事件、计时器,以及其它内核相关事件。无论你什么时候设置计时器、使用NSURLConnection或者调用performSelector:withObject:afterDelay:,其实背后都是 run loop 在处理这些异步任务。
无论何时你使用 run loop 来执行一个方法的时候,都需要记住一点:run loop 可以运行在不同的模式中,每种模式都定义了一组事件,供 run loop 做出响应。这在对应 main run loop 中暂时性的将某个任务优先执行这种任务上是一种聪明的做法。
滚动,trackingMode,不会响应defaultMode添加的计时器,停止滚动,回到default才响应。如果滚动时候要响应计时器,需要将其设为NSRunLoopCommonModes的模式,并添加到 run loop 中。
如果你真需要在别的线程中添加一个 run loop ,那么不要忘记在 run loop 中至少添加一个 input source 。如果 run loop 中没有设置好的 input source,那么每次运行这个 run loop ,它都会立即退出。
并发编程中面临的挑战
资源共享
并发编程中许多问题的根源就是在多线程中访问共享资源。在多线程中任何一个共享的资源都可能是一个潜在的冲突点,你必须精心设计以防止这种冲突的发生。
在多线程里面访问一个共享的资源,如果没有一种机制来确保在线程 A 结束访问一个共享资源之前,线程 B 就不会开始访问该共享资源的话,资源竞争的问题就总是会发生。多线程需要一种互斥的机制来访问共享资源、
互斥锁 -- 解决了竞态条件的问题
(1)互斥访问的意思就是同一时刻,只允许一个线程访问某个特定资源。为了保证这一点,每个希望访问共享资源的线程,首先需要获得一个共享资源的互斥锁,一旦某个线程对资源完成了操作,就释放掉这个互斥锁,这样别的线程就有机会访问该共享资源了。
(2)为了解决由 CPU 的优化策略引起的副作用,还需要引入内存屏障。通过设置内存屏障,来确保没有无序执行的指令能跨过屏障而执行。
将一个属性声明为 atomic 表示每次访问该属性都会进行隐式的加锁和解锁操作。
死锁 -- 自己持有一个锁,想要拿对象的锁
当多个线程在相互等待着对方的结束时,就会发生死锁。
资源饥饿(Starvation)-- 没有写入锁可持有读取锁,持有读取锁等待写入锁,造成其他饥饿
锁定的共享资源会引起读写问题。大多数情况下,限制资源一次只能有一个线程进行读取访问其实是非常浪费的。因此,在资源上没有写入锁的时候,持有一个读取锁是被允许的。这种情况下,如果一个持有读取锁的线程在等待获取写入锁的时候,其他希望读取资源的线程则因为无法获得这个读取锁而导致资源饥饿的发生。
优先级反转(乱入,第三者) -- 程序在运行时低优先级的任务阻塞了高优先级的任务,有效的反转了任务的优先级。
高低优先级任务共享资源,低优先级任务拿到锁,高优先级任务阻塞,出现中优先级任务,因为高的被阻塞,所以中的优先级最高,阻塞低的,然后自己执行,这个过程中间接阻塞了高优先级任务。简直就是乱入带来的后果。
总结
在开发中,关键的一点就是尽量让并发模型保持简单,这样可以限制所需要的锁的数量。
我们建议采纳的安全模式是这样的:从主线程中提取出要使用到的数据,并利用一个操作队列在后台处理相关的数据,最后回到主队列中来发送你在后台队列中得到的结果。使用这种方式,你不需要自己做任何锁操作,这也就大大减少了犯错误的几率。
常见的后台实践
如何并发地使用 Core Data ,如何并行绘制 UI ,如何做异步网络请求等。如何异步处理大型文件,以保持较低的内存占用。
操作队列 (Operation Queues) 还是 GCD ?
其中 GCD 是基于 C 的底层的 API ,而操作队列则是 GCD 实现的 Objective-C API。
OP 比 GCD 最重要的一个就是可以取消在任务处理队列中的任务。相反,GCD 给予你更多的控制权力以及操作队列中所不能使用的底层函数。
后台的 Core Data
-- Core Data Programming - Part V: Advanced Topics - Concurrency
绝对不要在线程间传递 managed objects等。要想传递这样的对象,正确做法是通过传递它的 object ID ,然后从其他对应线程所绑定的 context 中去获取这个对象。
然后,如果你想要做大量的处理,那么把它放到一个后台上下文来做会比较好。一个典型的应用场景是将大量数据导入到 Core Data 中。
(1)我们为导入工作单独创建一个操作
(2)我们创建一个 managed object context ,它和主 managed object context 使用同样的 persistent store coordinator
(3)一旦导入 context 保存了,我们就通知 主 managed object context 并且合并这些改变
源码:
我们创建一个NSOperation的子类,将其叫做ImportOperation,我们通过重写main方法,用来处理所有的导入工作。这里我们使用NSPrivateQueueConcurrencyType(也就还有一种MainQueue的)来创建一个独立并拥有自己的私有 dispatch queue 的 managed object context,这个 context 需要管理自己的队列。在队列中的所有操作必须使用performBlock或者performBlockAndWait来进行触发。这点对于保证这些操作能在正确的线程上执行是相当重要的。
NSFetchedResultsCon做数据源 + Store用于配置各种context + operation
Store: -- 暴露一个objContext,-save方法,-privateContext
<1> 初始化注册通知DidSave = 如果是privateContext发送的通知,非privateContext执行合并改变 (现在在后台 context中导入的数据还不能传送到主 context 中,显式地让它这么去做。)
<2> getter,暴露非Private objContext
<3> getter,Private objContext
<4> getter,pSC(<- objectModel) => addPersis:configure:URL(storeURL) = store
<5> getter,objectModel(modelURL)
ImportViewController
<1> start(配置operation,添加队列)(前台更新UI,后台处理数据导入) + cancel + progress + tableView
<2> init + viewDidLoad()
ImportOperation -- privateContext
progressCallback -- 需要注意的是,更新进度条必须在主线程中完成,否则会导致 UIKit 崩溃。
批量保存。在导入较大的数据时,我们需要定期保存,逐渐导入,否则内存很可能就会被耗光,性能一般也会更坏。每 250 次导入就保存一次。
<1> init
<2> main -- import
FetchedResultsTableDataSource -- fetchedResultsController(mainContext)是数据源 like NSArray
<1> tableView + fetchedResultsController
<2> - init + changePredicate + selectedItem + configureCell
<3> DataSource4:titleForHeader
<4> Delegate4:通过fetchedResultsController这个数据源数据的动态改变来更新tableView
Stop
<1> Model
<2> Category2:fetch + insert
PS:
<1> performBlockAndWait 可以通过队列cancel掉,而performBlock不可以
<2> cell不动态加入,发生在最不可能的类中。_mainManagedObjectContext getter方法中错误
其他
(1)导入操作时,我们将整个文件都读入到一个字符串中,然后将其分割成行 => 相对小的文件。对于大文件,最好采用惰性读取 (lazily read) 的方式逐行读入。使用输入流的方式来实现这个特性。
(2)在 app 第一次运行时,除大量数据导入 Core Data 以外,<1> 也可以在你的 app bundle 中直接放一个 sqlite 文件。<2>从一个可以动态生成数据的服务器下载。这些方式可以节省不少在设备上的处理时间。
(3)对于 child contexts 争议。不要在后台操作中使用它。<1> 如果你以主 context 的 child 的方式创建了一个后台 context 的话,保存这个后台 context 将阻塞主线程。<2> 将主 context 作为后台 context 的 child 的话,实际上和与创建两个传统的独立 contexts 来说是没有区别的。因为你仍然需要手动将后台的改变合并回主 context 中去。
(4)设置一个 persistent store coordinator 和 两个独立的 contexts 是在后台处理 Core Data BP 。
后台 UI 代码
为了避免在运行 block 时访问到已被释放的对象,在 block 中我们又需要将其转回 strong 引用。
后台绘制 -- 之前 + 如何做 + 操作队列放入取消 + CALayer异步绘制
确定drawRect:是你的应用的性能瓶颈,那么你可以将这些绘制代码放到后台去做。做之前,检查下看看是不是有其他方法来。<1> 考虑使用 CALayers 或者预先渲染图片而不去做 CG 绘制。<2> Florian 对在真机上图像性能测量的帖子 <3> UIKit 工程师 Andy Matuschak 对个各种方式的权衡的评论。
如何做:在后台绘制代码会是你的最好选择时再这么做。把drawRect:中的代码放到一个后台操作中去做就可以了。然后将原本打算绘制的视图用一个 imageView 来替换,等到操作执行完后再去更新。在绘制的方法中,使用UIGraphicsBeginImageContextWithOptions(-,-,0_scale自动传入) + get Image + End
tableView OR collectionView 的 cell 上做了自定义绘制的话,最好放入 operation 的子类中去。你可以将它们添加到后台操作队列,也可以在用户将 cell 滚动出边界时的didEndDisplayingCell委托方法中进行取消。这些技巧都在 2012 年的WWDCSession 211 -- Building Concurrent User Interfaces on iOS
CALayer -- drawsAsynchronously
异步网络请求处理
你的所有网络请求都应该采取异步的方式完成。最好还是不要阻塞线程。(1)使用NSURLSession/Connection的异步方法,并且把所有操作转化为 operation 来执行。operationQueue的强大功能:控制并发操作的数量,添加依赖,以及取消操作。
NSURLConnection是通过 run loop 来发送事件的。因为时间发送不会花多少时间,因此最简单的是只使用 main run loop 。用后台线程来处理输入的数据了。
(2)另一种方式是AFNetworking:建立一个独立的线程,为建立的线程设置自己的 run loop,然后在其中调度 URL 连接。不推荐
自定义的 operation 子类中的start方法:+ cancel: + isFinished,isExecuting + 代理回调
进阶:后台文件 I/O
大文件对内存负担大,要解决这个问题,构建一个类,负责一行一行读取文件而不是一次将整个文件读入内存,另外要在后台队列处理文件,以保持应用响应用户的操作。
异步处理文件的NSInputStream -- 官方文档
不管你是否使用 streams,大体上逐行读取一个文件的模式是这样的:
(1)建立一个中间缓冲层以提供,当没有找到换行符号的时候可以向其中添加数据
(2)从 stream 中读取一块数据
(3)对于这块数据中发现的每一个换行符,取中间缓冲层,向其中添加数据,直到(并包括)这个换行符,并将其输出
(4)将剩余的字节添加到中间缓冲层去
(5)回到 2,直到 stream 关闭
源码:
绝大部分时候,使用逐块读入的方式来处理大文件,是非常有用的技术。
在主队列中接收事件或者数据,然后用后台操作队列来执行实际操作,然后回到主队列去传递结果,遵循这样的原则来编写尽量简单的并行代码,高效。