最近看的memcache和redis都使用了基于IO多路复用的高性能网络库.memcache使用了libevent,redis使用了自己封装的Mainae,原理都一样,都是封装底层的epoll,select,kqueue等等.而在linux平台下,使用最多的就是epoll,所以这篇文章想对epoll做个总结.
epoll接口非常简单,只有三个:
1 |
int epoll_create(int size); |
这就是创建一个epoll句柄,同时也占用一个文件描述符.size指明这个epoll监听的数目有多大.
因为经常看到说这个size参数是个hint,所以我就man了下,发现从Linux 2.8.8开始,这个 size就被忽略了,只是个hint,内核会自动分配所有事件所需要的内存,但是size必须大于0,主要是为了与旧版本的epoll兼容.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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_event结构体,events有以下几种:
而epoll_data_t是一个union,所以一次只能存储其中一种数据,可以是文件描述符fd,可以是传递的数据void*,可以是一个无符号长整形等等,但是最经常使用的是fd.
1 |
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); |
这个函数用于等待事件的发生.第二个参数是用户自己开辟的一块事件数组,用于存储就绪的事件,第三个参数为这个数组的最大值,就是第二个参数事件数组的最大值,用户想监听fd的个数,第四个参数为超时时间(0表示立即返回,-1表示永久阻塞,直到有就绪事件)
epoll经常使用框架包括监听listenfd以及clientfd,当epoll_wait返回时,迭代每个事件,如果是listenfd,则接收客户端fd,并在epoll注册一个读事件;如果是clientfd的可读事件,则先读取数据,然后处理数据,将数据写进输出缓冲区,最后将clientfd可读事件改为可写事件,这也是异步写的精髓;如果是clientfd可写事件,则先发送数据,然后将可写事件改为可读事件.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
for( ; ; ) { nfds = epoll_wait(epfd,events,20,500); for(i=0;i { if(events[i].data.fd==listenfd) //如果是主socket的事件,则表示有新的连接 { connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接 ev.data.fd=connfd; ev.events=EPOLLIN|EPOLLET; epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中 } else if( events[i].events&EPOLLIN ) //接收到数据,读socket { if ( (sockfd = events[i].data.fd) < 0) continue; n = read(sockfd, line, MAXLINE)) < 0 //读 ev.data.ptr = md; //md为自定义类型,添加数据 ev.events=EPOLLOUT|EPOLLET; epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓 } else if(events[i].events&EPOLLOUT) //有数据待发送,写socket { struct myepoll_data* md = (myepoll_data*)events[i].data.ptr; //取数据 sockfd = md->fd; 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模块,原理一样,只是将处理数据部分替换成了回调函数,稍微更复杂一些.
在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/poll相比的优点.
还有两点是关于用户态和内核态复制文件描述符,epoll使用的是共享内存,select全部复制,所以效率更低;epoll支持内核微调.
参考: