计算机网络之多路转接epoll

发布于:2024-12-22 ⋅ 阅读:(18) ⋅ 点赞:(0)

个人主页:C++忠实粉丝
欢迎 点赞👍 收藏✨ 留言✉ 加关注💓本文由 C++忠实粉丝 原创

计算机网络之多路转接epoll

收录于专栏【计算机网络
本专栏旨在分享学习计算机网络的一点学习笔记,欢迎大家在评论区交流讨论💌 
  

目录

epoll 初识 

epoll 相关系统调用

epoll_create 

epoll_ctl

epoll_wait 

epoll 工作原理 

epoll 的优先(和 select 的缺点对应) 

epoll 工作方式 

水平触发 Level Triggered 工作模式

边缘触发 Edge Triggered 工作模式 

对比 LT 和 ET 

理解 ET 模式和非阻塞文件描述符

epoll 使用场景 

epoll 的惊群效应 

epoll 惊群效应产生的原因

多线程环境下解决惊群问题方法

多进程下的解决方法 


epoll 初识 

按照 man 手册的说法:是为了处理大批量句柄而作了改进的 poll。 

它是在 2.5.44 内核中被引进的 (epoll(4) is a new API introduced in Linux kernel 2.5.44)

它几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法。

epoll 相关系统调用

epoll 有 3 个相关的系统调用。

epoll_create 

int epoll_create(int size);

创建一个 epoll 句柄。 

自从 Linux2.6.8 之后,size 参数是被忽略的。

用完之后,必须调用 close() 关闭。

epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 

 epoll 的事件注册函数。

它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

第一个参数是 epoll_create() 的返回值(epoll 句柄)。

第二个参数表示动作,用三个宏来表示。

第三个参数是需要监听的 fd。

第四个参数是告诉内核需要监听什么事。 

第二个参数的取值:

1. EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;

2. EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;

3. EPOLL_CTL_DEL:从 epfd 中删除一个 fd; 

struct epill_event 结构如下:

events 可以是以下几个宏的集合:

1. EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)

2. EPOLLOUT:表示对应文件描述符可以写;

3. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)

4. EPOLLERR:表示对应的文件描述符发生错误;

5. EPOLLHUP:表示对应的文件描述符被挂断

6. EPOLLET:将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。

7. EPOLLONESHOT:只监听一次事件,当监听完这个事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入 EPOLL 队列里

epoll_wait 

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

收集在 epoll 监控的事件中已经发送的事件。

参数 events 是分配好的 epoll_event 结构体数组

epoll 将会把发生的事件赋值到 events 数组中(evens 不可以是空指针,内核只负责把数据赋值到这个 events 数组中,不会去帮助我们在用户态中分配内存)。

maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的 size。

参数 timeout 是超时时间(毫秒,0会立即返回,-1是永久阻塞)。

如果函数调用成功,返回对应 I/O 上已经准备好的文件秒描述符数目,如返回 0 表示已超时,返回小于 0 表示函数失败。

epoll 工作原理 

