所有的语言都会涉及并发编程,并发就是多个任务同时运行,这也几乎是所有语言最难的地方。iOS 开发中,并发编程主要用于提升 App 的运行性能,保证App实时响应用户的操作。其中我们日常操作的 UI 界面就是运行在主线程之上,是一个串行线程。如果我们将所有的代码放在主线程上运行,那么主线程将承担网络请求、数据处理、图像渲染等各种操作,无论是 GPU 还是内存都会性能耗尽,从而影响用户体验。
本章将从并发编程的理论说起,重点关注 GCD 和 Operations——iOS 开发中最主要的处理并发编程的两套 API。除了理论,实战部分将涉及如何将并发编程运用在实际 App 开发中。最后,本章将涉及如何 debug 并发编程的问题:包括如何观察在 Xcode 中观察线程信息、如何发现并解决编程中的并发问题。
1.iOS 开发中对于并发操作有哪三种方式?
关键词:#NSThread #GCD #Operations
在 iOS 开发中,基本有 3 种方式实现多线程:
- NSThread 可以最大限度的掌控每一个线程的生命周期。但是同时也需要开发者手动管理所有的线程活动,比如创建、同步、暂停、取消等等,其中手动加锁操作挑战性很高。总体使用场景很小,基本是造轮子或是测试时使用。
- GCD(Grand Central Dispatch) 是 Apple 推荐的方式,它将线程管理推给了系统,用的是名为 dispatch queue 的队列。开发者只要定义每个线程需要执行的工作即可。所有的工作都是先进先出,每一个 block 运转速度极快(纳秒级别)。使用场景主要是为了追求高效处理大量并发数据,如图片异步加载、网络请求等。
- Operations 与 GCD 类似。虽然是 OperationQueue 队列实现,但是它并不局限于先进先出的队列操作。它提供了多个接口可以实现暂停、继续、终止、优先顺序、依赖等复杂操作,比 GCD 更加灵活。应用场景最广,效率上每个 Operation 处理速度较快(毫秒级别),几乎所有的基本线程操作都可以实现。
2.试比较以下关键词:Serial, Concurrent, Sync, Async?
关键词:#多任务 #阻塞线程
这 4 个关键词,前两个 Serial/Concurrent 构成一对,后两个 Sync/Async 构成一对。我们分别来看:
- Serial/Concurrent 声明队列的属性是串行还是并发。串行队列(Serial Queue)指队列中同一时间只能执行一个任务,当前任务执行完后才能执行下一个任务,在串行队列中只有一个线程。并发队列(Concurrent Queue)允许多个任务在同一个时间同时进行,在并发队列中有多个线程。串行队列的任务一定是按开始的顺序结束,而并发队列的任务并不一定会按照开始的顺序而结束。
- Sync/Async 表明任务是同步还是异步执行。同步(Sync)会把当前的任务加入到队列中,除非等到任务执行完成,线程才会返回继续运行,也就是说同步会阻塞线程。异步(Async)也会把当前的任务加入到队列中,但它会立刻返回,无需等任务执行完成,也就是说异步不会阻塞线程。
- 无论是串行还是并发队列都可以执行执行同步或异步操作。注意在串行队列上执行同步操作容易造成死锁,在并发队列上则不用担心。异步操作无论实在串行队列还是并发队列上都可能出现竞态条件的问题;同时异步操作经常与逃逸闭包一起出现在 API 的设计当中。
3.代码实战:以下代码均在串行队列中发生,执行之后会打印出什么?
关键词:#串行 #同步 #异步
代码语言:txt复制// 串行同步
serialQueue.sync {
print(1)
}
print(2)
serialQueue.sync {
print(3)
}
print(4)
// 串行异步
serialQueue.async {
print(1)
}
print(2)
serialQueue.async {
print(3)
}
print(4)
// 串行异步中嵌套同步
print(1)
serialQueue.async {
print(2)
serialQueue.sync {
print(3)
}
print(4)
}
print(5)
// 串行同步中嵌套异步
print(1)
serialQueue.sync {
print(2)
serialQueue.async {
print(3)
}
print(4)
}
print(5)
首先,在串行队列上进行同步操作,所有任务将顺序发生,所以第一段的打印结果一定是 1234;
其次,在串行队列上进行异步操作,此时任务完成的顺序并不保证。所以可能会打印出这几种结果:1234 ,2134,1243,2413,2143。注意 1 一定在 3 之前打印出来,因为前者在后者之前派发,串行队列一次只能执行一个任务,所以一旦派发完成就执行。同理 2 一定在 4 之前打印,2 一定在 3 之前打印。
接着,对同一个串行队列中进行异步、同步嵌套。这里会构成死锁,所以只会打印出 125 或者 152。
最后,在串行队列中进行同步、异步嵌套,不会构成死锁。这里会打印出 3 个结果:12345,12435,12453。这里1一定在最前,2 一定在 4 前,4 一定在 5 前。
4.代码实战:以下代码均在并发队列中发生,执行之后会打印出什么?
关键词:#并发 #同步 #异步
代码语言:txt复制// 并发同步
concurrentQueue.sync {
print(1)
}
print(2)
concurrentQueue.sync {
print(3)
}
print(4)
// 并发异步
concurrentQueue.async {
print(1)
}
print(2)
concurrentQueue.async {
print(3)
}
print(4)
// 并发异步中嵌套同步
print(1)
concurrentQueue.async {
print(2)
concurrentQueue.sync {
print(3)
}
print(4)
}
print(5)
// 并发同步中嵌套异步
print(1)
concurrentQueue.sync {
print(2)
concurrentQueue.async {
print(3)
}
print(4)
}
print(5)
如果你正在跳槽或者正准备跳槽不妨动动小手,添加一下咱们的交流群931 542 608来获取一份详细的大厂面试资料为你的跳槽多添一份保障。
首先,在并发队列上进行同步操作,所有任务将顺序执行、顺序完成,所以第一段的打印结果一定是 1234;
其次,在并发队列上进行异步操作,因为并发对列有多个线程 。所以这里只能保证 24 顺序执行,13 乱序,可能插在任意位置:1234,1243,2413 ,2431,2143,2341,2134,2314。
接着,对同一个并发队列中进行异步、同步嵌套。这里不会构成死锁,因为同步操作只会阻塞一个线程,而并发队列对应多个线程。这里会打印出 4 个结果:12345,12534,12354,15234。注意同步操作保证了 3 一定会在 4 之前打印出来。
最后,在并发队列中进行同步、异步嵌套,不会构成死锁。而且由于是并发队列,所以在运行异步操作时也同时会运行其他操作。这里会打印出 3 个结果:12345,12435,12453。这里同步操作保证了 2 和 4 一定在 3 和 5 之前打印出来。
5.举例说明 iOS 并发编程中的三大问题?
关键词:#竞态条件 #优先倒置 #死锁问题
在并发编程中,一般会面对这样的三个问题:竞态条件、优先倒置、死锁问题。针对 iOS 开发,它们的具体定义为:
- 竞态条件(Race Condition)。指两个或两个以上线程对共享的数据进行读写操作时,最终的数据结果不确定的情况。例如以下代码:
var num = 0
DispatchQueue.global().async {
for _ in 1…10000 {
num = 1
}
}
for _ in 1…10000 {
num = 1
}
最后的计算结果 num 很有可能小于 20000,因为其操作为非原子操作。在上述两个线程对num进行读写时其值会随着进程执行顺序的不同而产生不同结果。
- 优先倒置(Priority Inverstion)。指低优先级的任务会因为各种原因先于高优先级任务执行。例如以下代码:
var highPriorityQueue = DispatchQueue.global(qos: .userInitiated)
var lowPriorityQueue = DispatchQueue.global(qos: .utility)
let semaphore = DispatchSemaphore(value: 1)
lowPriorityQueue.async {
semaphore.wait()
for i in 0...10 {
print(i)
}
semaphore.signal()
}
highPriorityQueue.async {
semaphore.wait()
for i in 11...20 {
print(i)
}
semaphore.signal()
}
上述代码如果没有 semaphore,高优先权的 highPriorityQueue 会优先执行,所以程序会优先打印完 11 到 20。而加了 semaphore 之后,低优先权的 lowPriorityQueue 会先挂起 semaphore,高优先权的highPriorityQueue 就只有等 semaphore 被释放才能再执行打印。
也就是说,低优先权的线程可以锁上某种高优先权线程需要的资源,从而优于迫使高优先权的线程等待低优先权的线程,这就叫做优先倒置。
- 死锁问题(Dead Lock)。指两个或两个以上的线程,它们之间互相等待彼此停止执行,以获得某种资源,但是没有一方会提前退出的情况。iOS 中有个经典的例子就是两个 Operation 互相依赖:
let operationA = Operation()
let operationB = Operation()
operationA.addDependency(operationB)
operationB.addDependency(operationA)
还有一种经典的情况,就是在对同一个串行队列中进行异步、同步嵌套:
代码语言:txt复制serialQueue.async {
serialQueue.sync {
}
}
因为串行队列一次只能执行一个任务,所以首先它会把异步 block 中的任务派发执行,当进入到 block 中时,同步操作意味着阻塞当前队列 。而此时外部 block 正在等待内部 block 操作完成,而内部block 又阻塞其操作完成,即内部 block 在等待外部 block 操作完成。所以串行队列自己等待自己释放资源,构成死锁。这也提醒了我们,千万不要在主线程中用同步操作。
如果你正在跳槽或者正准备跳槽不妨动动小手,添加一下咱们的交流群931 542 608来获取一份详细的大厂面试资料为你的跳槽多添一份保障。
6.代码实战:以下代码有什么隐患?
关键词:#竞态条件 #thread sanitizer
代码语言:txt复制func getUser(id: String) throws -> User {
return try storage.getUser(id)
}
func setUser(_ user: User) throws {
try storage.setUser(user)
}
上面这段代码的功能是读写用户信息。乍一看上去没有什么问题,但是一旦多线程涉及到读写,就会产生竞态条件(race condition)。解决方法是打开Xcode中的线程检测工具 thread sanitizer(在 Xcode 的scheme 中勾选 Thread Sanitizer 即可),它会检测出代码中出现竞态条件之处,并提醒我们修改。
对于读写问题,一般有 3 种处理方式。
第 1 种是用串行队列,无论读写,同一时间只能做一个操作,这样就保证了队列的安全。其缺点是速度慢,尤其是在大量读写发生时,每次只能做单个读或写操作的效率实在太低。修改代码如下:
代码语言:txt复制func getUser(id: String) throws -> User {
return serialQueue.sync {
return try storage.getUser(id)
}
}
func setUser(_ user: User) throws {
try serialQueue.sync {
try storage.setUser(user)
}
}
第 2 种是用并发队列配合异步操作完成。异步操作由于会直接返回,所以必须配合逃逸闭包来保证后续操作的合法性。
代码语言:txt复制enum Result<T> {
case value(T)
case error(Error)
}
func getUser(id: String, completion: @escaping (Result<User>) -> Void) {
try serialQueue.async {
do {
user = try storage.getUser(id)
completion(.value(user))
} catch {
completion(.error(error))
}
}
}
func setUser(_ user: User, completion: @escaping (Result<()>) -> Void) {
try serialQueue.async {
do {
try storage.setUser(user)
completion(.value(())
} catch {
completion(.error(error))
}
}
}
第 3 种方法是用并发队列,读操作时用 sync 直接返回结果,写操作时用 barrier flag 来保证此时并发队列只进行当前的写操作(类似将并发队列暂时转为串行队列),而无视其他操作。示例代码如下:
代码语言:txt复制enum Result<T> {
case value(T)
case error(Error)
}
func getUser(id: String) throws -> User {
var user: User!
try concurrentQueue.sync {
user = try storage.getUser(id)
}
return user
}
func setUser(_ user: User, completion: @escaping (Result<()>) -> Void) {
try concurrentQueue.async(flags: .barrier) {
do {
try storage.setUser(user)
completion(.value(())
} catch {
completion(.error(error))
}
}
}
7.试比较以下 GCD 中的方法 dispatch_async, dispatch_after, dispatch_once, dispatch_group
关键词:#异步 #延时 #单例 #线程同步
首先要明确,这几个关键词都是 Objective-C 编程中出现的。它们分别有如下用法:
- dispatch_async 用于对某个线程进行异步操作。异步操作可以让我们在不阻塞线程的情况下充分利用不同线程和队列来处理任务。例如我们需要从网络端下载图片,然后将图片赋予某个 UIImageView,就可以用到 dispatch_async:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
UIImage *image = [client fetchImageFromURL: url];
dispatch_async(dispatch_get_main_queue(), ^{
self.imageView.image = image;
});
});
- dispatch_after 一般用于主线程的延时操作。例如要将一个页面的导航标题由“等待”之后 2 秒改为“完成”,可以用 dispatch_after 来实现:
self.title = @”等待”;
double delayInSeconds = 2.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
self.title = @”完成”;
});
如果你正在跳槽或者正准备跳槽不妨动动小手,添加一下咱们的交流群931 542 608来获取一份详细的大厂面试资料为你的跳槽多添一份保障。
- dispatch_once 用于确保单例的线程安全。它表示修饰的区域只会访问一次,这样多线程情况下类也只会初始化一次,确保了 Objective-C 中单例的原子化。
(instancetype)sharedManager {
static Manager *sharedManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedManager = [[Manager alloc] init];
});
return sharedManager;
}
- dispatch_group 一般用于多个任务同步。一般用法是当多个任务关联到同一个群组(group)后,所有的任务在执行完后我们执行一个统一的后续工作。注意 dispatch_group_wait 是个同步操作,它会阻塞线程。
dispatch_group_t group = dispatch_group_create();
dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@”开始做任务1!”);
});
dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@”开始做任务2!”)
});
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
NSLog(@”任务1和任务2都完成了!”)
});
8.GCD 中全局(global)队列有哪几种优先级?
关键词:#QoS
官方链接:https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/PrioritizeWorkWithQoS.html
首先,全局队列肯定是并发队列。如果不指定优先级,就是默认(default)优先级。另外还有 background,utility,user-Initiated,unspecified,user-Interactive。下面按照优先级顺序从低到高来排列:
- Background:用来处理特别耗时(几分钟甚至上小时)的后台操作,例如同步、备份数据;性能方面优先考虑电量消耗。
- Utility:用来处理需要一点时间(几十秒到几分钟)而又不需要立刻返回结果的操作。特别适用于异步操作,例如下载或是解码编码数据;性能方面会综合考虑电量消耗和即时反馈。
- Default:默认优先级。一般来说开发者应该指定优先级。属于特殊情况。
- User-Initiated:用来处理用户触发的、需要立刻(几秒)返回结果的操作。比如打开用户点击的文件;性能方面优先考虑即时反馈。
- User-Interactive:用来相应用户交互或展现动画。如果不及时响应就可能阻塞主线程的操作;性能方面优先考虑立刻响应。
- Unspecified:未确定优先级,由系统根据不同环境推断。比如使用过时的 API 不支持优先级,此时就可以设定为未确定优先级。属于特殊情况。
9.试比较以下 Operations 中的关键词:Operation,BlockOperation,OperationQueue
关键词:#Operation
Operations 是 iOS 中除 GCD 外有一种实现并发编程的方式。它将单个任务算作一个 Operation,然后放在 OperationQueue 队列中进行管理和运行。其中最常用的三个关键词就是 Operation,BlockOperation,OperationQueue。
- Operation 指代一系列工作或者任务。Operation 本身是个抽象类,我们一般通过集成它来完成自定义的工作。每个单独的 Operation 有个 4 种状态:准备就绪(Ready),执行中(Executing),暂停(cancelled),完成(Finished)。下面就是一个自定义的图片转化的 Operation 子类:
class ImageFilterOperation: Operation {
var inputImage: UIImage?
var outputImage: UIImage?
override func main() {
outputImage = filter(image: inputImage)
}
}
- BlockOperation 是系统定义的一个 Operation 的子类。它本身在默认权限的全局队列上进行,负责并发执行多个任务。其中任务之间互不依赖,同时 BlockOperation 也可以像 dispatch_group 一样同步管理多个任务。例如下面的示例代码:
let multiTasks = BlockOperation()
multiPrinter.completionBlock = {
print("所有任务完成!")
}
multiTasks.addExecutionBlock { print("任务1开始"); sleep(1) }
multiTasks.addExecutionBlock { print("任务2开始"); sleep(2) }
multiTasks.addExecutionBlock { print("任务3开始"); sleep(3) }
multiPrinter.start()
- OperationQueue 是负责安排和运行多个 Operation 的队列。但是它并不局限于先进先出的队列操作。它提供了多个接口可以实现暂停、继续、终止、优先顺序、依赖等复杂操作。同时,还可以通过设置其 maxConcurrentOperationCount 属性来区分其是串行还是并发。下面是示例代码:
Let multiTaskQueue = OperationQueue()
multiTaskQueue.addOperation { print("任务1开始"); sleep(1) }
multiTaskQueue.addOperation { print("任务2开始"); sleep(2) }
multiTaskQueue.addOperation { print("任务3开始"); sleep(3) }
multiTaskQueue.waitUntilAllOperationsAreFinished()
10.如何在 OperationQueue 中取消某个 Operation 操作?
关键词:#cancel() #isCancelled
在 Operation 抽象类中,有一个 cancel() 方法,它做的唯一一件事情就是将 Operation 的 isCancelled 属性从 false 改为 true。由于它并不会真正去深入代码将具体执行的工作暂停,所以我们必须利用 isCancelled 属性的变化来暂停 main() 方法中的工作。
举个例子,我们有个求和整型数组的操作,其对应 Operation 如下:
代码语言:txt复制class ArraySumOperation: Operation {
let nums: [Int]
var sum: Int
init(nums: [Int]) {
self.nums = nums
self.sum = 0
super.init()
}
override func main() {
for num in nums {
sum = num
}
}
}
如果我们要运行该 Operation,就应该将其加入到 OperationQueue 中:
代码语言:txt复制let queue = OperationQueue()
let sumOperation = ArraySumOperation(nums: Array(1...1000))
// 一旦假如到OperationQueue中,Operation就开始执行
queue.addOperation(sumOperation)
// 打印出500500(1 2 3 ... 1000)
sumOperation.completionBlock = { print(sumOperation.sum) }
如果要取消,我们应该调用 cancel() 方法,而它只会将 isCancelled 改成 false,而我们要利用 isCancelled 的状态控制 main() 中的操作,所以可将 ArraySumOperation 改成如下:
代码语言:txt复制class ArraySumOperation: Operation {
let nums: [Int]
var sum: Int
init(nums: [Int]) {
self.nums = nums
self.sum = 0
super.init()
}
override func main() {
for num in nums {
if isCancelled {
return
}
sum = num
}
}
}
此时运行 Operation,就会得到不同结果:
代码语言:txt复制let queue = OperationQueue()
let sumOperation = ArraySumOperation(nums: Array(1...1000))
// 一旦假如到OperationQueue中,Operation就开始执行
queue.addOperation(sumOperation)
sumOperation.cancel()
// sumOperation在彻底完成前已经暂停,sum值小于500500
sumOperation.completionBlock = { print(sumOperation.sum) }
同时 OperationQueue 还有 cancelAllOperations() 方法可以调用,它相当于是对于所有在该队列上工作的 Operation,都调用其 cancel() 方法。
11.说说在实际开发中,主线程和其他线程的使用场景
关键词:#UI #耗时
主线程一般用于负责 UI 相关操作,如绘制图层、布局、响应用户响应。很多 UIKit 相关的控件如果不在主线程操作,会产生未知效果。Xcode 中的 Main Thread Checker 可以将相关问题检测出来并报错。
其他线程例如后台线程一般用来处理比较耗时的工作。网络请求、数据解析、复杂计算、图片的编码解码管理等都属于耗时的工作,应该放在其他线程处理。如果放在主线程,由于其是串行队列,会直接阻塞主线程的 UI 操作,直接影响用户体验。
文章到这里就结束了,感谢你的观看,只是有些话想对读者们说说:
iOS开发人群越来越少,说实在的,每次在后台看到一些读者的回应都觉得很欣慰,至少你们依然坚守iOS技术岗…为了感谢读者们,我想把我收藏的一些编程干货贡献给大家,回馈每一个读者,希望能帮到你们。
干货主要有:
① iOS中高级开发必看的热门书籍(经典必看)
② iOS开发全套面试教学视频
③ BAT等各个大厂iOS面试真题 答案.PDF文档
④ iOS开发高级面试"简历制作"指导
如果你用得到的话可以直接拿走;如何获取,具体内容请转看-我的GitHub
我的:GitHub地址