有了select,为什么还要引入poll
前面我们介绍了通过select实现的IO多路转接方案,既然如此,为什么还要有poll呢?肯定是select方案有一些缺陷,而poll方案能够针对这些缺陷进行优化。前面我们说过select的方案最主要的缺陷是:
- select所能等待的文件数量是有上限的
- 并且由于select的大部分参数都是输入输出型参数,因此每次调用select时都需要重新去手动设置fd集合,从接口使用角度来说也非常不便。
- 线性检测所有文件,花费的时间开销也是不小的
- 每次调用时都需要将fd集合从用户区拷贝到内核区,这个时间开销也是不小的。
而你采用poll之后,就能解决前两个问题,其后所能等待的文件数量是没有上限的,循环调用poll时,也不用每次都重新构造fd集合了,直接一直沿用最开始初始化好的就行了。但是后面两个问题解决不了,即poll依然是线性探测各个文件是否就绪,每次调用poll时依然需要将fd集合从用户态拷贝到内核态。但即使如此,因为有了前两个点的优化,采用poll方案的处理上限就变高了,实现起来也比select的方案要简单。那下面我们就来正式认识一下poll这个系统调用接口
poll系统调用介绍
poll的功能定位
在调用poll的时候,用户通过poll的输入型参数告诉内核:内核你要帮我关心哪个fd上的哪些事件
在调用poll返回的时候,内核通过poll的输出型参数告诉用户:用户啊,你要我关心的那些fd上面,xxx事件已经就绪了!
poll的定位核心依然是多个IO事件的等待机制,通过poll我们就可以实现多个IO事件的等待与派发(IO多路转接)
函数原型
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
参数说明
(1)int timeout
相比于select里面的timeout,poll里面的timeout介绍起来就很简单了
select里面的timeout是一个struct timeval 类型的结构体,里面记录了一个毫秒时间和一个微妙时间,而poll里面的timeout只有毫秒时间,类型是int,值是多少,就表示多少毫秒
timeout:指定poll函数的超时时间,单位是毫秒(ms)。
timeout > 0:表示在指定的毫秒数内等待事件发生,超过这个时间,无论是否有事件发生,poll都会返回。timeout = 0:表示非阻塞模式,poll会立即返回,即使没有任何事件发生。timeout = -1:表示无限期阻塞,直到有文件描述符上的事件发生或者捕获到信号时,poll才会返回。
(2)poll的返回值
这个和select的返回值含义是一模一样的,我们记poll的返回值为n
- n大于0:表示
fds数组中已经准备好的文件描述符的数量,即revents字段中被设置了非零值的pollfd结构体的个数。 - n等于0:表示在指定的超时时间内,没有任何文件描述符上的事件发生。
- n等于-1:表示发生了错误,此时可以通过
errno获取具体的错误码,常见的错误包括EINTR(被信号中断)、ENOMEM(内存不足)等。
(3) struct pollfd fds[]
我们乍一看poll这个参数类型,相比于select就少了三个位图嘛。大家肯定就会纳闷,这么重要的三个输入输出型参数,怎么能没了呢?其实并没有消失,而是都集成到 struct pollfd fds[]这个参数里面了。
参数fds是一个数组,里面每个元素的类型都是struct pollfd,一个struct pollfd对应一个文件,用来记录这个文件中有多少事件是用户关心的,以及poll之后有多少用户关心的事件就绪了。struct pollfd结构体的定义如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生的事件,由内核填充 */
};
(3.1)int fd指定要监听的文件描述符。
如果pollfd结构体中的fd设置为-1,则poll会忽略events字段,并且在poll返回时,revents字段会被设置为0。
(3.2)short events:用于指定用户想让内核关心的事件。
大家看events的类型是short,可能会比较奇怪。为啥会是short呢?这是因为events本质上其实是一个位图,里面的每一位都对应了这个文件的某个事件。如果某一位为1,就表示用户想让内核关心这个文件的这个事件是否就绪。如果events中多个比特位为1,就表示用户同时关心这个文件中的这几个事件是否就绪。因此events可以是多个事件的按位或组合:
| 事件 | 含义 |
|---|---|
POLLIN | 有数据可读(包括普通数据和优先数据)(可读事件就绪) |
POLLPRI | 有紧急数据可读 |
POLLOUT | 可以进行写操作(可写事件就绪) |
POLLERR | 发生错误(错误事件就绪) |
POLLHUP | 挂起(连接被关闭,常用于套接字) |
POLLNVAL | 文件描述符无效(例如没有打开) |
表中的这些事件,其实都是一个个的宏,比如常见的三个事件宏定义
| 事件 | 宏定义 |
|---|---|
POLLIN | #define POLLIN 0x0001 |
POLLOUT | #define POLLOUT 0x0004 |
POLLERR | #define POLLERR 0x0010 |
由于这里的位图类型就是简单的short,并没有做进一步的封装。因此我们在置位的时候就需要通过按位或的方式进行手操,没有专门供我们调用的接口了。
(3.3 )short revents
由内核填充,用于返回实际发生的事件,其取值与events类似,程序可以通过检查该字段来判断具体发生了哪些事件好
(4)nfds_t nfds
这个参数专门用来记录第三个参数fds数组中元素的个数,即要监听的文件描述符的数量。
poll的简单工作原理
- 程序调用
poll时,会将fds数组以及要监听的事件等信息传递给内核。 - 内核会检查每个文件描述符的状态,判断是否有满足
events中指定事件的情况发生。 - 如果在
timeout时间内,有文件描述符上发生了感兴趣的事件,内核会填充这些pollfd结构体中的revents字段,并返回已经准备好的文件描述符的数量。 - 如果超时时间到了,仍没有事件发生,
poll会根据timeout的设置返回相应的值(返回0或 -1 ,取决于timeout是否为 -1 )。
poll的设计比select好在哪里?(poll相对应select的优点)
设计优化主要就是将select的三个输入输出型参数fd_set *readfds, fd_set *writefds, fd_set *exceptfds优化成了一个参数 struct pollfd fds[]
这带来的好处如下:
(1)用户每次调用select之前都必须重新初始化fd_set 集合,而用户每次调用poll时则不需要重新构造fd数组,直接一直沿用最初的fd数组的就行
struct pollfd 类型的结构体中有两个参数:events和revents,这俩参数一个专门用来记录用户给内核儿提的要求(即我用户要求内核要关注这个文件的哪些事件是否就绪),一个专门用来记录内核的检测结果(即我内核按照你用户的要求去检测了这些你关心的事件,他们中有xxx事件已经就绪了)
(2)select能够同时等待的文件数量受fd_set位图大小的限制,而poll能够同时等待的文件数量不会受fd数组大小的限制
主要原因就是fd_set的大小是系统规定的,而fd数组的大小是可以由用户规定的!比如说我想同时等待1万个文件,那我传参之前构造fd数组的大小设置为1万就行
(3)等待的事件更多样
之前select是只能等待可读事件,可写事件和错误事件。而poll能等待的事件类型就比这要多的多,主要就是因为events的类型是short,它有16位
PollServer简单实现
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <poll.h>
#include "Log.hpp"
#include "Socket.hpp"
using namespace SocketModule;
using namespace LogModule;
const int gdefaultfd = -1;
#define MAX 4096
// 最开始的时候,tcpserver只有一个listensockfd
class PollServer
{
public:
PollServer(int port)
: _port(port),
_listen_socket(std::make_unique<TcpSocket>()),
_isrunning(false)
{
}
void Init()
{
// 1. 创建监听套接字
_listen_socket->BuildTcpSocketMethod(_port);
// 2. 初始化pollfd数组
for(int i = 0;i < MAX; i++)
{
_fds[i].fd = gdefaultfd;
_fds[i].events = 0;
_fds[i].revents = 0;
}
// 先把唯一的一个fd添加到poll中
_fds[0].fd = _listen_socket->Fd();
_fds[0].events |= POLLIN;
// _fds[0].revents;
}
void Loop()
{
int timeout = -1;
_isrunning = true;
while (_isrunning)
{
// 我们不能让accept来阻塞检测新连接到来,而应该让select来负责进行就绪事件的检测
// 用户告诉内核,你要帮我关心&rfds,读事件啊!!
int n = poll(_fds, MAX, timeout); // 通知上层的任务!
switch (n)
{
case 0:
std::cout << "time out..." << std::endl;
break;
case -1:
perror("select");
break;
default:
// 有事件就绪了
// rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!
std::cout << "有事件就绪啦..., timeout: " << std::endl;
Dispatcher(); // 把已经就绪的sockfd,派发给指定的模块
TestFd();
break;
}
}
_isrunning = false;
}
// 从监听套接字中获取一个新连接,为其创建一个通信套接字
// 并为这个通信套接字创建相应的 struct pollfd 结构体,加入到pollfd数组中
void Accepter() // 回调函数呢?
{
InetAddr client;
// listensockfd就绪了!获取新连接不就好了吗?
int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"
if (newfd < 0)
return;
else
{
std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;
// recv()?? 读事件是否就绪,我们并不清楚!newfd也托管给select,让select帮我进行关心新的sockfd上面的读事件就绪
// 怎么把新的newfd托管给poll?让poll帮我去关心newfd上面的读事件呢?把newfd,添加到辅助数组即可!
int pos = -1;
for (int j = 0; j < MAX; j++)
{
if (_fds[j].fd == gdefaultfd)
{
pos = j;
break;
}
}
if (pos == -1)
{
// _fds进行自动扩容
LOG(LogLevel::ERROR) << "服务器已经满了...";
close(newfd);
}
else
{
_fds[pos].fd = newfd;
_fds[pos].events = POLLIN;
}
}
}
// 这个传入的参数who就是我们前面调用accepter创建的一个新通信套接字
// 调用这个函数时,说明这个新的通信套接字上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你处理呢
// 这里我们就执行最简单的处理方法,即把客户端发来的数据打印到我们的屏幕上,并把它原封不动地发送回去
void Recver(int who) // 回调函数?
{
// 合法的,就绪的,普通的fd
// 这里的recv,对不对啊!不完善!必须得有协议!
char buffer[1024];
ssize_t n = recv(_fds[who].fd, buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
// 把读到的信息,在回显会去
std::string message = "echo# ";
message += buffer;
send(_fds[who].fd, message.c_str(), message.size(), 0); // bug
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fds[who].fd;
close(_fds[who].fd);
_fds[who].fd = gdefaultfd;
_fds[who].events = _fds[who].revents = 0;
}
else
{
LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " <<_fds[who].fd;
close(_fds[who].fd);
_fds[who].fd = gdefaultfd;
_fds[who].events = _fds[who].revents = 0;
}
}
// 当poll返回值大于0时,说明有事件就绪了,此时我们就需要找到就绪事件的fd
// 如果这个就绪的fd是监听套接字,说明这时候监听套接字又收到了其他客户端发来的链接请求
//这时候我们就调用accepter,从监听套接字中获取一个新请求,为其创建一个新的通信套接字
// 同时也将这个新创建的通信套接字加入到我们的pollfd数组中
// 如果这个就绪的fd不是监听套接字,说明这个fd就是我们前面调用accepter创建的一个新通信套接字,
// 他就绪了,说明这个新的通信套接字上面的读事件已经就绪了,也就是说客户端给服务器发来了新的数据,正等着你收呢
// 这个时候我们就调用recver函数,去处理这个客户端的IO请求(比如读取客户端发来的数据,并将其打印在屏幕上)
void Dispatcher() // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了
{
for (int i = 0; i < MAX; i++)
{
if (_fds[i].fd == gdefaultfd)
continue;
// 文件描述符,先得是合法的
if (_fds[i].fd == _listen_socket->Fd()) // 该fd是listenfd
{
if (_fds[i].revents & POLLIN)
{
Accepter(); // 连接的获取
}
}
else
{
if (_fds[i].revents & POLLIN)
{
Recver(i); // IO的处理
}
// else if(_fds[i].revents & POLLOUT)
// {
// // wirte
// }
}
}
}
void TestFd()
{
std::cout << "pollfd: ";
for(int i = 0; i < MAX; i++)
{
if(_fds[i].fd == gdefaultfd)
continue;
std::cout << _fds[i].fd << "[" << Events2Str(_fds[i].events) << "] ";
}
std::cout << "\n";
}
std::string Events2Str(short events)
{
std::string s = ((events & POLLIN) ? "EPOLLIN" : "");
s += ((events & POLLOUT) ? "POLLOUT" : "");
return s;
}
~PollServer()
{
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listen_socket;
bool _isrunning;
struct pollfd _fds[MAX];
// struct pollfd *_fds; // malloc
};
poll方案存在的缺陷
我们在最开始就说了poll方案的设计解决了select存在的前两个问题。但是后两个问题即线性遍历和Fd集合的拷贝问题并没有解决。
- 每次调用需要传递所有信息:和
select类似,每次调用poll时,都需要将整个fds数组从用户空间传递到内核空间,当fds数组很大时,这会带来一定的性能开销。 - 线性遍历开销:poll执行结束,将等待结果返回给用户时,用户需要线性遍历pollfd数组,通过去一个一个地查看
pollfd::revents,来检测里面的嗯文件是否有事件就绪了
Fd集合的拷贝问题可能还好,因为用户进程请求内核帮忙监视文件描述符状态,而内核无法直接访问用户空间的数据,所以必须先将用户空间中描述要监视的 fd 集合的数据结构(通常是fd_set类型的变量 )拷贝到内核空间,内核才能对这些文件描述符进行后续的检查和处理。
但是线性遍历的开销确实还是太伤了,假如说你有十几万个文件等着你去检查,你想想一一遍历该有多浪费时间
为了进一步解决poll依然存在的这俩问题,才有了我们后面的epoll
1604

被折叠的 条评论
为什么被折叠?



