道可叨

Free Will

推与拉

推与拉是两种设计风格,各有优劣。本文略述一二,抛砖引玉。

如何推,怎么拉

众所周知,程序=数据+算法。数据和算法互相配合的方式,决定了这段程序是推模型还是拉模型。

  • 推模型:数据源将数据推给算法,算法被动地等待数据的到来
  • 拉模型:数据源等待算法的访问,算法主动地将数据从数据源中拉出

在程序世界中,推与拉经常在不同的层次出现。

回调函数是典型的推模型的应用,设计模式中的 Template Method,Command,Observer,Visitor 中都有回调函数的身影,更别提 GUI 编程中无处不在的事件处理函数了。这里是好莱坞原则的天下:Don't call us, we will call you.

相反,普通的循环,设计模式中的 Iterator,通常的命令行交互程序则是拉模型的应用了。

有时,相同的功能还有推模型与拉模型两种 API 实现,最典型的要数 XML 解析库了,DOM 方式的 API 是拉模型的代表,而 SAX API 则可算做推模型的典范。

推模型的特点是性能好,但编写不容易,经常需要自己维护系统的状态。拉模型则相反,它符合人的直觉,状态由运行时的栈自动维护,理解容易,可惜往往性能不高,同时,单个栈也很难满足复杂的应用逻辑。

“長短之相形也,高下之相呈也”,推与拉也是相生相克的一对。往往系统中某一个推模型需要另一个拉模型来支持,反之亦然。或言之,没有人推送,你又怎么能拉到数据呢。

网络编程中的推与拉

让我们用上面的观点来分析一下 Windows 平台上不同的网络模型对于 socket 的读请求的处理方式。

最简单的是阻塞模型,对一个 socket 发起读请求后,程序会阻塞直到读请求调用完成后再返回。这是一个直白的拉模型,编写最为方便,当然,效率也是最低的。程序会经常阻塞在 IO 事件上,不能充分利用 CPU。

再好一点的是 select 模型:先用 select 函数判断 socket 是否可读,如可读则可安全的调用阻塞的读函数而不用等待 IO 数据了。如果 select 判定该 socket 不可读,程序可以做些别的事情(比如读另外一个可读的 socket,或做些计算等等),不用象阻塞模型一样一直等待下去。可以看出,select 模型比阻塞模型要复杂些,但本质上,它还是一个拉模型。有趣的是,常有人把它包装成 Reactor 模式:当有事件到来时,Reactor 会调用预先注册的回调函数来处理。可以看出,推与拉在这里做了一次转换,select 做为拉模型,将数据从底层拉过来,Reactor 做为推模型,又将拉来的数据推送到相应的回调函数上。

Windows 平台上性能最好的当数完成端口(IOCP)模型了。它是开发高并发网络服务器程序的不二选择。这是一个编写起来有些困难的拉模型:当网络事件完成后,系统会把事件投递到完成端口的工作队列中,等待在队列另一端的处理线程会被唤醒进行相应的处理。

为什么完成端口的性能高呢?从最底层看,所有的 IO 事件都是通过网络推过来的。操作系统通过中断相量(可以理解为最底层的回调函数)响应网络包数据。系统内核在一系列协议栈解析和系统内核对象映射之后,若干 IO 事件被构建成一个 socket 句柄的上的读事件。select 模型这时就需要在用户层遍历所有的 fd_set 中的 socket 句柄来找到可读的 socket,(没错,Windows 平台的 select 就是简单的 for 循环)这无疑浪费了 CPU 资源,如果 socket 数量成千上万的话,select 模型的大部份消耗都将花费在内部的傻瓜循环上。完成端口模型则相反,它不需要在用户空间由应用层再次遍历,反而会在内核直接把 socket 上读到的数据拷入用户缓冲区中,再唤醒一个工作线程处理读到的数据。虽然完成端口是拉模型,但从逻辑上看,它更象一个推模型(预先准备好的用户空间的缓冲区,阻塞在完成端口上等待被调度的工作线程)。事实上,它一般被封装成名为 Proactor 的推模型。

