过去想在不装Swoole或pcntl的情况下搞并发,几乎不可能。PHP 8.1带来了Fibers(纤维),它不像Generator只能单向传值,真正实现了在代码中挂起和恢复,而且能像普通函数一样嵌套调用。为了搞懂这玩意到底能干嘛,我写了一个轻量的协程池,用来并发请求多个URL,顺手验证了一下Fibers的性能边界。
Fibers是什么?
简单说,Fiber可以让你在PHP代码中手动切换执行上下文。就像你写任务A做到一半,挂起来,去执行任务B,B做了一半,又回来继续做A。这种挂起和恢复完全是程序员自己控制,不像Generator那样被foreach驱动。
$fiber = new Fiber(function () {
echo "Fiber 开始n";
Fiber::suspend();
echo "Fiber 恢复n";
});
echo "主流程开始n";
$fiber->start();
echo "主流程继续n";
$fiber->resume();
echo "主流程结束n";
输出顺序是:主流程开始 -> Fiber 开始 -> 主流程继续 -> Fiber 恢复 -> 主流程结束。Fiber::suspend()后,主流程拿到控制权,然后resume()恢复Fiber继续执行。这就是协程最基础的行为。
为什么需要一个协程池?
协程能让我们「同时」发起多个操作。比如有10个URL需要请求,串行可能总共耗时5秒。如果用协程,每个请求等待时挂起,切到下一个请求,那么总耗时约等于最慢的那个请求,比如2秒。但直接裸用Fiber,你得给每个任务手动包装Fiber,管理状态。协程池就是把这种复杂的调度封装起来。
设计一个最简单的协程池
我的需求很明确:把一组闭包任务丢进池子里并发执行,等所有任务完成后,拿到每个任务的返回值。为了模拟耗时的IO操作,我用usleep()代替。
下面这段代码是核心的协程调度器。
class CoroutinePool
{
private array $fibers = [];
private array $tasks = [];
private array $results = [];
public function add(callable $task): void
{
$this->tasks[] = $task;
}
public function run(): array
{
// 给每个任务创建Fiber
foreach ($this->tasks as $i => $task) {
$this->fibers[$i] = new Fiber(function () use ($task) {
return $task();
});
// 启动Fiber直到遇到第一个suspend
$this->fibers[$i]->start();
}
// 循环调度
do {
$completed = true;
foreach ($this->fibers as $i => $fiber) {
if (!$fiber->isTerminated()) {
$completed = false;
// 如果Fiber在挂起状态,就恢复它
if ($fiber->isSuspended()) {
$fiber->resume();
}
} elseif (!array_key_exists($i, $this->results)) {
$this->results[$i] = $fiber->getReturn();
}
}
} while (!$completed);
// 按原始索引排序返回
ksort($this->results);
return $this->results;
}
}
这个简单版本有一个明显问题:如果某个Fiber内部没有遇到Fiber::suspend(),它会在start()时就一口气执行完,变成同步了。要让协程真正“让位”,你的任务里面必须主动调用Fiber::suspend()。在真实IO场景中,比如curl或redis,它们一般有相应的非阻塞扩展支持Fiber,会自动挂起。这里我们手动模拟。
让任务主动让位
写几个模拟任务:
$pool = new CoroutinePool();
foreach (range(1, 5) as $i) {
$pool->add(function () use ($i) {
// 模拟耗时2秒的IO
$start = microtime(true);
while (microtime(true) - $start < 0.5) {
// 主动让出控制权,让别的任务跑
Fiber::suspend();
}
return "任务{$i}完成";
});
}
$result = $pool->run();
print_r($result);
每个任务会循环0.5秒,但每次循环都会Fiber::suspend()让出。调度器会轮流恢复所有Fiber,所以总时间大约是0.5秒而不是2.5秒。
看输出,你会发现任务的完成顺序不一定是1、2、3,因为协程调度并不是排队。这个效果已经接近“并发”了。
完善:支持返回值和异常传递
如果任务抛了异常,Fiber会终止,getReturn()拿不到值,但会把异常吞掉。得通过isTerminated()后接getReturn()时捕获异常。改进一下run方法,把异常存进结果或者重新抛出。
其实更常见的做法是用Fiber::suspend()来回传中间状态。比如任务内多次Fiber::suspend($progress),主调度器就能收到进度。我做了个升级版,支持每次suspend时传递当前进度。
class CoroutinePool
{
private array $fibers = [];
private array $results = [];
public function run(callable ...$tasks): array
{
foreach ($tasks as $i => $task) {
$this->fibers[$i] = new Fiber(function () use ($task) {
return $task();
});
$this->fibers[$i]->start();
}
$finishedCount = 0;
$total = count($this->fibers);
while ($finishedCount fibers as $i => $fiber) {
if ($fiber->isTerminated()) {
continue;
}
if ($fiber->isSuspended()) {
$fiber->resume();
// 如果resume之后终结了,则收集返回值
if ($fiber->isTerminated()) {
$this->results[$i] = $fiber->getReturn();
$finishedCount++;
}
} else {
// 正在运行的Fiber(可能在start时没挂起)
// 如果是首次运行,且还未执行完,继续让它执行
$fiber->resume();
}
}
}
ksort($this->results);
return $this->results;
}
}
说实话,用循环去调度确实有点像手写事件循环。不过Fiber已经帮你搞定了栈和状态,不用自己维护执行栈。
真实场景:用协程池发HTTP请求
如果只是模拟时间片,意义不大。但结合PHP的curl扩展,你就能真正并发请求多个URL。关键在于curl需要在非阻塞模式下运行,并且通过curl_multi_*或Fiber配合。如果你用自己写的Fiber调度,但curl还是阻塞的,那就没效果。还好,一些库比如 amp/php-http 已经支持Fiber。
我这里不引入第三库,教你一个取巧的办法:使用stream_socket_pair加上非阻塞流,但太复杂了。为了演示,我直接用proc_open()开多个子进程,但那就不是协程了。
实际上更好玩的场景是协程调度多个数据库查询。PDO本身不支持Fiber,但是MySQL的异步接口可以通过mysqli::poll()实现。用Fiber包一层就能实现优雅的异步查询。
我踩的坑
1. Fiber::getReturn()只能调用一次,所以我在收集结果时用了一个单独的数组记录,别重复get。
2. 在Fiber内部如果return之前没有调用suspend(),那么start()会执行完整个Fiber,此时isTerminated()直接返回true。这会导致调度器第一次循环就会收集它,不会出问题,但失去了并发效果。
3. 千万别在Fiber里使用全局exit(),那会直接终止整个进程,不能只终止Fiber。
这个协程池跟Swoole有什么区别?
说实话,Swoole的协程是更底层的,它在IO操作上自动让出,不用你手动写Fiber::suspend()。而PHP原生Fiber需要你自己在耗时操作前挂起,写起来繁琐。但好处是原生、不依赖扩展,适合在普通PHP-FPM环境中做轻量级的并发调度。
比如我要在请求里同时查三个接口,数据不互相依赖,那就可以用这个协程池同时发起,等全部挂起时,CPU去执行其他任务,本质上就是手动把三个阻塞调用交错进行。但是注意,如果IO操作是同步阻塞的(例如file_get_contents()),你挂起也没用,因为它不会自动让出。必须使用非阻塞IO或者能响应挂起的扩展。
最后:什么时候真正值得用Fiber?
当你的业务中有大量IO等待,而且这些IO都支持非阻塞模式时,Fibers可以极大减少串行等待时间。PHP 8.1的Fiber算是给纯PHP用户提供了一把钥匙,但没有对应非阻塞IO的锁,这门还是开不了。我的建议是,如果你想用Fiber做真正的网络并发,配合ext-uv或Swoole才舒服;如果是做计算密集型的并发,Fiber只会增加切换开销。
好了,我的协程池虽然简单,但完整展示了Fibers的核心机制。你能基于它改造成一个优先队列、支持超时、实现协程waitGroup。但我觉得,最值钱的还是动手跑一遍的过程,能感觉到PHP底层正在慢慢长出协程的肌肉。

