php里的select与epoll

socket_select

https://www.php.net/manual/zh/function.socket-select.php

socket_select(
    ?array &$read,
    ?array &$write,
    ?array &$except,
    ?int $seconds,
    int $microseconds = 0
): int|false

当启动select后,需要将三组不同的socket fd加入到作为select的参数

传统意义上这种fd的集合就叫做fd_set,三组fd_set依次是可读集合,可写集合,异常集合

三组fd_set由系统内核来维护,每当select监控管理的三个fd_set中有可读或者可写或者异常出现的时候,就会通知调用方.调用方调用select后,调用方就会被select阻塞,等待可读可写等事件的发生.

一旦有了可读可写或者异常发生,需要将三个fd_set从内核态全部copy到用户态中,然后调用方通过轮询的方式遍历所有fd,从中取出可读可写或者异常的fd并作出相应操作

如果某次调用方没有理会某个可操作的fd,那么下一次其余fd可操作时,也会再次将上次调用方未处理的fd继续返回给调用方
也就是说去遍历fd的时候,未理会的fd依然是可读可写等状态,一直到调用方理会

在php中,可以通过stream_select或者socket_select来操作select系统调用

select1.png

select.php

https://gitee.com/hk/process_thread_study/blob/ec9a8d62f3f732b1f0fdbed82d9fddf55e224cff/php/advanced/select/select.php

<?php
$host = '0.0.0.0';
$port = 8888;
$listen_socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($listen_socket, $host, $port);
if (true !== socket_set_option($listen_socket, SOL_SOCKET, SO_REUSEADDR, 1)) {
    errhandle(__LINE__);
}
//这项拿掉会出现地址占用的报错
if (true !== socket_set_option($listen_socket, SOL_SOCKET, SO_REUSEPORT, 1)) {
    errhandle(__LINE__);
}

socket_listen($listen_socket);
// END 创建服务器完毕
echo "listen:" . print_r($listen_socket, 1) . PHP_EOL;

$client = [$listen_socket];
$write = [];
$exp = [];

// 开始进入循环
while (true) {
    $read = $client;
    echo "watchs:" . ids($read) . PHP_EOL;
    // 当select监听到了fd变化,注意第四个参数为null
    // 如果写成大于0的整数那么表示将在规定时间内超时
    // 如果写成等于0的整数那么表示不断调用select,执行后立马返回,然后继续
    // 如果写成null,那么表示select会阻塞一直到监听发生变化
    if (socket_select($read, $write, $exp, null) > 0) {
        echo "changes:" . ids($read) . PHP_EOL;
        // 判断listen_socket有没有发生变化,如果有就是有客户端发生连接操作了
        if (in_array($listen_socket, $read)) {
            // 将客户端socket加入到client数组中
            $client_socket = socket_accept($listen_socket);
            echo "new connect client:" . spl_object_id($client_socket) . PHP_EOL;
            $client[] = $client_socket;
            // 然后将listen_socket从read中去除掉
            $key = array_search($listen_socket, $read);
            unset($read[$key]);
        }

        // 查看去除listen_socket中是否还有client_socket
        if (count($read) > 0) {
            foreach ($read as $socket_item) {
                // 从可读取的fd中读取出来数据内容,然后发送给其他客户端
                $content = socket_read($socket_item, 5);

                // 检查连接是否已关闭
                if ($content === false || $content === '' || strlen($content) === 0) {
                    echo "Client disconnected: " . spl_object_id($socket_item) . PHP_EOL;

                    // 从客户端列表中移除
                    $key_to_remove = array_search($socket_item, $client);
                    if ($key_to_remove !== false) {
                        unset($client[$key_to_remove]);
                    }
                    // 关闭 socket
                    socket_close($socket_item);

                    continue; // 跳过后续广播
                }
                
                echo "Recv:" . var_export($content,1) . PHP_EOL;
                $msg = "From " . spl_object_id($socket_item) . ":" .var_export( $content,1) . PHP_EOL;
                // 循环client数组,将内容发送给其余所有客户端
                foreach ($client as $client_socket) {
                    // 因为client数组中包含了 listen_socket 以及当前发送者自己socket,所以需要排除二者
                    //必须排除 listen_socket 否者出现 Broken pipe
                    if ($client_socket != $listen_socket && $client_socket != $socket_item) {
                        socket_write($client_socket, $msg, strlen($msg));
                    }
                }
            }
        }
    } // 当select没有监听到可操作fd的时候,直接continue进入下一次循环
    else {
        echo "no select \n";
        continue;
    }
    echo PHP_EOL . str_repeat('-', 30) . PHP_EOL;
}

