高并发服务器总结 epoll部分

高并发服务器总结 - epoll部分

epoll 接口 是为解决 Linux 内核处理大量文件描述符而提出的方案。该接口属于 Linux 下 多路 I/O 复用接口 中 select/poll 的增强。其经常应用于 Linux 下高并发服务型程序, 特别是在大量并发连接中只有少部分连接处于活跃下的情况 (通常是这种情况),在该情况下能显著的提高程序的 CPU 利用率。

epoll 采用的是 事件驱动,并且设计的十分高效。在用户空间获取事件时,不需要去遍历被监听描述符集合中所有的文件描述符,而是遍历那些被内核 I/O 事件异步唤醒之后 加入到就绪队列 并返回到用户空间的描述符集合。

epoll 提供了 两种触发模式,水平触发 (LT) 和边沿触发(ET)。当然,涉及到 I/O 操作也必然会有阻塞和非阻塞两种方案。目前效率相对较高的是 epoll+ET + 非阻塞 I/O 模型,在具体情况下应该合理选用当前情形中最优的搭配方案。

一、epoll 接口的基本思想概述

epoll 的设计:

(1)epoll 在 Linux 内核中构建了一个文件系统,该文件系统采用红黑树来构建,红黑树在增加和删除上面的效率极高,因此是 epoll 高效的原因之一。有兴趣可以百度红黑树了解,但在这里你只需知道其算法效率超高即可。

(2)epoll 红黑树上采用事件异步唤醒,内核监听 I/O,事件发生后内核搜索红黑树并将对应节点数据放入异步唤醒的事件队列中。

(3)epoll 的数据从用户空间到内核空间采用 mmap 存储 I/O 映射来加速。该方法是目前 Linux 进程间通信中 传递最快, 消耗最小, 传递数据过程不涉及系统调用 的方法。

image-20220329101119023

epoll 接口相对于传统的 select/poll 而言,有以下优点:

(1)支持单个进程打开大数量的文件描述符。受进程最大打开的文件描述符数量限制,而不是受自身实现限制。而 select 单个进程能够打开的文件描述符的数量存在最大限制,这个限制是 select 自身实现的限制。通常是 1024。poll 采用链表,也是远超 select 的。

(2)Linux 的 I/O 效率不会随着文件描述符数量的增加而线性下降。较之于 select/poll,当处于一个高并发时 (例如 10 万,100 万)。在如此庞大的 socket 集合中,任一时间里其实只有部分的 socket 是“活跃” 的。select/poll 的处理方式是, 对用如此庞大的集合进行线性扫描并对有事件发生的 socket 进行处理,这将极大的浪费 CPU 资源。因此 epoll 的改进是,由于 I/O 事件发生,内核将活跃的 socket 放入队列并交给 mmap 加速到用户空间,程序拿到的集合是处于活跃的 socket 集合,而不是所有 socket 集合。

(3)使用 mmap 加速内核与用户空间的消息传递。select/poll 采用的方式是,将所有要监听的文件描述符集合拷贝到内核空间(用户态到内核态切换)。接着内核对集合进行轮询检测, 当有事件发生时,内核从中集合并将集合复制到用户空间。再看看 epoll 怎么做的,内核与程序共用一块内存,请看 epoll 总体描述 01 这幅图,用户与 mmap 加速区进行数据交互不涉及权限的切换 (用户态到内核态,内核态到用户态)。内核对于处于非内核空间的内存有权限进行读取。

接下来我们结合 epoll 总体描述 01 与上述的内容,将图示进行升级为 epoll 总体描述 02。

image-20220329101141444

让我们来看看图的红黑树文件系统。以 epfd 为根,挂载了一个监听描述符和 5 个与客户端建立连接的 cfd。fd 的增删按照红黑树的操作方式,每一个文件描述符都有一个对应的结构,该结构为

/*
 *  -[ epoll结构体描述01 ]-
 */
struct epoll_event {
            __uint32_t events; /* Epoll events */
            epoll_data_t data; /* User data variable */
        };
typedef union epoll_data {
            void *ptr;
            int fd;
            uint32_t u32;
            uint64_t u64;
        } epoll_data_t;

在 epoll 总体描述 02 中,每个 fd 上关联的即是结构体 epoll_event。它由需要监听的事件类型和一个联合体构成。一般的 epoll 接口将传递自身 fd 到联合体。

因此,使用 epoll 接口的一般操作流程为:

(1)使用 epoll_create() 创建一个 epoll 对象,该对象与 epfd 关联,后续操作使用 epfd 来使用这个 epoll 对象,这个 epoll 对象才是红黑树,epfd 作为描述符只是能关联而已。

