众所周知,PHP 占据了服务端编程语言的半壁江山,正如汪峰在音乐圈的地位一般。随着 Node.js 逐渐走上服务端编程的舞台,关于 PHP 和 Node.js 孰优孰劣的争论也不曾间断。

垄断性的市场份额足以佐证 PHP 的优秀。并且 HHVM 虚拟机、PHP 7 的革新,也给 PHP 带来了跨越式的性能突破。然而,当我们为语言层面的性能差异喋喋不休时,却往往忽略了 Web 模型在性能表现中的权重。

从 CGI 到 FastCGI

早期的 Web 服务,是基于传统的 CGI 协议实现的。每个发送到服务器的请求,都需要经过启动进程、处理请求、结束进程三个步骤,以至于访问量增大时,系统资源(如内存、CPU 等)开销也巨大,导致服务器性能下降甚至服务中断。

图 1:简单的 CGI 流程示意

在 CGI 协议下,解析器的反复加载是性能低下的主要原因。如果让解析器进程长驻内存,那么它只需启动一次,就可以一直执行着,不必每次都重新 fork 进程,这就有了后来的 FastCGI 协议。

如果 FastCGI 仅仅做到这样,那么和 Node.js 单进程单线程的模型是基本一致的:Node.js 进程启动后保持持续运行,所有的请求都由这个进程接收和处理,当某个请求引起未知错误时,才可能致使进程退出。

事实上 FastCGI 并没有那么简单,为了保证服务的稳定性,他被设计成了多进程调度的模式:

图 2:Nginx + FastCGI 执行过程

这个过程同样可以描述为三个步骤:

  • 首先,初始化 FastCGI 进程管理器,并启动多个 CGI 解释器子进程;
  • 接着,当请求到达 Web 服务器时,进程管理器选择并连接一个子进程,将环境变量和标准输入发送给它,处理完成后将标准输出和错误信息返还给 Web 服务器;
  • 最终,子进程关闭连接,继续等待下一个请求的到来;

从 child_process 到 cluster

我们回过头来看看 Node.js 的进程管理方式。

原生 Node.js 的单进程单线程模型是一个极易被喷的槽点。这种机制也决定了 Node.js 天生只支持单核 CPU,无法有效地利用多核资源,一旦进程崩溃,还会导致整个 Web 服务的土崩瓦解。

图 3:简单的 Node.js 的请求模型

和 CGI 一样,单一进程始终面临着可靠性低、稳定性差的问题,当真正服务于生产环境时,这样的弱点相当致命。如果代码本身足够健壮,倒可以在一定程度上避免出错,但同时也对测试工作提出了更高要求。现实中我们无法避免代码 100% 不出纰漏,有些东西容易编写测试用例,有些东西却只能依靠人肉目测。

所幸 Node.js 提供了 child_process 模块,通过简单 fork 即可随意创建出子进程。如果为每个 CPU 分别指派一个子进程,多核利用就完美实现了。于此同时,由于child_process 模块本身继承自 EventEmitter 这个基础类,事件驱动使得进程间的通信非常高效。

图 4:简单的 Node.js master-worker 模型(扒的淘杰老湿的图)

为了简化庞杂的父子进程模型实现,Node.js 紧接着又封装了 cluster 模块,不论是负载均衡、资源回收,还是进程守护,它都会像保姆一样帮你默默地搞定一切。具体技术细节可以参考淘杰老湿的《当我们谈论 cluster 时我们在谈论什么(上)》《当我们谈论 cluster 时我们在谈论什么(下)》,里面有所有关于 cluster 方案的推演和实现,这里不再赘述。

在 Node.js 里,要让应用跑在多核集群上,只需寥寥几行代码就万事大吉了:

 

var cluster = require('cluster');
var os = require('os');

if (cluster.isMaster) {
	for (var i = 0, n = os.cpus().length; i < n; i ++) {
		cluster.fork();
	}
} else {
	// 启动应用...
}

 

那么反观 FastCGI 协议,它又是如何处理这种模型的呢?

PHP-FPM 的天生缺陷

PHP-FPM 是 PHP 针对 FastCGI 协议的具体实现,也是 PHP 在多种服务器端应用编程端口(SAPI:cgi、fast-cgi、cli、isapi、apache)里使用最普遍、性能最佳的一款进程管理器。它同样实现了类似 Node.js 的父子进程管理模型,确保了 Web 服务的可靠性和高性能。

