引言
传统 PHP 的编程模型是「一个请求一个进程」:PHP-FPM 为每个请求新建/复用 worker,请求结束即释放一切。这种模型简单、安全、易于横向扩展,但对长连接、高并发 IO、实时推送这类场景却力不从心——每个 worker 阻塞等待 IO 时,进程就白占着内存。
PHP 异步与常驻内存技术正是为填补这个空白而生:Swoole 用协程把「并发 IO」变成同步写法;ReactPHP 用纯 PHP 事件循环实现非阻塞 IO;FrankenPHP 把 PHP 打进 Caddy,用 Worker 模式常驻。本文将从阻塞模型讲起,手写三个路线的可运行示例,最后给出协程并发、连接池与任务队列的生产级写法,帮你判断「我该不该上异步」。
关联阅读:https://plumephp.com/php-performance-tuning/ 里的 FPM 调优是异步化的前置;https://plumephp.com/php8-modern-features/ 的 Fibers 是 Swoole 协程的底层基础之一。
目录
- 1. 从阻塞到异步:为什么需要常驻内存
- 2. 协程:用同步的写法做并发
- 3. Swoole 入门:HTTP 与 WebSocket 服务器
- 4. Swoole 协程并发控制与超时
- 5. ReactPHP:纯 PHP 事件循环
- 6. FrankenPHP:常驻 Worker 的现代部署
- 7. 连接池与任务队列实战
- 8. 选型决策:什么时候上异步
- 9. 总结与踩坑清单
- 延伸阅读
1. 从阻塞到异步:为什么需要常驻内存
1.1 阻塞模型的问题
传统 FPM 处理 3 个慢请求:
worker1: [DB查询 200ms] [渲染] [返回] ← 全程独占
worker2: [HTTP调用 300ms] [渲染] [返回]
worker3: [Redis 5ms] [返回]
└ 并发能力 = worker 数;每个 worker 在 IO 等待时闲置
IO 等待占请求时长的绝大部分,而 worker 在等待时空转占用内存。
1.2 常驻模型的目标
常驻进程(协程)处理 N 个请求:
协程A: [DB查询] ──挂起──▶ [拿到结果继续]
协程B: [HTTP调用] ──挂起──▶ [继续]
协程C: [Redis] ──▶ [立即返回]
└ 一个进程内,IO 等待时切换到其他协程,利用率拉满
1.3 三条路线定位
| 方案 | 本质 | 最擅长 |
|---|---|---|
| Swoole | 协程 + 事件循环,进程内并发 | 高性能 HTTP/TCP/WS |
| ReactPHP | 纯 PHP 事件循环库 | 中间层、CLI、流式 IO |
| FrankenPHP | Caddy 内置 PHP,Worker 常驻 | 现代框架无痛部署 |
2. 协程:用同步的写法做并发
2.1 协程是什么
协程(Coroutine)是「可暂停/恢复的函数」:碰到 IO 阻塞时主动让出 CPU,IO 完成后从暂停点继续。Swoole 在 PHP 层实现了协程调度器,代码却保持同步风格。
2.2 同步 vs 协程对比
// 同步阻塞:串行等待
$users = [];
foreach ($ids as $id) {
$users[] = $db->query("SELECT * FROM users WHERE id=$id"); // 逐个等
}
// Swoole 协程:并行发起
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;
$ch = new Channel(count($ids));
foreach ($ids as $id) {
Coroutine::create(function () use ($db, $id, $ch) {
$ch->push($db->query("SELECT * FROM users WHERE id=$id"));
});
}
$users = [];
for ($i = 0; $i < count($ids); $i++) {
$users[] = $ch->pop(); // 按序收结果,但并发执行
}
2.3 协程 vs 多进程
| 维度 | 协程 | 多进程 |
|---|---|---|
| 内存 | 共享,低 | 每进程一份 |
| 切换开销 | 用户态极低 | 内核调度 |
| 编程模型 | 同步写法 | 需 IPC |
| 多核利用 | 需配合多 worker | 天然 |
生产实践:每核心 1 个 worker 进程,进程内多协程并发。
3. Swoole 入门:HTTP 与 WebSocket 服务器
3.1 安装
pecl install swoole
php -m | grep swoole # 确认扩展加载
3.2 HTTP 服务器
<?php
// http_server.php
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
$server = new Server('0.0.0.0', 9501);
$server->set([
'worker_num' => 4, // 4 worker 进程
'max_conn' => 10000,
]);
$server->on('request', function (Request $req, Response $res) {
$res->header('Content-Type', 'application/json');
$res->end(json_encode(['path' => $req->server['request_uri']]));
});
$server->start();
php http_server.php # 常驻
curl http://127.0.0.1:9501/hello
3.3 WebSocket 实时推送
use Swoole\WebSocket\Server;
$ws = new Server('0.0.0.0', 9502);
$ws->on('open', function ($server, $req) {
echo "client connected: {$req->fd}\n";
});
$ws->on('message', function ($server, $frame) {
// 群发:把消息推给所有连接
foreach ($server->connections as $fd) {
$server->push($fd, "echo: {$frame->data}");
}
});
$ws->start();
3.4 与 FPM 协同
Swoole 可以只做「实时层」,业务读写仍走 PHP-FPM;也可以完全替代 FPM 直接跑框架(配合框架的 Swoole 适配器)。
4. Swoole 协程并发控制与超时
4.1 并发上限控制
并发请求太多会打爆下游,需要限流:
use Swoole\Coroutine;
use Swoole\Coroutine\Semaphore;
$sem = new Semaphore(10); // 最多 10 个同时进行
Coroutine::create(function () use ($sem, $task) {
$sem->wait(); // 拿不到信号量就挂起
try {
$result = $http->get($task->url);
} finally {
$sem->post(); // 释放
}
});
4.2 超时保护
use Swoole\Coroutine\Http\Client;
$client = new Client('api.example.com', 443, true);
$client->set(['timeout' => 2.0]); // 2 秒超时
$client->get('/v1/orders');
if ($client->errCode === 110) {
// 超时处理:降级或重试
}
4.3 协程并行等待(并发聚合)
use Swoole\Coroutine\WaitGroup;
$wg = new WaitGroup();
$results = [];
foreach (['user', 'orders', 'recommend'] as $key) {
$wg->add();
Coroutine::create(function () use ($wg, $key, &$results) {
try {
$results[$key] = callService($key);
} finally {
$wg->done();
}
});
}
$wg->wait(); // 等待全部完成
// $results 已就绪
这就是「BFF 聚合多个下游接口」的协程版实现。
5. ReactPHP:纯 PHP 事件循环
5.1 安装与最小事件循环
composer require react/event-loop react/http
<?php
// async_server.php
require __DIR__.'/vendor/autoload.php';
use React\EventLoop\Loop;
use React\Http\Server;
use Psr\Http\Message\ServerRequestInterface;
$server = new Server(function (ServerRequestInterface $request) {
return new React\Http\Message\Response(
200,
['Content-Type' => 'application/json'],
json_encode(['hello' => 'reactphp'])
);
});
$socket = new React\Socket\SocketServer('127.0.0.1:8080');
$server->listen($socket);
echo "Listening on 8080\n";
Loop::run(); // 启动事件循环,常驻
5.2 非阻塞延迟任务
use React\EventLoop\Loop;
// 定时任务(无需 crontab)
Loop::addPeriodicTimer(5.0, function () {
echo "每 5 秒执行\n";
});
5.3 ReactPHP 的定位
它不提供协程(PHP 7 时代),而是用「回调/Promise」风格。现代选择通常倾向 Swoole 或 FrankenPHP;ReactPHP 更适合轻量 CLI 工具与中间件。
6. FrankenPHP:常驻 Worker 的现代部署
6.1 它是什么
FrankenPHP 把 PHP 直接编译进 Caddy Web 服务器,用 Worker 模式让 PHP 应用常驻内存,无需额外 FPM。
6.2 Caddyfile 配置
# Caddyfile
:8080 {
root * /app/public
php_server {
worker /app/public/index.php 4
}
}
worker 启动 4 个常驻 worker,Laravel/Symfony 直接跑在内存里。
6.3 一个请求的生命
Caddy 收请求 → 交给常驻 worker(已加载框架)
→ 框架处理(无需重建容器/连接) → 返回
6.4 Docker 部署
FROM dunglas/frankenphp
COPY --from=composer /app/vendor /app/vendor
COPY . /app
WORKDIR /app
RUN frankenphp php-cli artisan config:cache
CMD ["frankenphp", "run"]
6.5 FrankenPHP vs Swoole
| 维度 | FrankenPHP | Swoole |
|---|---|---|
| 上手 | 极简(Caddy 原生) | 需学协程 API |
| 框架兼容 | Laravel 原生 | 需适配器 |
| 协程 | 无(靠 Worker 并发) | 有 |
| 定位 | 替代 FPM 的部署升级 | 高性能定制服务 |
7. 连接池与任务队列实战
7.1 数据库连接池(Swoole 协程)
常驻进程必须复用连接,否则每请求建连反而更慢:
use Swoole\Coroutine\Channel;
use Swoole\Coroutine;
class MysqlPool
{
private Channel $pool;
public function __construct(int $size = 10)
{
$this->pool = new Channel($size);
for ($i = 0; $i < $size; $i++) {
$this->pool->push($this->newConnection());
}
}
private function newConnection() { /* 返回协程版 PDO 连接 */ }
public function get()
{
return $this->pool->pop(2.0); // 2 秒内拿不到连接则失败
}
public function put($conn): void
{
$this->pool->push($conn);
}
}
7.2 任务队列消费
// 从 Redis 队列消费,用协程并发处理
use Swoole\Coroutine;
Coroutine::create(function () {
while (true) {
$task = $redis->brPop(['task:queue'], 1);
if (!$task) continue;
Coroutine::create(fn () => processTask($task[1]));
}
});
7.3 信号处理与优雅退出
$server->on('shutdown', function ($server) {
// 关闭连接池、刷日志、保存状态
});
8. 选型决策:什么时候上异步
8.1 该上异步的信号
| 场景 | 传统 FPM 的困境 | 异步的解法 |
|---|---|---|
| WebSocket 实时推送 | worker 被长连接占死 | Swoole/WS 常驻 |
| BFF 聚合多个接口 | 串行等待慢 | 协程并行 |
| 高并发短请求 | 进程数受限 | 单进程协程 |
| 大量后台任务 | 靠队列外挂 | 常驻 worker 消费 |
8.2 不该上异步的信号
- 流量不高、部署简单 → FPM + OPcache 足够。
- 团队不熟悉协程/事件循环模型 → 维护成本高。
- 依赖阻塞式 C 扩展 → 无法协程化。
8.3 渐进式迁移建议
- 先把「实时推送」独立成 Swoole 微服务,FPM 继续跑常规 Web。
- 再把「BFF 聚合」「后台任务」迁到常驻。
- 最后评估是否整体替换 FPM(框架适配成熟度决定)。
不要为了异步而异步——先量化瓶颈,再选工具。
9. 总结与踩坑清单
9.1 常驻内存的典型坑
| 坑 | 说明 | 对策 |
|---|---|---|
| 全局变量残留 | 常驻进程里全局变量跨请求保留 | 用请求作用域容器 |
| 单例持有连接 | 连接断开未重建 | 连接池 + 健康检查 |
| 协程内用阻塞函数 | 阻塞整个 worker | 用协程版客户端 |
| 内存泄漏 | 循环引用/大缓存不释放 | 定期重启 worker |
| 与 FPM 语义差异 | die()、header() 行为不同 | 用框架适配层 |
9.2 关键结论
- 常驻内存解决「重建开销 + IO 等待」,不是魔法加速。
- 协程让并发代码像同步一样好读,但必须用协程安全的客户端。
- 从最小切口开始:先把实时/聚合场景异步化,其余保持简单。
延伸阅读
- https://plumephp.com/php-performance-tuning/ — FPM/OPcache/JIT 的调优基础
- https://plumephp.com/php8-modern-features/ — Fibers 与语言层面的并发底座
- Swoole 官方文档 与 FrankenPHP 项目主页
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。