为什么协程不会阻塞线程

「挂起」≠「阻塞」——delay 期间线程被释放去干别的,sleep 期间线程真睡着了

挂起 vs 阻塞 delay 机制 Thread.sleep 定时器队列 百万协程

目录导航(点击跳转)

一、它怎么知道要挂起多久?——定时器队列

1 协程不需要「知道要多久」

它只需要告诉 Runtime:「我这个协程,1000ms 之后你叫醒我。」

delay(1000)  // 翻译成人话:别管多久,你先把我挂起来,1秒后来捞我
2 Runtime 内部干了什么:完整流程
协程A Runtime OS 内核 (epoll)
1 delay(1000) 被调用
协程A 把自己注册进 Runtime 的定时器队列
2 "我叫协程A,当前时间 + 1000ms = 10:00:01.000,到点了叫我"
3 协程A 立刻挂起,交出控制权
Runtime 的定时器队列(按时间排序)
[协程B, 10:00:00.500] [协程A, 10:00:01.000] [协程C, 10:00:02.300]
4 Runtime 用 epoll_wait(timeout=最近到期时间) 等待
等的时候不占 CPU
5 10:00:00.500 到了 → 唤醒协程B
6 10:00:01.000 到了 → 唤醒协程A

不是协程知道要等多久,是协程告诉 Runtime 一个「闹钟」,Runtime 到点了去叫它。

3 定时器队列的数据结构

Runtime 内部维护一个按到期时间排序的优先队列(通常是小顶堆):

// Runtime 内部定时器队列的简化模型
class TimerQueue {
    // 小顶堆:到期时间最早的排在前面
    PriorityQueue<TimerTask> queue = new PriorityQueue<>(
        (a, b) -> Long.compare(a.deadlineMs, b.deadlineMs)
    );

    void schedule(Coroutine co, long delayMs) {
        long deadline = System.currentTimeMillis() + delayMs;
        queue.offer(new TimerTask(co, deadline));
    }

    void runLoop() {
        while (true) {
            TimerTask next = queue.peek();
            if (next == null) {
                // 没有定时任务,epoll 无限等
                epoll_wait(-1);
            } else {
                long waitMs = next.deadlineMs - System.currentTimeMillis();
                if (waitMs <= 0) {
                    // 已经到期了,立刻唤醒
                    queue.poll().coroutine.resume();
                } else {
                    // 还没到期,epoll 等到最近一个到期时间
                    epoll_wait(waitMs);
                }
            }
        }
    }
}

每一步 epoll_wait(timeout)的 timeout 都是动态计算的——Runtime 看定时器队列里最早到期的那个任务还剩多久,就把 timeout 设成那个值。期间有网络事件到达也会提前返回。

二、delay 期间线程能去执行别的任务吗?

1 能。而且这正是 delay 和 sleep 的本质区别。

直接看两端代码和时间线。

2 sleep:线程真睡着了
Thread.sleep(1000);  // OS 线程被标记为 SLEEPING,1秒内不能执行任何东西
OS 线程 1
执行协程A的代码
Thread.sleep(1000) — 线程进入内核,OS 把它踢下 CPU
线程 = SLEEPING 状态。这一秒内线程什么都不能干,不能跑协程B/C,就是一块死肉。
1秒后线程被唤醒,继续跑协程A
协程A
:
运行
sleep — 线程卡死
继续
线程
:
闲置 —— 浪费!
3 delay:协程挂起了,线程没闲
delay(1000)  // 协程A 挂起,但 OS 线程立刻被释放去跑别的协程
OS 线程 1
执行协程A的代码
delay(1000) — 协程A 挂起,Runtime 把 A 的 frame 挂到定时器队列
Runtime: "线程1你别闲着,来,协程B需要跑!"
执行协程B的代码 ← 线程立刻干活,一刻不等
执行协程C的代码
执行协程D的代码
1秒到了,协程A被放回就绪队列
执行协程A的代码(从 delay 后面继续)
协程A
:
运行
挂起 — 定时器队列睡觉
恢复
协程B
:
运行  |  运行  |  运行
协程C
:
      运行  |              |  运行
线程
:
忙  |  忙  |  忙  |  忙  |  忙 ← 一刻没闲!

三、sleep vs delay:一张图放在一起

1 并排对比
Thread.sleep(1000)
OS线程:
├─ 协程A running ──┐
│                │
│  sleep(1000)    │ ← 内核态
│  线程进入      │
│  SLEEPING     │
│  状态        │
│                │
│  这一秒      │
│  OS线程      │
│  完全浪费     │
│                │
│  1秒后被     │
│  OS唤醒      │
│                │
└─ 协程A running ──┘
结论:线程浪费了 1 秒
delay(1000)
OS线程:
├─ 协程A running ──┐
│                │
│  delay(1000)   │ ← 纯用户态
│  协程A        │
│  SUSPENDED    │
│                │
│  线程继续跑   │
│  协程B、C、D  │
│                │
│  1秒后:      │
│  协程A Ready   │
│                │
└─ 协程A running ──┘
结论:线程 1 秒都没浪费

四、本质区别总结

1 对比表
维度 sleep(1000) delay(1000)
谁在等 操作系统在等 Runtime 在等
线程去哪了 线程真睡了(SLEEPING) 线程没睡,立刻跑别的协程
等的时候跑别的? 不能
操作级别 内核态操作 纯用户态操作
阻塞了什么 阻塞了 OS 线程 只挂起了协程
类比 阻塞了「工人」(线程) 只阻塞了「工单」(协程),工人马上去干下一张

五、打个比方:收银员的故事

1 sleep(1000) —— 死等

sleep:你是收银员(线程),来了一个顾客(协程)说"我要去车里取钱包,等我一分钟"→ 你就真的原地站着不动,盯着门口等他,后面排队的 50 个顾客你全不理。

2 delay(1000) —— 先服务下一个

delay:同样顾客说"我去取钱包,等我一分钟"→ 你说"行,一分钟后你再来",先服务下一个顾客。后面 50 个顾客一个个都结完账了,那个取钱包的刚好回来,继续结账。

3 结果对比

sleep — 一分钟内

  • 服务了:1 个人
  • 线程状态:闲置浪费
  • 其他人:全在排队干等

delay — 一分钟内

  • 服务了:50 个人
  • 线程状态:一直在干活
  • 取钱包的:回来刚好继续

sleep 是线程说自己要睡;delay 是协程说自己要睡。线程睡期间什么都干不了;协程睡期间线程可以去服务其他协程。

六、为什么协程可以开百万个

1 数学很简单
Thread.sleep 模式

100 万个协程 × 每个 sleep(1000)

= 需要 100 万个线程

= 100 万 × 1MB = 1TB 栈内存

= 不可能

delay 模式

100 万个协程 × 每个 delay(1000)

= 协程在定时器队列睡觉

= 0 个线程被占用

= 实际只需要几个线程来回执行到期的协程

2 内存视角
1000 线程 1000 协程 100 万协程
栈内存 ~1GB(1000 × 1MB) ~几百 KB(按需增长) ~几百 MB(按需增长)
创建开销 内核系统调用 + 内核数据结构 堆上分配一个小对象 堆上分配百万个小对象
切换开销 用户态↔内核态往返 纯用户态函数调用 纯用户态函数调用
可行性 勉强可行 轻松 完全可行

七、核心思想总结

第二章核心要点

一句话

Thread.sleep() 阻塞了「工人」(线程);delay() 只阻塞了「工单」(协程),工人马上去干下一张工单。