PHP `Stream Select` / `Poll` / `Epoll`:I/O 多路复用的底层原理

各位观众老爷,早上好!我是老码农,今天跟大家聊聊PHP里那些“见多识广”的 I/O 多路复用技术,什么Stream SelectPollEpoll,听起来是不是像武林秘籍?别怕,咱们把它拆解了,保证你听完能用它们在PHP的世界里“降妖伏魔”。

开场白:为啥需要“多路复用”?

想象一下,你是一个餐厅服务员,只有一个服务员,但是有很多顾客同时点餐。传统的做法是:

  1. 跑到A顾客那里问:“点啥?”
  2. 跑到B顾客那里问:“点啥?”
  3. 跑到C顾客那里问:“点啥?”
  4. ……

如果顾客很多,你就会累死。而且,如果A顾客点了菜之后要等很久才能做好,你还得一直守着他,浪费时间。

这种模式,就类似于传统的阻塞I/O。 PHP脚本就像这个服务员,每个连接就像一个顾客。如果PHP要处理多个连接,就得一个一个地处理,如果某个连接阻塞了(比如等待网络数据),整个PHP进程就会卡住,其他连接就得等着。

所以,我们需要更高效的服务员,他能同时观察多个顾客,谁准备好了就先服务谁。这就是 I/O 多路复用的思想。

主角登场:Stream Select、Poll、Epoll

这三位就是PHP I/O 多路复用的三大法宝,它们的作用都是监控多个文件描述符(file descriptor,简称fd),看看哪些fd准备好了(可读、可写、出错)。

  • 文件描述符(fd)是什么?

    简单来说,fd就是操作系统给每个打开的文件(包括socket)分配的一个数字编号。你可以把它想象成餐厅里每个顾客的桌号。PHP通过fd来操作这些文件或socket。

  • 三者的区别:

    特性 Stream Select Poll Epoll
    实现方式 轮询 轮询 事件通知
    文件描述符限制 有上限(通常1024) 理论上无上限 理论上无上限
    性能 较低 中等 较高
    适用场景 连接数较少 连接数中等 连接数非常多
    操作系统支持 所有 POSIX标准 Linux特有

1. Stream Select:最原始的“巡逻员”

stream_select() 函数是PHP里最基础的 I/O 多路复用函数,它会监控你指定的socket集合,看看哪些socket可以读、可以写、或者出错。

函数原型:

int stream_select ( array &$read , array &$write , array &$except , int $seconds [, int $microseconds = 0 ] )
  • $read:需要监控的可读socket数组,函数返回时,只有可读的socket会被保留在数组中。
  • $write:需要监控的可写socket数组,函数返回时,只有可写的socket会被保留在数组中。
  • $except:需要监控的异常socket数组,函数返回时,只有异常的socket会被保留在数组中。
  • $seconds:超时时间(秒)。
  • $microseconds:超时时间(微秒)。

代码示例:

<?php

// 创建两个socket
$socket1 = stream_socket_server("tcp://127.0.0.1:8080", $errno, $errstr);
$socket2 = stream_socket_server("tcp://127.0.0.1:8081", $errno, $errstr);

if (!$socket1 || !$socket2) {
    die("Failed to create sockets: $errstr ($errno)n");
}

stream_set_blocking($socket1, 0); // 设置非阻塞模式
stream_set_blocking($socket2, 0); // 设置非阻塞模式

$read = [$socket1, $socket2];
$write = [];
$except = [];

