引言
Richard Stevens的经典书籍UNP(UNIX:registered: Network Programming),书中给出了5种IO模型:
- blocking IO - 阻塞IO
- nonblocking IO - 非阻塞IO
- IO multiplexing - IO多路复用
- signal driven IO - 信号驱动IO
- asynchronous IO - 异步IO
其中前面4种IO都可以归类为synchronous IO - 同步IO
同步:我去询问交代的事是否处理完成。阻塞:我不去做其他事。
异步:交代的事情处理完后通知我。非阻塞:我去做其他事了。
IO多路复用:多路是指多个客户端的连接,复用就是指复用少数几个进程。 多路复用本身依然隶属于同步通信方式,只是表现出的结果看起来像异步。
多路复用三种常用的方案:
- select,最早的解决方案,同步的。
- poll,算是select的升级版。
- epoll,目前的最终解决版,解决c10k问题的功臣,epoll是同步的,而不是异步。
select、poll、epoll都是系统底层实现的,供上层应用调用。
select/poll的缺点:
- 每次调用select/poll时都需要在用户态/内核态之间的拷贝数据,select使用数组方式,poll使用链表方式
- 在内核中,select/poll在检测IO事件时,只要有一个fd有事件发生,就会线性扫描所有的fds,时间复杂度O(n)
epoll:
- 将应用进程关心的fd维护在内核中(支持高效的增删改),使用红黑树方式
- 当某个epitem中的fd发生了IO事件,就会将这个epitem,挂到rdllist中,时间复杂度O(1)
epoll中,循环监听是否有事件就绪,如果没有进程会进入睡眠。
epoll中,当前进程可能会被唤醒,唤醒的条件包括有以下四种:
- 当前进程超时;
- 当前进程收到一个signal信号;
- 某个fd上有事件发生;
- 当前进程被CPU重新调度,进入for循环重新判断
如果没有满足第3个条件,就又重新进入休眠。
参阅:
- https://ibaiyang.github.io/blog/php/2020/06/30/PHP-多进程初探.html
- https://ibaiyang.github.io/blog/php/2020/07/01/PHP-Socket初探.html
- https://cloud.tencent.com/developer/article/1005481
IO多路复用说明
IO多路复用是一种能够阻塞等待,非忙轮询装填,不浪费CPU等资源,也能同一时刻监听多个IO请求的机制。 它的实现机制可以通过以下步骤来理解:
首先,假设有一个文件描述符集合,应用程序通过这个集合来监听多个IO请求。 当一个文件描述符就绪时(比如数据已经到达可以进行读写操作),就会触发一个事件。
然后,应用程序会被唤醒并执行相应的读写操作。如果没有文件描述符就绪,那么应用程序就会被阻塞,交出CPU,等待IO操作就绪。
具体实现时,可以使用select、poll或epoll等方法。这些方法的基本思想是通过一种数据结构(如数组或树) 来存储文件描述符集合和就绪的文件描述符。
例如,在epoll中,会有一个红黑树来存储所有的文件描述符和它们的事件。当某个文件描述符的事件发生时, 就会将这个文件描述符的相关信息存入到一个事件数组中。 然后应用程序就可以通过遍历这个事件数组来检查哪些文件描述符就绪了,并进行相应的读写操作。
IO多路复用具体方案
IO多路复用具体方案如下:
一、select实现
- 初始化:将所有的文件句柄放到一个集合中。
- 遍历:应用程序会遍历整个文件句柄集合,检查哪些文件句柄就绪了(比如可读或者可写)。
- 读写:当找到就绪的文件句柄后,应用程序就会执行相应的读写操作。
- 阻塞:如果没有文件句柄就绪,那么应用程序就会被阻塞,等待IO操作就绪。
二、poll实现
- 初始化:将所有的文件句柄和它们的事件(比如可读或者可写)放到一个集合中。
- 注册:应用程序会将所有的文件句柄注册到一个poll对象中。
- 等待:应用程序会调用poll的wait函数,等待文件句柄就绪。
- 处理:当某个文件句柄就绪时,poll就会返回这个文件句柄的信息,应用程序就可以执行相应的读写操作。
三、epoll实现
- 初始化:将所有的文件句柄和它们的事件(比如可读或者可写)放到一个红黑树中。
- 注册:应用程序会将所有的文件句柄注册到一个epoll对象中。
- 等待:应用程序会调用epoll的wait函数,等待文件句柄就绪。
- 处理:当某个文件句柄就绪时,epoll就会将这个文件句柄的相关信息存入到一个事件数组中。 应用程序就可以遍历这个事件数组,找到就绪的文件句柄,执行相应的读写操作。 同时,epoll可以通过事件再次触发已就绪的文件句柄,避免了应用程序反复轮询的开销。
总的来说,IO多路复用机制通过优化CPU和IO操作的使用,提高了应用程序的性能和响应速度。
问题
问题一:怎么检查文件句柄就绪了?
在IO多路复用中,文件句柄就绪通常指的是该文件句柄对应的文件描述符的状态已经改变, 可以使用读写操作而不会阻塞。检查文件句柄是否就绪通常是通过以下方式进行的:
对于select和poll,应用程序需要在每次调用select或poll后,通过检查文件描述符的状态位来确定文件句柄是否就绪。 例如,在select中,应用程序需要在调用select后,再次遍历所有的文件描述符,检查它们的状态位是否已经改变。 在poll中,应用程序需要在调用poll后,再次遍历所有的文件句柄,检查它们的状态是否已经改变。
对于epoll,应用程序可以在epoll_wait函数返回后,通过检查返回的事件数组来确定哪些文件句柄就绪了。 事件数组中每个元素对应一个文件句柄,应用程序可以检查元素对应的文件句柄状态位来确定文件句柄是否就绪。
在实际编程中,为了方便起见,应用程序通常会使用一些封装好的库函数来处理IO多路复用和文件句柄的读写操作。 例如,在C语言中,可以使用libevent库来封装select、poll或epoll,并简化文件句柄的管理和读写操作。
问题二:在select中,应用程序需要在调用select后,再次遍历所有的文件描述符,这句话怎么理解?
在select中,应用程序需要遍历所有的文件描述符,检查它们的状态位是否已经改变。 这是因为select函数只是返回了哪些文件描述符已经就绪,但并没有提供具体是哪些文件描述符。 因此,应用程序需要再次遍历所有的文件描述符,以找到就绪的文件描述符,并进行相应的读写操作。 这个过程可能需要消耗一定的时间和资源,因此在高并发的场景下,select可能会存在一些性能瓶颈。 为了解决这个问题,epoll等更高级的文件描述符多路复用技术被引入了进来。
问题三
IO多路复用是通过底层操作系统提供的机制来处理的。在Unix和类Unix操作系统中, select或poll系统调用可以用来同时监听多个文件描述符(或Socket)的状态,以便在它们就绪时进行相应的IO操作。
当应用程序调用select或poll时,它会将一个或多个文件描述符(或Socket)传递给这些系统调用, 并指定应用程序感兴趣的操作(例如读就绪、写就绪等)。然后,select或poll会阻塞当前进程, 直到有一个或多个文件描述符就绪。当有文件描述符就绪时,select或poll会返回,应用程序可以继续执行相应的IO操作。
在这个过程中,select或poll实现了IO多路复用。它们可以在单线程的情况下同时处理多个客户端连接请求。 当一个客户端连接请求到达时,应用程序可以在select或poll的返回结果中找到对应的文件描述符(或Socket), 然后使用它进行进一步的IO操作。这样,应用程序可以在同一时间处理多个客户端连接请求,提高其性能和响应速度。
总结
为了快速和准确的理解,还是直接看视频吧:
动画 + 代码演示什么是 IO 多路复用? https://www.bilibili.com/video/BV1r94y1q7hT
阻塞 IO vs 非阻塞 IO (为什么需要 IO 多路复用?) https://www.bilibili.com/video/BV1tN41127un
多路复用之 epoll 实现原理 https://www.bilibili.com/video/BV12p4y1372v
select
使用select系统调用演示一个客户端进行群聊的例子:
<?php
/* BEGIN 创建一个tcp socket服务器 */
$serverAddress = '0.0.0.0';
$serverPort = 9999;
$serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if ($serverSocket === false) {
echo "无法创建 Socket: " . socket_strerror(socket_last_error()) . "\n";
exit;
}
if (socket_bind($serverSocket, $serverAddress, $serverPort) === false) {
echo "无法绑定 Socket: " . socket_strerror(socket_last_error($serverSocket)) . "\n";
exit;
}
if (socket_listen($serverSocket) === false) {
echo "无法监听 Socket: " . socket_strerror(socket_last_error($serverSocket)) . "\n";
exit;
}
/* END 创建服务器完毕 */
// $clientSockets用来保存连接的客户端,也将监听socket放入到read fd set中去,因为select也要监听listen_socket上发生事件
$clientSockets = [$serverSocket];
// 先暂时只引入读事件,避免有同学晕头
$writeSockets = [];
$exceptSockets = [];
$timeout = null;
// 开始进入循环
while (true) {
// $readSockets读取的客户端为连接的客户端
$readSockets = $clientSockets;
/*
当select监听到了fd变化,注意第四个参数$timeout为null;如果写成大于0的整数那么表示将在规定时间内超时;
如果写成等于0的整数那么表示不断调用select,执行后立马返回,然后继续;
如果写成null,那么表示select会阻塞一直到监听发生变化。
*/
/*
第一个参数必须是数组,数组里面含有待检测的套接字,而且第四个参数写成null阻塞就是代表程序就一直停在socket_select这个函数上,
什么都不干,等你有连接或者有数据发送,我才继续执行,所以你使用var_dump后,再进行telnet 才会有返回值,否则没有任何输出的
*/
$result = socket_select($readSockets, $writeSockets, $exceptSockets, $timeout);
if ($result === false) {
echo "select 调用失败: " . socket_strerror(socket_last_error()) . "\n";
exit;
} elseif ($result === 0) {
// select 返回 0 表示没有就绪的 Socket 资源,继续循环等待
continue;
}
// 判断$serverSocket有没有发生变化,如果有就是有客户端发生连接操作了。刚开始把$serverSocket放入了$clientSockets,$clientSockets又放入了$readSockets
if (in_array($serverSocket, $readSockets)) {
// 将客户端socket加入到客户端连接client数组中
// $serverSocket创建一个可用套接字传送数据,后面准备给其他客户端发送数据用的
$clientSocket = socket_accept($serverSocket);
//下面这句很有用,避免了 unset( $readSockets[ $key ] )后,在while时,客户端进来再次用 $clientSockets赋值给$readSockets
$clientSockets[] = $clientSocket;
// 然后将$serverSocket从read中去除掉
$key = array_search($serverSocket, $readSockets);
unset($readSockets[$key]);
}
// 查看去除$serverSocket中是否还有$clientSocket。
// 已经进行telnet连接后,会直接走这一步,不会进去上面代码的in_array。
// 第一个客户端连接,第一次到这里$readSockets数为0,第二次循环执行到这里$readSockets中为其自身的连接,
// 或者其他后续的客户端连接发生了连接,会执行到这里。
if (count($readSockets) > 0) {
// 循环监听的所有客户端连接
foreach ($readSockets as $socket_item) {
// 从可读取的客户端连接fd中读取出来数据内容,然后发送给其他客户端
$content = socket_read($socket_item, 2048);
if ($content === false) {
echo "读取数据失败: " . socket_strerror(socket_last_error($socket_item)) . "\n";
// 关闭客户端连接
socket_close($socket_item);
// 从客户端 Socket 列表中移除连接
$index = array_search($socket_item, $clientSockets);
unset($clientSockets[$index]);
} else {
// 循环$clientSockets数组,将内容发送给其余所有客户端
foreach ($clientSockets as $clientSocket) {
// 因为$clientSockets数组中包含了 $serverSocket 以及当前发送者自己socket,$clientSocket != $socket_item 再次排除自已,所以需要排除二者
if ($clientSocket != $serverSocket && $clientSocket != $socket_item) {
socket_write($clientSocket, $content, strlen($content));
}
}
}
}
}
}
poll
使用poll实现IO多路复用的基本步骤如下:
- 创建一个poll句柄,可以使用poll_init()函数来完成。
- 对于每个客户端连接,都需要创建一个新的Socket资源,并将其添加到poll的监视列表中。 这可以通过poll_fd_set()函数来实现。在调用poll_fd_set()时,需要指定Socket资源以及事件类型 (例如,POLLRDNORM表示数据可读、POLLWRNORM表示数据可写、POLLERR表示出错等)。
- 在主循环中,使用poll_wait()函数来等待事件的发生。poll_wait()函数会阻塞当前进程, 直到有一个或多个Socket资源就绪(即有数据可读或可写或者出错)。
- 在事件发生时,执行相应的回调函数。在回调函数中,可以处理就绪的Socket资源,读取或发送数据。
下面是一个使用poll实现IO多路复用的PHP代码示例:
<?php
// 创建poll句柄
$poll = poll_create();
if ($poll === false) {
die("Failed to create poll handle: " . error_get_last());
}
// 添加监听Socket到poll
$serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($serverSocket, '127.0.0.1', 8000);
socket_listen($serverSocket);
$event = new stdClass();
$event->fd = $serverSocket;
$event->events = POLLRDNORM | POLLWRNORM;
if (poll_add($poll, $serverSocket, $event) === false) {
die("Failed to add server socket to poll: " . error_get_last());
}
// 事件循环
while (true) {
$result = poll_wait($poll, -1);
if ($result === false) {
die("Failed to wait for events: " . error_get_last());
}
foreach ($result as $fd => $revents) {
if ($fd === $serverSocket) {
$clientSocket = socket_accept($serverSocket);
if ($clientSocket === false) {
die("Failed to accept client connection: " . error_get_last());
}
$event = new stdClass();
$event->fd = $clientSocket;
$event->events = POLLRDNORM | POLLWRNORM;
if (poll_add($poll, $clientSocket, $event) === false) {
die("Failed to add client socket to poll: " . error_get_last());
}
} else {
// 处理客户端Socket事件
$data = socket_read($fd, 1024);
if ($data === false) {
die("Failed to read data from client: " . error_get_last());
}
// 在这里处理接收到的数据,并发送响应给客户端
$response = "Hello, client!";
socket_write($fd, $response, strlen($response));
}
}
}
?>
上述示例中,首先创建了一个poll句柄,然后创建了一个服务器Socket并将其添加到poll的监视列表中。 在事件循环中,使用poll_wait()函数等待事件的发生。当服务器Socket有连接请求时, 接受客户端连接并将其添加到poll的监视列表中。当客户端Socket有数据可读时,读取数据并在处理后发送响应给客户端。
epoll
如果要用epoll实现IO多路复用,需要进行以下步骤:
- 创建一个epoll句柄,可以使用epoll_create1()函数来完成。
- 对于每个客户端连接,都需要创建一个新的Socket资源,并将其添加到epoll的监视列表中。 这可以通过epoll_ctl()函数来实现。在调用epoll_ctl()时,需要指定事件类型(EPOLLIN表示数据可读、EPOLLOUT表示数据可写)、 Socket描述符以及一个回调函数。
- 在主循环中,使用epoll_wait()函数来等待事件的发生。epoll_wait()函数会阻塞当前进程, 直到有一个或多个Socket资源就绪(即有数据可读或可写)。
- 在事件发生时,执行相应的回调函数。在回调函数中,可以处理就绪的Socket资源,读取或发送数据。
与select相比,epoll的性能表现更优秀,因为它只对进程进行一次阻塞,而不是对所有被监视的Socket资源进行阻塞。 因此,当有大量客户端连接时,epoll的效率更高。
示例是一个简单的示例,仅用于说明使用epoll实现IO多路复用的基本原理:
<?php
// 创建epoll句柄
$epoll = epoll_create1(0);
if ($epoll === false) {
die("Failed to create epoll handle: " . error_get_last());
}
// 添加监听Socket到epoll
$serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_bind($serverSocket, '127.0.0.1', 8000);
socket_listen($serverSocket);
$event = new stdClass();
$event->data = $serverSocket;
$event->events = EPOLLIN;
if (epoll_ctl($epoll, $serverSocket, EPOLL_CTL_ADD, $event) === false) {
die("Failed to add server socket to epoll: " . error_get_last());
}
// 事件循环
while (true) {
$events = epoll_wait($epoll, -1);
if ($events === false) {
die("Failed to wait for events: " . error_get_last());
}
foreach ($events as $event) {
$socket = $event->data;
// 处理服务器Socket事件
if ($socket === $serverSocket) {
$clientSocket = socket_accept($serverSocket);
if ($clientSocket === false) {
die("Failed to accept client connection: " . error_get_last());
}
$event = new stdClass();
$event->data = $clientSocket;
$event->events = EPOLLIN;
if (epoll_ctl($epoll, $clientSocket, EPOLL_CTL_ADD, $event) === false) {
die("Failed to add client socket to epoll: " . error_get_last());
}
} else {
// 处理客户端Socket事件
$data = socket_read($socket, 1024);
if ($data === false) {
die("Failed to read data from client: " . error_get_last());
}
// 在这里处理接收到的数据,并发送响应给客户端
$response = "Hello, client!";
socket_write($socket, $response, strlen($response));
}
}
}
?>
上述示例中,首先创建了一个epoll句柄,然后创建了一个服务器Socket并将其添加到epoll的监视列表中。 在事件循环中,使用epoll_wait()函数等待事件的发生。当服务器Socket有连接请求时, 接受客户端连接并将其添加到epoll的监视列表中。当客户端Socket有数据可读时,读取数据并在处理后发送响应给客户端。
其他
红黑树
参阅 https://blog.csdn.net/qq_38937634/article/details/110957235
PHP 实现红黑树:
<?php
class Node {
public $data;
public $left;
public $right;
public $parent;
public $color;
public function __construct($data, $color = 'red') {
$this->data = $data;
$this->left = null;
$this->right = null;
$this->parent = null;
$this->color = $color;
}
}
class RedBlackTree {
public $root;
public $nil;
public function __construct() {
$this->root = null;
$this->nil = new Node(null, 'black');
$this->nil->left = $this->nil;
$this->nil->right = $this->nil;
}
private function rotateLeft($y) {
$x = $y->right;
$T2 = $x->left;
$x->left = $y;
$y->right = $T2;
if ($T2 !== $this->nil) {
$T2->parent = $y;
}
$y->parent = $x;
$x->parent = null;
if ($y === $this->root) {
$this->root = $x;
} else {
if ($y === $y->parent->left) {
$y->parent->left = $x;
} else {
$y->parent->right = $x;
}
}
$x->left->parent = $x;
$x->right->parent = $x;
return $x;
}
private function rotateRight($y) {
$x = $y->left;
$T2 = $x->right;
$x->right = $y;
$y->left = $T2;
if ($T2 !== $this->nil) {
$T2->parent = $y;
}
$y->parent = $x;
$x->parent = null;
if ($y === $this->root) {
$this->root = $x;
} else {
if ($y === $y->parent->right) {
$y->parent->right = $x;
} else {
$y->parent->left = $x;
}
}
$x->right->parent = $x;
$x->left->parent = $x;
return $x;
}
private function insertFixup($z) {
while ($z->parent->color === 'red') {
if ($z->parent === $z->parent->parent->right) {
$y = $z->parent->parent->left;
if ($y->color === 'red') {
$z->parent->color = 'black';
$y->color = 'black';
$z->parent->parent->color = 'red';
$z = $z->parent->parent;
} else {
if ($z === $z->parent->left) {
$z = $z->parent;
$this->rotateRight($z);
} else {
$z->parent->color = 'black';
$z->parent->parent->color = 'red';
$this->rotateLeft($z->parent->parent);
return;
}
}
} else {
$y = $z->parent->parent->right;
if ($y->color === 'red') {
$z->parent->color = 'black';
$y->color = 'black';
$z->parent->parent->color = 'red';
$z = $z->parent->parent;
} else {
if ($z === $z->parent->right) {
$z = $z->parent;
$this->rotateLeft($z);
} else {
$z->parent->color = 'black';
$z->parent->parent->color = 'red';
$this->rotateRight($z->parent->parent);
return;
}
}
}
}
$this->root->color = 'black';
}
private function removeFixup($z) {
while (($z !== $this->nil) && ($z->color === 'black')) {
if ($z === $z->parent->left) {
$y = $z->parent->right;
if ($y->color === 'red') {
$y->color = 'black';
$z->parent->color = 'red';
$this->rotateLeft($z->parent);
$z = $y;
} else {
if ($y !== $this->nil) {
$y->parent->color = 'black';
$z->parent->color = 'red';
$this->rotateRight($y->parent);
$y = $y->right;
} else {
$z->parent->color = 'black';
$this->rotateLeft($z->parent);
break;
}
}
} else {
$y = $z->parent->left;
if ($y->color === 'red') {
$y->color = 'black';
$z->parent->color = 'red';
$this->rotateRight($z->parent);
$z = $y;
} else {
if ($y !== $this->nil) {
$y->parent->color = 'black';
$z->parent->color = 'red';
$this->rotateLeft($y->parent);
$y = $y->left;
} else {
$z->parent->color = 'black';
$this->rotateRight($z->parent);
break;
}
}
}
$z = $z->parent;
}
$this->root->color = 'black';
}
private function findFixup($z) {
while (($z->parent !== $this->nil) && ($z->parent->color === 'red')) {
if ($z->parent === $z->parent->parent->left) {
$y = $z->parent->parent->right;
if ($y->color === 'red') {
$y->color = 'black';
$z->parent->color = 'black';
$z->parent->parent->color = 'red';
$z = $z->parent->parent;
} else {
if ($z === $z->parent->right) {
$z = $z->parent;
$this->rotateLeft($z);
}
$z->parent->color = 'black';
$z->parent->parent->color = 'red';
$this->rotateRight($z->parent->parent);
}
} else {
$y = $z->parent->parent->left;
if ($y->color === 'red') {
$y->color = 'black';
$z->parent->color = 'black';
$z->parent->parent->color = 'red';
$z = $z->parent->parent;
} else {
if ($z === $z->parent->left) {
$z = $z->parent;
$this->rotateRight($z);
}
$z->parent->color = 'black';
$z->parent->parent->color = 'red';
$this->rotateLeft($z->parent->parent);
}
}
$z = $z->parent;
}
$this->root->color = 'black';
}
private function get_root() {
return $this->root;
}
private function get_color($z) {
return $z->color;
}
private function set_color($z, $color) {
$z->color = $color;
}
private function get_parent($z) {
return $z->parent;
}
private function get_left_child($z) {
return $z->left;
}
private function get_right_child($z) {
return $z->right;
}
}
?>
insert(value): 向红黑树中插入一个新节点,根据情况调用 rotateLeft() 或 rotateRight() 进行修正。
remove(value): 从红黑树中删除一个节点,根据情况调用 rotateLeft() 或 rotateRight() 进行修正。
find(value): 在红黑树中查找一个特定的值,返回对应的节点。
get_root(): 获取红黑树的根节点。
get_color(node): 获取指定节点的颜色(红或黑)。
set_color(node, color): 设置指定节点的颜色。
get_parent(node): 获取指定节点的父节点。
get_left_child(node): 获取指定节点的左子节点。
get_right_child(node): 获取指定节点的右子节点。