目录
一、poll
1.select的缺点
2.认识poll系统调用
3.poll的优点
二、poll服务器
三、epoll
1.poll的缺点
2.认识epoll的三个接口
3.epoll的原理
四、epoll服务器
一、poll
1.select的缺点
select虽然可以增加IO的效率,但是它有两个问题:
(1)等待fd有上限
fd_set类型的大小为128字节,每个字节有8个比特位,所以fd_set类型能包含1024个比特位。它最多能监视1024个文件描述符,在高访问量的服务器中是严重不足的。
(2)每次调用都需要重新设置fd_set。
每次调用select都需要重新设置fd_set,而且读写异常三种事件每个都需要维护一个变量,而且程序员也要维护相应的数组,增加了我们维护的成本。
所以为了解决select的问题,我们引入了另一种多路转接的方案poll。
2.认识poll系统调用
(1)poll函数声明
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
头文件:poll.h
功能:poll负责IO中多个描述符等的那部分,该函数会在描述符的读写异常事件就绪时,提醒进程进行处理。
参数:后面讲。
返回值:返回值大于0表示有相应个文件描述符就绪,返回值等于0表示没有文件描述符就绪,超时返回,返回值小于0表示poll调用失败。
(2)struct pollfd* fds
它是一个struct pollfd类型的类指针,我们传参需要传入一个该类型的数组。
struct pollfd结构体定义如下:
struct pollfd
{
int fd;
short events;
short revents;
};
struct pollfd结构体中存在三个成员变量。
第一个是fd,表示需要操作系统等待的文件描述符。第二个是short events,表示该描述符需要poll等待该fd的事件类型,也就是读事件,写事件,异常事件。第三个是short revents,可以告知用户该描述符的哪个事件就绪了。
此时我们需要关心文件描述符的哪个事件,就直接将事件设置到struct pollfd结构体中的short events。struct pollfd结构体将用户和操作系统设置的字段分开了,所以就不存在相互干扰的问题,也更不需要位图结构了。
当指定文件描述符的事件就绪时,操作系统会设置其结构体的short revents,用户直接读取这个字段便知道是哪个事件就绪,并进行相应处理了。
event和revent有以下参数可选择:
其中最常用的是POLLIN和POLLOUT。
POLLIN:是否可以从操作系统中读数据
POLLOUT:是否可以向操作系统写数据(写的本质是操作系统的缓冲区有空间让你写数据,如果没有,写事件不会就绪)
而且这些events和revents可以使用按位或接收多个宏,比方说,你要关心一个fd的读和写事件,你就可以写events = EPOLLIN | EPOLLOUT,这样就实现了多个事件的传递。
(3)nfds_t nfds
nfds表示fds数组的长度。
(4)int timeout
timeout表示poll函数的超时时间,单位是毫秒(ms),如果是0表示没有timeout时间,为非阻塞状态,如果为-1表示永久阻塞状态。
3.poll的优点
pollfd结构包含了要监视的events和发生的revents,不再使用select“参数值”传递,使用比select更方便。
poll在设计上没有监视的最大数量限制,但是太多也会导致运行过慢。
二、poll服务器
poll服务器与我们之前实现的select服务器的整体架构一致。
err.hpp、log.hpp、sock.hpp三个头文件可以直接使用,server.cc做少量修改,再根据之前的selectserver.hpp做一定修改实现一个pollserver.hpp,就可以运行了。
pollserver.hpp
#pragma once
#include<iostream>
#include<poll.h>
#include<string>
#include<functional>
#include"sock.hpp"
namespace poll_func
{
static const int default_port = 8080;//默认端口号为8080
static const int num = 2048;//设置元素数为2048,当然poll可监视的文件描述符无上限,也可以设计令其动态增长
static const int default_fd = -1;//将所有需要管理的文件描述符放入一个数组,-1是数组中的无效元素
using func_t = std::function<std::string (const std::string&)>;
class PollServer
{
public:
PollServer(func_t func, int port = default_port)
:_listensock(-1)
,_port(port)
,_fds(nullptr)
,_func(func)
{}
~PollServer()
{
if(_listensock > 0)
close(_listensock);//关闭监听文件描述符
if(_fds)
delete []_fds;//释放存储文件描述符的数组
}
//对数组内的某个结构体进行初始化
void reset_item(int i)
{
_fds[i].fd = default_fd;
_fds[i].events = 0;
_fds[i].revents = 0;
}
void initserver()
{
//创建listen套接字,绑定端口号,设为监听状态
_listensock = Sock::Socket();
Sock::Bind(_listensock, _port);
Sock::Listen(_listensock);
//构建一个结构体数组
_fds = new struct pollfd[num];
//初始化所有数据
for(int i = 0; i<num; ++i)
{
reset_item(i);
}
//第一个是监听描述符,对其初始化
_fds[0].fd = _listensock;
_fds[0].events = POLLIN;
}
void start()
{
int timeout = -1;
while(1)
{
//调用poll
int n = poll(_fds, num, timeout);//timeout为-1表示阻塞调用,大于0的值表示这段时间内阻塞
switch(n)
{
case 0://没有描述符就绪
logmessage(NORMAL, "time out.");
break;
case -1://select出错了
logmessage(ERROR, "select error, error code:%d %s", errno, strerror(errno));
break;
default://有描述符就绪(获取链接就属于读就绪)
logmessage(NORMAL, "server get new tasks.");
handler_read();//处理数据
break;
}
}
}
void Accepter()
{
//走到这里说明等的过程select已经完成了
std::string clientip;
uint16_t clientport = 0;
//poll只负责等,接收链接还是需要accept,但是这次调用不会阻塞了
int sock = Sock::Accept(_listensock, &clientip, &clientport);
if (sock < 0)//接收出错不执行
return;
logmessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);
//链接已经被建立,新的描述符通信产生
//这个描述符我们也要再次插入数组
int i = 0;
for(i = 0; i<num; ++i)
{
if(_fds[i].fd == default_fd)
break;
}
if(i == num)//数组满了
{
logmessage(WARNING, "server if full, please wait");
close(sock);//关闭该链接
}
else
{
//将数据插入数组
_fds[i].fd = sock;
_fds[i].events = POLLIN;
_fds[i].revents = 0;
}
print_list();//打印数组的内容
}
void Receiver(int pos)
{
//接收客户端发来的数据
char buffer[1024];
ssize_t n = recv(_fds[pos].fd, buffer, sizeof(buffer)-1, 0);
if (n > 0)
{
buffer[n] = 0;//在末尾加上/0
logmessage(NORMAL, "client# %s", buffer);
}
else if (n == 0)
{
close(_fds[pos].fd);
reset_item(pos);
logmessage(NORMAL, "client quit");
return;
}
else
{
close(_fds[pos].fd);
reset_item(pos);
logmessage(ERROR, "client quit: %s", strerror(errno));
return;
}
//使用回调函数处理数据
std::string response = _func(buffer);
//发回响应
write(_fds[pos].fd, response.c_str(), response.size());
}
void handler_read()
{
//我们将读取数据的处理分为两种:
//第一种是获取到了新链接
//第二种是有数据需要被读取
for(int i = 0; i<num; ++i)
{
//筛选出有效的文件描述符
if(_fds[i].fd != default_fd)
{
//筛选出events为POLLIN的结构体
if(_fds[i].events & POLLIN)
{
if (_fds[i].fd == _listensock && (_fds[i].revents & POLLIN))//监听套接字存在了
Accepter();
else if(_fds[i].revents & POLLIN)
Receiver(i);
}
}
}
}
void print_list()
{
std::cout << "fd list:" << std::endl;
for(int i = 0; i<num; ++i)
{
if(_fds[i].fd != default_fd)
std::cout << _fds[i].fd << " ";
}
std::cout << std::endl;
}
private:
int _listensock;
int _port;
struct pollfd* _fds;
func_t _func;
};
}
server.cc
#include"pollserver.hpp"
#include"err.hpp"
#include<memory>
using namespace std;
using namespace poll_func;
static void Usage(std::string proc)
{
std::cerr << "Usage:\n\t" << proc << " port" << "\n\n";
}
string transaction(const string& str)
{
return str;
}
int main(int argc, char *argv[])
{
unique_ptr<PollServer> p(new PollServer(transaction));
p->initserver();
p->start();
return 0;
}
三、epoll
1.poll的缺点
poll虽然允许我们监视没有上限的描述符,但它也有自己的缺点。
(1)和select函数一样,poll返回后,也需要以遍历的方式轮询获取就绪的描述符。
(2)每次调用poll都需要把大量的struct pollfd结构从用户态拷贝到内核中。
(3)同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视描述符数量的增长,poll的效率也会线性下降。
为了解决poll的问题,我们又引入了epoll的三个接口。
2.认识epoll的三个接口
(1)epoll_create
int epoll_create(int size);
头文件:sys/epoll.h
功能:创建epoll模型。
参数:int size该值必须大于0,自Linux2.6.8(我们目前使用Linux的版本都高于该版本)以后,该参数事实上是被忽略的,不起实际作用。
返回值:成功返回epoll模型的文件描述符,失败返回-1并设置错误码。
(2)epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
头文件:sys/epoll.h
功能:修改epoll句柄的属性。
参数:int epfd该值是epoll模型的描述符,int op表示修改句柄的选项,int fd要操作的文件描述符,struct epoll_event* event是用于epoll中的描述符属性结构体指针。
返回值:成功返回epoll模型的文件描述符,失败返回-1并设置错误码。
struct epoll_event结构体及其内部的struct epoll_data联合体在内核中的定义如下:
typedef union epoll_data{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event{
uint32_t events;
epoll_data_t data;
}
struct epoll_event结构体有两个成员变量。第一个成员是uint32_t events,用来设置需要等待的事件,它也有相关的宏,而且也支持按位或。
宏定义如下:
宏 | 含义 |
EPOLLIN | 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭) |
EPOLLOUT | 表示对应的文件描述符可以写 |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来) |
EPOLLERR | 表示对应的文件描述符发生错误 |
EPOLLHUP | 表示对应的文件描述符被挂断 |
EPOLLET | 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的 |
EPOLLONESHOT | 只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里 |
第二个参数是一个联合体epoll_data_t data,可以看到有四个成员共用这个联合体。
(3)epoll_wait
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
头文件:sys/epoll.h
功能:从epoll模型中获取被等待的文件描述符的状态。
参数:int epfd该值是epoll模型的描述符,struct epoll_event* events是一个数组,epoll_wait会将就绪的文件描述符的结构体放入这个数组中,供用户层读取,int maxevents是events数组的大小,这个值不能大于epoll_create的size,int timeout和poll中是一样的,不解释了。
返回值:大于0表示就绪的文件描述符个数,等于0表示超时返回,小于0表示调用失败。
3.epoll的原理
(1)描述符就绪的本质
我们以数据接收的读事件为例。
网络通信的接收端会将数据从网卡(物理层)逐层向上交付,最后到达应用层。那么接收端的操作系统是怎么知道数据到了网卡里的?
计算机遵循冯诺依曼体系,我们再增加中断向量表一起讲解。
当网卡接收到数据后,作为外设的网卡会产生一个控制信号并发给CPU的控制器。这样的外设给CPU发送一个信号表示数据到来,这叫做中断事件发生。
冯诺依曼体系中,外设的数据信号不能直接传递到CPU中,必须经过存储器(红线);而外设的控制信号可以直接传递给CPU的控制器(黑线)。
CPU收到信号后,会根据中断信号的编号,去操作系统维护的中断向量表中找到对应的中断服务函数并且执行。中断服务函数会调用网卡接收数据的驱动程序,读取数据并且向上层交付(绿线)。
在这里要重点关注中断服务函数,从网卡中接收数据是从它开始的。
(2)epoll模型的执行
epoll模型理论图包含计算机体系结构中的驱动,操作系统,系统调用三部分。
在调用epoll_create创建模型后,会返回一个文件描述符fd,这个fd同样放在服务器进程PCB所维护的进程描述符表中,通过fd这个句柄就可以找到对应的epoll模型。
epoll模型也是一个复杂的大结构体。由于在Linux中一切皆文件,所以创建模型后返回的也是一个文件描述符。
下图操作系统红框中黑框内的部分就是epoll模型,它维护了一个红黑树和一个就绪队列。
当我们需要增加需要等待的文件描述符时,可以调用epoll_ctl,将fd以及要关心的事件构建成一个struct epoll_event变量插入红黑树。红黑树的key值是文件描述符,value值能找到对应struct epoll_event变量。
如果是删除或者修改数据,同样是在修改红黑树,而红黑树查找效率非常高,所以相比poll的遍历,其操作会很高效。
当操作系统发现红黑树中有节点的事件就绪后,就会将该节点放入到就绪队列中,就绪队列本质是一个双向循环链表。
节点从红黑树放入就绪队列的过程并没有发生拷贝,我们操作系统中的某个数据结构中的节点往往也是其他数据结构的节点。比方说,我们增加一些指针变量等数据,红黑树的一个节点,也能成为链表中的一个节点。
当网卡中有数据到来时,通过中断函数最终会调用网卡驱动程序。驱动程序中有一个操作系统提供的回调函数void* private_data。
private_data回调函数会将红黑树节点中的next和prev指针的指向关系做修改,让该节点链入就绪队列中去,也就是图中的struct epoll_event结构,凡是处于就绪队列中的节点必然有事件已经就绪。
用户层在调用epoll_wait后,就能获取内核中就绪队列的内容,所以用户层获取到的struct epoll_event数组中,是当前就绪全部事件。
从内核到用户层虽然也需要遍历,但是此时是遍历拷贝,不再需要遍历检测。所以时间复杂度由O(N)变成了O(1),效率大大提升。
四、epoll服务器
epoll服务器与我们之前实现的select、poll服务器的整体架构一致。
err.hpp、log.hpp、sock.hpp三个头文件依旧直接使用,server.cc做少量修改,再根据之前的pollserver.hpp做一定修改实现一个epollserver.hpp,就可以运行了。
epollserver.hpp
#pragma once
#include<iostream>
#include<string>
#include<string.h>
#include<sys/epoll.h>
#include<functional>
#include"err.hpp"
#include"log.hpp"
#include"sock.hpp"
namespace epoll_func
{
static const int default_port = 8080;//默认端口号为8080
static const int size = 128;//epoll模型可容纳结构体的数目
static const int default_fd = -1;//将所有需要管理的文件描述符放入一个数组,-1是数组中的无效元素
static const int default_num = 64;//
using func_t = std::function<std::string (const std::string&)>;
class EpollServer
{
public:
EpollServer(func_t func, int port = default_port, int num = default_num, int fd = default_fd)
:_port(port)
,_listensock(fd)
,_epfd(fd)
,_revs(nullptr)
,_num(num)
,_func(func)
{}
~EpollServer()
{
if(_listensock > 0)
close(_listensock);//关闭监听文件描述符
if(_epfd > 0)
close(_epfd);//关闭epoll模型的文件描述符
if(_revs)
delete []_revs;//释放存储文件描述符的数组
}
void initserver()
{
//创建listen套接字,绑定端口号,设为监听状态
_listensock = Sock::Socket();
Sock::Bind(_listensock, _port);
Sock::Listen(_listensock);
//创建epoll模型
_epfd = epoll_create(size);
if(_epfd < 0)
{
//创建epoll模型失败
logmessage(FATAL, "epoll create error: %s", strerror(errno));
exit(EPOLL_CREATE_ERROR);
}
//建立listen套接字的epoll_event结构体
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = _listensock;
//将该数据加入epoll模型
epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock, &ev);
//申请就绪事件的空间
_revs = new struct epoll_event[_num];
//初始化完成,打印提示
logmessage(NORMAL, "init server success");
}
void start()
{
//timeout为-1表示阻塞调用
int timeout = -1;
while(1)
{
int n = epoll_wait(_epfd, _revs, _num, timeout);
//epoll_wait将所有就绪的节点放入_revs数组中,n为元素个数
switch(n)
{
case 0://没有描述符就绪
logmessage(NORMAL, "time out.");
break;
case -1://select出错了
logmessage(ERROR, "epoll_wait error, error code:%d %s", errno, strerror(errno));
break;
default://有描述符就绪(获取链接就属于读就绪)
logmessage(NORMAL, "server get new tasks.");
handler_event(n);//处理数据
break;
}
}
}
void handler_event(int n)
{
logmessage(DEBUG, "handler_event in");
for(int i = 0; i<n; ++i)
{
//将events和fd从结构体中拿出来
uint32_t events = _revs[i].events;
int sock = _revs[i].data.fd;
//链接事件的处理
if(sock == _listensock && (events & EPOLLIN))
{
//listen套接字的读事件就绪
std::string clientip;
uint16_t clientport;
int fd = Sock::Accept(_listensock, &clientip, &clientport);//接收链接,获取各个输出型参数
if(fd < 0)
{
logmessage(ERROR, "Accept error");
continue;//再次接收
}
//链接建立完成,但我们还需要关心新建立的链接的读事件
//所以还需要在epoll模型中添加新套接字
//建立listen套接字的epoll_event结构体
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = fd;
//将该数据加入epoll模型
epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
}
else if(events & EPOLLIN)
{
//普通的读事件就绪
char buffer[1024];
//读取数据
ssize_t n = read(sock, buffer, sizeof(buffer)-1);
if(n > 0)//读到了数据
{
buffer[n] = '\0';//将/n替换为/0
logmessage(NORMAL, "client# %s", buffer);
//使用回调函数处理数据
std::string resp = _func(std::string(buffer));
//发回数据
send(sock, resp.c_str(), resp.size(), 0);
}
else if(n == 0)//读到了结尾
{
//将sock从epoll模型中移除
epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);
//关闭文件描述符
close(sock);
logmessage(NORMAL, "client quit");
//注意移除和关闭描述符不能交换顺序
//因为先关闭描述符,该套接字对应的结构体就会从epoll模型中移除,再次移除会出错
}
else//出错了
{
//将sock从epoll模型中移除
epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);
//关闭文件描述符
close(sock);
//打印错误码
logmessage(ERROR, "recv error, code: %d, errstring: %s", errno, strerror(errno));
}
}
else//还可以增加处理写时间的代码,我这里只处理读事件,就不写这些代码了
{}
}
logmessage(DEBUG, "handler_event out");
}
private:
uint16_t _port;
int _listensock;
int _epfd;
struct epoll_event *_revs;
int _num;
func_t _func;
};
}
server.cc
#include"epollserver.hpp"
#include<memory>
using namespace std;
using namespace epoll_func;
string echo(const string& message)
{
return message;
}
void Usage(string proc)
{
std::cerr << "Usage:\n\t" << proc << " port" << "\n\n";
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<EpollServer> p(new EpollServer(echo, port));
p->initserver();
p->start();
return 0;
}