(2)调用 epoll_ctl() 向 epoll 对象中进行增加、删除等操作。

(3)调用 epoll_wait() 可以阻塞 (或非阻塞或定时) 返回待处理的事件集合。

(4)处理事件。

/*
 *  -[  一般epoll接口使用描述01  ]-
 */
int main(void)
{
 /* 
  *   此处省略网络编程常用初始化方式(从申请到最后listen)
  *   并且部分的错误处理省略,我会在后面放上所有的源码,这里只放重要步骤
  *   部分初始化也没写
  */ 
  // [1] 创建一个epoll对象
  ep_fd = epoll_create(OPEN_MAX);       /* 创建epoll模型,ep_fd指向红黑树根节点 */
  listen_ep_event.events  = EPOLLIN;    /* 指定监听读事件 注意:默认为水平触发LT */
  listen_ep_event.data.fd = listen_fd;  /* 注意:一般的epoll在这里放fd */ 
  // [2] 将listen_fd和对应的结构体设置到树上
  epoll_ctl(ep_fd, EPOLL_CTL_ADD, listen_fd, &listen_ep_event);
  while(1) { 
      // [3] 为server阻塞(默认)监听事件,ep_event是数组,装满足条件后的所有事件结构体
      n_ready = epoll_wait(ep_fd, ep_event, OPEN_MAX, -1); 
      for(i=0; i<n_ready; i++) {
         temp_fd = ep_event[i].data.fd;
         if(ep_event[i].events & EPOLLIN){
            if(temp_fd == listen_fd) {  //说明有新连接到来
               connect_fd = accept(listen_fd, (struct sockaddr *)&client_socket_addr, &client_socket_len);
               // 给即将上树的结构体初始化
               temp_ep_event.events  = EPOLLIN;
               temp_ep_event.data.fd = connect_fd;
               // 上树
               epoll_ctl(ep_fd, EPOLL_CTL_ADD, connect_fd, &temp_ep_event);
             }
             else {                      //cfd有数据到来
               n_data = read(temp_fd , buf, sizeof(buf));
               if(n_data == 0)  {        //客户端关闭
                   epoll_ctl(ep_fd, EPOLL_CTL_DEL, temp_fd, NULL) //下树
                   close(temp_fd);
                }
                else if(n_data < 0) {}
                do {
                   //处理数据
                 }while( (n_data = read(temp_fd , buf, sizeof(buf))) >0 ) ;
             }
          }
         else if(ep_event[i].events & EPOLLOUT){
                //处理写事件
         }
         else if(ep_event[i].events & EPOLLERR) {
                //处理异常事件
         }
      }      
   }
  close(listen_fd);
  close(ep_fd);
}

二、epoll 水平触发 (LT)、epoll 边沿触发 (ET)

(1) epoll 水平触发, 此方式为默认情况。

当设置了水平触发以后,以可读事件为例,当有数据到来并且数据在缓冲区待读。即使我这一次没有读取完数据,只要缓冲区里还有数据就会触发第二次,直到缓冲区里没数据。

(2) epoll 边沿触发,此方式需要在设置

listen_ep_event.events  = EPOLLIN | EPOLLET;   /*边沿触发 */

当设置了边沿触发以后,以可读事件为例,对 “有数据到来” 这件事为触发。

image-20220329101222339

总结:

  1. 用高低电平举例子就是

水平触发:0 为无数据,1 为有数据。缓冲区有数据则一直为 1,则一直触发。

边沿触发:0 为无数据,1 为有数据,只要在 0 变到 1 的上升沿才触发。

缓冲区有数据可读,触发 ⇒ 水平触发

缓冲区有数据到来,触发 ⇒ 边沿触发

那么,为什么说边沿触发 (ET) 的效率更高呢?

(1) 边沿触发只在数据到来的一刻才触发,很多时候服务器在接受大量数据时会先接受数据头部 (水平触发在此触发第一次,边沿触发第一次)。

(2) 接着服务器通过解析头部决定要不要接这个数据。此时,如果不接受数据,水平触发需要手动清除,而边沿触发可以将清除工作交给一个定时的清除程序去做,自己立刻返回。

(3) 如果接受,两种方式都可以用 while 接收完整数据。

三、epoll + 非阻塞 I/O

在第一大部分中的一般 epoll 接口使用描述 01 代码中,我们使用的读取数据函数为 read 函数,在默认情况下此类函数是阻塞式的,在没有数据时会一直阻塞等待数据到来。

(1)数据到来 100B,在 epoll 模式下调用 read 时,即使 read() 是阻塞式的也不会在这里等待,因为既然运行到 read(),说明数据缓冲区已经有数据,因此这处无影响。

