PHP 这么拉?长连接都搞不了?说说 PHP 的 socket 编程

date
Aug 13, 2024
slug
php-socket-programming
status
Published
tags
PHP
summary
PHP 表示不服
type
Post

对 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 的多样性和灵活性。
notion image
遵循 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 的参数:
notion image
下面实现一个最基本的 CGI server,接到请求会启动一个 PHP 子进程处理,最后接到 PHP 的输出后返回客户端
PHP 脚本,需要添加可执行权限,指定默认使用 #!/usr/local/bin/php-cgi 执行,$_GET$_SERVER 都是 PHP 根据 CGI 协议从环境变量中解析出来的,最终通过 echo 输出结果,传递给 Web 服务器。
通过编译并启动 server.c 就可以访问 8080 端口,看到输出结果。
 

CLI 模式示例

PHP 通过 sockets 扩展提供了 socket 网络编程相关的系统调用封装,下面代码中使用的是 socket_createsocket_bindsocket_listensocket_acceptsocket_readsocket_writesocket_close 等一系列 socket 函数实现的 TCP 长连接服务
服务端测试
客户端测试
除此了直接使用 socket 相关函数之外,PHP 还提供了以 stream 方式处理 socket 的一系列函数,如 stream_socket_server 相当于整合了 socket_createsocket_bindsocket_listen 函数。
 

Workerman 的实现

Workerman 是一款高性能 PHP 应用容器,是一个典型的基于 PHP socket 的以 CLI 模式运行的应用容器,结合 IO 多路复用和多进程达到了相当不错的性能。下面就看看 Workerman 的核心部分是如何实现的。
以下代码来自 Workerman 4.1.0 版本,只展示了核心部分。
Workerman 入口函数是 runAll
initWorkers 函数中初始化 server 实例,其中会根据 reusePort 属性判断是否要在主进程中调用 listen 初始化 socket。
reusePort 属性相当于 socket 的 SO_REUSEPORT 选项,表示是否开启端口重用,这个选项涉及到惊群问题。
默认没有开启 SO_REUSEPORT,那么主进程会在 initWorkers 函数中主动调用一次 listen 函数创建 socket,之后在 forkWorkers 函数中 fork 出子进程,子进程会继承这个 socket,并在其之上进行事件循环的阻塞等待。之后当客户端请求到来时,所有子进程都会被唤醒尝试去 accept 客户端连接,但最终只有一个子进程可以 accpet 成功,其他子进程只能重新阻塞挂起,这种现象就是惊群,频繁且大量的进程状态切换会浪费系统资源。
而如果开启 SO_REUSEPORT 那么主进程中不会调用 listen,而是在 forkOneWorkerForLinux 时由每个子进程各自创建 socket 并分别在自己的 socket 上进行事件循环,由于开启了端口重用,所以操作系统运行不同进程监听相同端口。当客户端请求到来时,操作系统会以负载均衡的方式唤醒其中一个子进程处理请求,这样就避免了惊群问题导致的性能损耗。
最终在 run 方法中创建并启动事件循环
workerman 在 CLI 模式下结合多路复用 IO 和事件循环,并采用多进程模式运行,可以较好的支持高并发长连接场景。
 

PHP 不适合干这个?

可能有的人会说 PHP 不适合干这种活,不过在我看来适不适合应该以成本为前提。Web 应用属于典型的 IO 密集型应用,这种场景下使用这种方案已经可以应对大部分业务规模,如果团队是 PHP 为主语言那么使用这个方案成本是最低的而且效果也相当不错,或者说在业务发展到瓶颈之前这个方案一般不会先遇到瓶颈,如果遇到了那么首先恭喜你的业务取得了长足进步,其次应该考虑的是通过架构的方式来解决更大规模问题,例如进行服务化和分层化等等。
总的来说 PHP 不仅仅停留在 FPM,也绝不是低性能的代名词,结合业务场景和团队实际情况,采用合适的 PHP 解决方案不仅能达到不错的效果,开发和维护成本方面也具有一定优势。
 

© 菜皮 2020 - 2024