Linux网络:多路转接 epoll - 详解
Linux网络:多路转接 epoll
一、epoll三个接口函数
多路转接是非常高效的一种IO模型,它可以在同一时间等待多个套接字,从而提高效率。Linux提供了三种系统调用实现多路转接:select、poll、epoll。本博客讲解epoll。
epoll是经过改进的poll,在Linux 2.5.44版本引入内核,并认为是Linux2.6最好的多路转接实现方案
1、epoll_create
epoll_create用于创建一个epoll模型,需要头文件<sys/epoll.h>,函数原型如下:
int epoll_create(int size);此处的参数size已经被废弃,可以填入大于0的任何值。
返回值是一个文件描述符,通过这个文件描述符,可以操控Linux底层创建的epoll(主要是那两个模型)
2、epoll_ctl
epoll_ctl用于控制epoll模型,需要头文件<sys/epoll.h>,函数原型如下:
int epoll_ctl(int epfd, int op, int fd,
struct epoll_event *_Nullable event);参数:
- epfd:通过- epoll_create获取到的文件描述符
- op:本次执行的操作,传入宏
- fd:要监听的文件的文件描述符
- event:对文件要执行的监听类型
其中:op操作包括
- EPOLL_CTL_ADD:- 新增一个文件描述符到epoll中
- EPOLL_CTL_MOD:- 修改一个epoll中的文件描述符
- EPOLL_CTL_DEL:从epoll中- 删除一个文件描述符
其中event的类型是struct epoll_event*,该结构体定义如下:
struct epoll_event {
uint32_t events;
/* Epoll events */
epoll_data_t data;
/* User data variable */
};
union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};这个结构体中,包含events和data两个字段:
- events:一个位图,存储要监听的事件以及一些其它配置
- data:当epoll返回时,携带的数据
此处的data是一个联合体,它可以存储四种类型的数据:ptr指针,int文件描述符,uint32_t和uint64_t的整型。
其中events包括:
- EPOLLIN:监听读事件
- EPOLLOUT:监听写事件
- EPOLLERR:监听错误事件
- EPOLLHUP:文件描述符被关闭
- EPOLLONESHOT:只监听一次事件,本次监听完毕,文件描述符被从epoll中移除
当一个epoll返回已经就绪的文件时,用户其实无法得知这个文件的描述符,那么就可以通过这个data.fd获取到文件描述符,当然也可以通过其它的参数,传递更复杂的信息。
3、epoll_wait
epoll_wait用于等待epoll模型中的文件就绪,需要头文件<sys/epoll.h>,函数原型如下:
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);参数:
- epfd:通过epoll_create获取到的文件描述符
- events:输出型参数,指向一个epoll_event- 就绪数组,获取之前通过epoll_ctl传入的events
- maxevents:用户传入的events数组的最大长度(- 就绪事件个数)
- timeout:超时时间,以ms为单位
此处用户要传入一个epoll_event数组,这个数组用于存储本次就绪的所有文件的epoll_event,为了防止越界,所以还要传入maxevents。
就是说,epoll的使用方式是通过epoll_wait获取就绪的文件,这些文件存储到epoll_event数组中。
函数返回,用户可以遍历数组,获取到所有就绪的文件的epoll_event结构体。
这个结构体是在epoll_ctl时传入的,从它的events字段可以得知这个文件监听的事件,从data字段可以获取之前预设的其他信息,一般会预设data.fd获取这个文件的描述符。
返回值:
- 0:超时,指定时间内没有文件就绪
- <0:出现错误
- >0:就绪的文件的个数
通过此处,已经可以看出epoll相比于select的优势了:
epoll返回时,把已经就绪的文件
放到数组中,后续遍历数组,每一个元素都是已经就绪的文件
在select中,就绪的事件通过一张位图返回,用户需要遍历整个位图所有元素,并判断该元素是否就绪,那么就会浪费大量的时间在未就绪的文件上。
epoll返回时,不会把已经加入epoll的文件删除,而是继续监听该文件
这是另一大优势,在select中,每次返回都会重置用户传入的位图,因此用户在每次轮询都要重新把文件描述符设置到select。
当然,用户也可以在epoll_ctl的时候,设置EPOLLONESHOT,那么这个文件被epoll返回后,就会从epoll中删除,也就是只监听一次事件。
二、epoll的工作原理


Epoll的工作原理:
 一种特殊的数据结构:
 双层结构体:结构体嵌套结构体

