尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Swift GCD并发编程:从队列、线程到死锁预防的实战指南

Swift GCD并发编程:从队列、线程到死锁预防的实战指南 1. 从“卡顿”到“丝滑”为什么我们需要GCD如果你在iOS或macOS上开发过应用尤其是处理过UI更新、网络请求或者文件读写那你大概率遇到过界面“卡住”的情况。用户点了一个按钮屏幕就“冻”住了转圈圈过几秒才恢复。这种体验非常糟糕而它的根源往往就在于我们把耗时的任务放在了错误的“地方”执行。这个“地方”在Swift或者说整个苹果生态里指的就是线程。现代CPU都是多核的但默认情况下我们的App代码只运行在一个线程上也就是主线程。主线程有个神圣的职责处理和更新用户界面。想象一下主线程就像一家餐厅唯一的前台服务员他既要接待新客人响应用户点击又要给已坐下的客人点单、上菜更新UI。如果这时有个客人要求现做一份非常复杂的菜比如下载一个大文件服务员如果亲自跑到后厨去盯着做那前台就没人管了其他客人的所有请求都会被晾着餐厅看起来就像“卡住”了一样。Grand Central Dispatch简称GCD就是苹果为我们提供的“后厨管理系统”。它帮我们管理着一群“后台厨师”线程池。当有耗时任务时我们不用自己操心去创建、管理线程只需要告诉GCD“嘿把这个任务放到后厨去做”然后GCD就会智能地从线程池里分配一个空闲的“厨师”来处理。而我们的“前台服务员”主线程可以继续流畅地服务其他客人等后厨的菜做好了任务完成了服务员只需要把成品端上来回到主线程更新UI即可。这套机制就是并发编程的核心而GCD让这一切在Swift中变得异常简单和高效。今天我们就来彻底拆解GCD的基础让你不仅能写出不卡顿的App更能理解其背后的设计哲学。2. 核心概念拆解队列、任务与线程的三者关系刚接触GCD时DispatchQueue、async、sync、main、global这些词可能会让人混淆。我们先抛开代码用公司团队管理的模型来理解它们。2.1 队列DispatchQueue你的任务待办清单队列顾名思义就是一个先进先出FIFO的任务列表。它不是线程它不执行代码它只负责管理任务的执行顺序。你可以创建两种队列串行队列Serial Queue就像公司里一个严格的、一次只处理一件事的专员。他有一个任务清单必须做完第一项才去看第二项。这保证了任务执行的顺序绝对可控。并发队列Concurrent Queue就像一个有多个工位的项目组。任务清单来了组长会把任务分发给组里空闲的成员同时进行。任务的开始顺序是清单顺序但结束顺序是不确定的取决于每个任务的耗时。GCD为我们预置了两个特殊的队列主队列Main Queue这是一个特殊的串行队列它关联着应用的主线程。所有用户界面的更新操作必须被派发到这个队列上执行。它就是我们例子里的“前台服务员”。全局并发队列Global Concurrent Queues这是系统提供的几个不同优先级的并发队列。我们最常用的是.default优先级。它们就是我们的“后厨厨师团队”。2.2 任务Task/Block你要做的具体工作任务就是一段你想要执行的代码在Swift中通常是一个闭包{ }。比如下载图片、解析JSON、复杂计算等。2.3 线程Thread真正干活的工人线程是CPU调度的基本单位是代码实际运行的地方。GCD的核心魔法就在于它在底层管理着一个线程池。我们作为开发者几乎永远不需要直接创建或管理线程。我们只和队列打交道告诉队列“以什么方式执行这个任务”。GCD会根据队列的类型串行/并发和系统负载自动从线程池中取出线程来执行任务。三者关系总结你开发者创建任务闭包然后将任务提交给一个队列。队列根据自身特性串行/并发决定如何将任务分配给底层线程池中的线程去执行。主队列比较特殊它固定绑定主线程。// 创建一个串行队列标签有助于调试 let serialQueue DispatchQueue(label: com.example.mySerialQueue) // 获取系统提供的全局并发队列 let globalQueue DispatchQueue.global(qos: .default) // 获取主队列 let mainQueue DispatchQueue.main3. 派发方式async与sync的本质区别这是GCD中最关键也最容易用错的一对方法。它们的区别不是“异步”和“同步”的字面意思而是当前线程是否会等待。3.1 异步派发async调用queue.async { }时它的意思是“队列啊我把这个任务交给你了你稍后在合适的时机执行它。我现在当前线程不等你做完立刻继续执行我后面的代码。”这就像你给同事发了一封邮件请求协助异步任务然后你不需要坐在电脑前等他回复可以立刻去忙下一件事。这不会阻塞你的当前工作流。print(1 - 我在主线程) DispatchQueue.global().async { // 这个闭包会在后台线程执行 sleep(2) // 模拟2秒耗时任务 print(3 - 耗时任务完成在后台线程) } print(2 - 主线程继续执行无需等待) // 输出顺序永远是1 - 2 - (等待约2秒) - 3 // “2”不会等待闭包里的sleep结束3.2 同步派发sync调用queue.sync { }时它的意思是“队列啊我现在就要你执行这个任务并且我当前线程会在这里等着直到你执行完毕并把结果返回给我我才会继续往下走。”这就像你走到同事工位前当面请他立刻处理一件事同步任务你站在旁边等他处理完拿到结果后才离开。这会阻塞你的当前工作流。print(A - 开始) DispatchQueue.global().sync { sleep(1) print(B - 同步任务完成) } print(C - 必须等B完成后才执行) // 输出顺序永远是A - (等待1秒) - B - C // 执行到 sync 时当前线程即使是主线程会挂起等待警告死锁陷阱这是一个经典的错误必须理解千万不要在主线程上同步派发任务到主队列。DispatchQueue.main.sync { print(这行代码永远不会执行) }原因分析sync要求当前线程主线程等待闭包执行完毕。但闭包被派发到主队列而主队列的任务需要主线程来执行。此时主线程正在等待因为调用了sync它就不可能去执行那个闭包。闭包没人执行sync就永远等不到结束。双方互相等待程序“卡死”这就是死锁。记住sync要格外小心尤其是在主线程上。3.3 如何选择绝大多数情况用async将耗时操作网络、IO、计算异步派发到后台队列完成后如需更新UI再async回主队列。这是保证界面流畅的标准模式。谨慎使用sync通常用于简单的线程安全数据访问配合串行队列或者需要等待一个特定后台任务完成才能继续的少数场景。要绝对避免在可能涉及同一队列的嵌套派发中使用sync。4. 实战模式从常见场景深入GCD应用理解了基础概念我们来看几种最常用的代码模式。这些模式就像工具箱里的标准件掌握了就能解决80%的并发问题。4.1 模式一后台处理主线程更新这是iOS开发中最最经典的模式没有之一。// 用户点击按钮触发一个耗时操作 IBAction func fetchDataButtonTapped(_ sender: UIButton) { // 1. 为了防止界面卡住先给用户一个反馈如显示加载动画 showLoadingIndicator() // 2. 将耗时操作异步派发到全局后台队列 DispatchQueue.global(qos: .userInitiated).async { [weak self] in // 模拟网络请求 let simulatedData self?.simulateNetworkRequest() let processedData self?.processData(simulatedData) // 3. 数据准备完毕回到主队列更新UI DispatchQueue.main.async { // 所有UI操作必须在主线程 self?.hideLoadingIndicator() self?.updateUI(with: processedData) } } } private func simulateNetworkRequest() - Data { // 模拟2秒网络延迟 Thread.sleep(forTimeInterval: 2) return Data() }为什么这样写.userInitiated的QoS服务质量表示这是用户主动触发的、需要即时反馈的任务优先级较高。使用[weak self]避免循环引用。最后必须通过DispatchQueue.main.async跳回主线程更新UI。4.2 模式二使用串行队列实现线程安全当多个线程可能同时访问同一块数据如一个数组时就会发生数据竞争导致崩溃或数据错乱。串行队列是解决此问题的优雅方案。class ThreadSafeDataManager { // 创建一个私有的串行队列作为“锁” private let serialQueue DispatchQueue(label: com.example.dataManagerQueue) // 在私有队列中访问的共享数据 private var internalArray: [String] [] // 对外提供线程安全的写入接口 func addItem(_ item: String) { serialQueue.async { self.internalArray.append(item) print(添加成功: \(item) 当前数组: \(self.internalArray)) } } // 对外提供线程安全的读取接口使用sync获取结果 func getAllItems() - [String] { // 这里必须用sync因为我们需要立刻拿到返回值 return serialQueue.sync { // 返回内部数组的拷贝避免外部直接修改内部数据 return self.internalArray } } } // 使用 let manager ThreadSafeDataManager() DispatchQueue.concurrentPerform(iterations: 10) { i in // 模拟多线程并发调用 manager.addItem(Item\(i)) } let items manager.getAllItems() // 安全地获取最终结果核心原理所有对internalArray的访问读和写都被强制放到同一个串行队列serialQueue中执行。串行队列一次只执行一个任务因此即使外部多线程同时调用addItem这些“添加操作”也会在队列里排队依次执行彻底杜绝了数据竞争。读取时用sync是为了能立即拿到当前值。4.3 模式三使用DispatchGroup管理一组任务有时我们需要并发执行多个独立任务等它们全部完成后再进行后续操作。比如同时下载多张图片。func downloadMultipleImages(imageURLs: [URL], completion: escaping ([UIImage]) - Void) { let downloadGroup DispatchGroup() var downloadedImages: [UIImage] [] // 由于多线程可能同时修改数组需要保证线程安全 let threadSafeQueue DispatchQueue(label: com.example.imageArrayQueue) for url in imageURLs { // 进入组 downloadGroup.enter() DispatchQueue.global().async { // 模拟下载 let image self.downloadImage(from: url) // 将下载完成的图片安全地加入数组 threadSafeQueue.async { if let image image { downloadedImages.append(image) } // 离开组必须与enter()成对调用 downloadGroup.leave() } } } // 监听组内所有任务完成不阻塞当前线程 downloadGroup.notify(queue: .main) { // 所有图片下载完成回到主线程回调 completion(downloadedImages) } } // 如果你想**阻塞当前线程**等待所有任务完成极少在主线程使用可以用 // downloadGroup.wait() // 小心死锁enter()和leave()必须成对出现类似于引用计数。notify是异步回调比同步的wait()更安全常用。4.4 模式四屏障任务Barrier实现高效的读写锁对于“读多写少”的数据结构使用纯串行队列会限制读操作的并发性因为读也要排队。屏障任务可以提供更好的性能。class EfficientDataManager { // 创建一个并发队列 private let concurrentQueue DispatchQueue(label: com.example.efficientQueue, attributes: .concurrent) private var internalDictionary: [String: Any] [:] // 读操作可以并发执行 func value(forKey key: String) - Any? { // 同步返回需要立即得到值 return concurrentQueue.sync { return internalDictionary[key] } } // 写操作使用屏障保证独占写入 func setValue(_ value: Any, forKey key: String) { // 注意这里用的是 async不是 sync concurrentQueue.async(flags: .barrier) { self.internalDictionary[key] value print(写入完成: \(key) \(value)) } } }工作原理当并发队列执行一个屏障任务.barrier时队列会确保在此屏障任务之前提交的所有任务都执行完毕后才会执行这个屏障任务。并且在执行屏障任务时队列不会同时执行任何其他任务就像临时变成了串行队列。屏障任务完成后队列恢复正常的并发执行。这保证了写操作的原子性同时允许读操作并发进行提升了效率。5. 进阶理解服务质量与死锁预防5.1 服务质量Quality of Service, QoS当你把任务派发到全局队列时可以指定其优先级帮助系统更智能地调度任务优化性能和能效。.userInteractive用户交互相关要求极快响应如动画、主事件循环。不要在此执行耗时操作。.userInitiated用户主动发起需要即时结果的任务如点击按钮加载内容。我们模式一中使用的是这个。.default默认优先级介于userInitiated和utility之间。.utility耗时操作用户不需要立即知道结果但有进度可感知如下载、导入数据。.background完全在后台运行用户不可见如索引、预处理、备份。.unspecified未指定系统会自行推断。选择原则准确分类你的任务。错误的QoS可能导致高优先级任务资源不足或低优先级任务耗电过快。例如一个后台数据同步任务应该用.background而不是.userInitiated。5.2 深度剖析与死锁预防死锁是并发编程中的噩梦。除了前面提到的main.sync经典死锁还有更隐蔽的情况。场景一同一串行队列的嵌套synclet serialQueue DispatchQueue(label: com.example.serial) serialQueue.async { // 任务A print(Outer task starts) serialQueue.sync { // 任务B print(Inner task) } print(This will never be printed) }分析任务A被async到serialQueue执行。在任务A内部它又向同一个队列serialQueue同步派发了任务B。sync要求任务A等待任务B完成。但serialQueue是串行的它必须等当前正在执行的任务即任务A执行完毕才会去执行下一个任务即任务B。于是任务A在等任务B任务B在等任务A。死锁。黄金法则尽量避免在同一个串行队列或目标队列相同的上下文中使用sync。如果非要用确保sync派发到的是另一个不同的队列。场景二多个队列/资源的循环等待这是更复杂的死锁涉及两个队列和两个资源。let queueA DispatchQueue(label: A) let queueB DispatchQueue(label: B) var resourceA 0 var resourceB 0 queueA.async { resourceA 1 queueB.sync { // 在队列A中同步调用队列B resourceB 2 } } queueB.async { resourceB 3 queueA.sync { // 在队列B中同步调用队列A resourceA 4 } }在一定执行时序下可能发生队列A持有资源A等待队列B队列B持有资源B等待队列A。解决方法通常是规定所有线程必须以相同的顺序如先A后B来申请这些资源破坏循环等待条件。在实际App开发中最实用的建议是简化设计。优先使用async谨慎使用sync使用串行队列保护共享数据使用DispatchGroup管理任务组对于复杂同步需求考虑使用更高级的API如OperationQueue它提供了任务依赖、取消等更强功能。GCD是Swift并发编程的基石它抽象了复杂的线程管理让我们能专注于业务逻辑。理解队列、async/sync、死锁这些核心概念并熟练运用几种经典模式足以让你构建出流畅、响应迅速的应用程序。记住并发工具的目的是服务于更好的用户体验而不是为了用而用。在不确定的时候保持代码简单清晰往往是避免并发陷阱的最佳策略。
返回列表