谈谈epoll实现原理(转)

最近看的memcache和redis都使用了基于IO多路复用的高性能网络库.memcache使用了libevent,redis使用了自己封装的Mainae,原理都一样,都是封装底层的epoll,select,kqueue等等.而在linux平台下,使用最多的就是epoll,所以这篇文章想对epoll做个总结.

Epoll接口

epoll接口非常简单,只有三个:

int epoll_create(int size);

这就是创建一个epoll句柄,同时也占用一个文件描述符.size指明这个epoll监听的数目有多大.

因为经常看到说这个size参数是个hint,所以我就man了下,发现从Linux 2.8.8开始,这个 size就被忽略了,只是个hint,内核会自动分配所有事件所需要的内存,但是size必须大于0,主要是为了与旧版本的epoll兼容.

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;
struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
};

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

这个函数用于向epoll注册一个事件,而且明确监听的事件类型;第一个参数为epoll句柄,第一个参数表示对这个fd监听事件的操作,用宏来表示,有以下三种:

EPOLL_CTL_ADD 将fd监听事件添加进epfd中;
EPOLL_CTL_MOD 修改已经注册的fd监听事件;
EPOLL_CTL_DEL 从epfd中删除fd事件 第三个参数为监听的套接字,第四个参数为监听fd的事件.
对于epoll_event结构体,events有以下几种:

EPOLLIN 表示对应的文件描述符可读(包括对端socket关闭)
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

而epoll_data_t是一个union,所以一次只能存储其中一种数据,可以是文件描述符fd,可以是传递的数据void*,可以是一个无符号长整形等等,但是最经常使用的是fd.

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

这个函数用于等待事件的发生.第二个参数是用户自己开辟的一块事件数组,用于存储就绪的事件,第三个参数为这个数组的最大值,就是第二个参数事件数组的最大值,用户想监听fd的个数,第四个参数为超时时间(0表示立即返回,-1表示永久阻塞,直到有就绪事件)

Epoll数据结构

Epoll主要由两个结构体:eventpoll与epitem。Epitem是每一个IO所对应的的事件。比如 epoll_ctl EPOLL_CTL_ADD操作的时候,就需要创建一个epitem。Eventpoll是每一个epoll所对应的的。比如epoll_create 就是创建一个eventpoll。

Epitem的定义:

