从零手写一个PHP协程池:用Fibers让多任务并发不再依赖扩展

2026-08-15 0 552

过去想在不装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底层正在慢慢长出协程的肌肉。

从零手写一个PHP协程池:用Fibers让多任务并发不再依赖扩展
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php 从零手写一个PHP协程池:用Fibers让多任务并发不再依赖扩展 https://www.taomawang.com/server/php/2549.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务