function ids($arr)
{
    $arr = array_map(function ($v) {
        return spl_object_id($v);
    }, $arr);
    return implode(",", $arr);
}

function errhandle($line_num, $exit = true)
{
    echo $line_num . ":" . socket_last_error() . ":" . socket_strerror(socket_last_error()) . PHP_EOL;
    if ($exit) {
        exit();
    }
}
  • 监听listen_socket的变化,有变化则表明有新的client连接上来了
  • 从变化的listen_socket读出(socket_accept) 客户端的通信socket,即client_socket
  • 然后监听listen_socket,client_socket的变化,listent_socket有变化则表明有新的连接来了,client_socket有变化说明 已经连接上的client有数据传输了

socket_set_nonblocksocket_select

✅ 核心原则:socket_select() + 阻塞 I/O 是经典搭配

  • socket_select() 的作用是 “等待多个 socket 中任意一个变为可读/可写”。
  • 一旦 socket_select() 返回某个 socket 可读(或可写),接下来的 socket_read() / socket_write() 应该能立即完成而不阻塞。
  • 因此,保持 socket 为默认的阻塞模式(blocking)是最简单、最安全的做法。

🚫 如果设置了 socket_set_nonblock() 会怎样?

  • socket_read() 可能返回空或 false 即使连接未断开
    • 非阻塞模式下,即使 select 说“可读”,也可能因为内核缓冲区数据被其他进程读走等原因,导致 read 立即返回“无数据”(EAGAIN/EWOULDBLOCK)。
    • 你需要额外判断错误码,逻辑更复杂。
  • socket_write() 可能无法一次性写完所有数据
    • 非阻塞写可能只写入部分数据,你需要循环写直到全部发送完毕,并处理 EAGAIN。
    • 而在阻塞模式下,只要 select 说“可写”,write 就会尽可能写入全部数据(或直到缓冲区满,但通常不会)。
  • 容易误判连接断开
    • 非阻塞 socket_read() 返回 false 不一定表示连接关闭,可能是“暂时无数据”,需用 socket_last_error() 判断是否为 SOCKET_EAGAIN。

🔧 总结

场景 是否设置 socket_set_nonblock()
使用 socket_select() 监控 I/O ❌ 不要设置(保持默认阻塞)
纯轮询(不用 select) ✅ 必须设置非阻塞,否则 read/write 会卡死
高性能异步框架 ✅ 需要,但配合事件循环

📌 记住:select 的存在就是为了避免使用非阻塞 I/O 的复杂性。两者混合反而增加 bug 风险。

💡 附加建议
socket_accept() 返回的新 socket 默认是阻塞的,无需也不应改为非阻塞
如果你看到某些示例代码用了 socket_set_nonblock() + select,那通常是错误的,或者是为了解决特定平台问题(极少见)。
所以,在你的代码中:完全不需要调用 socket_set_nonblock()。保持默认阻塞模式,配合 socket_select(),是最清晰、最可靠的方式。

yum install libevent-devel

select缺点

select虽然一定程度上解决了一个进程可以读写多个fd的问题,但是select有如下致命缺点:

  • 默认情况下,select可管理的fd的数量是1024个
  • select每次检测到fd集合中有可读写的fd时,它会把整个fd全部复制一遍给你,然后你自己再去逐个轮询究竟是哪个fd可读写
  • 正如以上所说,它会把整个fd全部复制给你,从术语上讲,这个过程是将fd从内核态复制一遍给用户态的调用进程
  • 正如以上所说,你自己逐个轮询所有fd才能知道究竟是哪个可读写
  • 你自己个轮询的过程是线性的,如果有个n个fd,那么时间复杂度一定是O(n)

epoll

而epoll则拥有更加专业的高端大气上档次的技能指标:

  • 理论上可以搞定无上限的fd
  • 只挑出可读写(其实严格意义上还有异常)的活跃的fd,其余的fd不理会
  • 使用MMAP加速内核态数据拷贝

