多路转接 poll
上一篇:多路转接 selecthttps://blog.csdn.net/Small_entreprene/article/details/148983827?sharetype=blogdetail&sharerId=148983827&sharerefer=PC&sharesource=Small_entreprene&sharefrom=mp_from_link在 Linux 当中,多路转接的常见方案有三种:
第一种是我们上一篇的 select ;第二种就是本篇要讲的 --- poll!
poll 的作用和定位
IO = 等 + 拷贝; 一样的,poll 只负责等!一次可以等待等多个 fd 事件就绪,就可以对上层进行事件通知!
是一种以等待多种文件描述符的事件的手段来达到对上层指定文件描述符事件是否就绪的通知机制!---- 这一点的作用等同于 select !
poll 的接口和 poll 解决了 select 的什么问题?
poll
是一种用于多路复用 I/O 的系统调用,它与 select
类似,但有一些关键的区别和特点。以下是关于 poll
的详细介绍:
poll
用于同时监视多个文件描述符,以确定它们是否准备好进行 I/O 操作。它通过一个数组来管理文件描述符的状态,而不是像 select
那样使用位掩码。
poll 接口
#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
fds
:一个指向struct pollfd
类型数组的指针,每个数组元素包含一个文件描述符及其状态。 -
nfds
:数组fds
中的元素数量。 -
timeout
:指定poll
的超时时间,单位是毫秒(对于 select,是简化的了)而且单纯是一个输入性参数,不是输入输出型参数!!!。如果设置为-1
,则poll
会阻塞直到有文件描述符准备好;如果设置为0
,则poll
会立即返回,即非阻塞 poll 。
返回值
-
>0
:表示有文件描述符准备好。 -
0
:超时,没有文件描述符准备好。 -
-1
:发生错误,可能的原因包括无效的文件描述符或超时参数错误等。
poll的第一个参数(struct pollfd
结构)
poll 的第一个参数是一个指针,其实我们可以将其当作数组的起始地址!第二个参数就是 fds 指向的数组的元素的个数!
struct pollfd {int fd; // 文件描述符short events; // 请求的事件掩码short revents; // 实际发生的事件掩码
};
-
fd
:需要监视的文件描述符。 -
events
:指定要监视的事件类型,例如POLLIN
(可读)、POLLOUT
(可写)等。 -
revents
:poll
返回时,该字段会包含实际发生的事件。
宏观上我们说:poll 一次可以等待多个 fd,因为我们未来可以将对应的数组定义为100,每一个数组的元素 --- 结构体,结构体种都会包含 fd --- 将来要处理的文件描述符!
- poll 调用的时候,整个结构体中的 fd 和 events 起效果,也就是字段有效!表示用户告诉内核:你要帮我关心 fd 上面的 events 事件!
- poll 成功返回的时候,整个结构体中的 fd 和 revents 有效,表示的是,内核告诉用户:你要让我关心的 fd 上面的 events 事件,已经就绪了!
所以对于 poll 来说,他是通过数组来传结构体,传结构体的时候,关心哪一个文件描述符的哪一个事件,返回哪一个文件描述符的哪一个事件就绪了!是通过 struct pollfd
结构体 返回的!
可是 select 包含读事件,写事件,还有异常事件!那么所谓的"事件",在 poll 这里是什么意思呢?
事件类型
事件 | 描述 | 是否可作为输入 | 是否可作为输出 |
---|---|---|---|
POLLIN | 数据(包括普通数据和优先数据)可读 | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Linux 不支持) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如 TCP 带外数据 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先数据)可写 | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP 连接被对方关闭,或者对方关闭了写操作。它由 GNU 引入 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起。比如管道的写端被关闭后,读端描述符上将收到 POLLHUP 事件 | 否 | 是 |
POLLNVAL | 文件描述符没有打开 | 否 | 是 |
这个表格总结了 poll
系统调用中 events
和 revents
参数可能的取值及其描述,以及它们是否可以作为输入或输出事件。
这些值本质都是宏,所谓的 events
和 revents
参数,就是事件位图,一个一个的事件所对应的为体结构!每一个事件都是其中一个比特位为1,便于我们后面通过"&操作符"来进行检测事件是否就绪!
poll 相应的事件很多,我们重点要知道两个:
- POLLIN:读事件
- POLLOUT:写事件
举个例子,我们今天在调用 poll 的时候,想知道3号文件描述符的读事件,那么 fd = 3; ,events 当中就设置 POLLIN ,将来3号文件描述符读事件就绪了,我们不用查看 events ,而是查看 revents ,内核会把 revents 设置为 POLLIN ,未来我们只需要查 revents 对应的比特位里面有没有设置 POLLIN 比特位,有的话就代表读事件就绪了!
使用:
revents & POLLIN
为真就代表读事件就绪了!
使用:
events |= POLLIN;
来让 poll 关心指定文件描述符的读事件!
问题1:poll 输入输出参数分离了,所以不用再 poll 之前进行参数重置了!
在 select
调用中,fd_set
结构用于同时存储感兴趣的输入和输出文件描述符集合,由于 select
系统调用完成后会清空这些集合,因此在每次调用 select
之前,必须重新初始化这些集合。而 poll
通过 struct pollfd
结构来管理文件描述符,每个 pollfd
元素分别对应一个文件描述符,并包含 events
和 revents
两个字段。events
字段用于指定对哪些事件感兴趣,而 revents
字段则在调用返回时由内核填充,指示实际发生的事件。由于 poll
的这种设计,每次调用时 events
字段都可以直接设置为需要监视的事件,而不需要像 select
那样在每次调用前重置整个集合,因此 poll
在处理输入和输出参数时更为灵活和高效。
问题2:poll 等待的 fd 个数没有上限!
poll
系统调用不限制等待的文件描述符(fd)数量,因为它通过传递一个 struct pollfd
数组来管理文件描述符,数组的大小由用户指定,理论上只受限于系统内存和进程地址空间。(有上限问题和我 poll 无关)这与 select
不同,select
使用 fd_set
结构,其大小固定(通常由 FD_SETSIZE
定义),因此能监视的文件描述符数量有限。由于 poll
调用时传递的是动态数组,所以它能够处理的文件描述符数量没有硬性上限,从而更适用于需要监视大量文件描述符的场景。
poll 的优缺点
优点
-
无文件描述符数量限制:与
select
不同,poll
不受文件描述符数量的限制,可以处理更多的文件描述符。 -
效率更高:
poll
不需要像select
那样在每次调用时都从用户态拷贝整个文件描述符集合到内核态,因此在处理大量文件描述符时效率更高。 -
使用更灵活:
poll
的接口相对更直观,不需要像select
那样进行复杂的位操作。
缺点
-
线性扫描:
poll
仍然需要线性扫描整个文件描述符数组来确定哪些文件描述符准备好,当文件描述符数量非常多时,性能可能会受到影响。
在面对众多客户端连接服务器的场景时,如果只有少数客户端有数据交互,多路转接技术是较为适用的解决方案。像 select 和 poll 这两种多路转接方式,虽然在一定程度上能满足需求,但存在效率问题。随着监视的文件描述符数量不断增加,其效率会呈现线性下降的趋势。文件描述符增多会导致轮询周期延长,进而使得响应用户的效率降低,这正是 poll 的一个明显缺陷。为克服这一缺陷,epoll 应运而生,它作为第三种多路转接方式,能有效解决 select 和 poll 在高并发场景下效率低下的问题。
需要注意的一点:当struct pollfd
结构体中,fd 设置为 -1 ,那么内核不关心这类 fd 的events!
-
资源消耗:虽然
poll
没有文件描述符数量的限制,但过多的文件描述符仍然会消耗大量系统资源。
改写上一篇的 select 代码,变成 poll 服务器
PollServer.hpp
#pragma once#include <iostream>
#include <memory>
#include <unistd.h>
#include <sys/poll.h>
#include "Socket.hpp"
#include "Log.hpp"using namespace SocketModule;
using namespace LogModule;class PollServer
{const static int size = 4096; // 最大事件数const static int defaultfd = -1;public:PollServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false){_listensock->BuildTcpSocketMethod(port);for (int i = 0; i < size; ++i){_fds[i].fd = defaultfd;_fds[i].events = 0;_fds[i].revents = 0;}_fds[0].fd = _listensock->Fd();_fds[0].events = POLLIN; // 监听套接字的读事件就绪}~PollServer(){}void Start(){int timeout = -1; // 超时时间_isrunning = true;while (_isrunning){PrintFd(); // 打印当前的 fd 状态int n = poll(_fds, size, timeout); // 阻塞等待事件就绪switch (n){case -1:LOG(LogLevel::ERROR) << "poll error";break;case 0:LOG(LogLevel::INFO) << "poll time out ...";break;default:// 有事件就绪了: 不仅仅是新连接到来了,还有读事件就绪了!!!(未来还可以有写事件就绪)LOG(LogLevel::DEBUG) << "poll has events ... event num: " << n;sleep(1);// 处理事件Dispatcher(); // 处理就绪的事件!!!break;}}_isrunning = false;}void Stop(){_isrunning = false;}void PrintFd(){for (int i = 0; i < size; ++i){if (_fds[i].fd == defaultfd){continue;}LOG(LogLevel::DEBUG) << "fd: " << _fds[i].fd << ", events: " << _fds[i].events << ", revents: " << _fds[i].revents;}}private:// 连接管理器void Accepter() // 新连接到来处理{InetAddr client;int sockfd = _listensock->Accept(&client); // 这里的 Accept 就不会阻塞了!!!因为 listen 套接字已经就绪了!这就是把等过程和拷贝的过程分离了!if (sockfd >= 0){// 获取新连接成功LOG(LogLevel::DEBUG) << "get a new link, sockfd: " << sockfd << ", client is: " << client.StringAddr();// 获取新连接到来成功,然后呢?可以直接进行 read/recv() 操作吗?// 可不敢!!!我们获得新连接,下一步要做的是:将新的 sockfd 托管给 select !!!// 如何托管? ---- 将新连接的 sockfd 添加到 _fd_array 中,然后 while 循环中,将再次调用 select !!!int pos = 0;for (; pos < size; pos++){if (_fds[pos].fd == defaultfd){break;}}if (pos == size){LOG(LogLevel::ERROR) << "poll server is full!";close(sockfd); // 关闭新连接}else{_fds[pos].fd = sockfd; // 将新连接的 sockfd 添加到 _fd_array 中_fds[pos].events = POLLIN; // 监听新连接的读事件_fds[pos].revents = 0; // 可做可不做}}}// IO 处理器void Recver(int pos) // 普通fd收到数据的读事件处理{// 处理 sockfd 读事件// 我们在这里读取的时候,就不会阻塞了 --- 因为 select 已经完成等操作了!char buf[1024];ssize_t n = recv(_fds[pos].fd, buf, sizeof(buf - 1), 0);// recv 读的时候会有BUG!因为无法保证能够收到一个完整的请求!--- TCP 是流式协议!// 我们目前先不做处理,等到 epoll 的时候,再做处理!if (n > 0){buf[n] = 0;LOG(LogLevel::DEBUG) << "Client say#" << buf;}else if (n == 0){// 客户端关闭连接LOG(LogLevel::DEBUG) << "Client close the link, sockfd: " << _fds[pos].fd;close(_fds[pos].fd); // 关闭连接_fds[pos].fd = defaultfd; // 将 sockfd 从 _fd_array 中移除_fds[pos].events = 0; // 取消监听_fds[pos].revents = 0; // 可做可不做}else{// 读错误LOG(LogLevel::ERROR) << "recv error, sockfd: " << _fds[pos].fd;close(_fds[pos].fd); // 关闭连接_fds[pos].fd = defaultfd; // 将 sockfd 从 _fd_array 中移除_fds[pos].events = 0; // 取消监听_fds[pos].revents = 0; // 可做可不做}}// 事件派发器void Dispatcher(){// 就不仅仅是处理新连接到来,还可以处理读事件就绪!// 只要指定的文件描述符,在 rfds 中,就证明该 fd 就绪了?for (int i = 0; i < size; ++i){if (_fds[i].fd == defaultfd){continue;}// fd 合法,并不一定就绪if (_fds[i].revents & POLLIN) // 判断一个文件描述符是否在 rfds 中,在就证明该 fd 就绪了{// listensockfd 新连接到来,也是读事件就绪!// sockfd 数据到来,也是读事件就绪!// 怎么区分?if (_fds[i].fd == _listensock->Fd()){// 新连接到来Accepter();}else{// sockfd 数据到来// 处理 sockfd 读事件Recver(i);}}// else if (_fds[i].revents & POLLOUT)}}private:std::unique_ptr<Socket> _listensock;bool _isrunning;struct pollfd _fds[size]; // 我们这里写成指针也是可以的,这样就可以进行扩容了!
};
Main.cc
#include "PollServer.hpp"int main(int argc, char *argv[])
{if (argc != 2){std::cout << "Usage: " << argv[0] << " port" << std::endl;exit(USAGE_ERR);}Enable_Console_Log_Strategy();uint16_t port = std::stoi(argv[1]);std::unique_ptr<PollServer> svr = std::make_unique<PollServer>(port);svr->Start();return 0;
}
初始化和配置:
-
在构造函数中,
PollServer
初始化一个监听套接字_listensock
并设置其监听端口。同时,它创建一个pollfd
数组_fds
来存储需要监控的文件描述符及其相关事件。 -
监听套接字的文件描述符被添加到
_fds
数组的第一个元素中,并设置为监控读事件(POLLIN
)。
事件循环:
Start()
方法启动服务器的主事件循环。它调用 poll
系统调用来等待文件描述符上的事件。poll
的超时时间设置为 -1
,表示无限等待。
根据 poll
的返回值,服务器处理不同的事件:
-
如果
poll
返回-1
,表示发生错误,服务器记录错误日志。 -
如果
poll
返回0
,表示超时,服务器记录超时日志。 -
如果
poll
返回大于0
的值,表示有文件描述符准备好了,服务器调用Dispatcher()
方法来处理这些事件。
事件处理:
-
Dispatcher()
方法遍历_fds
数组,检查每个文件描述符的revents
字段,以确定哪些事件已经发生。 -
如果监听套接字的文件描述符准备好了(即
revents
包含POLLIN
),则调用Accepter()
方法来接受新的客户端连接。 -
如果其他文件描述符准备好了(即
revents
包含POLLIN
),则调用Recver(int pos)
方法来处理接收到的数据。
连接管理:Accepter()
方法接受新的客户端连接,并将新的文件描述符添加到 _fds
数组中,设置为监控读事件。如果 _fds
数组已满(即没有可用的空位),则关闭新连接并记录错误日志。
数据处理:Recver(int pos)
方法从指定的文件描述符读取数据,并根据读取结果进行相应的处理。如果读取成功,它记录接收到的数据;如果读取失败或客户端关闭连接,则关闭文件描述符并从 _fds
数组中移除。
停止服务器:Stop()
方法停止服务器的主事件循环,将 _isrunning
设置为 false
。
这段代码提供了一个基于 poll
的服务器的基本框架,可以根据需要进行扩展和优化。它展示了如何使用 poll
系统调用来处理多个文件描述符的 I/O 事件,并提供了基本的连接管理和数据处理功能。
由于 poll 的缺点,我们下一篇开启 epoll --- 多路转接的最后一个话题!