1-7.【GCD】串行队列一定只用一个线程吗?为什么?

2 阅读5分钟

不一定,而且这是 GCD 里最容易被误解的一点之一
先给一句“反直觉但正确”的结论👇

串行队列保证的是“同一时间只执行一个任务”,而不是“永远只使用同一个线程”。

下面把 为什么怎么做到的什么时候看起来像一个线程 全讲清楚。


1️⃣ 串行队列真正的承诺是什么?

❌ 它不承诺

  • 只用一个线程
  • 任务都在同一条线程上跑

✅ 它只承诺

  • FIFO 顺序
  • 任意时刻最多只有一个 block 在执行

也就是说:

task A  ──▶ 运行中
task B  ──▶ 等待
task C  ──▶ 等待

而不是:

thread 1: A
thread 1: B
thread 1: C

2️⃣ 那为什么“看起来”常常只用一个线程?

因为 很多时候“没必要换线程”

举个例子:

let serial = DispatchQueue(label: "serial")

serial.async { print(Thread.current) }
serial.async { print(Thread.current) }

很可能你看到:

<Thread: 0x...>{number = 5}
<Thread: 0x...>{number = 5}

这是因为:

  • 第一个任务执行完
  • 线程还空闲
  • GCD 直接复用它
  • 省得切换线程(更快)

👉 但这是优化结果,不是语义保证。


3️⃣ 什么时候会用到“不同线程”?(关键)

情况 1:线程被阻塞 / 挂起

serial.async {
    sleep(2)
}
serial.async {
    print("next")
}
  • 第一个 block 睡眠

  • 原线程被占用

  • GCD 可能:

    • 让第二个 block
    • 另一个 worker thread 上跑
  • 但仍然保证:

    • 等第一个完成后才执行

情况 2:QoS / 优先级变化

serial.async {
    DispatchQueue.global(qos: .userInteractive).sync {
        // 高 QoS
    }
}
  • 当前线程 QoS 可能不合适

  • GCD 可能切换到:

    • 更高优先级的线程
  • 继续执行后续 block


情况 3:线程回收 / 系统压力

  • 系统回收空闲线程
  • 下一个 block 到来
  • 重新分配一个新线程

串行队列对此完全不关心


4️⃣ 那 GCD 是怎么保证“不会并行”的?

🔒 靠的是队列内部状态,不是线程锁

对于串行队列:

  • 队列维护一个状态:

    • “是否正在执行任务”
  • 每次:

    • 只允许一个 block 被派发到 target queue
  • 直到:

    • 当前 block 完成
    • 才派发下一个

即使 target queue 是并发的:

  • 这个限制仍然成立

👉 串行是调度规则,不是执行资源限制


5️⃣ 和主队列的区别(容易混)

主队列为什么“像是一个线程”?

因为:

  • 主队列 target = 主线程
  • 主线程只有一个
  • RunLoop 串行执行

所以主队列是:

串行队列 + 单一执行线程

但这只是一个特殊情况


6️⃣ 一个常见错误假设(⚠️)

“串行队列 = 线程安全”

❌ 错。

let serial = DispatchQueue(label: "serial")
var value = 0

DispatchQueue.global().async {
    value += 1
}

serial.async {
    print(value)
}
  • serial 只能保证:

    • serial 内部的任务不并发
  • 并不能阻止:

    • 其他队列并发访问同一资源

线程安全 ≠ 串行队列。


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

串行队列保证的是执行的顺序性和互斥性,而不是线程的唯一性;GCD 可以在不同时间使用不同线程来执行串行队列的任务,只要不破坏“同时只有一个任务在运行”的约束。

👉 追问:串行队列中的任务可能运行在不同线程上吗?

可以,而且这是完全合法、也是 GCD 的常态行为。

一句话版结论先放这儿 👇

串行队列只保证“同一时间只有一个任务在执行”,并不保证“所有任务都在同一条线程上运行”。

下面把这个说清楚,并顺手把常见误区一次性扫掉。


1️⃣ 为什么“可以在不同线程上”?

因为在 GCD 里:

  • 队列 ≠ 线程

  • 串行队列只是一个调度约束

  • 真正执行任务的是:

    • target queue
    • 共享线程池里的 worker thread

只要满足:

前一个任务已经结束,下一个任务就可以用任何可用线程来跑


2️⃣ 一个最直观的例子

let serial = DispatchQueue(label: "com.example.serial")

serial.async {
    print("A:", Thread.current)
}

serial.async {
    print("B:", Thread.current)
}

可能看到:

A: <Thread 0x...>{number = 5}
B: <Thread 0x...>{number = 7}

可能看到:

A: <Thread 0x...>{number = 5}
B: <Thread 0x...>{number = 5}

两种结果都 100% 正确


3️⃣ GCD 是怎么保证“不会并发”的?

重点来了:
不是靠“只用一个线程”

而是靠 队列状态机

  • 串行队列内部维护一个状态:

    • isExecuting
  • isExecuting == true

    • 不会把下一个任务派发出去
  • 当前任务完成后:

    • isExecuting = false
    • 才允许派发下一个

👉 所以:

  • 线程可以换
  • 同一时刻只能有一个在跑

4️⃣ 什么情况下更容易“换线程”?

🧩 情况 1:线程被阻塞或让出 CPU

serial.async {
    sleep(1)
}

serial.async {
    print("next")
}
  • 第一个任务睡眠

  • 原线程被占用

  • 下一个任务:

    • 可能由另一个 worker thread 执行

🧩 情况 2:QoS / 优先级变化

  • 前一个 block 是 .utility

  • 后一个 block 是 .userInitiated

  • GCD 可能:

    • 换到更高优先级的线程

🧩 情况 3:线程回收 / 系统压力

  • 线程被系统回收
  • 下一个任务到来
  • 分配新线程

5️⃣ 唯一的“例外”:主队列

DispatchQueue.main.async {
    // 一定在 main thread
}
  • 主队列 = 串行
  • target = 主线程
  • 主线程只有一个

所以:

主队列的串行 + 单线程是“特例”,不是串行队列的通性


6️⃣ 一个非常容易踩的坑(⚠️)

❌ 错误假设

“我在串行队列里存 Thread.local / TLS,一定安全”

Thread.current.threadDictionary["key"] = value

如果任务换线程:

  • thread-local 数据就丢了
  • 行为会非常诡异

👉 串行队列 ≠ 线程局部环境


7️⃣ 正确心智模型(请记住)

  • 串行队列:

    • 保证 顺序
    • 保证 互斥
  • 线程:

    • 执行资源
    • 可被复用、替换、提升优先级

🔚 一句话终极总结

串行队列中的任务完全可能运行在不同线程上;GCD 只保证这些任务不会并发执行,而不保证它们使用同一个线程。