epoll的2种模式

除此之外,需要特殊指出的是,epoll本身的两种模式:

  • 水平触发
    这种方式下,如果监听到了有X个事件发生,那么内核态会将这些事件拷贝到用户态,但是可惜的是,如果用户只处理了其中一件,剩余的X-1件出于某种原因并没有理会,那么下次的时候,这些未处理完的X-1个事件依然会从内核态拷贝到用户态。
    这样做是有阴阳两面的,阳面是事件安全的不会发生丢失,阴面是对于性能来说是一种浪费。其实这个时候的epoll颇有些类似于poll的工作方式
  • 边缘触发
    这种方式下,也是应该是正确的使用方式。这种情况下,如果发生了X个事件,然而你只处理了其中1个事件,那么剩余的X-1个事件就算“丢失”了。性能是上去了,与之俱来的就是可能的事件丢失

PHP 的 event 扩展(即 ext-event)在 Linux 系统上默认使用 epoll 作为底层事件通知机制

查看当前的event扩展用的是哪个方法

method.php

// 查看当前系统平台支持的IO多路复用的方法都有哪些
print_r(Event::getSupportedMethods() );

$eventBase=new EventBase();
echo "当前event的方法是:".$eventBase->getMethod().PHP_EOL;

$eventConfig=new EventConfig;
$eventConfig->avoidMethod("epoll");

$eventBase=new EventBase($eventConfig);

echo "当前event的方法是:".$eventBase->getMethod().PHP_EOL;

[root@hkui event]# php method.php 
Array
(
    [0] => epoll
    [1] => poll
    [2] => select
)
当前event的方法是:epoll
当前event的方法是:poll

水平触发还是边缘触发

features.php

$base=new EventBase();

$features=$base->getFeatures();
echo $features;
echo PHP_EOL;
if( $features & EventConfig::FEATURE_ET ){
  echo "边缘触发".PHP_EOL;
}
if( $features & EventConfig::FEATURE_O1 ){
    echo "O1添加删除事件".PHP_EOL;
}
if( $features & EventConfig::FEATURE_FDS ){
    echo "任意文件描述符,不光socket".PHP_EOL;
}
[root@hkui event]# php features.php 
3
边缘触发
O1添加删除事件

epoll并不支持所有的文件描述符

select()/poll()/epoll()不能工作在常规的磁盘文件上
这是因为 epoll有一个强烈基于准备就绪模型的假设前提
你监视的是准备就绪的套接字,因此套接字上的顺序IO调用不会发生阻塞。但是磁盘文件并不符合这种模 型,因为它们总是处于就绪状态

磁盘I/O只有在数据没有被缓存到内存时会发生阻塞,而不是因为客户端没发送消息
磁盘文件的模型是完成通知模型。在这样的模型里,你只是产生I/O操 纵,然后等待完成通知。

kqueue支持这种方式,通过设置EVFILT_AIO 过滤器类型来关联到 POSIX AIO功能上,诸如aio_read()。
在Linux中,你只能祈祷因为缓存命中率高而磁盘发生不阻塞(这种情况在通常的网络服务器上是个彩蛋),或者 通过分离线程来使得磁盘I/O阻塞不会影响网络套接字的处理(如FLASH架构)。

信号

08.event_signal.php

echo "####" .date("H:i:s "). posix_getpid() . PHP_EOL;


$eventConfig = new EventConfig();

$eventBase = new EventBase($eventConfig);
$i = 0;
//信号
$event1 = new Event($eventBase, SIGINT, Event::SIGNAL | Event::PERSIST, function () use (&$i,$eventBase) {
    echo date("H:i:s") . " signal Int   i=" . $i . PHP_EOL;
    $i++;
    if ($i > 5) {
        $eventBase->stop();
    }

});
$event1->add();

$signalEvent = Event::signal($eventBase, SIGUSR1,function ($signo) use (&$i,$eventBase) {

    echo "signo=" . $signo . "  i=" . $i . PHP_EOL;
    $i++;
    if ($i > 10) {
        $eventBase->stop(); //不要用exit
    }
},'arg');
$signalEvent->add();