while (true) {
    // 复制数组,因为 stream_select 会修改原数组
    $read_copy = $read;
    $write_copy = $write;
    $except_copy = $except;

    // 监控socket
    $num_changed_streams = stream_select($read_copy, $write_copy, $except_copy, null); // 第四个参数 null 表示一直阻塞直到有事件发生

    if ($num_changed_streams === false) {
        echo "stream_select() failedn";
        break;
    } elseif ($num_changed_streams > 0) {
        // 遍历可读socket
        foreach ($read_copy as $socket) {
            if ($socket == $socket1) {
                // socket1 有新的连接
                $new_conn = stream_socket_accept($socket1);
                if ($new_conn) {
                    echo "New connection on socket1n";
                    $read[] = $new_conn; // 将新连接添加到监控列表中
                    stream_set_blocking($new_conn, 0);
                }
            } elseif ($socket == $socket2) {
                // socket2 有新的连接
                $new_conn = stream_socket_accept($socket2);
                if ($new_conn) {
                    echo "New connection on socket2n";
                    $read[] = $new_conn; // 将新连接添加到监控列表中
                    stream_set_blocking($new_conn, 0);
                }
            } else {
                // 其他socket 有数据可读
                $data = fread($socket, 1024);
                if ($data === false || strlen($data) === 0) {
                    // 连接关闭或出错
                    echo "Connection closed or errorn";
                    fclose($socket);
                    $key = array_search($socket, $read);
                    if ($key !== false) {
                        unset($read[$key]); // 从监控列表中移除
                    }
                } else {
                    echo "Received data: " . $data . "n";
                    fwrite($socket, "Echo: " . $data); // Echo back
                }
            }
        }

        // 处理可写socket(这里省略,因为示例主要关注可读)
        // 处理异常socket(这里省略)
    }
}

fclose($socket1);
fclose($socket2);

?>

Stream Select 的缺点:

  • 轮询: stream_select() 需要轮询整个socket数组,效率较低。想象一下,你每次都要把所有顾客都看一遍,才能知道谁准备好了,效率肯定不高。
  • 文件描述符限制: 在一些老版本的操作系统上,stream_select() 有文件描述符数量的限制(通常是1024)。

2. Poll:升级版的“巡逻员”

Poll 也是一种 I/O 多路复用技术,它比 stream_select 稍先进一些。理论上,Poll 没有文件描述符数量的限制。

PHP里没有直接对应的poll()函数,通常需要借助扩展,或者使用stream_select()模拟。 由于直接使用扩展较少,此处重点讲解使用stream_select()模拟poll()的行为。

模拟poll()的代码示例:

<?php

// 创建两个socket
$socket1 = stream_socket_server("tcp://127.0.0.1:8080", $errno, $errstr);
$socket2 = stream_socket_server("tcp://127.0.0.1:8081", $errno, $errstr);

if (!$socket1 || !$socket2) {
    die("Failed to create sockets: $errstr ($errno)n");
}

stream_set_blocking($socket1, 0); // 设置非阻塞模式
stream_set_blocking($socket2, 0); // 设置非阻塞模式

$sockets = [$socket1, $socket2]; // 所有的socket

while (true) {
    $read = $sockets; // 所有socket都参与读事件的监控
    $write = [];
    $except = [];

    // 监控socket
    $num_changed_streams = stream_select($read, $write, $except, null);

    if ($num_changed_streams === false) {
        echo "stream_select() failedn";
        break;
    } elseif ($num_changed_streams > 0) {
        // 遍历可读socket
        foreach ($read as $socket) {
            if ($socket == $socket1 || $socket == $socket2) {
                // socket1 or socket2 有新的连接
                $new_conn = stream_socket_accept($socket);
                if ($new_conn) {
                    echo "New connection on " . ($socket == $socket1 ? "socket1" : "socket2") . "n";
                    $sockets[] = $new_conn; // 将新连接添加到监控列表中
                    stream_set_blocking($new_conn, 0);
                }
            } else {
                // 其他socket 有数据可读
                $data = fread($socket, 1024);
                if ($data === false || strlen($data) === 0) {
                    // 连接关闭或出错
                    echo "Connection closed or errorn";
                    fclose($socket);
                    $key = array_search($socket, $sockets);
                    if ($key !== false) {
                        unset($sockets[$key]); // 从监控列表中移除
                    }
                } else {
                    echo "Received data: " . $data . "n";
                    fwrite($socket, "Echo: " . $data); // Echo back
                }
            }
        }

        // 处理可写socket(这里省略)
        // 处理异常socket(这里省略)
    }
}

fclose($socket1);
fclose($socket2);

?>

Poll 的优点 (相对 Stream Select):

  • 理论上没有文件描述符数量的限制。
  • 在一些系统上,Poll 可能比 stream_select 更高效,因为它使用更底层的数据结构。

Poll 的缺点:

  • 仍然需要轮询整个socket数组,效率相对较低。

3. Epoll:最先进的“情报员”

