目录
1. poll 的基本认识
poll 是一种多路转接的方案, 它的核心功能和 select 一模一样,我们知道 IO = 等待事件就绪 + 拷贝数据, 而它们只负责IO过程中的等待事件就绪;
用户和内核通过 poll 想告诉对方:
- 用户告诉内核 (调用 poll() 时):内核帮用户关心哪些文件描述符的哪些事件;
- 内核告诉用户 (poll() 返回时):哪些文件描述符的哪些事件已经就绪;
2. poll 基于 select 的突破
因为 select 服务器有如下缺点:
- 因为 select 服务器需要维护一个第三方数组,因此,select 服务器会充斥着大量的遍历操作 (时间复杂度O(N));
- 我们知道 fd_set 是一个固定大小的位图,因此也就决定了 select 服务器所能监测的文件描述符的数量是有上限的;
- 除开第一个参数,剩下的后四个参数,都是输入输出型参数,每调用一次 select,用户需要对这些参数进行重新设定;
- 同时,也因为它们是输入输出型参数,即内核和用户都需要对其进行修改,因此,select 会进行频繁的用户到内核,内核到用户的数据拷贝;
- 上面几个问题,也间接导致了 select 服务器的编码比较复杂。
因此,设计者们提出了 poll ,poll 基于 select 的突破:
- poll 将输入型参数和输出型参数进行了分离:这也就意味着用户不用对参数进行重新设定, 这是其一; 同时,也因为输入参数和输出参数分离,poll 不会进行频繁的用户到内核的数据拷贝, 但是内核到用户的数据拷贝是不可少的,这是其二;
- poll 没有最大文件描述符数量的限制:这里的文件描述符的数量由用户决定,poll 自身没有限制,只要服务器有能力承载更多的连接,poll 就可以监测更多的文件描述符。
3. poll() 系统调用
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
首先解释后两个参数。
timeout: timeout 代表超时时间,但这里的 timeout 是以毫秒 (ms) 为单位的。
- 如果 timeout = 0, 代表 poll() 非阻塞等待;
- 如果 timeout = -1, 代表 poll() 阻塞等待;
- 如果 timeout > 0,比如 1000,那么代表,在1000毫秒内,阻塞等待,如果超时,没有事件就绪,那么 poll () 返回0。
nfds:代表 fds 这个数组的长度;
poll() 返回值:
- 如果大于 0, 代表事件已就绪的文件描述符的个数;
- 如果等于 0, time out,没有事件发生;
- 如果小于 0, poll() error,可通过 errno 查看错误原因。
3.1. struct pollfd 结构
struct pollfd 结构如下:
/* Type used for the number of file descriptors. */
typedef unsigned long int nfds_t;
/* Data structure describing a polling request. */
struct pollfd
{
int fd; /* File descriptor to poll. */
short int events; /* Types of events poller cares about. */
short int revents; /* Types of events that actually occurred. */
}
int fd:
在使用 poll() 时,无论是用户告诉内核 ( 调用poll() ),还是内核告诉用户 ( poll() 返回时),它们都不会对文件描述符的值做修改,即这个值只要用户设定一次就好了。
- 当用户告诉内核时,这里的 fd 就代表,内核需要关心这个文件描述符的某个IO事件;
- 当内核告诉用户时,这里的 fd 就代表,这个文件描述符的某个IO事件已经就绪了。
光有一个 fd 不够,因为无论是用户告诉内核,还是内核告诉用户,都需要知道这个文件描述符所关心的IO事件是什么。
因此有了 events 和 revents;
- events:代表请求事件。用户告诉内核,用户所关心的这个文件描述符的 IO 事件都在 events 里;
- revents:代表返回事件。内核告诉用户,这个文件描述符上的哪些IO事件 (revents) 已经就绪了;
因此,可以看到, poll 是如何做到将输入型参数和输出型参数分离的呢?
本质上是通过 events 和 revents 这两个参数做到的。
- 用户告诉内核时,只会修改 events 这个数据,而不会对 revents 做任何修改;
- 内核告诉用户时,只会修改 revents 这个数据,而不会对 events 做任何修改;
可是现在有一个问题, events 和 revents 的类型都是 short int 啊,而 short int 才2字节,怎样用 short int 来表示不同的IO事件呢?
这个问题,我们以前就遇到过,在学习基础IO的时候,我们学习的 open 系统调用,也使用了同样的方式,用一个 int 来表示不同的打开方式,那是怎样做到的呢?
用 short int 的不同比特位来表示不同的 IO 事件,具体如下:
事 件 | 描 述 | 是否可作为输入 | 是否可作为输出 |
POLLIN | 数据 ( 包括普通数据和优先数据 ) 可读 | 是 | 是 |
POLLOUT | 数据 ( 包括普通数据和优先数据 ) 可写 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLPRI | 高优先级数据可读,比如TCP的紧急数据(URG) | 是 | 是 |
上面列举了一些标志位,但它们本质上都是宏,如下:
#define POLLIN 0x001 /* There is data to read. */
#define POLLPRI 0x002 /* There is urgent data to read. */
#define POLLOUT 0x004 /* Writing now will not block. */
#define POLLERR 0x008 /* Error condition. */
关于标志位就说到这里。
我们说过,poll 较于 select 解决了一个问题,poll 没有最大文件描述符数量的限制, 可是,我们发现,poll 的第一个参数是 struct pollfd *fds,这不就是一个数组吗,而数组是有范围的,那么为什么说 poll 没有最大文件描述符数量的限制呢?
- 首先,select 是存在最大文件描述符数量的限制的,因为它受限于 fd_set 位图结构;
- 而 poll 是不存在最大文件描述符数量限制的,它可以是 1024,也可以是 2048,甚至是4096,只要服务器有能力能够承载更多的连接, poll 就可以监测更多的文件描述符, 换言之,poll () 自身没有文件描述符数量的限制,实际上,文件描述符的数量是受用户和服务器的承受能力的限制,只要用户认为有必要且服务器有能力,poll() 就可以监视更多的文件描述符。
4. poll() 的 demo
demo 所需要的小组件,例如 Sock.hpp、Date.hpp、Log.hpp 在 IO多路转接之poll 文章中有。
声明:poll() demo 只处理读事件,即POLLIN,暂时不考虑POLLOUT;
实现思路:
- constructor:
- 创建套接字、监听、绑定;
- 动态申请数组 (struct pollfd* ),并初始化;
- 约定监听套接字为数组的第一个元素;
- 设置超时时间 (如果你愿意的话)。
- start:
- 因为 poll() 将输入参数 (events) 和 输出参数 (revents) 分离,故用户在轮询时不需要对数据进行重新设定;
- 直接调用 poll(), 通过返回值,确定不同的执行策略;
- 当 poll() return > 0 时,代表着有文件描述符的IO事件就绪,此时调用处理事件函数,HandleEvent;
- 遍历整个数组,找到事件就绪的文件描述符,判断是监听套接字,还是服务套接字;
- 如果是监听套接字,调用accept,获取新连接,并将新连接 Load 到数组中;
- 如果是服务套接字,调用read/recv,拷贝数据。 注意:当read/recv返回0时,代表着对端关闭连接,服务端需要 close 该连接,并将这个套接字在数组中清除。
- 以上就是整体实现思路,在实现过程中,注意耦合度,适当解耦,提高代码的可读性。
#ifndef _POLLSERVER_HPP_
#define _POLLSERVER_HPP_
#include "Date.hpp"
#include "Log.hpp"
#include "Sock.hpp"
#include <sys/poll.h>
namespace Xq
{
const static int NUM = 1;
const static int FD_NONE = -1;
const static int timeout = 1000; // 以毫秒为单位
class PollServer
{
public:
PollServer(uint16_t port = 8080)
:_port(port)
, _nfds(NUM)
{
_sock.Socket();
_sock.Bind("", _port);
_sock.Listen();
_pollfd = new struct pollfd[_nfds];
// 初始化数组
for (nfds_t i = 0; i < _nfds; ++i)
{
_pollfd[i].fd = FD_NONE;
_pollfd[i].events = _pollfd[i].revents = 0;
}
// 约定监听套接字为数组的第一个元素
_pollfd[0].fd = _sock._sock;
_pollfd[0].events = POLLIN;
LogMessage(DEBUG, "server init success");
}
void start(void)
{
while (true)
{
#ifdef DEBUG_SHOW
debug_show();
#endif
int n = poll(_pollfd, _nfds, -1);
if (n == 0)
{
LogMessage(NORMAL, "time out ... ");
}
else if (n < 0)
{
LogMessage(ERROR, "errno: %d, errno message: %s", errno, strerror(errno));
}
else
{
LogMessage(DEBUG, "IO event already, can handle");
HandleEvent();
}
}
}
void HandleEvent()
{
for (nfds_t pos = 0; pos < _nfds; ++pos)
{
// 用户不关心的文件描述符跳过
if (_pollfd[pos].fd == FD_NONE) continue;
else
{
// 如果读事件就绪
if (_pollfd[pos].revents & POLLIN)
{
// 如果是listen sock, accept 获取新连接
if (_pollfd[pos].fd == _sock._sock)
{
Accepter();
}
// 如果是server sock, read / recv, 拷贝数据
else
{
Recver(pos);
}
}
// 读事件未就绪
else
{
#ifdef DEBUG_SHOW
LogMessage(DEBUG, "%d file descriptor IO event not ready", _pollfd[pos].fd);
#endif
}
}
}
}
// 扩容
void broaden_capacity()
{ // 1. 确定新容量
nfds_t new_capacity = 2 * _nfds;
// 2. 开辟新空间
struct pollfd* newpollfd = new struct pollfd[new_capacity];
// 3. 拷贝数据
for (nfds_t pos = 0; pos < new_capacity; ++pos)
{
if (pos < _nfds)
{
newpollfd[pos].fd = _pollfd[pos].fd;
newpollfd[pos].events = _pollfd[pos].events;
newpollfd[pos].revents = _pollfd[pos].revents;
}
else
{
newpollfd[pos].fd = FD_NONE;
newpollfd[pos].events = 0;
newpollfd[pos].revents = 0;
}
}
// 4. 释放旧空间
delete[] _pollfd;
// 5. 更新
_pollfd = newpollfd;
_nfds = new_capacity;
}
void Accepter()
{
std::string client_ip;
uint16_t client_port;
int server_sock = _sock.Accept(client_ip, &client_port);
nfds_t pos = 1;
for (; pos < _nfds; ++pos)
{
if (_pollfd[pos].fd == FD_NONE) break;
}
// 走到这里有两种情况
// case 1: 数组已满, 将这个新连接关掉
//if(pos == _nfds)
//{
// LogMessage(WARNING, "poll array full");
// close(server_sock);
//}
// case 2: break 跳出循环, 添加即可
//else
//{
// 将服务套接字添加到这个数组即可
// _pollfd[pos].fd = server_sock;
// _pollfd[pos].events = 1;
//}
// 用户也可以采用扩容机制
// 如果数组满了, 进行扩容
if (pos == _nfds)
{
std::cout << "old_capacity: " << _nfds << std::endl;
broaden_capacity();
std::cout << "new_capacity: " << _nfds << std::endl;
}
// 此时再添加服务套接字
_pollfd[pos].fd = server_sock;
_pollfd[pos].events = 1;
}
void Recver(int pos)
{
char buffer[1024] = { 0 };
// 此时一定不会被阻塞
ssize_t real_size = recv(_pollfd[pos].fd, buffer, sizeof buffer - 1, 0);
if (real_size < 0)
{
LogMessage(ERROR, "errno: %d, errno message: %s, server close the link: %d", errno, strerror(errno), _pollfd[pos]);
// 1. 关闭这个服务套接字
close(_pollfd[pos].fd);
// 2. 将这个位置的数据还原
_pollfd[pos].fd = FD_NONE;
_pollfd[pos].events = _pollfd[pos].revents = 0;
}
else if (real_size == 0)
{
LogMessage(NORMAL, "client [%d] close link, me too", _pollfd[pos]);
// 1. 关闭这个服务套接字
close(_pollfd[pos].fd);
// 2. 将这个位置的数据还原
_pollfd[pos].fd = FD_NONE;
_pollfd[pos].events = _pollfd[pos].revents = 0;
}
else
{
buffer[real_size - 1] = 0;
LogMessage(NORMAL, "client [%d] echo$ %s", _pollfd[pos], buffer);
}
}
void debug_show(void)
{
std::cout << "poll array: ";
for (nfds_t pos = 0; pos < _nfds; ++pos)
{
if (_pollfd[pos].fd == FD_NONE) continue;
else
{
std::cout << _pollfd[pos].fd << " ";
}
}
std::cout << "\n";
}
~PollServer(void)
{
// 释放用户动态申请的资源
if (_pollfd)
delete[] _pollfd;
}
private:
uint16_t _port;
nfds_t _nfds;
struct pollfd* _pollfd;
Sock _sock;
};
}
#endif
5. poll 的总结
poll 的优点:
- IO效率高:因为 poll 服务器可以一次性等待多个套接字就绪,而IO过程 = 等待事件就绪 + 拷贝数据,而 poll 会将多个套接字的等待时间进行重叠,换言之,在单位时间内,poll 服务器等待的比重是比较低的,因此,它的IO效率就高;
- 有适合 poll 的应用场景:当有大量的连接,但只有少量连接是活跃的。因为 poll 服务器是单进程的,因此,对于 poll 服务器的维护成本非常低 (不需要维护过多的执行流),哪怕有非常多的连接,poll 服务器的成本也微乎其微,即节省资源。
- 输入输出参数分离:用户不需要对参数进行重复设定;
- poll 没有最大文件描述符数量的限制:站在 poll 自身视角,它没有文件描述符的上限,只要服务器的资源足够,能够承载更多的连接,它就可以监测更多的文件描述符;
poll 的缺点:
- poll 依旧需要遍历操作 (时间复杂度 O(N)): 无论是用户层面,还是内核层面,都需要对数组进行遍历, 特别是连接非常多的情况,此时 poll 就可能会高频的检测到IO事件就绪,进而导致高频的遍历操作,导致效率降低;
- poll 需要内核到用户的拷贝:确切的说,因为输入输出参数分离,故用户不需要每次重新设定参数 (只需要第一次用户将数据拷贝给内核),而大部分都是内核将数据拷贝给用户,这是少不了的;
poll 最核心的缺点是第一点,即用户还是需要维护一个数组,无论是用户和内核,都需要对这个数组进行遍历操作。
那么 poll 的缺点如何解决呢? 因此我们需要学习 epoll (event poll) ,具体细节在下篇文章 IO多路转接之epoll 。