//定时器
$timeEvent1 = new Event($eventBase, -1, Event::TIMEOUT | Event::PERSIST, function () use (&$i) {
    echo date("H:i:s") . "---" . $i . PHP_EOL;
    if ($i % 10 == 0) {
        posix_kill(posix_getpid(), SIGUSR1);
    }
    $i++;

}, $i);
//每隔多久
$timeEvent1->add(2);

$eventBase->loop();

echo "exited" . PHP_EOL;

发送SIGINT,后会sleep(10),但如果在10s内再发送SIGINT或者SIGTERM等其它信号,会立刻执行之前的在阻塞的信号

  • 1.创建EventConfig(非必需)
  • 2.创建EventBase
  • 3.创建Event
  • 4.将Event挂起,也就是执行了Event对象的add方法,不执行add方法那么这个event对象就无法挂起,也就不会执行
    将EventBase执行进入循环中,也就是loop方法

07.event_http_stream.php

<?php
$host = '0.0.0.0';
$port = 8080;
$address = "tcp://{$host}:{$port}";

// 使用 stream_socket_server,并设置 SO_REUSEADDR
$context = stream_context_create([
    'socket' => [
        'so_reuseaddr' => true,
    ]
]);

$listen_socket = stream_socket_server($address, $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context);

if (!$listen_socket) {
    die("Failed to create server: {$errstr} ({$errno})\n");
}

// 设置为非阻塞(对 stream 有效)
//在使用 libevent(通过 PHP 的 Event 扩展)进行事件驱动编程时,必须将监听套接字($listen_socket)设置为非阻塞模式,即调用
stream_set_blocking($listen_socket, false);

echo PHP_EOL . "http server on:http://{$host}:{$port}" . PHP_EOL;


$eventBase = new EventBase();

$event = new Event($eventBase, $listen_socket, Event::READ | Event::PERSIST,
    function ($listen_socket) {
        $connect_socket = stream_socket_accept($listen_socket, 0); // 0
        if ($connect_socket != false) {
            stream_set_blocking($connect_socket, false);
            echo "new connect " . intval($connect_socket) . PHP_EOL;
            $content = "Hi " . date("Y-m-d H:i:s") . PHP_EOL;
            $msg = "HTTP/1.0 200 OK\r\nContent-Length:" . strlen($content) . "\r\n\r\n" . $content;
            fwrite($connect_socket, $msg, strlen($msg));
            fclose($connect_socket);
        }
    }, $listen_socket);
$event->add();
$eventBase->loop();

05.event_chat_stream.php

<?php
// 替换 socket_create/bind/listen 为 stream_socket_server
$host = '0.0.0.0';
$port = 8081;
$address = "tcp://{$host}:{$port}";

// 使用 stream_socket_server,并设置 SO_REUSEADDR
$context = stream_context_create([
    'socket' => [
        'so_reuseaddr' => true,
    ]
]);

$listen_socket = stream_socket_server($address, $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context);

if (!$listen_socket) {
    die("Failed to create server: {$errstr} ({$errno})\n");
}

// 设置为非阻塞(对 stream 有效)
stream_set_blocking($listen_socket, false);

echo "TCP server on {$host}:{$port}" . PHP_EOL;

$conn_arr = [];
$event_arr = [];

$eventBase = new EventBase();

