Skip to main content
Featured image for post: Pull 与 Push 模式

Pull 与 Push 模式

6 min 1,786 words

“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)(如 tokioasync-std),或者在另一个 async 块中 .await 它时,执行器才会调用它的 poll() 方法。

  • 工作流程:

    1. 执行器调用 future.poll()
    2. Future 尝试运行。如果遇到阻塞(比如等待 socket 数据),它会注册一个 Waker(唤醒器)给操作系统/Reactor,然后返回 Poll::Pending(我还没好)。
    3. 执行器收到 Pending,就去处理别的任务了(即 CPU 此时不等待)。
    4. 关键点: 当数据到达时,操作系统通知 Reactor,Reactor 调用之前注册的 Waker.wake()
    5. wake() 不会直接执行代码,而是告诉执行器:“嘿,这个任务可能准备好了,你再去 拉(Poll) 它一下试试。”
    6. 执行器再次调用 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)。

  • 工作流程:

    1. 协程运行,发起异步操作(如读取文件)。
    2. 底层 I/O 库接管请求,协程挂起(Suspend)。
    3. 当操作系统完成 I/O 操作后,它会触发回调。
    4. 关键点: 这个回调会直接调用 handle.resume()推动(Push) 协程继续从上次暂停的地方往下跑。
比喻

你(主线程)去餐厅点餐(启动协程)。
服务员(底层运行时)给你一个呼叫器。
当菜做好了(I/O 完成),呼叫器震动,甚至服务员直接把菜端到你桌上(Resume),推着你开始吃饭。
你不需要每隔几分钟去问厨房“菜好了没”。

二者对比

特性Rust (Pull / Poll)C++ (Push / Callback-style)
启动方式惰性 (Lazy)。调用函数仅返回 Future,必须 .awaitspawn 才运行。急切 (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) 或者...

Referenced in this post