(2)在服务器开发中,一般不会直接用采用类似 read() 函数这一类系统调用 (只有内核缓冲区),会使用封装好的一些库函数 (有内核缓冲区 + 用户缓冲区) 或者自己封装的函数。

例如:使用 readn() 函数,设置读取 200B 返回,假设数据到来 100B, 可读事件触发,而程序要使用 readn() 读 200B,那么此时如果是阻塞式的,将在此处形成死锁

流程是:100B ⇒ 触发可读事件 ⇒ readn() 调用 ⇒ readn() 都不够 200B, 阻塞 ⇒ cfd 又到来 200B ⇒ 此时程序在 readn() 处暂停,没有机会调用 epoll_wait() ⇒ 完成死锁

解决:

将该 cfd 在上树前设置为非阻塞式

/* 修改cfd为非阻塞读 */
flag  = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(connect_fd, F_SETFL, flag);

四、epoll + 非阻塞 I/O + 边沿触发

/*
 *  -[  epoll+非阻塞+边沿触发 使用描述01  ]-
 */
 /* 其他情况不变,与*一般epoll接口使用描述01*描述一致 
  * 变化的仅有第二大部分和第三大部分的这两段代码即可
  */ 
/* ... ... */
listen_ep_event.events  = EPOLLIN | EPOLLET;   /*边沿触发 */
/* ... ... */
/* 修改cfd为非阻塞读 */
flag  = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(connect_fd, F_SETFL, flag); 
/* ... ...  */

五、epoll 反应堆模型 (Libevent 库核心思想)

第一步,epoll 反应堆模型雏形 —— epoll 模型

我们将 epoll 总体描述 02 进行升级为 epoll 反应堆模型总体描述 01

这里和一般的 epoll 接口不同的是,现在正式称之为 epoll 模型 (epoll+ET + 非阻塞 + 自定义结构体)

(1) 还记得每一个在红黑树上的文件描述符所对应的结构体吗?它的结构描述在第一大部分的 epoll 结构体描述 01 中。在 epoll 接口使用代码中,我们在该结构体中的联合体上传入的是文件描述符本身,那么 epoll 模型和 epoll 接口最本质的区别在于 epoll 模型中,传入联合体的是一个自定义结构体指针,该结构体的基本结构至少包括

struct my_events {  
    int        m_fd;                             //监听的文件描述符
    void       *m_arg;                           //泛型参数
    void       (*call_back)(void *arg);          //回调函数
    /*
     *  你可以在此处封装更多的数据内容
     *  例如用户缓冲区、节点状态、节点上树时间等等
     */
};
/*
 * 注意:用户需要自行开辟空间存放my_events类型的数组,并在每次上树前用epoll_data_t里的  
 *      ptr指向一个my_events元素。
 */

根据该模型,我们在程序中可以让所有的事件都拥有自己的处理函数,你只需要使用 ptr 传入即可。

(2) epoll_wait() 返回后,epoll 模型将不会采用一般 epoll 接口使用描述 01 代码中的事件分类处理的办法,而是直接调用事件中对应的回调函数,就像这样

/*
 *  -[ epoll模型使用描述01  ]-
 */
 while(1) {
      /* 监听红黑树, 1秒没事件满足则返回0 */ 
      int n_ready = epoll_wait(ep_fd, events, MAX_EVENTS, 1000);
      if (n_ready > 0) {
         for (i=0; i<n_ready; i++) 
            events[i].data.ptr->call_back(/* void *arg */);
       }
       else
          /*  
           * (3) 这里可以做很多很多其他的工作,例如定时清除没读完的不要的数据
           *     也可以做点和数据库有关的设置
           *     玩大点你在这里搞搞分布式的代码也可以
           */
 }

image-20220329101658912

第二步,epoll 反应堆模型成型

到了这里,也将是 epoll 的最终成型,如果从前面到这里你都明白了,epoll 的知识你已经十之七八了

让我们先回想以下 epoll 模型的那张图,我们来理一理思路。

(1) 程序设置边沿触发以及每一个上树的文件描述符设置非阻塞

(2) 调用 epoll_create() 创建一个 epoll 对象

(3) 调用 epoll_ctl() 向 epoll 对象中进行增加、删除等操作

上树的文件描述符与之对应的结构体,该结构体应该满足填充事件与自定义结构体 ptr,此时,监听的事件与回调函数已经确定了对吧?

(4) 调用 epoll_wait()(定时检测) 返回待处理的事件集合。

(5) 依次调用事件集合中的每一个元素中的 ptr 所指向那个结构体中的回调函数。

以上为雏形版本,那么 epoll 反应堆模型还要比这个雏形版本多了什么呢?