![i(https://upload-images.jianshu.io/upload_images/1765518-c4b3b5ba3516cabf.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

Eventpoll的定义

谈谈epoll实现原理(转)_第1张图片
image

数据结构如下图所示。

谈谈epoll实现原理(转)_第2张图片
image

List 用来存储准备就绪的IO。对于数据结构主要讨论两方面:insert与remove。同样如此,对于list我们也讨论insert与remove。何时将数据插入到list中呢?当内核IO准备就绪的时候,则会执行epoll_event_callback的回调函数,将epitem添加到list中。

那何时删除list中的数据呢?当epoll_wait激活重新运行的时候,将list的epitem逐一copy到events参数中。

Rbtree用来存储所有io的数据,方便快速通io_fd查找。也从insert与remove来讨论。

对于rbtree何时添加:当App执行epoll_ctl EPOLL_CTL_ADD操作,将epitem添加到rbtree中。何时删除呢?当App执行epoll_ctl EPOLL_CTL_DEL操作,将epitem添加到rbtree中。

List与rbtree的操作又如何做到线程安全,SMP,防止死锁呢?

Epoll锁机制(线程安全,SMP的运行,以及防止死锁)

Epoll 从以下几个方面是需要加锁保护的。List的操作,rbtree的操作,epoll_wait的等待。

List使用最小粒度的锁spinlock,便于在SMP下添加操作的时候,能够快速操作list。

  1. List添加
谈谈epoll实现原理(转)_第3张图片
image

346行:获取spinlock。

347行:epitem 的rdy置为1,代表epitem已经在就绪队列中,后续再触发相同事件就只需更改event。

348行:添加到list中。

349行:将eventpoll的rdnum域 加1。

350行:释放spinlock

2. List删除

谈谈epoll实现原理(转)_第4张图片
image

301行:获取spinlock

304行:判读rdnum与maxevents的大小,避免event溢出。

307行:循环遍历list,判断添加list不能为空

309行:获取list首个结点

310行:移除list首个结点。

311行:将epitem的rdy域置为0,标识epitem不再就绪队列中。

313行:copy epitem的event到用户空间的events。

316行:copy数量加1

317行:eventpoll中rdnum减一。

避免SMP体系下,多核竞争。此处采用自旋锁,不适合采用睡眠锁。

3. Rbtree的添加

谈谈epoll实现原理(转)_第5张图片
image

149行:获取互斥锁。

153行:查找sockid的epitem是否存在。存在则不能添加,不存在则可以添加。

160行:分配epitem。

167行:sockid赋值

168行:将设置的event添加到epitem的event域。

170行:将epitem添加到rbrtree中。

173行:释放互斥锁。

Rbtree删除:

谈谈epoll实现原理(转)_第6张图片
image

177行:获取互斥锁。

181行:删除sockid的结点,如果不存在,则rbtree返回-1。

188行:释放epitem

190行:释放互斥锁。

Epoll_wait的挂起。

Epoll回调

Epoll 的回调函数何时执行,此部分需要与Tcp的协议栈一起来阐述。Tcp协议栈的时序图如下图所示,epoll从协议栈回调的部分从下图的编号1,2,3,4。具体Tcp协议栈的实现,后续从另外的文章中表述出来。下面分别对四个步骤详细描述

编号1:是tcp三次握手,对端反馈ack后,socket进入rcvd状态。需要将监听socket的event置为EPOLLIN,此时标识可以进入到accept读取socket数据。

编号2:在established状态,收到数据以后,需要将socket的event置为EPOLLIN状态。

编号3:在established状态,收到fin时,此时socket进入到close_wait。需要socket的event置为EPOLLIN。读取断开信息。

编号4:检测socket的send状态,如果对端cwnd>0是可以,发送的数据。故需要将socket置为EPOLLOUT。

所以在此四处添加EPOLL的回调函数,即可使得epoll正常接收到io事件。

谈谈epoll实现原理(转)_第7张图片
image

Epoll使用框架

epoll经常使用框架包括监听listenfd以及clientfd,当epoll_wait返回时,迭代每个事件,如果是listenfd,则接收客户端fd,并在epoll注册一个读事件;如果是clientfd的可读事件,则先读取数据,然后处理数据,将数据写进输出缓冲区,最后将clientfd可读事件改为可写事件,这也是异步写的精髓;如果是clientfd可写事件,则先发送数据,然后将可写事件改为可读事件.

for( ; ; )
    {
        nfds = epoll_wait(epfd,events,20,500);
        for(i=0;ifd;
                send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //发送数据
                ev.data.fd=sockfd;
                ev.events=EPOLLIN|EPOLLET;
                epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
            }
            else
            {
                //其他情况的处理
            }
        }
    }

对于libevent和redis的Mainae模块,原理一样,只是将处理数据部分替换成了回调函数,稍微更复杂一些.

epoll实现原理

在linux,一切皆文件.所以当调用epoll_create时,内核给这个epoll分配一个file,但是这个不是普通的文件,而是只服务于epoll.

所以当内核初始化epoll时,会开辟一块内核高速cache区,用于安置我们监听的socket,这些socket会以红黑树的形式保存在内核的cache里,以支持快速的查找,插入,删除.同时,建立了一盒list链表,用于存储准备就绪的事件.所以调用epoll_wait时,在timeout时间内,只是简单的观察这个list链表是否有数据,如果没有,则睡眠至超时时间到返回;如果有数据,则在超时时间到,拷贝至用户态events数组中.

那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

Epoll有两种模式LT(水平触发)和ET(边缘触发),LT模式下,主要缓冲区数据一次没有处理完,那么下次epoll_wait返回时,还会返回这个句柄;而ET模式下,缓冲区数据一次没处理结束,那么下次是不会再通知了,只在第一次返回.所以在ET模式下,一般是通过while循环,一次性读完全部数据.epoll默认使用的是LT.

这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的.

经常看到比较ET和LT模式到底哪个效率高的问题.有一个回答是说ET模式下减少epoll系统调用.这话没错,也可以理解,但是在ET模式下,为了避免数据饿死问题,用户态必须用一个循环,将所有的数据一次性处理结束.所以在ET模式下下,虽然epoll系统调用减少了,但是用户态的逻辑复杂了,write/read调用增多了.所以这不好判断,要看用户的性能瓶颈在哪.

epoll与select

最后需要说明的就是epoll与select/poll相比的优点.

首先select/poll监听的文件描述符个数受限.select的文件描述符默认为2048,而现在的服务器连接数在轻轻松松就超过2048个;epoll支持的fd个数不受限制,它支持的fd上限是最大可以打开文件的数目,一般远大于2048,1G内存的机器上是大约10万左右.
select和poll需要循环检测所有fd是否就绪,当fd数量百万或者更多时,这是很耗时的,根据前面原理分析可知,epoll只处理就绪的fd,而一般一次epoll_wait返回时,就绪的fd是不多的,所以处理起来不是很耗时.
还有两点是关于用户态和内核态复制文件描述符,epoll使用的是共享内存,select全部复制,所以效率更低;epoll支持内核微调.

参考
http://luodw.cc/2016/01/24/epoll/
https://zhuanlan.zhihu.com/p/50984245

你可能感兴趣的:(谈谈epoll实现原理(转))