
TrueAsync 0.7.0 即将发布,附带线程池和其他一些功能。
但最有趣的部分可能是 TrueAsync Server:一个直接嵌入 PHP 的高性能 HTTP/1.1、HTTP/2 和 HTTP/3 服务器。无需单独的进程,无需反向代理。

首先也是最重要的:TrueAsync Server 是一个"一切都在一个线程中"的服务器。整个请求处理过程,从解析到发送响应,都在单个线程上完成。在这方面,TrueAsync Server 是 PHP 中一个几乎独一无二的项目,它不是用 PHP 编写的(尽管 Swoole 在其基础模式下也运行单个 worker)。
AMPHP 服务器使用类似的单线程事件循环模型——不同之处在于 AMPHP 是用 PHP 实现的,而 TrueAsync Server 作为原生扩展嵌入到 PHP 进程中。
"每个线程一个事件循环"模型本身并不奇特:NGINX、Envoy、Node.js 以及 Rust 技术栈 Tokio + hyper 都是这样构建的。其核心思想是一个线程从头到尾拥有连接和请求:没有接受线程和工作线程之间的交接,没有锁,没有上下文切换。

https://www.http-arena.com/leaderboard/#v=composite&res=mem
这种架构有一个明显的缺点。如果 PHP VM 和 TrueAsync Server 驻留在同一线程上,并且 PHP VM 崩溃——服务器 worker 也会随之崩溃。客户端可能会突然失去连接。如果反应器和 PHP VM 驻留在不同的线程,甚至不同的进程上,架构看起来会更健壮:客户端至少可能会收到一个错误。
缺点到此为止——优点依然存在:
setWorkers(N) 启动,内核通过 SO_REUSEPORT 在它们之间分配连接。每个 worker 都是一个独立的事件循环,没有共享状态,没有全局锁。PHP 有很多现代服务器项目。FrankenPHP 基于 Go 解决方案 Caddy,还有一个 Rust 服务器项目。TrueAsync Server 用 C 编写是有充分理由的:
memory_limit 下共同计算的,包括服务器和 PHP 代码。缺点——Zend VM 有性能问题,有时会影响服务器。单个服务器上的多种协议。HTTP/1.1、HTTP/2、WebSocket、SSE 和 gRPC 共享一个 TCP 端口和一个事件循环;协议选择通过 ALPN(对于 TLS)或 HTTP Upgrade 进行。HTTP/3 通过 QUIC 在同一 UDP 端口上运行,并通过 Alt-Svc 头向客户端通告,因此它们会在后续请求中透明地切换。 这意味着一个 $server->start() 调用就可以通过 HTTP/2 提供 REST API,通过 Server-Sent Events 推送事件,保持 WebSocket 连接,并暴露 gRPC 端点!
高吞吐量不是一个大技巧——它是许多小决策的总和:
smart_str 有一个隐藏的悬崖:超过某个阈值后,每次增长都会变成一个系统调用,其成本随大小而增长。在大型 body 上,这曾经消耗高达一半的请求时间。sendfile()。SO_REUSEPORT 将连接分散到 worker,将头与响应体合并,块大小与 TLS 记录大小匹配。
服务器的公共 API 由两个基本类组成:
HttpServerConfig ——配置对象。HttpServer ——服务器本身,从配置创建并启动。一个最小的应用程序:
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
$server = new HttpServer(
(new HttpServerConfig())->addListener('0.0.0.0', 8080)
);
$server->addHttpHandler(function ($request, $response) {
$response->setStatusCode(200)->setBody('Hello, World!');
});
$server->start(); // 阻塞线程直到调用 stop()
监听器是"协议 + 传输 + 主机 + 端口"的组合。
addListener() —— TCP,HTTP/1.1 + HTTP/2(由第一个字节或 ALPN 选择);addHttp1Listener() / addHttp2Listener() —— 限制为单个协议的端口;addHttp3Listener() —— UDP/QUIC;addUnixListener() —— Unix 域套接字。处理器是在每个新请求上调用的函数。它们接收请求和响应对象,并可以像往常一样与它们一起工作。
服务器支持多种处理器,每种匹配特定的协议:
$server->addHttpHandler(fn ($req, $res) => /* ... */); // HTTP/1.1 + HTTP/2
$server->addHttp2Handler(fn ($req, $res) => /* ... */); // HTTP/2 特定
$server->addWebSocketHandler(fn ($req, $res) => /* ... */);
每个处理器在自己的协程中运行:HTTP/1——每个请求一个协程,HTTP/2 和 HTTP/3——每个流一个协程。
当处理器进入 await(例如等待数据库)时,它既不会阻塞其他连接,也不会阻塞其他流。
请求对象是只读的。其 API:getMethod()、getUri()、getHttpVersion()、getHeader() / getHeaderLine() / getHeaders() / hasHeader()(头不区分大小写)、getContentType()、getContentLength()、getBody()。对于表单和上传——getPost()、getFiles()、getFile()。响应对象是唯一的输出通道。
Setter 链式调用(流畅接口):
$response
->setStatusCode(200)
->setHeader('Content-Type', 'text/plain')
->setBody('payload')
->end();
由于服务器支持 HTTP/2 和 HTTP/3,它从一开始就为流式传输而构建。您可以完全控制何时将数据发送到客户端,而无需等待处理器完成。服务器不会强迫您将整个响应保存在内存中以便一次性发送。
响应有两种模式:
setBody() / write() 累积 body,最后一次性发送到网络;send() 将下一个块直接推送到线路上。$server->addHttpHandler(function ($req, $res) {
$res->setStatusCode(200)->setHeader('Content-Type', 'text/event-stream');
foreach (fetch_events() as $event) { // 源可能是无限的
$res->send("data: {$event}\n\n"); // 块立即发出
}
$res->end(); // 关闭流
});
第一次 send() 提交头;从那时起,对于 HTTP/1.1,这是 Transfer-Encoding: chunked,对于 HTTP/2 和 HTTP/3,它是单独的 DATA 帧。换句话说,相同的处理器代码在任何协议上都会产生正确的流——服务器负责处理差异。
为什么这对 HTTP/2 特别有用。 HTTP/2 中的每个请求是单个连接内的单独流,每个流存在于自己的协程中。流式输出意味着您可以在数据出现时发送它——Server-Sent Events、导出大型报告、gRPC 流——而无需将整个卷展开到内存中。由于 HTTP/2 具有每流流控制窗口,慢速客户端不应该使服务器的内存膨胀。为此,有 $res->sendable()——它报告流现在是否准备好接受下一个块;如果没有,协程只需让出给其他协程,直到窗口释放。
请求 body 也可以作为流到达。使用 Request::readBody() 方法,您可以分部分读取 body,而无需等待它完全接收:
while (($chunk = $req->readBody()) !== null) {
$sink->write($chunk); // 每次处理 64 KiB,而不是一次 2 GiB
}
这样,一个千兆字节大小的上传可以代理到文件或另一个服务,而无需在内存中组装它。
服务器实现了一个特殊的、优化的路径来提供静态文件。 它是一个构建器类 StaticHandler,与常规处理器一起注册到服务器上:
use TrueAsync\StaticHandler;
$static = (new StaticHandler('/assets/', '/var/www/public'))
->setIndexFiles('index.html')
->enablePrecompressed('br', 'gzip', 'zstd')
->setCacheControl('public, max-age=86400');
$server->addStaticHandler($static);
第一个参数是 URL 前缀(虚拟挂载路径),第二个是磁盘上的目录。处理器的主要目标是尽可能快地提供文件,而无需切换到 PHP 代码。
以下功能已经开箱即用:
setMimeType() 的自定义覆盖。(mtime, size, inode) 的弱 ETag,处理 If-None-Match / If-Modified-Since 并返回 304 响应,HEAD 支持。206 Partial Content、Content-Range,对损坏范围的正确 416。main.css.br / .gz / .zst 与文件相邻且客户端接受,服务器提供现成的压缩文件而不是即时压缩。../、%2e%2e、NUL 字节、反斜杠),点文件策略(.git/ 默认关闭),符号链接策略(Reject / Follow / OwnerMatch),通过 glob 模式隐藏文件。stat、ETag 计算和 MIME 查找,在作者的基准测试中大约再增加 +20%。 尽管看起来这样的处理器对 API 应用程序没有用处,但它可能适用于提供隐藏在 URL 前缀后面的机密文件。例如,如果您即时生成报告并将它们保存到受保护的目录,它们可以通过 StaticHandler 提供,而无需打开 PHP 文件,也无需将内容读入内存。