推拉转换

从上面的分析可以看出,推模型与拉模型并非泾渭分明,却是互为表里。可谓“推拉之相合也”,两者各有优劣,常常需要相互转换。在上面分析网络模型里,select 的拉模型就转换成了 Reactor 的推模型。而操作系统内核的推模型也被装点成了拉模型的完成端口 API 供用户空间调用。

拉模型转换成推模型比较容易,只需要将拉取数据的部分与处理数据的部分分开来。拉数据的部分还是使用拉模型,如 for 循环,阻塞调用等,而处理数据的部分变成回调函数,由下层的拉模型将数据推送给回调函数。下面是个简单例子:

// C++ 0x

std::vector<int> data = {1, 2, 3};

for (int i: data) {
    std::cout << i << std::endl;
}

这是一个拉模型,程序将数据从 data 数组中拉出,进行处理(这里只是简单的将之输出)。下面是相应的推模型版本

// C++ 0x

auto printer = [](int i) {
    std::cout << i << std::endl;
};

std::for_each(data.begin(), data.end(), printer);

可见,如果要将原来的直白的拉模型转换为推模型,这里只需将处理数据的算法封装成单独的回调函数,另由一个驱动去专门遍历数据源即可。这还是相当容易的。不过,转换后的代码反而比之前的复杂了,有画蛇添足之嫌。它的好处是什么呢?拉模型转换成推模型的好处是数据与算法的分离。在拉模型中,数据的抽取与对数据应用的算法混在一个 for 循环中,这样便很难单独复用数据抽取或数据算法这两部分。再来看看转换后的推模型,应用在数据上的算法被封装成了 printer ,而数据的抽取也变成了独立的函数 std::for_each ,这两者互不相干,可以独立的被复用。

其实,通常有两种模式来分离数据的抽取,一种就是上面所示的方法,通过回调函数来进行分割的 Visitor 模式(不是 GoF 中复杂的那种 Visitor 模式)还有一种也在上面的代码中出现,那就是 STL 容器类普遍运用的 Iterator 模式。Iterator 模式的实现起来比 Visitor 模式要复杂的多,Boost 中甚至提供了专门的库 Boost.Iterator 来帮助人们实现一个合格的兼容 STL 的 Iterator。虽然实现复杂,但它的好处是:

  • Iterator 使用起来非常直观,不需要定义一个专门的回调,是一个典型的拉模型。
  • Iterator 的拉模型可以很容易的转换成 Visitor 式的推模型。反过来则不然。
  • Iterator 接口使用起来相当灵活。用户可以在使用时进一步的在 for 循环体内自由定义遍历方式。比如只处理偶数位的数据,或者遇到特殊值停止等。而 Visitor 模式在这方面就比较死板。它的遍历方式是固定的。只能通过回调函数的返回值来做细微的控制。所以 STL 中的算法会有很多种(for_each, find, find_if ...)

Visitor 式的推模型能转换成 Iterator 式的拉模型吗?答案是可以,但需要一个线程队列做辅助。在队列的一端,一个回调函数不断的将收到的数据再推送到线程队列中,而在队列另一端,通过 API 暴露一个读取并移除数据的阻塞调用。通过这样一个生产者消费者模型,就可以将生产者端的推模型转化成消费者端的拉模型。实际上,上面分析的 Windows 完成端口 API 正是这么做的。

语言的支持

既然推与拉这么的普遍,为何不在语言层面给以直接的支持呢。实际上,确实有很多语言这么做了,比如 Python。Python 通过 yield 关键字既可以方便的将数据推送给调用者,也可以从调用者那拉来数据。实际上 yield 关键字只是协程(Coroutine)不完全实现, 而协程恰恰是实现推拉互动的所谓非抢占式协作的关键。不过,哪怕是不完整的实现,也让 Python 在数据推拉相关的程序上的表达力丰富了许多,以至于很多人觉得有必要在 Python 中实现完整的协程支持。当然,那又是另外一个话题了。