PHP-FPM 这种模型是非常典型的多进程同步模型,意味着一个请求对应一个进程线程,并且 IO 是同步阻塞的。所以尽管 PHP-FPM 维护着独立的 CGI 进程池、系统也可以很轻松的管理进程的生命周期,但注定无法像 Node.js 那样,一个进程就可以承担巨大的请求压力。

受制于服务器的硬件设施,PHP-FPM 需要指定合理的 php-fpm.conf 配置:

 

pm.max_children # 子进程最大数
pm.start_servers # 启动时的子进程数
pm.min_spare_servers # 最小空闲进程数,空闲进程不够时自动补充
pm.max_spare_servers # 最大空闲进程数,空闲进程超过时自动清理
pm.max_requests = 1000 # 子进程请求数阈值,超过后自动回收

 

和 JS 不一样的是,PHP 进程本身并不存在内存泄露的问题,每个进程完成请求处理后会回收内存,但是并不会释放给操作系统,这就导致大量内存被 PHP-FPM 占用而无法释放,请求量升高时性能骤降。

所以 PHP-FPM 需要控制单个子进程请求次数的阈值。很多人会误以为 max_requests 控制了进程的并发连接数,实际上 PHP-FPM 模式下的进程是单一线程的,请求无法并发。这个参数的真正意义是提供请求计数器的功能,超过阈值数目后自动回收,缓解内存压力。

或许你已经发现了问题的关键:尽管 PHP-FPM 架构卓越,但还是卡在单一进程的性能上了。

Node.js 天生没有这个问题,而 PHP-FPM 却无法保证,它的稳定性受制于硬件设施和配置文件的契合度,以及 Web 服务器(通常是 Nginx)对 PHP-FPM 服务的负载调度能力。

ReactPHP,事件驱动,异步执行,非阻塞 IO

对 PHP 7 的狂热掩盖了 Node.js 带来的猛烈冲击。当大家还沉醉在如何选择 HHVM 还是 PHP 7 的时候,ReactPHP 也在茁壮成长,它彻彻底底抛弃了 nginx + php-fpm 的传统架构,转而模仿并接纳了 Node.js 的事件驱动和非阻塞 IO 模型,甚至连副标题,都起得一毛一样:

 

Event-driven, non-blocking I/O with PHP.

 

鉴于大家都比较了解 Node.js,对 ReactPHP 的原理就不再赘述了,我们可以认为它就是个 PHP 版的 Node.js。拿它和传统架构(Nginx + PHP-FPM,公平起见,PHP-FPM 只开一个进程)去做对比,结果是这样的:

图 5:输出“Hello World”时的 QPS 曲线

图 6:查询 SQL 时的 QPS 曲线

我们可以看到,当事件驱动、异步执行、非阻塞 IO 被移植嫁接到 PHP 上后,即便没了 PHP-FPM 支撑,QPS 曲线依然不错,在 IO 密集型的场景下,性能甚至得到了成倍成倍的提升。

事件和异步回调机制真是太赞了,它巧妙地将大规模并发、大吞吐量时的拥堵化解为一个异步事件队列,然后挨个解决阻塞(如文件读取,数据库查询等)。

针对单进程模型的吐槽,或许有些偏激。不过显而易见的事实是,单进程模型的可靠性,在 Web 服务器和进程管理器层面是有很大的优化空间的,而高并发的处理能力取决于语言特性,说白了就是事件和异步的支持。

这两点想必是让 Node.js 天生骄傲的事情,但在 PHP 里没有得到原生支持,只能通过模拟步进操作的方式来支持类似 Node.js 的事件机制,所以 ReactPHP 其实也并没有想象中那么完美。

结束语

大部分时候,当我们比较语言优劣,容易局限在语言本身,而忽视了配套的一些关键因素。

就拿 PHP 来说,这两年听到了太多关于即时编译器(JIT)、opcode 缓存、抽象语法树(AST)、HHVM 等等之类的话题。当这些优化逐步完备,语言层面的问题,早已不再是 Web 性能的短板了。如果实在不行,我们还可以把复杂任务交给 C 和 C++,以 Node.js addon 或者 PHP 扩展的形式,轻轻松松就搞定了。

都说 PHP 是“世界上最好的语言”,既然如此,也是时候学习下 Node.js 事件驱动和异步回调,考虑考虑如何对 PHP-FPM 进行大刀阔斧的革新。毕竟不管是 Node.js 还是 PHP,我们所擅长的地方,终将还是 Web,高性能的 Web。

相关资料

浅谈 Node.js 和 PHP 进程管理
标签: