了解 PHP 异步编程模型

在详细介绍如何使用异步库开发 PHP 应用程序之前,我们有必要回过头来了解一下 PHP 异步编程模型。了解异步编程模型与传统同步编程模型的区别,将为您在开发 PHP 应用程序时提供一个高性能的新世界。让我们先看看同步编程模型,然后再深入了解异步。

开发同步编程代码

在传统的 PHP 编程中,代码是以线性方式执行的。一旦代码被编译成机器代码,CPU 就会按顺序一行接一行地执行代码,直到代码结束。PHP 程序设计当然也是如此。令人惊讶的是,面向对象编程(OOP)也是如此!无论您是否将对象作为代码的一部分,OOP 代码都会被编译成首字节代码,然后是机器代码,并以与过程代码完全相同的方式进行同步处理。

使用 OPcache 和即时 (JIT) 编译器与代码是否以同步方式运行没有任何关系。OPcache 和 JIT 编译器带来的唯一好处就是能以比其他方式更快的速度运行同步代码。

请不要觉得使用同步编程模型编写代码有什么不妥!这种方法不仅屡试不爽,而且相当成功。此外,PHPUnit、Xdebug、众多框架等许多辅助工具都支持同步代码。

不过,同步编程模式有一个很大的缺点。使用这种模式,CPU 必须不断等待某些任务完成,然后才能继续运行程序。在很大程度上,这些任务包括访问外部资源,如查询数据库、写入日志文件或发送电子邮件。这类任务被称为 阻塞操作(阻碍进程的操作)。

下图直观地展示了应用程序流程,其中包括写入日志文件和发送电子邮件通知的阻塞操作:

image 2023 11 24 16 46 37 735
Figure 1. Figure 12.1 – Synchronous programming model

如图 12.1 所示,当应用程序写入日志文件时,CPU 会暂停程序代码的执行,直到操作系统(OS)发出日志文件写入操作完成的信号。之后,代码可能会发送电子邮件通知。同样,CPU 也会暂停代码执行,直到电子邮件发送操作结束。虽然每个等待间隔本身可能无关紧要,但如果将所有此类阻塞操作的等待间隔加在一起,性能就会开始下降,尤其是在涉及冗长循环的情况下。

解决方法之一就是广泛采用缓存解决方案。另一种解决方案是使用异步编程模型编写应用程序。现在就让我们来看看。

了解异步编程模型

