本节摘要:GCD(Grand Central Dispatch)用"队列"语言解决并发调度:主队列串行跑所有界面刷新,全局队列与自建队列承载后台任务,async 派发后立即返回。本节讲清队列种类、async 与 sync 的差异、延迟派发与 QoS 优先级,落点是"后台干活、主线程刷新"的标准句式和 sync 死锁事故的成因。
界面进入演出期(第一幕出生、第三幕管账、这一幕干活),第一个纪律:主舞台不能停。
用户滑动排片列表时,屏幕每秒要重绘几十次;如果这时在主线程上同步等一个两秒的网络请求,界面就冻住两秒——触摸没响应、动画停帧,用户只能杀掉 App。所以 iOS 立法:界面刷新必须在主线程,耗时操作禁止在主线程。GCD 就是执行这套法律的调度机构。

队列决定"在哪里排队",派发方式决定"要不要原地等":
import Dispatch // 标准句式:后台干活 → 主线程刷新 func loadShowtimes() { let workQueue = DispatchQueue.global(qos: .userInitiated) // 全局队列,交互级优先 workQueue.async { // async:派完就走,不等 let raw = "雾中灯塔|118|IMAX|12" // 模拟两秒的网络与解码 let parts = raw.split(separator: "|").map(String.init) DispatchQueue.main.async { // 回主舞台才能刷界面 print("刷新列表:\(parts[0]) 余 \(parts[3]) 座") // 输出:刷新列表:雾中灯塔 余 12 座 } } print("请求已派出,主线程继续响应用户") // 先打印:主线程没被卡住 } loadShowtimes() // 输出顺序: // 请求已派出,主线程继续响应用户 // 刷新列表:雾中灯塔 余 12 座
输出的先后是铁律的直观证据:async 派发后主线程立刻往下走,后台结果晚到,但必须坐回主队列的车才能碰界面。忘写 DispatchQueue.main.async 直接刷界面的代码,有时碰巧能跑(真机上偶发 UI 延迟刷新或崩溃)——这类"薛定谔的 bug"几乎都源于此。
自建串行队列用于"怕插队"的任务序列:
// 专属化妆间:写票务日志必须一条条来 let ticketWriteQueue = DispatchQueue(label: "cinema.ticket.write") // 默认串行 ticketWriteQueue.async { print("写入:出票 A") } ticketWriteQueue.async { print("写入:出票 B") } ticketWriteQueue.asyncAfter(deadline: .now() + 0.1) { print("补写:对账单") } // 延迟派发 // 无论提交顺序多乱,执行顺序永远是 A → B → 补写
sync 是"原地等结果"。它在后台队列上合理,在主线程上危险:
// 危险演示(原理说明,别在真工程的主线程上跑): // DispatchQueue.main.sync { print("永远跑不到") } // 死锁链:主线程等 sync 的闭包执行 → 闭包排在主队列尾部 → 主队列被主线程占着 → 互相等
死锁三步:主线程执行 sync 时停下来等;sync 的闭包需要主队列调度;主队列正被"停下来等"的主线程占用。双方互等,界面冻结。规则简化为一句:对主队列,只 async,永不 sync。
后台队列之间用 sync 是合法且有用的——比如"读取一个必须在某队列上执行的操作的结果":
var seatsCache = 42 let protectQueue = DispatchQueue(label: "cinema.seats") func readSeatsSafely() -> Int { return protectQueue.sync { seatsCache } // 在保护队列上同步读,保证不与写并发 } func writeSeats(_ v: Int) { protectQueue.async { seatsCache = v } // 写走 async 排队 } print(readSeatsSafely()) // 输出:42
这个"串行队列保护共享变量"的小模式,是 GCD 时代做线程安全的主流手段之一。
全局队列按 QoS 分四档,系统据此分配 CPU 与电量:
DispatchQueue.global(qos: .userInteractive).async { /* 刷新首屏:立即要 */ } DispatchQueue.global(qos: .userInitiated).async { /* 点击后的加载:用户在等 */ } DispatchQueue.global(qos: .utility).async { /* 预加载海报:闲时干 */ } DispatchQueue.global(qos: .background).async { /* 对账 日志上传:没人等 */ }
| QoS | 用户感知 | 排片界面的例子 |
|---|---|---|
| userInteractive | 卡一下就明显 | 列表滚动中的数据准备 |
| userInitiated | 点了就在等 | 下拉刷新取数 |
| utility | 无感,闲时跑 | 下一页预取、海报预解码 |
| background | 完全无感 | 日志打包、对账上传 |
⚠️ 常见坑:全部任务都塞 userInteractive,等于没有优先级——系统无法在低电量时降级跑闲活。标 QoS 的思路是"用户在不在等",不是"这个活重不重要"。
背景:下拉刷新要并发取两份接口数据(场次 + 影讯),全部回来后统一刷新。操作:用 group 把并发任务聚拢(GCD 的进阶常用件):
let group = DispatchGroup() var showtimes: [String] = [] var news: [String] = [] group.enter() DispatchQueue.global().async { Thread.sleep(forTimeInterval: 0.2) // 模拟场次接口 showtimes = ["雾中灯塔 19:00", "星际列车 20:30"] group.leave() } group.enter() DispatchQueue.global().async { Thread.sleep(forTimeInterval: 0.1) // 模拟影讯接口(先回) news = ["周五悬疑夜专题上线"] group.leave() } group.notify(queue: .main) { print("两份数据齐了,统一刷新") // 输出:两份数据齐了,统一刷新 print("场次 \(showtimes.count) 条,影讯 \(news.count) 条") // 输出:场次 2 条,影讯 1 条 }
结果解读:enter/leave 配对计数,notify 在计数归零后回主队列——这是"并发等齐再汇报"的 GCD 标准答案。变式:给两份数据加超时(用 group.wait(timeout:) 会阻塞,不推荐在主线程用;生产上更常见的是各自回调 + 状态合并)。下一节你会看到,async/await 版的同一需求只需 async let 两个词——这正是技术演进的引力所在。
💡 关键直觉:GCD 的思维模型是"邮局"——你把任务装进信封(闭包),投进某个邮箱(队列),邮差(线程池)按地址投递。你从不直接碰线程,只跟队列打交道。
队列语言练熟了,下一节换新一代语法:async/await 把"派信封、等回信"写成顺序代码,并用 Task 的取消机制对齐界面退场——GCD 与 async/await 将长期共存,两套语言都要会说。