工作进程模式
工作进程模式运行常驻的 PHP 工作进程,它们只引导一次,然后处理大量请求,因此启动 PHP 的开销只需支付一次,而不是每个请求都支付。你的应用无需在每个请求时销毁并重建 PHP 状态,而是只加载一次自动加载器、配置和数据库连接,并在工作进程的整个生命周期内复用它们。
工作原理
- 启用工作进程模式。 设置
WORKER_MODE_ENABLED=true并让ENTRY_FILE指向你的引导脚本。这会为进程池中的所有 PHP 工作进程启用工作进程模式。 - 只引导一次。 PHP 启动并运行外层作用域一次。自动加载器注册、配置加载、数据库连接以及任何其他初始化代码都只执行一次。
- 进入请求循环。 调用
oxphp_worker(callback)。OxPHP 开始将传入的 HTTP 请求分发给你的回调函数。 - 在请求之间重置。 超全局变量(
$_GET、$_POST、$_SERVER、$_COOKIE、$_FILES、$_REQUEST、php://input)、输出缓冲区、响应头、请求打开的会话以及请求改动过的 ini 指令都会自动重置。它覆盖什么、止于何处,参见哪些会重置,哪些会持续存在。软重置会清理每个请求的状态,同时保留外层作用域中已引导的资源。$_ENV是有意为之的例外:在 PHP 默认的auto_globals_jit=1下,它不会被重置(参见超全局变量)。 - 外层作用域持续存在。 在
oxphp_worker()之前定义的变量、静态属性、数据库连接和自动加载器,在该工作进程处理的所有请求之间都保持可用。
工作进程模式会改变路由行为。所有未匹配到磁盘上静态文件的请求都会被分发给工作进程,而不是返回 404。详见路由。
配置
| 变量 | 默认值 | 说明 |
|---|---|---|
WORKER_MODE_ENABLED |
false |
启用常驻工作进程模式。接受 true、1、yes。要求 ENTRY_FILE 指向一个 .php 脚本 |
ENTRY_FILE |
(未设置) | 工作进程引导脚本的路径。相对路径时会相对于 DOCUMENT_ROOT 解析;允许使用 .. 片段和绝对路径(工作进程引导脚本位于公共文档根目录之外是一种受支持的布局) |
WORKER_MAX_MEMORY_MIB |
0 |
每个工作进程回收前的最大 PHP 内存(以 MiB 为单位)。0 = 无限制 |
遗留的 WORKER_FILE 变量仍会被解析(并伴随一条启动时的 WARN),其行为等同于 WORKER_MODE_ENABLED=true ENTRY_FILE=$WORKER_FILE。新的部署应使用这一对显式变量;遗留形式将在未来版本中移除。
对于由应用驱动的回收,可在请求处理函数内部调用 OxPHP\Server\Worker::scheduleExit()。工作进程会在当前请求完成后干净地退出。
编写工作进程脚本
工作进程脚本有两部分:在启动时运行一次的外层作用域,以及传递给 oxphp_worker() 的在每个请求时运行的回调函数。
<?php
// Outer scope: runs once at startup
require __DIR__ . '/../vendor/autoload.php';
$config = parse_ini_file(__DIR__ . '/../config/app.ini');
$db = new PDO($config['dsn'], $config['user'], $config['pass'], [
PDO::ATTR_PERSISTENT => true,
]);
$app = new MyApp\Application($config, $db);
// Request loop: runs for every request
oxphp_worker(function () use ($app) {
$app->handle();
});
// 收尾:在该工作进程的循环结束时运行,而不仅仅是
// 在服务器关闭时——参见下文的“回收”
$app->terminate();哪些会重置,哪些会持续存在
OxPHP 会在请求之间执行软重置。每个请求的状态会被自动清理,而在外层作用域中引导的任何内容都会在工作进程的整个生命周期内存活。
- 超全局变量 —
$_GET、$_POST、$_SERVER、$_COOKIE、$_FILES、$_REQUEST和php://input会用新的请求数据重新填充 - 输出缓冲区 — 所有输出缓冲区都会被刷新并清理
- 响应头 — HTTP 状态码和响应头会重置为默认值
- 错误状态 — 最后一次的错误信息(消息、文件、行号、类型)和连接状态会被清除。用户注册的错误处理器(
set_error_handler())和异常处理器(set_exception_handler())会在请求之间持续存在 - 会话状态 — 请求打开的会话会在该工作进程的下一个请求之前被关闭,
$_SESSION会被清除。边界见下文 - 请求改动过的 ini 指令 —
ini_set()、ignore_user_abort()和error_reporting()的改动属于该请求,并在请求结束时回滚到引导时的基线。边界见下文
- 外层作用域中的变量 — 在
oxphp_worker()之前定义并通过use捕获的任何内容 - 静态属性 — 类的静态属性会保留其值
- 数据库连接 — PDO、MySQLi 和其他持久连接会保持打开
- 自动加载器 — 已注册的自动加载器(Composer、自定义)会保持活动状态
- 已加载的类和函数 — 所有之前加载的类、接口、trait 和函数
- 引导期间设置的 ini 指令 — 外层作用域中的
ini_set()在工作进程的整个生命期内有效,也是各请求的改动回滚的目标 $_ENV— 有意不重置(在 PHP 默认的auto_globals_jit=1下)。它描述的是工作进程而不是请求。这让.env加载器写入的值在写入它们的那次引导之后依然存活,也让请求处理函数内部做出的写入同样长久地存活——对该工作进程上此后的每个请求,以及与之多路复用的任何请求都可见。参见超全局变量
会话状态
请求打开的会话会在工作进程接下它的下一个请求之前被关闭:数据被交给保存处理器,会话 id 被释放,$_SESSION 也离开符号表。因此,下一个请求会在自己的 cookie 下启动会话;在此之前,session_id() 读到的是 '',$_SESSION 也处于未定义状态。有三条边界值得了解:
- 另一个请求仍在使用的会话,会在工作进程下一次空闲时关闭,而不是在打开它的请求返回时关闭。 没有其他请求在用的会话,会在请求结束时关闭:在其 shutdown 函数之后、其 ini 改动被放回之前。这正是 PHP 自身请求关闭所用的顺序,所以写入是在该请求的设置下进行的。共享的会话则在线程被交还时所带的设置下写入。在此之前,工作进程仍保持该会话打开,保存处理器也仍持有它锁住的一切。在默认的
files处理器中,那是会话文件上的一把排他、阻塞的锁:同一会话 id 的请求若从别处到来(另一个工作进程、另一个进程),就会在保存处理器里等待;而由于这种等待是工作线程上的阻塞系统调用,它会拖住该工作进程承载的每一个请求。一旦会话数据定稿就调用session_write_close(),写入和解锁就都会发生在你的应用安排的位置。 - 会话属于工作线程而不属于请求,因此在同一工作进程上重叠的请求会共享它。 只要正在运行的请求挂起(在
oxphp_async_await()、oxphp_sleep()、oxphp_usleep()中,或在RUNTIME_HOOKS下的套接字等待中:读取,启用net类别时还包括连接、TLS 握手,或需要等待缓冲区空间的写入;其中连接包括new PDO('mysql:…')和new mysqli(...)),工作进程就会接下新的请求;而只要它承载的任何请求仍可能在会话中,它就无法把会话交还。在这个窗口内接下的请求会转而拿到这个会话:session_start()不看新请求的 cookie 就回答true,$_SESSION就是被暂停请求的数组。session_write_close()也无法把两者分开:它关闭会话,却让会话 id 仍保持设置,于是窗口内接下的请求随后启动的是那个 id:它从存储中读出被暂停请求的数据,并在自己的Set-Cookie中拿回同一个 id。当应用既使用会话、又允许请求挂起时,可以依赖的隔离方式是不要让会话跨越挂起点保持打开。 - 在会话出现之前就已暂停的请求,不被视为处于该会话中。 共享从请求被接下的那一刻起算:启动会话的请求,以及会话存续期间接下的每个请求,都处于其中,只要其中任何一个仍然存活,工作进程就会保留该会话。更早挂起的请求则不在其中;否则,一个从不碰会话的长寿请求就会让工作进程无法交还任何会话。这样的请求在醒来后仍能读取
$_SESSION,读到的是那时存续的会话,而那可能属于另一个客户端。在没有自行启动会话的请求中,不要在挂起点之后读取$_SESSION。
在任何 oxphp_async_await()、oxphp_sleep() 或被挂钩的套接字等待之前,用 session_write_close() 关闭会话。跨越挂起点保持打开的会话,会与工作进程接下的下一个请求共享。
Ini 回滚
请求用 ini_set()、ignore_user_abort() 或 error_reporting() 改过的任何东西都属于该请求,就像在 PHP-FPM 下那样:请求结束时它会被放回去。大多数指令在请求暂停于 await、sleep 或套接字等待时也仍归该请求所有:它们会从工作进程上取下,因此工作进程在此期间运行的请求看到的是基线,而被暂停的请求恢复时带着自己的值。围绕一次 HTTP 调用的 ini_set('default_socket_timeout', 5),以及调试分支里的 ini_set('display_errors', '1'),只作用于做出这些改动的请求,而不作用于下一个请求。
它们被恢复到的基线是你的引导所设置的值,而不是 php.ini 里的值:外层作用域中的 ini_set() 属于应用配置,在每个请求之后都存活,每个请求也都从它开始。这包括 ignore_user_abort(true):在引导中调用时,它作用于该工作进程服务的每个请求,流式响应也不例外(参见 Server-Sent Events)。有七条边界值得了解:
- 当工作进程还有其他在途请求时,
memory_limit和session.*设置会留在工作进程上。 堆属于工作线程而不是请求,session.*设置所配置的会话也是如此,包括session_set_save_handler()安装的保存处理器。它们不会在请求暂停时从工作进程上取下,只有在工作进程手头没有其他任务、接下它下一个请求时才会被放回去。只要工作进程还有在途工作(每当有请求暂停时,以及一个发后不管(fire-and-forget)的 promise 还在被回收时),某个请求在这里设置的值就会作用于工作进程在这个窗口内接下的请求。 set_time_limit()设置的是调用它的请求的时限,但ini_get('max_execution_time')报告的是工作进程的值。 每个请求都有自己的时间限制(参见请求截止时间),而set_time_limit(),或对max_execution_time调用的ini_set()或ini_restore(),只改变这一个,不影响其他请求。该指令本身则像memory_limit一样留在工作进程上:ini_get('max_execution_time')给出的是自工作进程上一次没有在途任务以来,任何请求设置的最后一个值(如果没有请求设置过,则是引导设置的值)。在承载多个请求的工作进程上,这个值未必是调用者自己的时限。该指令会在工作进程手头没有其他任务、接下它下一个请求时被放回去。- 处理器不只是存储值的指令,在其请求暂停时会留在工作进程上。 移动一个指令会运行它的处理器,而有些处理器在存储时还会做别的事:
default_charset和各编码设置会重置 mbstring 的编码,zlib.output_compression会启动一个输出处理器,open_basedir会基于当前目录解析相对路径,已弃用的assert.*设置每次都会抛出弃用提示。如果在每次暂停和恢复时都运行它们,就会撤销请求在这期间所做的事,所以这些指令留在工作进程上:它在某个请求暂停期间运行的请求会看到该暂停请求的值,而该指令会在最后一个在途请求结束时被放回去。会随请求迁移的,是那些由 PHP 某个标准设置器存储的指令:ignore_user_abort、precision、display_errors、error_reporting、default_socket_timeout、user_agent以及大多数其他指令。少数只做校验的指令也仍留在工作进程上:OPcache 启用时的include_path(包括set_include_path();OPcache 关闭时它会迁移)、error_log、date.timezone和default_mimetype。请求通过函数而非 ini 指令设置的东西(mb_internal_encoding()、date_default_timezone_set()、setlocale())根本不是 ini 改动。它属于工作进程,除非请求自己把它放回去,否则会比请求活得更久。 - 处理器拒绝被放回的指令,会保持请求留下的样子。 这样的指令会一直保留请求设置的值,直到工作进程下一次在手头没有其他任务时接下请求;而一个被暂停的请求,如果恢复时它的改动被拒绝,就会在没有该改动的情况下继续运行。
- 在其某个指令正被移动时结束的请求,会让工作进程被回收。 处理器可能运行应用代码:放回
assert.callback会丢弃该回调,连同回调所持有的一切,其析构函数就在那里运行。任何在其中结束请求的事情(致命错误,或向客户端已离开的流写入输出)都会让处理器半途而废,回调仍处于设置状态,并指向一个拆到一半的闭包。只有全新的工作进程才能纠正这一点,因此工作进程会以oxphp_worker_recycles_by_reason_total{reason="scheduled"}退出,仍在它上面的在途请求会像任何其他回收一样被结束。请求本身按其结束方式归档:致命错误以500应答并计入错误熔断器,客户端离开则属于取消,不计入。这类析构函数抛出的异常会被报告为该请求的未捕获异常,除此之外不再有额外代价。 memory_limit先作为值被恢复,然后才作为限制生效。 当仍被占用的内存多于新限制时,PHP 拒绝调低分配器的上限。因此,一个还占着请求所分配内存的工作进程,ini_get()会报告恢复后的memory_limit,而分配器仍在执行调高后的那个。一旦工作进程自身的占用为其腾出了空间,上限就会跟上。opcache.enable完全不被恢复。 把 OPcache 关掉是请求唯一能对它做的事(PHP 拒绝在请求中途把它重新打开),而能把它重新拉起来的是 OPcache 自己的按请求启动逻辑——工作进程只在启动时运行它一次。因此,某个请求关掉了 OPcache 的工作进程,余生都会从源码编译每一个文件,该指令也保持读作0以如实反映这一点。若去恢复它,ini_get('opcache.enable')和opcache_get_status()会报告一个并没有在运行的、已启用的缓存——那是更糟的选项,因为一个为了做决定而查询的应用,会被告知与实际情况相反的答案。
回收
被回收的工作进程,其循环结束,PHP 状态被拆除;是否有全新的工作进程取而代之,取决于进程池,如下文所述。当满足以下任一条件时,工作进程会被自动回收:
- 超出最大内存 — 工作进程的 PHP 内存使用量超过
WORKER_MAX_MEMORY_MIBMiB - 应用请求退出 — 处理函数调用了
Worker::scheduleExit()。这对于应用控制的热重载、基于文件 mtime 的重载或每个请求的引导重新执行很有用 - 连续错误 — 工作进程连续接了 3 个崩坏的请求:致命错误、内存耗尽、栈溢出,或是围绕请求所在纤程的两种失败之一。连续三次说明出问题的是这个工作进程,而不是某个请求;一个不断崩坏的工作进程会被替换,而不是再修补一次。哪些计入、哪些不计入见下文
- 在内部函数中结束的请求留下了大量内存 — 致命错误或取消(例如
max_execution_time到期)在内部函数中结束请求时,会留下该函数为自己分配的内存,而工作进程无法把它们归还:usort()排序的是数组的副本,而这个副本让每个元素都保持存活。工作进程会在两个没有在途请求的时刻之间比较自己的内存使用量。如果在这期间有请求在调用栈上还有某个内部函数的调用时被结束,并且使用量增长超过 4 MiB,工作进程就会被回收,并在日志中写一条指明原因的警告。恰好在curl_exec()这类内部函数返回时被结束的请求同样计入,因为时间限制是在该调用仍在栈上时被察觉的。工作进程在这段时间内创建的纤程栈不计入,因为它会在整个生命周期内保留它们。只有循环回收器才能释放的垃圾会先被回收,但仅限于回收器已经知道的那部分:只有在中止(bailout)之后的清理中才被释放的循环永远不会交给它,于是它保持分配状态、被计入,并让工作进程被回收。此刻没有在途请求,因此不会有请求因此被结束,这次回收计入oxphp_worker_recycles_by_reason_total{reason="scheduled"}。它不依赖于WORKER_MAX_MEMORY_MIB。起决定作用的是增长量而不是那次调用,因此同一段时间内让工作进程内存增长的其他任何东西也会计入:首次填充的缓存、尚未释放的大请求体。它捕获不到的情况:约 4 MiB 以下的泄漏,它们会不断累积,直到超过WORKER_MAX_MEMORY_MIB,或者在未设置它时,直到请求开始因 PHP 的memory_limit而失败;工作进程忙碌的第一段时间内的泄漏,这段时间不做判断,否则全新工作进程在最初几个请求中为自己构建的东西,会在超时不断到来时让每个替代者依次被回收;服务器关闭期间的任何情况;以及从来没有一刻处于无在途请求状态的工作进程,在这种工作进程上,检查会一直等到第一个这样的时刻 - 内存耗尽错误让占用量超过了
memory_limit— PHP 会把限制搁置一旁来构建Allowed memory size … exhausted报告,因为报告本身也需要内存;在 PHP 8.5 上,该报告包含栈上每一帧的回溯(fatal_error_backtraces,默认开启)。在一次失控的递归之后,这个回溯可能比限制本身还大:在memory_limit=128M下深达 400,000 次调用的递归,留下了约 290 MiB 被计为已使用。只要分配器把超过限制的量计为已使用,它就不会拒绝任何分配,对工作进程上的任何请求都是如此。PHP-FPM 通过关闭请求来收回这些内存。工作进程不会关闭任何请求,因此它会在失败的请求结束时(在其 shutdown 函数之后)归还内存并让限制重新生效;在其中某个 shutdown 函数挂起期间(在oxphp_async_await()、oxphp_sleep()/oxphp_usleep()中,或在被RUNTIME_HOOKS接管的sleep()、usleep()或套接字等待中),工作进程所服务的请求同样在没有限制的情况下运行。如果即便到那时工作进程仍持有超过限制的内存(例如该请求的某个 shutdown 函数保留了这么多),工作进程就会在下一个没有在途请求的时刻被回收,并在日志中写一条指明原因的警告,计入oxphp_worker_recycles_by_reason_total{reason="scheduled"}。在此之前它所服务的请求都在memory_limit不生效的情况下运行,通常根本没有任何限制。与上面的检查不同,这项检查从工作进程的第一个请求起就适用,但服务器关闭期间不适用。异步池的线程会在失败的任务结束时归还内存,不够时再在之后每个任务结束时重试;它不会被回收。fatal_error_backtraces=0会省略回溯,也就省掉了报告所占用的大部分内存
并非每个失败的请求都计入,因为并非每种失败都说明这个工作进程已不适合继续服务:
| 结果 | 对计数的影响 |
|---|---|
| 在请求处理器、shutdown 函数,或在这些 shutdown 函数被释放时运行的析构函数中发生的致命错误、内存耗尽或栈溢出 | 计入 |
| 请求所在的纤程无法启动:引擎无法为其分配 C 栈,因此处理器从未运行 | 计入 |
| 请求不肯停止从用户态挂起该纤程,于是服务器结束了它 | 计入 |
未捕获的异常(以 500 应答)——无论来自请求处理器还是 shutdown 函数 |
中性 |
被取消的请求:max_execution_time 到期、服务器正在关闭,或未调用 ignore_user_abort(true) 的流式响应的客户端挂断 |
中性 |
请求完成,包括 exit()/die() |
清零计数 |
“中性”就是字面意思:夹在一串致命错误中间的这类结果,既不增加计数也不清零,所以“致命错误、异常、致命错误、致命错误”仍会回收该工作进程。shutdown 函数中的失败与请求处理器中的失败按同样的方式解读。PHP 用自己的保护机制运行 shutdown 函数,所以在其中失败的请求在工作进程看来是正常返回的。那里的致命错误仍然是请求自身的崩坏,所以同样计入;那里的异常与处理器抛出的异常一样干净地展开,所以同样中性。
计数取决于请求为什么结束,而不是它留下了什么。它回答的是这个工作进程是否有问题,而被服务器自己中途截断的请求并不构成对它不利的证据。因此,取消无论落在处理器中(渲染途中截止时间到期、服务器正在关闭)还是 shutdown 函数中,都是中性的,前提是该请求此前没有发生致命错误。如果处理器已经崩坏,截止时间在 shutdown 函数中到期,那次失败仍然计入。关闭服务器是唯一无条件中性的取消,因为一个即将退出的工作进程不在被评估之列。
在流式响应之外,客户端挂断在工作进程模式下并不是一种独立的结果。它不会结束请求,请求可以自由地跑完并运行自己的清理,随后按它的结束方式来解读:走到结尾会清零计数,致命错误计入,未捕获异常中性。这是有意为之。一个把处理器运行到底的请求证明了工作进程能够服务,无论是否还有人读取响应;而一个每个请求都致命错误的工作进程在这个请求上也会致命错误,所以离开的客户端无法让这样的工作进程苟活。
两行纤程相关的结果之所以计入,理由与致命错误相同:请求没有走到它自身工作的终点,而服务器也没有截断它。当引擎无法分配纤程运行所需的 C 栈时(fiber.stack_size 被设置为低于平台最小值两个内存页,或进程无法映射更多内存),纤程就无法启动,处理器也永远不会被执行。另一种则是应用自己造成的:服务器自己驱动请求的纤程,因此在其上从用户态调用 Fiber::suspend() 会被以 FiberError 拒绝,而捕获该错误并在循环中重试的处理器会被结束,而不是被允许无限重试下去。这种结束无法被用户态捕获,但它仍会运行请求的 finally 块、析构函数和 shutdown 函数,并仍以处理器设置的状态码发送请求已产生的内容。计入的失败所对应的 WARN 日志行会指明是两者中的哪一种:fiber would not start 或 fiber refused to park。
这一切都不能诊断出一个卡死而非失败的工作进程:卡在系统调用里的请求会通过 oxphp_worker_stuck_total 上报给运维人员处理,既不会被取消,也不计数。服务器中没有任何机制会自动结束这样的请求(不回收,也不取消),因此卡死的工作进程会一直卡着,直到调用返回或进程重启。max_execution_time 也不是它的兜底:时间限制只在 PHP 代码运行的地方结束请求,而卡在系统调用里的请求在调用返回之前到不了那里(参见请求截止时间)。
当工作进程被回收时,它的循环结束,由替代者重新执行工作进程脚本的外层作用域。工作进程是单个 OxPHP 进程内的操作系统线程,而不是独立的进程,因此操作系统看到的一切都不会重启,getmypid() 在每个工作进程中、回收前后都返回同一个值。替代者是否立即到来取决于进程池:
- 静态池(
PHP_WORKERS=N)会在下一次扫描时补足到N,因此替换是一对一的。 - 动态池(
PHP_WORKERS=MIN:MAX)只补足到MIN:在最小值之上被回收的工作进程会让池子少一个,直到常规扩容再把它补上。
对于基于内存和计划退出的回收,当前请求会正常完成,然后工作进程才退出。对于基于错误的回收,工作进程会在失败的请求之后退出。
基于内存和计划退出的回收会以 INFO 级别记录。工作进程会写出 Worker mode thread recycled,其 reason 设为 oxphp_worker_recycles_by_reason_total 计数时所用的标签:max_memory,并附带 memory_bytes(超过限制的使用量)和 max_memory_bytes(限制本身);或者 scheduled,它涵盖 Worker::scheduleExit() 以及工作进程为自己安排的回收。随后进程池会记录 Recycled workers detected。因连续错误而被回收的工作进程会以 WARN 级别记录为 Worker mode thread retired after consecutive request failures,而进程池会像对待任何并非自愿结束的工作线程一样,将其报告为 Dead workers detected, respawning。
因此,在 LOG_LEVEL=warn 下,因 WORKER_MAX_MEMORY_MIB 或 Worker::scheduleExit() 发生的回收不会留下自己的日志行。频繁因内存而回收的工作进程意味着一个值得追查的泄漏,而观察它的地方是 oxphp_worker_recycles_by_reason_total{reason="max_memory"} 的速率。
动态池让空闲工作进程退役(参见动态池)同样会结束该工作进程的循环,也同样会运行 oxphp_worker() 之后的代码,但它是缩小池子而不是替换工作进程:不会有新的工作进程生成来顶替它,oxphp_worker_recycles_total 和 oxphp_worker_recycles_by_reason_total 都不会变化,计数的是 oxphp_workers_retired_total。因此,放在 oxphp_worker() 之后的应用收尾代码,也会在健康、正在服务的池子上运行(参见 oxphp_worker())。
同一工作进程正在并发服务的其他请求——挂起在 oxphp_async_await()、oxphp_sleep() 或 RUNTIME_HOOKS 下的套接字等待中——无法跑完:每一个都会在挂起处被取消,在运行完自己的 shutdown 函数之后,以带 Retry-After 的 503 Service Unavailable 应答。因此,回收对恰好有在途请求的客户端是可见的;在为一个并发服务请求的工作进程选择 WORKER_MAX_MEMORY_MIB 或调用 scheduleExit() 时,这一点值得了解。整机的服务器关闭则不同:在那里,在途请求有排空窗口可以正常完成。
请求截止时间
工作进程服务的每个请求都由各自的 max_execution_time 约束,无论工作进程接下它时手头没有其他任务,还是同时承载着其他请求。在纤程多路复用下,一个工作进程在一个线程上运行多个请求,而每个请求仍能获得自己完整的时限,不多也不少:
- 请求的时限从工作进程接下它的那一刻起算。 它的初始值是工作进程的引导留下的
max_execution_time(php.ini中的值,或工作进程脚本外层作用域设置的值)。和 OxPHP 线程安全 PHP 构建中的任何地方一样,它计算的是挂钟时间,挂起的时间也包括在内(参见超时)。 set_time_limit()作用于调用它的请求。 它会从调用那一刻起重启该请求的时限,就像在 PHP-FPM 下一样,而该工作进程上的其他请求都不受影响。随之改变的只有该请求自己的截止时间;这次调用写入的指令则留在工作进程上(参见 Ini 回滚下列出的边界)。- 时限耗尽时,它结束的是它所属的那个请求。 该请求会被解读为超时:返回
504,在它自己的connection_status()中为其 shutdown 函数设置超时位,对回收计数而言是中性的。与它并行运行的请求照常继续。 - 挂起的请求在恢复时才被结束,而不是在截止时间过去的那一刻。 在
oxphp_async_await()、oxphp_sleep()/oxphp_usleep(),或被RUNTIME_HOOKS接管的sleep()或套接字等待(读取,启用net时还包括连接、TLS 握手或需要等待缓冲区空间的写入)中等待的请求不运行任何 PHP 代码,而时间限制只能结束正在运行的代码。等待期间时限已过的请求,一回到 PHP 代码就会被结束:它所等待的那个调用一返回,请求就在那里结束,与任何其他超时一样,也会运行它的 shutdown 函数。只等待一次的调用(一次 sleep、一次读取)在恢复时就会返回;而在内部反复等待的调用,例如把流读到末尾的stream_get_contents(),只有在整体完成时才会返回。当它等待的东西到来时(promise 落定、sleep 到时、套接字变为可读,对于等待缓冲区空间的写入则是变为可写,对于连接则是连接建立),当等待达到其超时,或当服务器取消它时,请求才会恢复。对于读取或写入,该超时是流自身的超时(除非stream_set_timeout()另行设置,否则就是default_socket_timeout);对于连接,则是传给stream_socket_client()或fsockopen()的超时。越过排空截止时间的服务器关闭,以及工作进程的回收,都会取消此刻挂起的每个请求,而时限已过的请求会像其他请求一样被这次取消结束:返回503,运行它的 shutdown 函数,且不会因为时限已耗尽而被中途截断。 - 工作进程在请求之间运行的东西不受任何时限约束。 工作进程在不再有请求使用某个会话时将其交还,这个会话会在任何请求之外通过其保存处理器写入;工作进程周期性垃圾回收所释放的对象,其析构函数同样在任何请求之外运行。时间限制约束不了这些。
除此之外,没有任何东西会替你结束挂起的请求。客户端断开不会结束非流式请求,这类请求按设计会跑到结尾(参见回收);运行时间超过监督器阈值的请求会通过 oxphp_worker_stuck_total 上报,而不会被取消。
把限制放在等待本身上:给 oxphp_async_await() 及其变体传入超时,并为套接字和数据库客户端设置超时。
开发时重载
工作进程模式会在内存中持续保存引导状态(自动加载器、DI 容器、数据库连接),因此仅靠 opcache.validate_timestamps=1 不足以让在外层作用域运行的代码的改动生效。对于开发循环,有两种选择:
- 每个请求都回收。 在每次处理函数调用结束时调用
OxPHP\Server\Worker::current()->scheduleExit()(例如,用一个OXPHP_DEV环境变量标志来控制)。当前请求会正常完成,然后工作进程退出,由替代者重新执行外层作用域。在静态池上(开发环境通常就是这样运行的),这会立即发生。这以牺牲工作进程模式的性能优势为代价,换取了 FPM 风格的重载语义。对于活跃的开发工作,这是最简单也最可靠的方法。 - 让工作进程保持热态,重载请求处理函数。 完全跳过
scheduleExit(),启用opcache.validate_timestamps=1,并让你的引导保持精简。在请求回调内部加载的代码会在下一个请求时被 OPcache 刷新;在外层作用域加载一次的代码则不会。完整的注意事项列表请参见 OPcache 与 JIT → 开发设置。
故障排查
请求挂起,永不完成
如果引导脚本中从未调用 oxphp_worker(),则不会分发任何请求,每个请求都会无限期等待。请确认你的脚本在正常代码路径中无条件地调用了 oxphp_worker()。
状态在请求之间泄漏
在 oxphp_worker() 回调内部定义的变量会被 PHP 的垃圾回收器清理,但在外层作用域定义的静态属性和全局变量会持续存在。如果你看到一个请求的数据出现在另一个请求中,请检查是否有静态属性或全局变量在多次调用之间累积状态。
修复方法: 在每个请求回调开始时显式重置静态状态,或者避免在静态变量中存储每个请求的状态。
工作进程立即回收(内存限制)
工作进程的内存限制会在每个请求之后使用 PHP 报告的内存使用量进行检查。如果你的引导阶段分配了大量内存(例如加载一个大缓存),初始内存占用可能已经接近限制。
修复方法: 提高 WORKER_MAX_MEMORY_MIB,或将大块分配推迟到第一个请求。
检查: 在 LOG_LEVEL=info 下,每次这样的回收都会把超过限制的使用量记录为 memory_bytes,旁边是记录为 max_memory_bytes 的限制。之前的每次检查都发现使用量在限制之内,因此两者之差至多是工作进程自它之前最后一次检查以来增长的量:差值小说明是逐渐增长,差值大说明是一次跳升。在第一个请求之后就回收的工作进程上,这次跳升包括了引导分配的一切。
docker logs <container> 2>&1 | grep '"reason":"max_memory"'工作进程立即回收(错误限制)
三次连续的计入失败会触发回收。最常见的是请求回调中的致命错误,所以先在应用日志中查找它们。未捕获的异常不计入;被取消的请求只有在同一请求上还报告了致命错误时才计入。
没有致命错误却发生回收,就是两种纤程失败之一,WARN 日志行会指明是哪一种:fiber would not start 指向 fiber.stack_size 或宿主机(该 C 栈是从操作系统映射而来的,而不是 PHP 的堆,所以 memory_limit 和 WORKER_MAX_MEMORY_MIB 都不是调节它的旋钮),fiber refused to park 则指向你自己的代码在反复重试的 Fiber::suspend()。
服务器会为每次计入的失败记录一行 WARN,指明工作进程、失败来源以及已连续发生的次数,并为第三次之后的回收再记录一行。计数按工作进程分别统计:同一工作进程的三行意味着一次回收,三个工作进程各一行则不是。
检查: 在结构化日志输出中查找错误和警告:
docker logs <container> 2>&1 | grep -E '"level":"(ERROR|WARN)"'空闲后数据库连接断开
如果你的数据库服务器会关闭空闲连接,那么下一个请求中的重连尝试可能会失败。请使用能处理重连的连接池,或者捕获异常并手动重连。
Docker 示例
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
environment:
- DOCUMENT_ROOT=/var/www/html/public
- WORKER_MODE_ENABLED=true
- ENTRY_FILE=/var/www/html/worker.php
- WORKER_MAX_MEMORY_MIB=128PHP API
工作进程的自省能力和工作进程入口点通过 OxPHP\Server\Worker 类暴露。
<?php
$worker = OxPHP\Server\Worker::current();
$worker->serve(function () {
handleRequest();
});遗留自由函数(oxphp_is_worker、oxphp_worker_id、oxphp_worker)仍然可用,并通过相同的内部状态进行路由。新代码应优先使用类 API。
该类还暴露了对优雅自我回收、可观测性和健康检查有用的运行时自省能力:
| 方法 | 返回值 |
|---|---|
Worker::isWorkerMode(): bool |
服务器是否运行在工作进程模式 |
$worker->id(): int |
稳定的每线程工作进程 ID |
$worker->startTime(): float |
该工作进程启动时的 Unix 时间戳 |
$worker->requestCount(): int |
该工作进程已处理的请求数 |
$worker->memoryUsage(): int |
该工作进程当前的 memory_get_usage(true) |
$worker->rss(): int |
当前的常驻集大小(字节,Linux/macOS) |
$worker->maxMemoryBytes(): int |
回收阈值 — WORKER_MAX_MEMORY_MIB × 1 MiB,无限制时为 0 |
$worker->isExitScheduled(): bool |
是否有一个原因为 "scheduled" 的退出待执行:调用过 scheduleExit(),或工作进程自行退役 |
$worker->exitReason(): ?string |
运行时为 null;工作进程正在关闭时为 "scheduled"、"max_memory" 或 "error" |
完整的签名和实例请参见 OxPHP\Server\Worker。
PHP 示例
检测工作进程模式
使用 OxPHP\Server\Worker::isWorkerMode() 检查当前进程是否运行在工作进程模式。这对于编写在传统模式和工作进程模式下都能工作的代码很有用。
<?php
if (OxPHP\Server\Worker::isWorkerMode()) {
// Reuse a persistent connection
$redis = new Redis();
$redis->pconnect('redis', 6379);
} else {
// Traditional mode: connect per request
$redis = new Redis();
$redis->connect('redis', 6379);
}Symfony 工作进程脚本
<?php
use App\Kernel;
require __DIR__ . '/../vendor/autoload.php';
$kernel = new Kernel('prod', false);
$kernel->boot();
oxphp_worker(function () use ($kernel) {
$request = Symfony\Component\HttpFoundation\Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
});
$kernel->shutdown();最佳实践
- 设置
WORKER_MAX_MEMORY_MIB(例如128),这样发生泄漏的工作进程就会自动回收,而不是耗尽主机资源。可再结合Worker::scheduleExit()实现由应用驱动的回收。即使应用本身没有任何泄漏,工作进程也可能增长:当致命错误或取消让请求中止在某个内部函数里时,该函数为自己分配的内存不会被归还。例如,一个在usort()中途被取消的请求,会让整个正在排序的数组一直存活,而以这种方式增长超过 4 MiB 的工作进程会被回收,如回收中所述。更小的泄漏会一直留着,直到超过WORKER_MAX_MEMORY_MIB,或者在未设置它时,直到请求开始因memory_limit而失败。在请求所运行的某个Fiber(它自己启动的,或 PHP 在其中运行垃圾回收析构函数的那个)内部结束该请求的致命错误或取消,同样会让该纤程自身各帧所持有的东西在工作进程的整个生命周期内一直保持分配状态。 - 在挂起点之前关闭会话。 会话属于工作线程,因此跨越
oxphp_async_await()或oxphp_sleep()保持打开的会话,会与工作进程接下的下一个请求共享。参见会话状态。 - 避免在静态属性或全局变量中存储每个请求的状态。 由于这些内容会在请求之间持续存在,一个请求残留的状态可能会泄漏到另一个请求中。
- 尽早验证软重置。 在开发标志下向你的处理函数添加
Worker::current()->scheduleExit(),并对应用进行端到端的演练。这能在你投入使用长期存活的工作进程之前捕获状态泄漏的 bug。 - 处理数据库空闲超时。 如果你的数据库驱动在空闲一段时间后会断开连接,请捕获异常并重连,或者使用能自动处理重连的连接池。
- 保持外层作用域精简。 只引导真正需要持续存在的内容:自动加载器、配置和共享服务。将请求相关的设置推迟到回调中。
给你的智能体的提示词
这些提示词假定你的智能体已连接 OxPHP 文档 MCP 服务器——需要哪些页面它会自己去读。
把我的应用迁到 OxPHP 的工作进程模式。通过 OxPHP 文档 MCP 服务器读取 `features/worker-mode` 页面。
审查我的引导代码和请求处理函数,找出那些会在请求之间存活下来、或被在同一工作进程上重叠的请求所共享的状态(包括跨越挂起点保持打开的会话),然后给我能让这个仓库在常驻工作进程里安全运行的改动。