异步操作背后的理念由来已久。Apache Web 服务器及其多处理模块(MPM)就是一个非常著名的例子。通过 MaxRequestWorkers 指令,您可以指定网络服务器可以同时处理多少个请求(更多信息请参见 https://httpd.apache.org/docs/current/mod/mpm_common.html#maxrequestworkers )。

异步编程模型通常涉及设置管理节点(称为 Worker)。这样,程序就可以继续执行,而无需等待任何给定任务的完成。性能的提升可以非常显著,尤其是在发生大量阻塞操作(例如文件系统访问或数据库查询)的情况下。

下图形象地说明了如何使用异步编程模型来完成写入日志文件和发送电子邮件的任务:

image 2023 11 24 16 48 51 369
Figure 2. Figure 12.2 – Asynchronous programming model

总等待时间的缩短是分配工人数量的系数。图 12.2 所示程序流程的等待时间只有图 12.1 的一半。随着分配处理阻塞操作的工人数量增加,整体性能也会提高。

异步编程模型不能与并行编程混为一谈。在 并行编程 中,任务实际上是同时执行的,通常分配给不同的 CPU 或 CPU 内核。而异步编程则是按顺序操作,但允许顺序代码在等待阻塞操作(如文件系统请求或数据库查询)结果时继续执行。

现在您已经了解了 PHP 异步编程模型的工作原理,让我们来看看对 coroutine 的支持。

使用异步协程支持

协程与线程类似,但在用户空间而非内核空间运行,因此无需操作系统参与。如果提供了这种支持,则 "例行程序支持组件" 会检测阻塞操作(如读取或写入文件),并有效地暂停该操作,直到收到结果为止。这样,CPU 就可以继续执行其他任务,直到阻塞进程返回结果。这一过程在机器代码级运行,因此我们无法检测到,只是我们的代码运行速度更快了。

从理论上讲,即使代码是使用同步编程模型编写的,使用提供协程支持的扩展或框架也可以提高性能。请注意,并不是所有的 PHP 异步框架或扩展都提供这种支持,这反过来可能会影响您在未来开发中对框架或扩展的选择。

Swoole 扩展 (https://www.swoole.co.uk/) 提供了对 coroutine 的支持。另一方面,最流行的 PHP 异步框架之一 ReactPHP (https://reactphp.org/) 并不提供对 coroutine 的支持,除非与 Swoole 扩展(将在下文讨论)或 PHP fibers(将在学习 PHP 8.1 fibers 部分讨论)一起使用。然而,ReactPHP 如此受欢迎的原因之一就是不需要 Swoole 扩展。如果您的主机环境无法控制 PHP 的安装,那么您仍然可以使用 ReactPHP,并在不接触 PHP 安装的情况下获得可观的性能提升。

现在,我们将注意力转向为异步模型编写代码。

创建 PHP 异步应用程序

现在,困难的部分来了!不幸的是,使用同步编程模型编写的应用程序无法利用 async 模型提供的优势。即使您使用的框架和/或扩展提供了 coroutine 支持,除非您重构代码以遵循 async 编程模型,否则也无法实现最大的性能增益。

大多数 PHP 异步框架和扩展都提供了多种方法来分离任务。以下是常用方法的简要总结。

事件循环

从某种意义上说,事件循环是一个重复的代码块,它持续运行直到指定事件发生。所有的 PHP 异步扩展和框架都以这样或那样的形式提供了这种功能。事件循环中添加了回调形式的监听器。当事件被触发时,监听器的逻辑将被调用。

Swoole 事件循环利用了 Linux epoll_wait ( https://linux.die.net/man/2/epoll_wait )功能。由于基于硬件的事件是通过伪文件句柄向 Linux 报告的,因此 Swoole 事件循环允许开发人员不仅根据实际文件的属性,还根据产生文件描述符 (FD) 的任何硬件进程的属性来启动和停止事件循环。

ReactPHP 框架提供相同的功能,但默认使用 PHP stream_select() 函数,而不是操作系统的 epoll_wait 功能。这使得 ReactPHP 事件循环应用编程接口(API)可以在不同服务器之间移植,不过反应时间会慢一些。ReactPHP 还提供了基于 ext-eventext-evext-uvext-libevent PHP 扩展定义事件循环的功能。利用这些扩展,ReactPHP 可以访问硬件,就像 Swoole 一样。

Promises

Promise 是一种软件结构,允许您将任务的处理推迟到稍后进行。这一概念最初是作为 CommonJS 项目的一部分提出的( http://wiki.commonjs.org/wiki/Promises/A )。它被设计为同步和异步编程世界之间的桥梁。

在同步编程中,函数(或类方法)通常要么成功要么失败。在 PHP 中,失败被处理为故意抛出的异常或致命错误。在异步模型中,承诺(Promise)有三种状态:已实现(fulfilled)、失败(failed)和未实现(unfulfilled)。因此,在创建承诺实例时,您需要提供三个处理程序,根据它们所代表的状态采取行动。

在 ReactPHP 中,当您创建 React\Promise\Promise 实例时,您需要提供一个解析器作为第一个构造函数参数。解析器本身需要三个回调,分别为 $resolve$reject$notify。这三个回调对应于承诺的三种可能状态:已实现、失败或未实现。

Streams

许多异步框架都为 PHP 流(streams)提供了封装。PHP 流最常用于处理涉及文件系统的操作。文件访问是一种阻塞操作,会导致程序执行暂停,直到操作系统返回结果。

为了避免文件访问阻塞异步应用程序的进程,需要使用流(streams)组件。例如,ReactPHP 在 React\Stream 命名空间下提供了实现 ReadableStreamInterfaceWritableStreamInterface 的类。这些类封装了普通的 PHP 流函数,如 fopen()fread()fwrite(),以及 file_get_contents()file_put_contents()。ReactPHP 类使用内存来避免阻塞,并将实际读取或写入推迟到稍后进行,从而允许异步活动继续进行。

Timers

定时器 是独立的任务,可以设置在给定时间间隔后运行。在这方面,定时器类似于 JavaScript 的 setTimeout() 函数。使用定时器安排的任务可以设置为只运行一次,也可以在指定的时间间隔内连续运行。

大多数 PHP 异步框架或扩展中的定时器实现通常都避免使用 PHP pcntl_alarm() 函数。后者允许开发人员在一定秒数后向进程发送 SIGALRM 信号。不过,pcntl_alarm() 函数一次只允许设置一个,而且最低时间间隔是以秒为单位的。相比之下,PHP 异步框架和扩展允许精确到毫秒地设置多个定时器。PHP 异步定时器实现的另一个不同之处是它不依赖于 declare(ticks=1) 语句。

定时器有很多潜在用途—​例如,定时器可以检查包含 "区分计算机和人类的完全自动公共图灵测试(CAPTCHA)" 图像的目录,并删除旧的图像。另一个潜在用途是定期刷新缓存。

Channels

通道是并发进程之间的一种通信方式。目前通道的实现基于查尔斯-安东尼-霍尔爵士(Sir Charles Antony Hoare)于 1978 年提出的代数模型。他的提议经过多年改进,逐渐发展成为 1985 年出版的《通信顺序进程》一书中描述的模型。通道和通信顺序过程(CSP)模型是当前许多流行语言(如 Go)的一个特征。

与其他更复杂的方法相比,使用通道时,CSP 进程是匿名的,而通道是明确命名的。通道方法的另一个方面是,在接收方准备好接收之前,发送方不能发送。这一简单的原则减轻了实现过多共享锁定逻辑的负担。例如,在 Swoole 扩展中,通道被用来实现连接池或作为调度并发任务的一种手段。

现在,您已经对 PHP 异步理论有了基本的了解,是时候将理论付诸实践了。我们先来看看如何使用 Swoole 扩展。