当某一个进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关。

    struct eventpoll
{
    ....
        /*红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件
         */
        struct rb_root rbr;
    /*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/ struct list_head rdlist;
    ....
};
struct eventpoll
{
    //红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件
    struct rb_root rbr;
    //双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/ 
    struct list_head rdlist;
};

1. 每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件

2. 这些事件都会被挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效识别出来(红黑树的插入时间效率是 logn,其中 n 为树的高度)。

3. 而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法。

4. 这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdist 双链表中。

5. 在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体。

struct epitem
{
    struct rb_node rbn;       // 红黑树节点
    struct list_head rdllink; // 双向链表节点
    struct epoll_filefd ffd;  // 事件句柄信息
    struct eventpoll *ep;     // 指向其所属的 eventpoll 对象struct epoll_event event; //期待发生的事件类型
}

当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdist 双链表中是否有 epitem 元素即可。

如果 rdist 不为空,则把发生的事件赋值到用户态,同时将事件数量返回给用户,这个操作的事件复杂度是 O(1).

总结以下,epoll 的使用过程就是三部曲:

1. 调用 epoll_create 创建一个 epoll 句柄、

2. 调用 epoll_ctl 将要监控的文件描述符进行注册

3. 调用 epoll_wait,等待文件描述符就绪 

epoll 的优先(和 select 的缺点对应) 

1. 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离。

2. 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝)

3. 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度O(1),即文件描述符数目很多,效率也不会收到影响。

epoll 工作方式 

epoll 有两种工作方式,这里举一个妈妈喊你回家吃饭的例子: 

你正在吃鸡, 眼看进入了决赛圈, 你妈饭做好了, 喊你吃饭的时候有两种方式:

1. 如果你妈喊你一次, 你没动, 那么你妈会继续喊你第二次, 第三次...(亲妈,水平触发)

2. 如果你妈喊你一次, 你没动, 你妈就不管你了(后妈, 边缘触发) 

epoll 有两种工作方式 - 水平触发(LT) 和 边缘触发(ET) 

假如有这样一个例子:

我们已经把一个 tcp socket 添加到 epoll 描述符

这个时候 socket 的另一端被写入了 2KB 的数据

调用 epoll_wait,并且它会返回,说明它已经准备好了读取操作

然后调用 read,只读取了 1KB 的数据

继续调用 epoll_wait ..... 

水平触发 Level Triggered 工作模式

epoll 默认状态下就是 LT 工作模式

当 epoll 检测到 socket 上事件就绪的时候,可以不立即进行处理。或者只处理一部分

如上面的例子,由于只读了 1K 数据,缓冲区中还剩 1K 数据,在第二次调用 epoll_wait 时,epoll_wait 仍然会立即返回并通知 socket 读事件就绪。

知道缓冲区上所有数据都被处理完,epoll_wait 才不会立刻返回

支持阻塞读写和非阻塞读写 

边缘触发 Edge Triggered 工作模式 

如果我们在第一步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志,epoll 进入 ET 工作模式。

当 epoll 检测到 socket 上事件就绪时,必须立刻处理

如上面的例子,虽然只读了 1K 的数据,缓冲区还剩 1K 的数据,在第二次调用 epoll_wait 的时候,epoll_wait 不会再返回了。

也就是说,ET 模式下,文件描述符上的事件就绪后,只有一次处理机会。

ET 的性能比 LT 性能更高(epoll_wait 返回的次数少了很多)。Nginx 默认采用 ET 模式使用 epoll。

只支持非阻塞的读写。

select 和 poll 其实也是工作在 LT 模式下,epoll 既可以支持 LT,也可以支持 ET。 

对比 LT 和 ET 

LT 是 epoll 的默认行为。

使用 ET 能够减少 epoll 触发的次数,但是代价就是强逼这程序员一次响应就绪过程中就把所有的数据都处理完。

相当于一个文件描述符就绪之后,不会反复提示就绪,看起来就比 LT 更高效一些,但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的。

另一方面,ET 的代码复杂程度更高了。 

理解 ET 模式和非阻塞文件描述符

使用 ET 模式的 epoll,需要将文件描述符设置为非阻塞,这个不是接口上的要求,而是 “工程实践” 上的要求。

假设这样的场景:服务器接收到一个 10K 请求,会向客户端返回一个应答数据。如果客户端收不到应答,不会发送第二个 10k 请求。

如果服务端写的代码是阻塞式的 read,并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的 9k 数据就会待在缓冲区中。

但是问题来了。

1. 服务器只读到了 1k 个数据,要 10k 读完才会给客户返回响应数据

2. 客服端读到服务器的响应,才会发送下一个请求

3. 客户端发送下一个请求,epoll_wait 才会返回,才能去读缓冲区中剩余的数据。 

所以,为了解决这个问题(阻塞 read 不一定能一下把完整的请求读完),于是就可以使用非阻塞轮询的方式来读取缓冲区,保证一定能把完整的请求都读出来。

而如果是 LT 没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符就绪。

epoll 使用场景 

epoll 的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。

对于多连接,且多连接只有一部分连接比较活跃时,比较适合使用 epoll。 

例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。 

如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适,具体要根据需要和场景特点来决定使用那种 IO 模型。 

epoll 的惊群效应 

epoll 惊群效应产生的原因

在 Linux 下使用 epoll 编写过 socket 的服务器程序,在多线程环境下可能会遇到 epoll 惊群效应。那么什么是惊群效应呢。其产生的原因是什么呢?

在多线程或者多进程环境下,有些人为了提高程序的稳定性,往往会让多个线程或者多个进程同时在 epoll_wait 监听的 socket 描述符。当一个新的链接请求进来时,操作系统不知道选派哪个进程或者进程处理此事件,则干脆将其中几个线程或者进程给唤醒,而实际上只有其中一个进程或者线程能够成功处理 accept 事件,其他线程都将失败,且 errno 操作码为 EAGAIN。这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响。

那么如何解决这个问题呢? 

多线程环境下解决惊群问题方法

这种情况,不建议让多个线程同时在 epoll_wait 监听的 socket,而是让其中一个线程 epoll_wait 监听的 socket,当有新的链接请求进来后,由 epoll_wait 的线程调用 accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样可以避免了由于多线程环境下的 epoll_wait 惊群效应的问题 

多进程下的解决方法 

目前很多开源软件,如lighttpd,nginx等都采用master/workers的模式提高软件的吞吐能力及并发能力,在nginx中甚至还采用了负载均衡的技术,在某个子进程的处理能力达到一定负载之后,由其他负载较轻的子进程负责epoll_wait的调用,那么nginx和Lighttpd是如何避免epoll_wait的惊群效用的。
lighttpd的解决思路是无视惊群效应,仍然采用master/workers模式,每个子进程仍然管自己在监听的socket上调用epoll_wait,当有新的链接请求发生时,操作系统仍然只是唤醒其中部分的子进程来处理该事件,仍然只有一个子进程能够成功处理此事件,那么其他被惊醒的子进程捕获EAGAIN错误,并无视。
nginx的解决思路:在同一时刻,永远都只有一个子进程在监听的socket上epoll_wait,其做法是,创建一个全局的pthread_mutex_t,在子进程进行epoll_wait前,则先获取锁。

ngx_int_t  ngx_trylock_accept_mutex(ngx_cycle_t *cycle)  
 {  
     if (ngx_shmtx_trylock(&ngx_accept_mutex))
     {
         if (ngx_enable_accept_events(cycle) == NGX_ERROR) 
         {  
             ngx_shmtx_unlock(&ngx_accept_mutex);  
             return NGX_ERROR;  
         }  
   
         ngx_accept_mutex_held = 1;  
         return NGX_OK;  
     }  
   
     if (ngx_accept_mutex_held)
     {  
         if (ngx_disable_accept_events(cycle) == NGX_ERROR)
         {  
             return NGX_ERROR;  
         }  
   
         ngx_accept_mutex_held = 0;  
     }  
     return NGX_OK;  
 } 

且只有在ngx_accept_disabled < 0 时,才会去获取全局锁,及只有在子进程的负载能力在一定的范围下才会尝试去获取锁,并进入epoll_wait监听的socket。

void ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
    if (ngx_accept_disabled > 0)
    {
        ngx_accept_disabled--;
    }

    else

    {
        if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR)
        {
            return;
        }
    }
}
ngx_accept_disabled = ngx_cycle->connection_n / 8  - ngx_cycle->free_connection_n;

表示当子进程的连接数达到连接总数的7/8时,是不会尝试去获取全局锁,只会专注于自己的连接事件请求。