Pull 与 Push 模式
“Pull”和“Push”模型是计算机科学中关于数据流向和控制权的两种最基本的架构模式。
它们的本质区别在于:是谁发起了“数据传输”这个动作?
- Producer: 产生数据的一方。
- Consumer: 处理数据的一方。
Push 模型
核心逻辑: 生产者是主动的,消费者是被动的。 当生产者产生了新数据,它会立即把数据“推”给消费者。消费者不知道数据什么时候来,必须时刻准备着接收。
-
生活类比: 手机短信。你不知道短信什么时候来,但一旦来了,手机就会响(通知你处理)。
-
代码体现: 回调函数(Callback)、事件监听(Event Listener)、RxJS (Observable)。
-
优点:
- 低延迟: 数据一产生就能送达,实时性极高。
- 生产者解耦: 生产者只管发,不关心消费者现在的状态(除非阻塞)。
-
缺点:
- 容易压垮消费者(Backpressure 问题): 如果生产者产生数据的速度(比如每秒 1000 条)远快于消费者处理的速度(每秒 10 条),消费者会被淹没,导致缓冲区溢出或崩溃。
- 控制流复杂: 调试时,堆栈往往很难追踪(Callback Hell)。
Pull 模型
核心逻辑: 消费者是主动的,生产者是被动的。 消费者根据自己的处理能力,主动向生产者“请求”数据。如果消费者不请求,生产者就暂停或把数据暂存。
-
生活类比: 刷自助餐。你(消费者)决定什么时候去拿盘子盛菜,吃完了再去拿下一盘。厨房(生产者)只负责把菜做好放在那。
-
代码体现: 迭代器(Iterator/Generator)、Rust 的
Future、传统的 HTTP 请求。 -
优点:
- 天然的背压(Backpressure)支持: 消费者完全控制速率。处理不过来时,只要不继续“拉”就行了,系统不会崩。
- 易于组合: 可以轻松实现“取前5个”、“过滤”等逻辑(比如 Rust 的
Iterator适配器)。
-
缺点:
- 潜在延迟: 即使数据准备好了,如果消费者不来拉,数据就不会被处理。
- 忙等待(Busy Waiting): 如果消费者通过“死循环”不断地去拉(Polling),通过空转会浪费 CPU。
Rust与C++的异步对比
Rust: 基于 Pull
在 Rust 中,Future 本质上只是一个 状态机(State Machine) 的数据结构。
-
惰性(Laziness): 当你创建一个
Future时(比如调用一个async函数),什么都不会发生。代码一行都不会执行。它只是生成了一个描述“我要做什么”的结构体。 -
Poll 机制: 只有当你把这个
Future交给一个 执行器(Executor)(如tokio或async-std),或者在另一个async块中.await它时,执行器才会调用它的poll()方法。 -
工作流程:
- 执行器调用
future.poll()。 - Future 尝试运行。如果遇到阻塞(比如等待 socket 数据),它会注册一个
Waker(唤醒器)给操作系统/Reactor,然后返回Poll::Pending(我还没好)。 - 执行器收到
Pending,就去处理别的任务了(即 CPU 此时不等待)。 - 关键点: 当数据到达时,操作系统通知 Reactor,Reactor 调用之前注册的
Waker.wake()。 wake()不会直接执行代码,而是告诉执行器:“嘿,这个任务可能准备好了,你再去 拉(Poll) 它一下试试。”- 执行器再次调用
poll(),这次 Future 返回Poll::Ready(result),任务完成。
- 执行器调用
你(Executor)是老板,Future 是员工。
你必须主动问员工:“工作做完??吗?”(Poll)。
员工如果没做完,会记下你的电话(Waker)。
等由于外部条件满足(比如文件送到了),员工打电话给你说:“老板,可以再来问我一次了。”
你只有再次去问(Poll),员工才会把结果给你。
如果你不问,员工就永远坐在那里不动。
C++20 : Push
虽然 C++20 协程标准本身提供了构建机制(promise_type, awaitable),允许实现多种模式,但在主流实现和通常理解中,它倾向于 Eager(急切) 执行和 Push 恢复。
-
急切执行(Eager): 在 C++ 中,当你调用一个协程函数时,它通常会立即开始执行,直到遇到第一个悬挂点(suspension point,
co_await)。 -
回调/恢复机制: 当协程在
co_await等待 I/O 时,它会挂起并注册一个回调(通常通过std::coroutine_handle)。 -
工作流程:
- 协程运行,发起异步操作(如读取文件)。
- 底层 I/O 库接管请求,协程挂起(Suspend)。
- 当操作系统完成 I/O 操作后,它会触发回调。
- 关键点: 这个回调会直接调用
handle.resume(),推动(Push) 协程继续从上次暂停的地方往下跑。
你(主线程)去餐厅点餐(启动协程)。
服务员(底层运行时)给你一个呼叫器。
当菜做好了(I/O 完成),呼叫器震动,甚至服务员直接把菜端到你桌上(Resume),推着你开始吃饭。
你不需要每隔几分钟去问厨房“菜好了没”。
二者对比
| 特性 | Rust (Pull / Poll) | C++ (Push / Callback-style) |
|---|---|---|
| 启动方式 | 惰性 (Lazy)。调用函数仅返回 Future,必须 .await 或 spawn 才运行。 | 急切 (Eager)。调用函数通常立即开始执行,直到遇到第一个 co_await。 |
| 状态机位置 | 内联/栈上 (通常)。编译器生成的状态机是一个结构体,可以嵌套在其他 Future 中,最终编译成一个大的状态机。 | 堆上 (Heap Allocation)。协程通常需要分配堆内存来存储协程帧(Halo 优化可消除部分分配)。 |
| 调度逻辑 | 状态机驱动。Executor 反复调用 poll。必须显式推进状态。 | 事件驱动。完成事件直接触发 resume。 |
| 取消任务 (Cancellation) | 极其简单。直接 Drop 掉 Future 即可。既然你不 poll 它了,它就停止工作了。 | 非常困难。因为协程可能已经在运行或被回调链持有,需要显式的 CancellationToken 机制来通知它停止。 |
| 内存开销 | 极低。因为不需要为每个等待的任务分配独立的堆空间(Zero-cost abstractions)。 | 较高。每个并发任务通常需要独立的堆分配帧(除非编译器能激进优化)。 |
Why we need it? 在[[Pull 与 Push 模式]]中,我们说到了 Rust 是基于 Pull 模型的,也就是说,在Rust中,一个异步任务(Future)是不会自己运行的,必须由执行器去推(Pull)一下,它才会去执行。 比如下面这个简化版的 : 当我们写一个 时,Rust 编译器会在背后悄悄把它编译成一个 枚举(Enum) 或者...