对 PHP 的误解颇深
网络上似乎存在一种现象,一提到 PHP 人们的第一反应是简单且慢,这种简单甚至已经到了简陋的地步,比如不少人认为 PHP 无法独立创建一个服务,只能配合 Apache 或 Nginx 一起使用,而且 PHP 只能在处理完请求后销毁资源关闭进程,所以也无法处理长连接业务,这些都是对 PHP 的误解,我想这种误解的形成可能与 PHP 的发展历史有关,实际上 PHP 能做的有很多,下面就先从 PHP 的发展历史说起。
PHP 的发展简史
在我看来,PHP 的发展路线确实与其他主流编程语言不太相同。PHP 天生就是为了 Web 而生的,早期的 Web 网页都是静态的,例如在个人主页上展示一些固定的个人信息,为了能够让网页展示一些动态的统计数据和简单的交互,Rasmus Lerdorf 在 1995 年开发了 Personal Home Page 工具集合,简称为 PHP,PHP 通过 CGI 协议与 Web 服务器交互,通过实时计算生成动态的内容。这里可能是与其他主流编程语言差别最大的点,其他语言的运行环境要么是通过编译后直接执行,要么是在命令行中调用解释器执行的。
因为 PHP 最初的目标就是做一些简单的计算,所以并不具备主流编程语言中的一些高级特性,后来越来越多的网站开始使用 PHP 并希望能提供更多的功能,之后 Lerdorf 将 PHP 开源,在这之后 Zeev Suraski 和 Andi Gutmans 重写了 PHP 的解析器,并从此开始 PHP 改为 Hypertext Preprocessor,新版的解析器命名为 Zend Engine。Zend 的命名来自于两位作者的名字,至此 PHP 支持了面向对象、命名空间等特性,已经脱胎换骨成为了一门完善的编程语言。在 2015 年 PHP 7 发布,重构了 PHP 中很多重要且常用的数据结构,内存占用得到显著优化,性能也得到了大幅提升。
火爆的 LAMP 架构
虽然 PHP 经过几次版本迭代已经具备了现代编程语言的必要特性,但依旧有很多人对 PHP 有着类似前面提到的种种误解,造成这种误解的原因很大程度上是因为曾经 Web 领域中应用最广泛的架构 - LAMP 实在是太火了。在这套架构中 Linux 作为操作系统,MySQL 用于数据存储,Apache 负责处理网络连接和 HTTP 协议,而 PHP 放在其后面负责处理动态内容。由于这套架构简单有效且开源免费,可以低成本快速搭建起一个可用的服务,这对于初创团队业务试错来说十分具有吸引力,一度出现了很多一键安装的集成软件包,让这套架构的上手门槛进一步降低,但长此以往可能让不少人以为 PHP 只能配合 Apache 或 Nginx 使用,而 PHP 远不止于此。放在 Apache 或 Nginx 后面只是 PHP 运行模式的一种,也就是 CGI 模式,此外 PHP 支持其他模式,下面做一个对比。
PHP 运行的几种模式
按我的理解,PHP 运行模式严格来说就分两种,CGI 模式和 CLI 模式,CGI 后来衍生出了 Apache mod、FastCGI、FPM 等模式。
CGI 模式
CGI (Common Gateway Interface)通用网关接口是一种协议,是早期 Web 服务器与外部程序交互的一种方式,Web 服务器与外部程序之间通过环境变量、标准输入和标准输出交换数据。
CGI 的 logo 是一个三棱镜,其中一束光穿过三棱镜被分解成不同颜色,象征着 CGI 可以将网络请求分解并传递给不同应用程序处理,展现出了 CGI 的多样性和灵活性。
遵循 CGI 协议的 Web 服务器一般会有一个名为 cgi-bin 的目录,目录下面默认都是可执行 CGI 脚本文件,如果前端访问到了这些文件那么 Web 服务器并不会像处理普通文件那样直接将文件返回给前端,而是会 fork 出子进程并在子进程中运行指定的 CGI 脚本,脚本运行完成后通过标准输出将结果返回给 Web 服务器,并关闭子进程。
运行前 Web 服务器会将一些必要的请求信息设置在环境变量中,CGI 脚本运行后便可以通过读取环境变量得到这些请求信息,例如 uri、请求参数等。CGI 脚本的标准输出会重定向给 Web 服务器,服务器接到输出后返回给前端,这就是为什么早期的 CGI 模式下运行的 PHP 程序可以通过 echo
来返回结果的原因。
这种模式特点是比较简单,并且由于每次处理完成后都会销毁进程和资源,所以也不会出现内存泄漏等问题,但缺点是由于每次都需要重新创建新的进程并销毁,性能开销较大,也无法利用到长连接或池化技术,在处理大量并发请求时处理能力较低。
FastCGI 模式与 PHP-FPM
为了解决 CGI 模式下每次都要新建子进程并销毁子进程导致的性能低下问题,FastCGI 模式在 CGI 基础上做出了改进,这种模式下会预先创建出一些 CGI 进程常驻内存,当有请求到来时会分配一个空闲进程处理,完成后并不销毁而是作为空闲进程重新等待处理请求。
FastCGI 是协议,而 PHP-FPM 是 FastCGI 的实现,全称为 PHP FastCGI Process Manager。这种模式根本上还是基于 CGI 模式衍生出来的,主要优化的是引入常驻内存特性以及多个 FPM 进程的管理,减少了频繁开启关闭进程带来的性能损耗,但由于 Web 服务器与 FPM 进程之间还是短连接,所以这种模式不支持与客户端的长连接。
CLI 模式
CLI 模式则是直接使用 PHP 解释器来运行 PHP 代码,例如 php test.php
,在我看来无论哪种编程语言,CLI 模式才应该是最为广大人民群众所喜闻乐见的模式,但由于 PHP 以 CGI 以及 FastCGI 模式运行实在太过深入人心,以至于 CLI 模式反而对很多人来说较为陌生。
在这种模式下 PHP 的运行方式与其他高级编程语言区别并不大,支持常见的系统调用,就算不支持还可以通过扩展的形式支持,自然可以实现 socket 网络编程以及常驻内存,实现长连接也是很自然的事。
CLI 模式下实现 socket 编程常见的方式有两种,一种是使用官方 sockets 扩展提供 socket 支持的方式,另一种是基于第三方扩展例如 swoole,本文主要介绍原生 PHP 的实现方式。
PHP CGI 与 CLI 示例
下面分别列出两个例子,介绍 CGI 和 CLI 两个典型模式是如何运行的。
CGI 模式示例
首先是一个 C 语言实现的服务器,监听 8080 端口,接到请求时如果请求的是指定 CGI 脚本则会通过 fp = popen(cgi_script, "r");
以子进程的方式启动 CGI 脚本,由于使用了 setenv
来设置环境变量,所以在子进程中可以读取到这些环境变量并做出一些计算处理。
下面就是 CGI 协议中规定的环境变量,是否很眼熟,例如 QUERY_STRING
环境变量就是 CGI 协议中规定的经过 URL-encoded 的参数:
下面实现一个最基本的 CGI server,接到请求会启动一个 PHP 子进程处理,最后接到 PHP 的输出后返回客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#define PORT 8080
#define BUFFER_SIZE 1024
void handle_cgi(int client_fd, const char *cgi_script, const char *query_string) {
char buffer[BUFFER_SIZE];
FILE *fp;
// 设置 CGI 环境变量
setenv("REQUEST_METHOD", "GET", 1);
// QUERY_STRING 是用来设置请求参数的
setenv("QUERY_STRING", query_string, 1);
setenv("SCRIPT_FILENAME", cgi_script, 1);
setenv("SERVER_PROTOCOL", "HTTP/1.1", 1);
setenv("GATEWAY_INTERFACE", "CGI/1.1", 1);
setenv("SERVER_SOFTWARE", "MyServer/1.0", 1);
setenv("REMOTE_ADDR", "127.0.0.1", 1);
setenv("REDIRECT_STATUS", "200", 1);
// 启动子进程启动 CGI 脚本
fp = popen(cgi_script, "r");
if (fp == NULL) {
perror("popen");
return;
}
// 发送 HTTP 头
write(client_fd, "HTTP/1.1 200 OK\r\n", 17);
// 读取 CGI 脚本的输出并发送给客户端
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
write(client_fd, buffer, strlen(buffer));
}
pclose(fp);
}
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
exit(EXIT_FAILURE);
}
if (listen(server_fd, 10) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server is running on port %d\n", PORT);
while (1) {
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue;
}
read(client_fd, buffer, sizeof(buffer) - 1);
char method[BUFFER_SIZE];
char path[BUFFER_SIZE];
char protocol[BUFFER_SIZE];
sscanf(buffer, "%s %s %s", method, path, protocol);
// 一个简单的路由处理
if (strncmp(path, "/cgi-bin/script.php", 19) == 0) {
char *query_string = strchr(path, '?');
if (query_string != NULL) {
query_string++;
} else {
query_string = "";
}
handle_cgi(client_fd, "./script.php", query_string); // 确保脚本路径正确
} else {
write(client_fd, "HTTP/1.1 404 Not Found\r\n", 24);
write(client_fd, "Content-Type: text/html\r\n\r\n", 27);
write(client_fd, "<html><body>CGI script Not Found</body></html>", 38);
}
close(client_fd);
}
close(server_fd);
return 0;
}
PHP 脚本,需要添加可执行权限,指定默认使用 #!/usr/local/bin/php-cgi
执行,$_GET
和 $_SERVER
都是 PHP 根据 CGI 协议从环境变量中解析出来的,最终通过 echo
输出结果,传递给 Web 服务器。
#!/usr/local/bin/php-cgi
<?php
// 解析 GET 请求参数
echo "<h2>GET 请求参数:</h2>";
echo "<pre>";
print_r($_GET);
echo "</pre>";
// 解析通用请求参数(GET 和 POST)
echo "<h2>SERVER:</h2>";
echo "<pre>";
print_r($_SERVER);
echo "</pre>";
?>
通过编译并启动 server.c
就可以访问 8080
端口,看到输出结果。
> gcc -o server server.c
> ./server
Server is running on port 8080
> curl localhost:8080
<h2>GET 请求参数:</h2><pre>Array
(
)
</pre><h2>SERVER:</h2><pre>Array
(
[GATEWAY_INTERFACE] => CGI/1.1
[HOSTNAME] => a05d15a93523
[PHP_INI_DIR] => /usr/local/etc/php
[REMOTE_ADDR] => 127.0.0.1
[HOME] => /root
[QUERY_STRING] =>
[PHP_LDFLAGS] => -Wl,-O1 -pie
[PHP_CFLAGS] => -fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
[PHP_VERSION] => 8.2.21
[SCRIPT_FILENAME] => ./script.php
[GPG_KEYS] => 39B641343D8C104B2B146DC3F9C39DC0B9698544 E60913E4DF209907D8E30D96659A97C9CF2A795A 1198C0117593497A5EC5C199286AF1F9897469DC
[PHP_CPPFLAGS] => -fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
[PHP_ASC_URL] => https://www.php.net/distributions/php-8.2.21.tar.xz.asc
[PHP_URL] => https://www.php.net/distributions/php-8.2.21.tar.xz
[SERVER_SOFTWARE] => MyServer/1.0
[TERM] => xterm
[PATH] => /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[SERVER_PROTOCOL] => HTTP/1.1
[REDIRECT_STATUS] => 200
[REQUEST_METHOD] => GET
[PHPIZE_DEPS] => autoconf dpkg-dev file g++ gcc libc-dev make pkg-config re2c
[PWD] => /app
[PHP_SHA256] => 8cc44d51bb2506399ec176f70fe110f0c9e1f7d852a5303a2cd1403402199707
[PHP_SELF] =>
[REQUEST_TIME_FLOAT] => 1722446968.3435
[REQUEST_TIME] => 1722446968
[argv] => Array
(
)
[argc] => 0
)
</pre>
CLI 模式示例
PHP 通过 sockets 扩展提供了 socket 网络编程相关的系统调用封装,下面代码中使用的是 socket_create
、socket_bind
、socket_listen
、socket_accept
、socket_read
、socket_write
、 socket_close
等一系列 socket 函数实现的 TCP 长连接服务
<?php
$address = '0.0.0.0';
$port = 8080;
$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if (!$sock) {
die("Could not create socket: " . socket_strerror(socket_last_error()) . "\n");
}
if (!socket_bind($sock, $address, $port)) {
die("Could not bind socket: " . socket_strerror(socket_last_error($sock)) . "\n");
}
if (!socket_listen($sock, 5)) {
die("Could not listen on socket: " . socket_strerror(socket_last_error($sock)) . "\n");
}
echo "Server listening on $address:$port\n";
do {
$client = socket_accept($sock);
if ($client) {
$input = socket_read($client, 1024);
echo "client say:" . $input;
$output = "Hello, " . trim($input) . "\n";
socket_write($client, $output, strlen($output));
socket_close($client);
}
} while (true);
socket_close($sock);
服务端测试
> php server.php
Server listening on 0.0.0.0:8080
client say:123
客户端测试
> nc localhost 8080
123
Hello, 123
除此了直接使用 socket 相关函数之外,PHP 还提供了以 stream 方式处理 socket 的一系列函数,如 stream_socket_server
相当于整合了 socket_create
、socket_bind
、socket_listen
函数。
Workerman 的实现
Workerman 是一款高性能 PHP 应用容器,是一个典型的基于 PHP socket 的以 CLI 模式运行的应用容器,结合 IO 多路复用和多进程达到了相当不错的性能。下面就看看 Workerman 的核心部分是如何实现的。
以下代码来自 Workerman 4.1.0 版本,只展示了核心部分。
Workerman 入口函数是 runAll
public static function runAll()
{
static::checkSapiEnv();
static::init();
static::parseCommand();
static::daemonize();
static::initWorkers();
static::installSignal();
static::saveMasterPid();
static::displayUI();
static::forkWorkers();
static::resetStd();
static::monitorWorkers();
}
在 initWorkers
函数中初始化 server 实例,其中会根据 reusePort
属性判断是否要在主进程中调用 listen
初始化 socket。
protected static function initWorkers()
{
foreach (static::$_workers as $worker) {
// 如果没开启端口重用,则主进程会主动 listen
if (!$worker->reusePort) {
$worker->listen();
}
}
}
public function listen()
{
if (!$this->_mainSocket) {
// 创建 socket
$this->_mainSocket = \stream_socket_server($local_socket, $errno, $errmsg, $flags, $this->_context);
if (!$this->_mainSocket) {
throw new Exception($errmsg);
}
if (\function_exists('socket_import_stream') && static::$_builtinTransports[$this->transport] === 'tcp') {
\set_error_handler(function(){});
$socket = \socket_import_stream($this->_mainSocket);
\socket_set_option($socket, SOL_SOCKET, SO_KEEPALIVE, 1);
\socket_set_option($socket, SOL_TCP, TCP_NODELAY, 1);
\restore_error_handler();
}
// 设置非阻塞 socket
\stream_set_blocking($this->_mainSocket, 0);
}
$this->resumeAccept();
}
reusePort
属性相当于 socket 的 SO_REUSEPORT
选项,表示是否开启端口重用,这个选项涉及到惊群问题。
默认没有开启 SO_REUSEPORT
,那么主进程会在 initWorkers
函数中主动调用一次 listen
函数创建 socket,之后在 forkWorkers
函数中 fork 出子进程,子进程会继承这个 socket,并在其之上进行事件循环的阻塞等待。之后当客户端请求到来时,所有子进程都会被唤醒尝试去 accept
客户端连接,但最终只有一个子进程可以 accpet
成功,其他子进程只能重新阻塞挂起,这种现象就是惊群,频繁且大量的进程状态切换会浪费系统资源。
而如果开启 SO_REUSEPORT
那么主进程中不会调用 listen
,而是在 forkOneWorkerForLinux
时由每个子进程各自创建 socket 并分别在自己的 socket 上进行事件循环,由于开启了端口重用,所以操作系统运行不同进程监听相同端口。当客户端请求到来时,操作系统会以负载均衡的方式唤醒其中一个子进程处理请求,这样就避免了惊群问题导致的性能损耗。
protected static function forkOneWorkerForLinux(self $worker)
{
$pid = \pcntl_fork();
// 主进程
if ($pid > 0) {
static::$_pidMap[$worker->workerId][$pid] = $pid;
static::$_idMap[$worker->workerId][$id] = $pid;
} // 子进程
elseif (0 === $pid) {
// 这里决定是否应该调用 listen 函数创建自己的 socket
if ($worker->reusePort) {
$worker->listen();
}
$worker->run();
} else {
throw new Exception("forkOneWorker fail");
}
}
最终在 run
方法中创建并启动事件循环
public function run()
{
// 创建事件循环
if (!static::$globalEvent) {
$event_loop_class = static::getEventLoopName();
static::$globalEvent = new $event_loop_class;
$this->resumeAccept();
}
// 启动事件循环
static::$globalEvent->loop();
}
workerman 在 CLI 模式下结合多路复用 IO 和事件循环,并采用多进程模式运行,可以较好的支持高并发长连接场景。
PHP 不适合干这个?
可能有的人会说 PHP 不适合干这种活,不过在我看来适不适合应该以成本为前提。Web 应用属于典型的 IO 密集型应用,这种场景下使用这种方案已经可以应对大部分业务规模,如果团队是 PHP 为主语言那么使用这个方案成本是最低的而且效果也相当不错,或者说在业务发展到瓶颈之前这个方案一般不会先遇到瓶颈,如果遇到了那么首先恭喜你的业务取得了长足进步,其次应该考虑的是通过架构的方式来解决更大规模问题,例如进行服务化和分层化等等。
总的来说 PHP 不仅仅停留在 FPM,也绝不是低性能的代名词,结合业务场景和团队实际情况,采用合适的 PHP 解决方案不仅能达到不错的效果,开发和维护成本方面也具有一定优势。