$event = new Event($eventBase, $listen_socket, Event::READ | Event::PERSIST,
    function ($listen_socket, $events) use ($eventBase, &$conn_arr, &$event_arr) {
        echo "[DEBUG] Accept event triggered\n";

        // 使用 stream_socket_accept
        $connect_socket = stream_socket_accept($listen_socket, 0); // 0 表示非阻塞

        if ($connect_socket === false) {
            // 非阻塞下无连接是正常的
            return;
        }

        $socket_id = (int)$connect_socket;
        echo "new connect {$socket_id}" . PHP_EOL;
       
        stream_set_blocking($connect_socket, false);

        // 广播新连接
        $message = "Client {$socket_id} come!" . PHP_EOL;
        foreach ($conn_arr as $client_socket) {
            fwrite($client_socket, $message);
        }

        $conn_arr[$socket_id] = $connect_socket;

        // 客户端读事件
        $client_event = new Event($eventBase, $connect_socket, Event::READ | Event::PERSIST,
            function ($connect_socket, $events) use (&$conn_arr, &$event_arr) {
                $socket_id = (int)$connect_socket;

                $buffer = fread($connect_socket, 65535);

                if ($buffer === false || $buffer === '' || feof($connect_socket)) {
                    // 连接关闭
                    echo "connection closed: {$socket_id}\n";

                    if (isset($event_arr[$socket_id])) {
                        $event_arr[$socket_id]->free();
                        unset($event_arr[$socket_id]);
                    }

                    if (isset($conn_arr[$socket_id])) {
                        fclose($conn_arr[$socket_id]);
                        unset($conn_arr[$socket_id]);
                    }

                    $message = "Client {$socket_id} left!" . PHP_EOL;
                    foreach ($conn_arr as $client_socket) {
                        fwrite($client_socket, $message);
                    }
                    return;
                }

                // 广播消息
                $msg = "Client {$socket_id} say: " . $buffer;
                foreach ($conn_arr as $conn_id => $conn_item) {
                    if ($conn_id != $socket_id) {
                        fwrite($conn_item, $msg);
                    }
                }
            }, $connect_socket);

        $client_event->add();
        $event_arr[$socket_id] = $client_event;
    });

$event->add();
echo "Event loop starting...\n";
$eventBase->loop();

event一定要在全局里

libevent的socket需要阻塞

在使用 libevent(通过 PHP 的 Event 扩展)进行事件驱动编程时,必须将监听套接字($listen_socket)设置为非阻塞模式,即调用:

stream_set_blocking($listen_socket, false);

原因如下:
Event::READ 事件触发表示“可读”,但不保证 accept() 会立即成功

在边缘触发(ET)或水平触发(LT)模式下(PHP 的 Event 默认是 LT),当有新连接到达时,Event::READ 会被触发。但如果你的 socket 是阻塞模式,那么在高并发或某些网络条件下,stream_socket_accept() 可能会阻塞当前线程,从而导致整个事件循环卡住,无法处理其他事件。
事件循环要求所有 I/O 操作是非阻塞的

事件驱动模型的核心假设是:所有回调函数必须尽快返回,不能阻塞。因此,任何在事件回调中使用的 socket(包括监听 socket 和连接 socket)都应设为非阻塞

stream_socket_accept() 在非阻塞 socket 上的行为
如果没有挂起的连接,它会立即返回 false(你可以忽略或记录)。
如果有连接,它会立即返回一个连接资源。 这正是事件循环所期望的行为。
补充建议
虽然你当前代码中已经设置了:

stream_set_blocking($listen_socket, false);

这是正确且必要的,但还需注意:

错误处理:stream_socket_accept() 返回 false 时,可能是暂时无连接(正常),也可能是出错(如 EMFILE、ENFILE 等)。在生产环境中建议检查 error_get_last() 或使用 socket_* 函数族获得更精细控制。
性能考虑:你当前在 accept 后立即读写并关闭连接,适用于简单 HTTP 响应。但如果要支持 Keep-Alive、POST 数据等,需要为每个 \(connect_socket 创建独立的 Event 监听其 READ 事件,并管理连接生命周期。 结论 ✅ 必须设置 stream_set_blocking(\)listen_socket, false);

这是使用 Event + stream_socket_server 的标准做法,否则可能造成事件循环阻塞,失去异步优势。

select与event区别

  • Event(基于 libevent)要求 socket 是非阻塞的,因为它的回调函数必须立即返回,不能阻塞事件循环。
  • socket_select 本身是阻塞的(或可超时等待),它负责“等待”I/O 就绪,之后你再用阻塞式 I/O 安全地读写,因为此时数据已经就绪

socket_select 的工作方式(轮询 + 阻塞 I/O)

$read = [$server_socket];
if (socket_select($read, $write, $except, $timeout)) {
    if (in_array($server_socket, $read)) {
        $client = socket_accept($server_socket); // 这里可以是阻塞的!
    }
}
  • socket_select() 会阻塞当前线程(或等待 $timeout),直到至少一个 socket 可读/可写。
  • 一旦 select 返回,说明该 socket 已经“就绪”(比如有新连接、有数据可读)。
  • 因此,后续调用 socket_accept()、socket_read() 等即使使用阻塞模式,也会立即返回,不会卡住。
  • 所以:select 负责“等”,应用代码负责“收”,而“收”的时候数据已经到了,阻塞也没关系。