三、epoll的echo_server
接下来使用epoll系统调用,实现一个简单的echo server。
1、EpollServer类
// 多路转接:事件循环、事件派发、事件处理!
class EpollServer
{
public:
// 构造函数
EpollServer(uint16_t port){
}
// 初始化函数
void InitServer(){
}
// 转换字符串
std::string EventsToString(uint32_t events)
// 事件处理:网络套接字文件
void Accepter(){
}
// 事件处理:普通文件
void HandlerIO(int fd){
}
// 事件派发
void HandlerEvent(int n){
}
// 事件循环
void Loop(){
}
// 析构
~EpollServer(){
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listensock;
  int _epfd;
  // epoll_create的返回值!epoll句柄
  struct epoll_event revs[num];
  // 将内核epoll模型里面的就绪事件,存入revs(revs是epoll_wait的参数)
  };2、构造函数
构造函数代码如下:
// 构造函数
EpollServer(uint16_t port)
: _port(port), _listensock(std::make_unique<TcpSocket>
  ())
  {
  _listensock->
  BuildListenSocket(port);
  // 根据传来的port来创建监听套接字!!!
  // 1、epoll_create创建成功,说明底层内核已经创建好了:红黑树+就绪队列
  _epfd = ::epoll_create(size);
  // 这里的参数size>0即可
  if (_epfd <
  0)
  {
  LOG(FATAL, "epoll create fail\n");
  exit(-1);
  }
  LOG(INFO, "epoll create sucess ,epfd:%d\n", _epfd);
  }3、事件循环
事件循环代码如下:
开启循环后,进入一个while(true)死循环,每一轮循环通过epoll_wait获取本轮循环就绪的文件:
// 事件循环
void Loop()
{
int timeout = 1000;
// 设置时限!1s
while (true)
{
int n = ::epoll_wait(_epfd, revs, num, timeout);
// epoll_wait的返回值就是就绪事件的个数
switch (n)
{
case 0:
LOG(INFO, "epoll time out...\n");
break;
case -1:
LOG(ERROR, "epoll wait fail\n");
break;
default:
LOG(INFO, "have event happend! n:%d\n", n);
HandlerEvent(n);
// 事件派发
break;
}
}
}4、事件派发
事件派发就是判断文件描述符是_listenfd还是普通的sockfd,调用不同的函数进行处理。
// 事件派发
void HandlerEvent(int n)
{
for (int i = 0; i < n; i++)
{
int fd = revs[i].data.fd;
uint32_t revents = revs[i].events;
// 具体是哪一个fd里面的什么事件就绪了!?
LOG(INFO, "%d 上面的有事件就绪了,具体事件是:%s\n", fd, EventsToString(revents).c_str());
// 事件处理:封装!
// 1、listensock就绪
if (fd = _listensock->
Sockfd())
{
Accepter();
}
// 2、处理普通fd
else
{
HandlerIO(fd);
}
}
}5、事件处理
void Accepter()
{
InetAddr addr;
int sockfd = _listensock->
Accepter(&addr);
if (sockfd <
0)
{
LOG(ERROR, "获取连接失败\n");
return ;
}
LOG(INFO, "得到一个新链接:%d,客户端信息:%s:%d\n", sockfd, addr.Ip().c_str(), addr.Port());
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
// 必须将sockfd加入到epoll里面,这样才能让epoll对提取出来的sockfd进行处理!
::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
LOG(INFO, "add sucess to epoll,new sockfd:%d\n", sockfd);
}
void HandlerIO(int fd)
{
char buffer[4096];
int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);
if (n >
0)
{
buffer[n] = 0;
std::cout << buffer;
}
else if (n == 0)
{
LOG(INFO, "client,quit...\n");
// 先将这个退出的fd从epoll中移除,再关闭fd
::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
::close(fd);
}
else
{
LOG(ERROR, "client,fail...\n");
::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
::close(fd);
}
}6、测试

四、LT和ET模式
思考一个问题:如果用户通过epoll检测到某个socket的事件已经就绪了,但是这个用户没有处理这个事情,下一次epoll_wait还要不要返回这一个事件?
就是基于这个问题,衍生了两种epoll工作模式:LT模式与ET模式
1、LT
LT模式下,当用户没有处理事件,那么事件就一直保留在就绪链表rdlink中,每次调用epoll_wait都会返回这个事件
这种模式是epoll的默认模式。
用户接收到事件后,可能某个报文太长了,一次读不完。那么LT模式下一次还会进行通知,用户可以把剩下的报文读完。但是这就可能导致一个报文,需要调用更多次的epoll_wait。
2、ET
ET模式下,当用户通过epoll_wait拿到事件后,事件直接从rdlink中删除,下一次不再进行通知
这种模式比LT更加高效,这可以从两个角度解读:
- 这种模式下,一个报文只需要调用一次epolll_wait,因此效率高一点
- 这倒逼程序员必须一次性把报文读完,那么就会更快的进行业务处理,报文响应速度也更快
这里主要是第二点比较重要,当一个报文太长了,但是ET模式下只进行一次通知。那么程序员收到通知后,就需要用一个while循环一直读取套接字,直到读不出数据为止。这样一次通知程序员就能拿到完整报文,进而更早的进行业务处理,更早响应。而且提早把数据读走,内核的缓冲区也会被空出来,接收更多的新数据。
默认情况下,从文件读取文件是阻塞的,当最后一次while循环读取不出内容了,程序就会阻塞住。因此这种情况下,要把文件读取改为非阻塞读取,如果读不出内容直接返回。
但是这也导致ET的程序会比LT更加复杂,实际开发中需要进行权衡。
 
                    
                     
                    
                 
                    
                 
 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号