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 的多样性和灵活性。
遵循 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 的输出后返回客户端
PHP 脚本,需要添加可执行权限,指定默认使用
#!/usr/local/bin/php-cgi
执行,$_GET
和 $_SERVER
都是 PHP 根据 CGI 协议从环境变量中解析出来的,最终通过 echo
输出结果,传递给 Web 服务器。通过编译并启动
server.c
就可以访问 8080
端口,看到输出结果。CLI 模式示例
PHP 通过 sockets 扩展提供了 socket 网络编程相关的系统调用封装,下面代码中使用的是
socket_create
、socket_bind
、socket_listen
、socket_accept
、socket_read
、socket_write
、 socket_close
等一系列 socket 函数实现的 TCP 长连接服务服务端测试
客户端测试
除此了直接使用 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
在
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 解决方案不仅能达到不错的效果,开发和维护成本方面也具有一定优势。