💡 在 select 模型中,socket 通常保持阻塞模式,因为 select 已经保证了操作不会真的阻塞。

Event(libevent)的工作方式(事件驱动 + 非阻塞 I/O)

$event = new Event($base, $sock, Event::READ, function($fd) {
    $client = stream_socket_accept($fd); // 必须是非阻塞!
});
  • Event 的底层(libevent)使用更高效的机制(如 epoll、kqueue)来监听 I/O 事件。
  • 当事件触发(如可读),立即调用你的回调函数。
  • 这个回调函数必须尽快执行完毕并返回,否则整个事件循环会被卡住,其他事件无法处理。
  • 如果 $sock 是阻塞模式,而恰好在 stream_socket_accept() 时没有连接(虽然理论上不该发生,但网络复杂),或者内核状态异常,accept 可能阻塞 → 事件循环死锁!

⚠️ 所以:所有在 Event 回调中使用的 I/O 操作,必须是非阻塞的,以确保“尝试一次,成功就处理,失败就放弃”。

核心联系(共同点)

方面 说明
目的相同 都是为了在一个线程中同时监听多个 socket 的 I/O 事件(可读、可写、异常等),避免为每个连接创建线程/进程。
都属于同步 I/O 模型 它们本身不执行真正的数据读写,只是“通知你什么时候可以安全地读写而不阻塞”。实际读写仍由应用程序完成(与异步 I/O 如 Linux AIO 不同)。
都需要配合 socket 使用 无论是 select 还是 Event,底层操作对象都是文件描述符(fd)或 stream socket。

关键区别

特性 select Event(基于 libevent / epoll / kqueue)
底层机制 系统调用 select()(POSIX 标准) 封装了更高效的系统调用:
• Linux: epoll
• macOS/BSD: kqueue
• fallback: poll/select
时间复杂度 O(n):每次都要遍历所有 fd O(1):内核维护就绪列表,只返回活跃 fd
最大 fd 限制 通常 ≤ 1024(FD_SETSIZE) 无硬限制(仅受系统资源限制)
fd 集合传递方式 每次调用都要重新传入整个 fd 集合 注册一次,后续自动监听(状态持久)
触发方式 水平触发(Level-Triggered, LT) 支持 LT 和边缘触发(Edge-Triggered, ET)
编程模型 轮询式:主循环不断调用 select() 事件驱动:注册回调函数,事件发生时自动调用
是否需要非阻塞 socket ❌ 通常不需要(因为 select 已确认就绪) ✅ 必须(防止回调中 I/O 阻塞事件循环)
可移植性 极高(几乎所有系统支持) 高(libevent 自动适配底层机制)
典型使用语言/库 C 原生、PHP socket_select() C (libevent/libev)、PHP Event 扩展、Node.js、Nginx 等
I/O 模型 同步 I/O 多路复用(轮询) 异步事件驱动
谁负责等待? select() 阻塞等待 内核通知(epoll/kqueue)
socket 模式 通常阻塞(安全,因为 select 已确认就绪) 必须非阻塞(防止回调阻塞)
编程风格 循环 + 条件判断 注册回调 + 事件触发
性能 O(n) 每次遍历 fd O(1) 事件通知(高效)

代码风格对比

. socket_select(阻塞 + 轮询)

$sockets = [$server];
while (true) {
    $read = $sockets;
    if (socket_select($read, $write, $except, 1)) {
        foreach ($read as $sock) {
            if ($sock === $server) {
                $client = socket_accept($server); // 可阻塞,安全
                $sockets[] = $client;
            } else {
                $data = socket_read($sock, 1024); // 可阻塞,因为 select 保证有数据
                // 处理...
            }
        }
    }
}

. Event(非阻塞 + 回调)

$base = new EventBase();
$event = new Event($base, $server, Event::READ | Event::PERSIST, function($fd) {
    $client = stream_socket_accept($fd, 0); // 必须非阻塞!
    if ($client) {
        stream_set_blocking($client, false);
        // 注册 client 的读事件...
    }
});
$event->add();
$base->loop(); // 事件循环启动
posted @ 2026-01-02 17:38  H&K  阅读(9)  评论(0)    收藏  举报