1-9.【GCD】async 和 sync 在调度和执行阶段的本质区别?

2 阅读6分钟

这是一个非常本质、非常“根”的问题
很多人停留在“async 不阻塞 / sync 阻塞”,但那只是表象

先给一句可以直接当结论用的 👇

asyncsync 的本质区别不在“是否新线程”,而在“提交阶段是否把当前线程纳入调度依赖”。

下面分 提交阶段(enqueue)执行阶段(execute) 拆清楚。


1️⃣ 先给一张“总览对照表”

维度asyncsync
提交是否立即返回
是否阻塞当前线程
当前线程是否参与执行❌(通常)✅(可能)
是否建立调度依赖
是否可能死锁较少很容易

2️⃣ 调度阶段的本质差异(最重要

async:纯提交,不参与调度

queue.async {
    work()
}

在调度层面:

  • 当前线程:

    • 只是把 block 放进 queue
    • 立刻返回
  • 从这一刻起:

    • 当前线程 和这个 block 完全无关
  • block:

    • 什么时候跑
    • 在哪条线程跑
    • 由队列 + 系统决定

👉 async 是:

“我把任务交给你了,你爱什么时候跑什么时候跑。”


sync:提交 + 等待 + 参与调度

queue.sync {
    work()
}

在调度层面:

  • 当前线程:

    • 把 block 放进 queue
    • 进入等待状态
  • 并且:

    • 当前线程 被视为该任务的潜在执行者

这点非常关键。

👉 sync 是:

“我把任务交给你,但我就在这等着,你要不就让我自己干,要不你安排别人干完再叫醒我。”


3️⃣ 执行阶段的差异(容易被忽略)

async 的执行阶段

  • block 一定在“别的时间”执行

  • 几乎不可能:

    • 在当前调用栈中执行
  • 当前线程:

    • 不会跑这个 block

sync 的执行阶段(⚠️重点)

block 有 两种可能执行方式

① 当前线程直接执行(fast-path)

queue.sync {
    work()
}

如果:

  • queue 是空的
  • 没有顺序冲突
  • 当前线程 QoS 合适

👉 block 会直接在当前线程执行

这也是为什么:

print("A")
queue.sync { print("B") }
print("C")

输出一定是:

A
B
C

而且 B 在同一线程


② 其他线程执行,当前线程等待

如果:

  • queue 忙
  • 或当前线程不适合执行(QoS、绑定限制)

那么:

  • block 被派发到其他 worker thread

  • 当前线程:

    • 阻塞
    • 等待完成信号

4️⃣ 死锁为什么几乎只和 sync 有关?

经典例子:

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

发生了什么?

  • 主线程:

    • sync 提交任务到 main queue
    • 进入等待
  • main queue:

    • 只能由主线程执行
  • 结果:

    • 互相等待 → 死锁

👉 根因不是“主线程特殊”,而是:

sync 把“当前线程”加入了调度依赖链

async 不会这么做。


5️⃣ 一个容易忽略的关键点

sync ≠ 一定“不开新线程”

很多人以为:

sync 就是不新建线程,async 才新建线程

这是 完全错误的模型

正确模型是:

  • async / sync 都不控制线程创建

  • 它们只控制:

    • 当前线程是否等待
    • 是否允许当前线程执行任务

6️⃣ 用一句话区分它们(工程直觉版)

  • async
    👉 fire-and-forget(发射完就走)
  • sync
    👉 submit-and-join(提交并加入执行 / 等待)

7️⃣ 一句话终极总结(请背)

async 只是把任务加入队列并立即返回;sync 不仅加入队列,还让当前线程参与到该任务的调度与完成依赖中,这也是它会阻塞、可能复用当前线程、并引发死锁的根本原因。

👉 追问:sync 为什么会阻塞当前线程?

sync 会阻塞当前线程,是因为它的语义不是“提交任务”,而是“在该队列的执行序中完成这段代码后才返回”,这在实现上只能通过让当前线程等待(或直接执行)来保证。

下面把这个“为什么只能这样做”拆清楚。


1️⃣ 先看 sync 的语义承诺(不是实现)

当你写:

queue.sync {
    work()
}

你向 GCD 要求的是:

在 queue 的顺序语义中,等这段代码执行完成之后,当前调用点才能继续。

注意关键词:

  • 顺序语义
  • 完成之后才返回

这意味着:

  • 不能提前返回
  • 不能“过一会儿再回来”
  • 必须在这里等结果

👉 这和 async 的承诺完全不同。


2️⃣ 那为什么“一定要阻塞当前线程”?

因为只有三种可能性,我们逐个排除。


❌ 方案一:返回一个 future / callback(不可行)

如果 sync 不阻塞:

let result = queue.sync { work() } // 立刻返回?

那就变成:

  • 要么返回占位符
  • 要么注册回调

这已经不是 sync 了,而是:

  • async + future
  • async + await

👉 违背 API 语义


❌ 方案二:偷偷在后台执行、假装同步(不成立)

如果:

  • block 在后台执行
  • 当前线程继续跑

那就无法保证:

  • block 已经完成
  • 后续代码 看到的是正确状态

这会直接破坏:

  • 串行队列的顺序一致性
  • 同步调用的可预测性

✅ 方案三:当前线程等待(或直接执行)——唯一解

所以 GCD 只能:

  • 把 block 放进队列

  • 然后:

    • 阻塞当前线程
    • 直到 block 执行完成

这不是实现选择,而是:

由 sync 的“完成即返回”语义强制决定的


3️⃣ 那为什么有时看起来“没有阻塞”?

因为有 fast-path

queue.sync {
    work()
}

如果:

  • queue 当前是空的
  • 当前线程允许执行该队列的任务

GCD 会:

  • 直接在当前线程执行 block
  • 执行完立刻返回

看起来像是“没阻塞”,但本质是:

当前线程已经把工作做完了,自然不用等

语义上仍然是“同步完成后返回”。


4️⃣ 阻塞不是“浪费”,而是同步屏障

从调度角度看:

  • sync 建立了一条 happens-before 关系

  • 保证:

    • queue 中在它之前的任务已完成
    • 当前 block 已完成
    • 后续代码看到的是稳定状态

这就像:

  • 内存屏障
  • 或 join 一个线程

5️⃣ 为什么 async 不会阻塞?

因为 async 的语义是:

“我不关心你什么时候跑,我先走了。”

它不要求:

  • 顺序完成
  • 立即可见结果

所以:

  • 不建立完成依赖
  • 不阻塞线程

6️⃣ 经典死锁,其实就是“阻塞用错地方”

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

发生了什么?

  • 主线程:

    • sync → 阻塞
  • main queue:

    • 只能由主线程执行
  • 结果:

    • 没有任何线程能执行 block
    • 永远等不到完成

👉 不是 sync “坏”,而是:

在“唯一执行者”上使用 sync 是逻辑矛盾


7️⃣ 和其他同步机制的对比

机制是否阻塞本质
DispatchQueue.sync等队列完成
NSLock.lock等锁
DispatchSemaphore.wait等信号
await❌(挂起)等结果但不占线程

这也解释了:

  • 为什么 Swift 引入 await
  • 而不是再加一个“非阻塞 sync”

🔚 一句话终极总结

sync 阻塞当前线程不是实现细节,而是其“必须在队列顺序中完成后才能返回”的语义所必然要求;阻塞(或直接执行)是唯一能满足该语义的实现方式。