由 PHP Fiber 引发的思考

2023/11/09 PHP

由于水平有限,可能有些术语并不是那么准确。

PHP 8.1的新特性Fiber,可能让很多PHPer误解了,以为Fiber的出现可以解决PHP生态的很多问题,如官方多线程方案的缺失、PHP-FPM多进程阻塞模型的优化。但实际上Fiber目前解决的问题并不多,只是在底层引入了用户栈空间切换的原语,并不能像进程和线程那样,在同步阻塞时,仍然可以在操作系统层面进行上下文切换,不至于让整个程序无法往后执行。

现在PHP生态中,目前在这个方向可能走得最远的还是Swoole,在PHP内核层面将同步阻塞的函数进行hook,让标准库的同步阻塞函数和类不再阻塞整个程序。而同类的如WorkermanReactPHPAMPHP都无法做到,它们在使用所以造很多异步非阻塞的轮子。正如AMPHP的官网上写的一句话“It’s important to avoid using blocking functions in concurrent code, such as sleep, usleep, fwrite, fread and other built-in functions doing I/O. We offer a great variety of non-blocking I/O implementations you can use instead.”。像SwooleWorkerman之类的库,一个很明显的特征就是使用了Event Loop(事件循环)。什么是事件循环?事件,就是读、写、异常、时间等事件,循环就很好理解了,以下就是一个简单的事件循环事例:

<?php

// 创建套接字
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($socket, '127.0.0.1', 10101);
socket_listen($socket);

// 创建套接字数组
$sockets = [$socket];

while (true) {
    $readSockets = $sockets;
    $writeSockets = $exceptSockets = null;

    // 选择可读、可写和异常套接字
    socket_select($readSockets, $writeSockets, $exceptSockets, null);

    // 处理可读套接字
    foreach ($readSockets as $readSocket) {
        if ($readSocket === $socket) {
            // 接受新连接
            $clientSocket = socket_accept($socket);
            $sockets[] = $clientSocket;
            echo "New client connected\n";
        } else {
            // 处理客户端请求
            $data = socket_read($readSocket, 1024);
            if ($data === false) {
                // 客户端断开连接
                $index = array_search($readSocket, $sockets);
                unset($sockets[$index]);
                socket_close($readSocket);
                echo "Client disconnected\n";
            } else if (!empty($data)) {
                // 处理客户端数据
                echo "Received data: $data\n";
                // 这里可以添加你的业务逻辑代码
            }
        }
    }
}

// 关闭套接字
socket_close($socket);

事件循环,也就是循环获取事件(通过selectpollepoll等机制),进行相对的业务操作,这就是Swoole等库的原理。通过以上代码,我们可以发现,事件循环中,一旦业务代码出现同步阻塞操作,会让异步非阻塞退化成同步阻塞,导致代码无法将控制权交还给事件循环,后续的逻辑也就会延时执行,这也是很多异步库的通病。而Swoole将标准库hook之后,同步阻塞的操作变成异步非阻塞的,从而避免这种情况,这也是它很受欢迎的原因。而Go语言的协程模型则更加优秀了,因为协程是包含在语言的设计中的,不像PHPPython等语言,出现的时间太早,在语言层面并没有做相应的设计,导致“协程”功能都是缝缝补补,用起来也就需要多加注意。

Fiber出现之初,Swoole的作者就写文反对,且不管其它的,他说的话是有一定道理的,Fiber需要一个统一的事件循环,最好是包含在PHP内核中的,不然会造成各家自造轮子且这些轮子之间又无法共用的情况。当然,现在PHP内核并没有事件循环,但社区也出现了Revolt统一事件循环,这里先不展开说,核心原理跟上述代码差不多。另外PHP的生态都是各种同步阻塞的库,Fiber对目前生态作用不太大。

FiberGo语言的协程相比,确实显得很简陋,它只提供了上下文切换的功能,而且是需要手动切换的,因此需要一些基础的设施才能让用户方便使用。以下代码简单封装了Fiber

<?php

class PHPCoroutine {
    private array $fibers = [];
    private array $params = [];
    private array $output = [];
    public function create(callable $callback, string|int $name, mixed $params): void
    {
        $this->params[$name] = $params;
        $this->fibers[$name] = new Fiber($callback);
    }

    public function start(): void
    {
        while ($this->fibers) {
            foreach ($this->fibers as $name => $fiber) {
                try {
                    if (!$fiber->isStarted()) {
                        $fiber->start($this->params[$name]);
                    } else if ($fiber->isSuspended()) {
                        $fiber->resume();
                    } else if ($fiber->isTerminated()) {
                        $this->output[$name] = $fiber->getReturn();
                        unset($this->fibers[$name]);
                    }
                } catch (Throwable $e) {
                    $this->output[$name] = $e;
                    unset($this->fibers[$name]);
                }
            }
        }
    }

    public function output(): array
    {
        return $this->output;
    }
}

$coroutine = new PHPCoroutine();
for ($i = 1; $i < 5; $i++) {
    $coroutine->create(function ($params) {
        if ($params < 3) {
            Fiber::suspend();
        }
        // sleep(1);
        return [$params, microtime(true)];
    }, $i, $i);
}

$coroutine->start();
$output = $coroutine->output();
print_r($output);

试着将上述代码sleep(1)前的注释去掉模拟同步阻塞任务,看看是什么效果。

下面将sleep(1)换成真正的业务代码,对多个网站的网络情况进行测试,核心代码PHPCoroutine不变:

<?php

function ping($url): false|string
{
    $ping = proc_open('ping -c 4 '. $url, [
        ['pipe', 'r'],
        ['pipe', 'w'],
        ['file', '/tmp/error-output.txt', 'a'],
    ], $pipe);

    while (proc_get_status($ping)['running']) {
        // todo: 注释
        Fiber::suspend();
    }
    return fread($pipe[1], 1024);
}

$coroutine = new PHPCoroutine();
$url = ['example.com', 'baidu.com', 'runoob.com'];
$start = microtime(true);
for ($i = 0; $i < 3; $i++) {
    $coroutine->create(function ($params) {
        $res = ping($params);
        return [$params, microtime(true), $res];
    }, $i, $url[$i]);
}

$coroutine->start();
$output = $coroutine->output();
echo $start, "\n";
print_r($output);

然后去除// todo: 注释下面一行代码对比耗时。

说到这里,不得不再提一下Go语言的协程,同样遇到同步阻塞操作,Go的运行时可以利用多线程,让空闲的线程执行其它协程,而不需要等业务将控制权手动交还给事件循环,从而避免协程阻塞导致整个进程等待,而且调度不依赖于用户的行为。这倒有点像操作系统调度器的进化历史,协作式调度(PHP)和抢占式调度(Go),有点意思,之前看过这么一个说法所以,一个完备的协程库你可以把它理解为用户态的操作系统,而协程就是用户态操作系统里面的 “进程”。而Go语言就实现了这么一个完备的协程库。

Search

    Table of Contents