「挂起」≠「阻塞」——delay 期间线程被释放去干别的,sleep 期间线程真睡着了
它只需要告诉 Runtime:「我这个协程,1000ms 之后你叫醒我。」
delay(1000) // 翻译成人话:别管多久,你先把我挂起来,1秒后来捞我
不是协程知道要等多久,是协程告诉 Runtime 一个「闹钟」,Runtime 到点了去叫它。
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 设成那个值。期间有网络事件到达也会提前返回。
直接看两端代码和时间线。
Thread.sleep(1000); // OS 线程被标记为 SLEEPING,1秒内不能执行任何东西
delay(1000) // 协程A 挂起,但 OS 线程立刻被释放去跑别的协程
| 维度 | sleep(1000) | delay(1000) |
|---|---|---|
| 谁在等 | 操作系统在等 | Runtime 在等 |
| 线程去哪了 | 线程真睡了(SLEEPING) | 线程没睡,立刻跑别的协程 |
| 等的时候跑别的? | 不能 | 能 |
| 操作级别 | 内核态操作 | 纯用户态操作 |
| 阻塞了什么 | 阻塞了 OS 线程 | 只挂起了协程 |
| 类比 | 阻塞了「工人」(线程) | 只阻塞了「工单」(协程),工人马上去干下一张 |
sleep:你是收银员(线程),来了一个顾客(协程)说"我要去车里取钱包,等我一分钟"→ 你就真的原地站着不动,盯着门口等他,后面排队的 50 个顾客你全不理。
delay:同样顾客说"我去取钱包,等我一分钟"→ 你说"行,一分钟后你再来",先服务下一个顾客。后面 50 个顾客一个个都结完账了,那个取钱包的刚好回来,继续结账。
sleep 是线程说自己要睡;delay 是协程说自己要睡。线程睡期间什么都干不了;协程睡期间线程可以去服务其他协程。
100 万个协程 × 每个 sleep(1000)
= 需要 100 万个线程
= 100 万 × 1MB = 1TB 栈内存
= 不可能
100 万个协程 × 每个 delay(1000)
= 协程在定时器队列睡觉
= 0 个线程被占用
= 实际只需要几个线程来回执行到期的协程
| 1000 线程 | 1000 协程 | 100 万协程 | |
|---|---|---|---|
| 栈内存 | ~1GB(1000 × 1MB) | ~几百 KB(按需增长) | ~几百 MB(按需增长) |
| 创建开销 | 内核系统调用 + 内核数据结构 | 堆上分配一个小对象 | 堆上分配百万个小对象 |
| 切换开销 | 用户态↔内核态往返 | 纯用户态函数调用 | 纯用户态函数调用 |
| 可行性 | 勉强可行 | 轻松 | 完全可行 |
Thread.sleep() 阻塞了「工人」(线程);delay() 只阻塞了「工单」(协程),工人马上去干下一张工单。