1-4.【GCD】主队列(main queue)和主线程的关系是怎样绑定的?

4 阅读5分钟

主队列(DispatchQueue.main)和主线程(main thread)几乎是“强绑定”的,但两者并不是同一个概念**。我们可以从“是什么 / 怎么绑 / 有什么坑”三个层面来看。


1️⃣ 概念区分(先别混)

主线程(Main Thread)

  • 进程创建时就存在的那个线程

  • 负责:

    • UI 渲染
    • 事件响应(触摸、手势)
    • RunLoop 驱动
  • iOS/macOS UIKit / AppKit 明确要求:UI 只能在主线程操作

主队列(Main Queue)

  • 是 GCD 中的一个 串行队列(Serial Queue)

  • 系统全局唯一

  • 用法是:

    DispatchQueue.main.async {
        // UI 更新
    }
    

👉 队列 ≠ 线程

  • 队列:任务的调度规则
  • 线程:真正执行任务的载体

2️⃣ 它们是如何“绑定”的?(关键点)

✅ 核心结论

主队列中的任务,永远只会在主线程上执行。

这是 GCD 的硬性保证,不是巧合,也不是最佳实践,而是设计契约


🧠 具体是怎么做到的?

1. 主队列是系统特权队列

  • DispatchQueue.main 在创建时:

    • 被标记为 target queue = main thread
    • 直接和 主线程的 RunLoop 绑定

2. 主线程的 RunLoop 会“主动拉取”主队列任务

  • 主线程不是被 GCD 随便调度的

  • 而是:

    • RunLoop 每次循环
    • 检查 main queue
    • 执行队列里的 block

所以流程大致是:

事件 / async 提交
        ↓
DispatchQueue.main
        ↓
主线程 RunLoop
        ↓
在 main thread 上执行

3. 为什么说“强绑定”但不是“同一个东西”?

因为:

  • 主线程可以做不通过主队列的事情

    Thread.isMainThread // true
    
  • 主队列只能在主线程执行

  • 但:

    • 主线程 ≠ 主队列
    • 主队列 ≠ 主线程

你可以在主线程里:

  • 直接执行函数
  • 跑 RunLoop
  • 处理系统回调
    不一定是通过 DispatchQueue.main

3️⃣ 几个非常重要的行为特性(面试/踩坑重点)

❗1. DispatchQueue.main.sync 会死锁(在主线程)

DispatchQueue.main.sync {
    print("Boom")
}

如果你当前就在主线程:

  • 主线程:等待 sync 完成
  • 主队列:等待主线程空闲来执行任务
  • 👉 互相等,直接死锁

这是因为:

  • 主队列是串行
  • 且执行者只能是主线程

❗2. main.async ≠ 立即执行

DispatchQueue.main.async {
    print("later")
}
print("now")

输出一定是:

now
later

因为:

  • async 只是把任务放进 main queue
  • 真正执行要等 RunLoop 下一轮

❗3. 后台线程 + main queue = 回到主线程

DispatchQueue.global().async {
    // background thread
    DispatchQueue.main.async {
        // guaranteed on main thread
    }
}

这是 UI 更新的标准范式


4️⃣ 一句话总结(可以直接背)

主队列是一个由主线程 RunLoop 驱动的串行队列,
GCD 保证所有提交到 DispatchQueue.main 的任务只能、也一定在主线程执行。

👉 追问:为什么主队列一定是串行的?

这是 GCD 设计里一个“看似显然、其实非常深”的点

一句话先给结论,然后拆原因 👇

主队列必须是串行的,因为它绑定的执行者只有一个——主线程,而主线程的 RunLoop 在任何时刻只能顺序执行一个任务。

下面我们从 “如果不是串行会发生什么” → “系统约束” → “设计收益” 来拆。


1️⃣ 先反推:如果主队列是并发的,会怎样?

假设(不现实,但用来思考):

DispatchQueue.main.concurrent {
    updateUI1()
}
DispatchQueue.main.concurrent {
    updateUI2()
}

🚨 问题 1:并发 ≠ 并行,但这里连并发都不成立

  • 并发队列的前提是:
    👉 有多个执行者(线程)
  • 但主队列的执行者只有一个:主线程

所以:

  • 即使你“标记”为并发
  • 最终仍然只能串行执行
  • 并发语义在这里是假的

👉 那还不如直接定义为串行,避免语义欺骗。


🚨 问题 2:UI 框架要求强顺序一致性

UIKit / AppKit 的设计假设是:

  • 所有 UI 操作:

    • 在同一线程
    • 按提交顺序执行
  • 没有锁

  • 没有原子性保护

如果主队列是并发的:

  • 即使仍然在主线程

  • 但调度顺序不再可预测

  • UI 状态可能出现:

    • 中间态
    • 重入
    • 非法生命周期访问

Apple 选择了最简单、最安全的模型:

UI 更新 = 严格 FIFO 顺序执行


2️⃣ 根本原因:主线程 RunLoop 天生是“串行执行模型”

主线程在干嘛?

主线程 RunLoop 每一轮大致是:

处理 input source
↓
处理 timer
↓
处理 main queue blocks
↓
渲染 UI
↓
sleep

关键点:

  • RunLoop 一次只处理一个 block

  • block 执行期间:

    • RunLoop 是“被占用”的
    • 不可能同时执行另一个 block

👉 执行模型 = 天然串行

所以:

  • 主队列如果不是串行
  • 就会和 RunLoop 的模型冲突

3️⃣ 再深一层:重入(Reentrancy)是 UI 的噩梦

什么是重入?

func updateUI() {
    label.text = "A"
    DispatchQueue.main.async {
        label.text = "B"
    }
}

在串行主队列下:

updateUI
  ↓
"A"return"B"

逻辑清晰 ✔️


如果主队列允许并发 / 抢占?

可能变成:

"A"
"B"
(updateUI 尚未结束)

UI 框架中大量代码 假设函数是不可重入的

串行主队列 = 天然的“重入屏障”


4️⃣ 设计层面的硬约束(不是性能问题)

很多人以为:

“主队列串行是为了性能 / 简化实现”

其实不是。

这是 模型一致性问题

约束结果
UI 必须在主线程只有一个执行者
RunLoop 串行无法并发执行
UIKit 非线程安全需要严格顺序
事件处理顺序敏感FIFO 执行

👉 四个约束叠加,只剩一个解:串行队列


5️⃣ 那为什么全局队列可以并发?

对比一下:

队列执行者是否可并发
main queue1 个(主线程)❌ 必须串行
global queue多个线程✅ 并发
自建 concurrent queue多线程✅ 并发

并发队列的本质是:

“调度多个线程”

主队列压根没有这个前提条件


6️⃣ 可以背的一句话总结(面试友好)

主队列之所以一定是串行的,是因为它绑定的主线程和 RunLoop 在任何时刻只能顺序执行一个任务,而 UIKit 又要求 UI 操作具备严格的顺序一致性和不可重入性,因此主队列在模型和实现上都只能是串行队列。