Epoll 是 Linux 系统特有的一种 I/O 多路复用技术,它采用了事件通知机制,只有当socket真正有事件发生时,才会通知PHP进程。

Epoll 的优势:

  • 高效: 不需要轮询,只有活跃的socket才会被处理。想象一下,情报员只在你需要的顾客准备好了的时候才告诉你,效率当然高。
  • 支持大量连接: 可以轻松处理数万甚至数十万个并发连接。

PHP里使用Epoll需要借助扩展,比如event扩展。

使用event扩展的代码示例:

<?php

// 创建EventBase
$base = new EventBase();

// 创建两个socket
$socket1 = stream_socket_server("tcp://127.0.0.1:8080", $errno, $errstr);
$socket2 = stream_socket_server("tcp://127.0.0.1:8081", $errno, $errstr);

if (!$socket1 || !$socket2) {
    die("Failed to create sockets: $errstr ($errno)n");
}

stream_set_blocking($socket1, 0);
stream_set_blocking($socket2, 0);

// 可读事件回调函数
$read_callback = function ($socket, $event, $arg) use ($base, &$read_event) {
    if ($socket == $arg['server_socket']) {
        // 有新的连接
        $new_conn = stream_socket_accept($socket);
        if ($new_conn) {
            echo "New connection on " . ($socket == $socket1 ? "socket1" : "socket2") . "n";
            stream_set_blocking($new_conn, 0);

            //为新连接创建新的可读事件
            $read_event_client = new Event($base, $new_conn, Event::READ | Event::PERSIST, $read_callback, ['server_socket' => $arg['server_socket']]);
            $read_event_client->add();

        }
    } else {
        // 有数据可读
        $data = fread($socket, 1024);
        if ($data === false || strlen($data) === 0) {
            // 连接关闭或出错
            echo "Connection closed or errorn";
            fclose($socket);
            $event->del(); //删除事件
        } else {
            echo "Received data: " . $data . "n";
            fwrite($socket, "Echo: " . $data); // Echo back
        }
    }

};

// 为两个server socket分别注册READ事件
$read_event1 = new Event($base, $socket1, Event::READ | Event::PERSIST, $read_callback, ['server_socket' => $socket1]);
$read_event1->add();

$read_event2 = new Event($base, $socket2, Event::READ | Event::PERSIST, $read_callback, ['server_socket' => $socket2]);
$read_event2->add();

// 循环处理事件
$base->loop();

fclose($socket1);
fclose($socket2);

?>

代码解释:

  1. 创建EventBase: EventBaseevent 扩展的核心,它负责管理所有的事件。
  2. 创建Event: Event 对象代表一个事件,我们需要指定监控的socket、事件类型(Event::READEvent::WRITE等)、以及回调函数。
  3. 添加事件: $event->add() 将事件添加到 EventBase 中,开始监控。
  4. 循环处理事件: $base->loop() 会一直循环,直到没有事件需要处理。当有事件发生时,event 扩展会自动调用相应的回调函数。
  5. Event::PERSIST 表示持久事件,触发一次后不会自动删除,可以一直监听。

Epoll 的缺点:

  • 只能在 Linux 系统上使用。
  • 使用起来比 stream_select 更复杂。

总结:选哪个好?

  • 连接数较少: stream_select 够用,简单易懂。
  • 连接数中等: 尝试使用其他扩展提供的 polllibevent 扩展,或者使用 stream_select 模拟。
  • 连接数非常多: 必须使用 Epoll (或者其他类似的事件通知机制),例如 libevent 扩展。

实际应用场景:

  • 高性能服务器: 比如 Node.js、Nginx 等,它们都使用了 I/O 多路复用技术来处理大量的并发连接。
  • 聊天室: 可以使用 I/O 多路复用技术来实时接收和发送消息。
  • 游戏服务器: 可以使用 I/O 多路复用技术来处理多个玩家的请求。

结尾:掌握“多路复用”,走向PHP高并发之路!

I/O 多路复用是构建高性能PHP应用的关键技术之一。理解了它的原理,你就能更好地利用PHP处理高并发,构建更强大的应用。希望今天的讲解对大家有所帮助!下次有机会再跟大家聊聊其他PHP底层技术。 散会!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注