请看第三步的粗体字,当我们把描述符和自定义结构体上树以后,如果放的是监听可读事件并做其对应的回调操作。也就是说,它将一直作为监听可读事件而存在。

其流程是:
监听可读事件 (ET) ⇒ 数据到来 ⇒ 触发事件 ⇒ epoll_wait() 返回 ⇒ 处理回调 ⇒ 继续 epoll_wait() ⇒ 直到程序停止前都是这么循环

那么接下来升级为成型版 epoll 反应堆模型

其流程是:

监听可读事件 (ET) ⇒ 数据到来 ⇒ 触发事件 ⇒ epoll_wait() 返回 ⇒

读取完数据 (可读事件回调函数内) ⇒ 将该节点从红黑树上摘下 (可读事件回调函数内) ⇒ 设置可写事件和对应可写回调函数 (可读事件回调函数内) ⇒ 挂上树 (可读事件回调函数内) ⇒ 处理数据 (可读事件回调函数内)

⇒ 监听可写事件 (ET) ⇒ 对方可读 ⇒ 触发事件 ⇒ epoll_wait() 返回 ⇒

写完数据 (可写事件回调函数内) ⇒ 将该节点从红黑树上摘下 (可写事件回调函数内) ⇒ 设置可读事件和对应可读回调函数 (可写读事件回调函数内) ⇒ 挂上树 (可写事件回调函数内) ⇒ 处理收尾工作 (可写事件回调函数内)⇒ 直到程序停止前一直这么交替循环

至此,结束

(1) 如此频繁的增加删除不是浪费 CPU 资源吗?

答:对于同一个 socket 而言,完成收发至少占用两个树上的位置。而交替只需要一个。任何一种设计方式都会有浪费 CPU 资源的时候,关键看你浪费得值不值,此处的耗费能否换来更大的收益才是衡量是否浪费的标准。和第二个问题综合来看,这里不算浪费

(2) 为什么要可读以后设置可写,然后一直交替?

答:服务器的基本工作无非数据的收发,epoll 反应堆模型准从 TCP 模式,一问一答。服务器收到了数据,再给与回复,是目前绝大多数服务器的情况。

(2-1) 服务器能收到数据并不是一定能写数据

假设一 :服务器接收到客户端数据,刚好此时客户端的接收滑动窗口满,我们假设不进行可写事件设置,并且客户端是有意让自己的接收滑动窗口满的情况 (黑客)。那么,当前服务器将随客户端的状态一直阻塞在可写事件,除非你自己在写数据时设置非阻塞 + 错误处理

假设二 :客户端在发送完数据后突然由于异常原因停止,这将导致一个 FIN 发送至服务器,如果服务器不设置可写事件监听,那么在接收数据后写入数据会引发异常 SIGPIPE,最终服务器进程终止。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是使用epoll和线程池实现高并发服务器的C++11代码示例: ```cpp #include <iostream> #include <thread> #include <vector> #include <queue> #include <mutex> #include <condition_variable> #include <sys/epoll.h> #include <unistd.h> #define MAX_EVENTS 100 #define THREAD_POOL_SIZE 10 std::mutex mtx; std::condition_variable cv; std::queue<int> taskQueue; void workerThread() { while (true) { std::unique_lock<std::mutex> lock(mtx); cv.wait(lock, [] { return !taskQueue.empty(); }); int fd = taskQueue.front(); taskQueue.pop(); // 处理任务,这里可以根据具体需求进行处理 lock.unlock(); // 继续监听其他事件 } } int main() { // 创建epoll句柄 int epoll_fd = epoll_create(1); if (epoll_fd == -1) { std::cerr << "Failed to create epoll" << std::endl; return 1; } // 创建线程池 std::vector<std::thread> threadPool; for (int i = 0; i < THREAD_POOL_SIZE; ++i) { threadPool.emplace_back(workerThread); } // 添加监听事件到epoll句柄 struct epoll_event event; event.events = EPOLLIN; event.data.fd = /* 监听的文件描述符 */; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, /* 监听的文件描述符 */, &event) == -1) { std::cerr << "Failed to add event to epoll" << std::endl; return 1; } // 开始监听事件 struct epoll_event events[MAX_EVENTS]; while (true) { int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (num_events == -1) { std::cerr << "Failed to wait for events" << std::endl; return 1; } for (int i = 0; i < num_events; ++i) { if (events[i].events & EPOLLIN) { // 处理读事件,将任务添加到任务队列 std::lock_guard<std::mutex> lock(mtx); taskQueue.push(events[i].data.fd); cv.notify_one(); } } } // 清理资源 close(epoll_fd); for (auto& thread : threadPool) { thread.join(); } return 0; } ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值