IO多路复用
通俗来说,如果是一个客户端跟一个服务器请求响应,无论用TCP还是UDP,建立连接,然后相互交换数据。但是如果要有多个客户端
与服务器连接,那么这里有两种做法,第一种就是来一个连接就创建一个线程去维护这条连接,专门来服务这个客户端,这时候会出现一个
问题,随着线程越开越多,开线程是消耗资源的,并发量上不去。第二种做法,把所有的网络连接放到一起,用一个组件来管理连接,这就
叫做IO多路复用。
官方来说,IO多路复用就是指单个进程/线程可以同时处理多个IO请求
Epoll总结
参考 技术之美,Epoll的原理
简要分析一下epoll的工作过程:
(1) epoll_wait 调用 ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒;
(2) 文件描述符fd状态改变(buffer由不可读变成可读或者由不可写变为可写),导致相应fd上的回调函数 ep_poll_callback()被调用;
(3)ep_poll_callback()将相应fd对应的epitem加入rdlist,导致rslist不空,进程被唤醒,epoll_wait得以继续;
(4)ep_events_transfer 函数将 rdlist中的epitem拷贝到 txlist 中,并将rdlist清空;
(5)ep_send_events函数,扫描 txlist 中的每个epitem,调用其关联 fd 对应的poll方法(图中蓝线),此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的 fd 发送到用户空间(封装在 struct epoll_event,从epoll_wait返回),之后如果这个epitem对应的 fd 是LT 模式监听且取得的events是用户所关心的,则将其重新加入rdlist(图中蓝线),否则(ET模式)不在加入rdlist。
epoll_wait返回的条件是rdlist不空,而使rdlist不空的途径有两个,分别对应图中的红线和蓝线。
ET 和 LT模式下的epitem都可以通过红线方式加入rdlist从而唤醒epoll_wait,但LT模式下的epitem还可以通过蓝线方式重新加入rdlist唤醒epoll_wait。所以ET模式下,fd就绪(通过红线加入rdlist)只会被通知一次,而LT模式下只要满足相应读写条件就返回就绪(通过蓝线加入rdlist)。
ET事件发生仅通知一次的原因是只被添加到rdlist中一次,而LT可以有多次添加的机会。
两种加入rdlist途径的不同
下面我们来分析一下图中两种将epitem加入rdlist方式(也就是红线和蓝线)的区别。
红线:fd状态改变是才会触发。那么什么情况会导致fd状态的改变呢?
对于读取操作:
(1) 当buffer由不可读状态变为可读的时候,即由空变为不空的时候。
(2) 当有新数据到达时,即buffer中的待读内容变多的时候。
对于写操作:
(1) 当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。
(2) 当有旧数据被发送走时,即buffer中待写的内容变少得时候。
蓝线:fd的events中有相应的时间(位置1)即会触发。那么什么情况下会改变events的相应位呢?
对于读操作:
(1) buffer中有数据可读的时候,即buffer不空的时候fd的events的可读为就置1。
对于写操作:
(1) buffer中有空间可写的时候,即buffer不满的时候fd的events的可写位就置1。
说明:红线是时间驱动被动触发,蓝线是函数查询主动触发。
Epoll的具体代码:
/* 扫描整个txlist链表... */
for (eventcnt = 0, uevent = esed->events; !list_empty(head) && eventcnt < esed->maxevents;) {
/* 取出第一个成员 */
epi = list_first_entry(head, struct epitem, rdllink);
/* 然后从链表里面移除 */
list_del_init(&epi->rdllink);
/* 读取events,
* 注意events我们ep_poll_callback()里面已经取过一次了, 为啥还要再取?
* 1. 我们当然希望能拿到此刻的最新数据, events是会变的~
* 2. 不是所有的poll实现, 都通过等待队列传递了events, 有可能某些驱动压根没传
* 必须主动去读取. */
revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL) & epi->event.events;
if (revents) {
/* 将当前的事件和用户传入的数据都copy给用户空间,
* 就是epoll_wait()后应用程序能读到的那一堆数据. */
if (__put_user(revents, &uevent->events) || __put_user(epi->event.data, &uevent->data)) {
/* 如果copy过程中发生错误, 会中断链表的扫描,
* 并把当前发生错误的epitem重新插入到ready list.
* 剩下的没处理的epitem也不会丢弃, 在ep_scan_ready_list()
* 中它们也会被重新插入到ready list */
list_add(&epi->rdllink, head);
return eventcnt ? eventcnt : -EFAULT;
}
eventcnt++;
uevent++;
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS;
else if (!(epi->event.events & EPOLLET)) {
/*
* If this file has been added with Level
* Trigger mode, we need to insert back inside
* the ready list, so that the next call to
* epoll_wait() will check again the events
* availability. At this point, noone can insert
* into ep->rdllist besides us. The epoll_ctl()
* callers are locked out by
* ep_scan_ready_list() holding "mtx" and the
* poll callback will queue them in ep->ovflist.
*/
/* 嘿嘿, EPOLLET和非ET的区别就在这一步之差呀~
* 如果是ET, epitem是不会再进入到readly list,
* 除非fd再次发生了状态改变, ep_poll_callback被调用.
* 如果是非ET, 不管你还有没有有效的事件或者数据,
* 都会被重新插入到ready list, 再下一次epoll_wait
* 时, 会立即返回, 并通知给用户空间. 当然如果这个
* 被监听的fds确实没事件也没数据了, epoll_wait会返回一个0,
* 空转一次.
*/
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
}
epoll 高效的原因:
epoll使用三个方法来实现select方法要做的事情
1.新建epoll描述符==epoll_create()
2.epoll_ctrl(epoll描述符,添加或者删除所有待监控的连接)
3.返回的活跃连接 ==epoll_wait(epoll描述符)
与select相比,epoll分清了频繁调用和不频繁调用的操作。例如,epoll_ctl是不太频繁调用的,而epoll_wait是非常频繁调用的。这时,epoll_wait却几乎没有入参,这比select的效率高出一大截,而且,它也不会随着并发连接的增加使得入参越发多起来,导致内核执行效率下降。
epoll是通过内核与用户空间mmap同一块内存实现的。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。内核可以直接看到epoll监听的句柄,效率高。
通过epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的,当把事件添加进来的时候会完成关键的一步,那就是该事件都会与相应的设备(比如网卡,)驱动程序建立回调关系,当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_pol_callback,这个回调函数其实就把这个事件添加到rdlist这个双向链表中。一旦有事件发生,epoll就会将该事件添加到双向链表中,那么当我们调用epoll_wait时,epoll_wait只需要检查rdlist双向链表中是否存在注册的事件,效率非常可观。这里也需要将发生了的事件复制到用户态内存中即可。
rdllist: 保存已经就绪的文件列表
rbr: 使用红黑树来管理所有被监听的文件
epitem:红黑树节点
epoll不空的途径
epoll_wait返回的条件是rdlist不为空,而rdlist不为空的途径有两个
1、文件描述符的改变
对于读操作:
当buffer由不可读状态变成可读的时候,即由空变成不空的时候
当有新的数据到达时,即buffer中的待读内容变多
对于写操作
当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候
当有旧的数据被读取走时,即buffer中待写内容变少的时候
2、文件描述符的事件位 events 置 1
对于读操作
buffer中有数据可读的时候,即buffer不为空的时候fd的events的可读位置就1
对于写操作
buffer中有空间可写的时候,即buffer不满的时候fd的events的可写位就置1
ET和LT模式下的epitem都可以通过途径1加入rdlist从而唤醒epoll_wait,但是LT模式下的epitem还可以通过途径2重新加入rdlist唤醒epoll_wait
从源码角度看,ET模式下,文件描述符fd只会加入rdlist一次,所以epoll_wait只会触发一次,然后移除此epitem;而LT模式下只要满足相应读写条件就会再次加入rdlist,epoll_wait会被触发多次。
epoll中的一些参数说明
#include <sys / epoll.h>
int epoll_ctl(int epfd,int op,int fd,struct epoll_event * event);
op参数的有效值为:
EPOLL_CTL_ADD:在文件描述符epfd所引用的epoll实例上注册目标文件描述符fd,并将事件事件与内部文件链接到fd。
EPOLL_CTL_MOD:更改与目标文件描述符fd相关联的事件。
EPOLL_CTL_DEL:从epfd引用的epoll实例中删除(注销)目标文件描述符fd。该事件将被忽略,并且可以为NULL(但请参见下面的错误)。
events 成员变量:
可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLRDHUP :代表对端断开连接
EPOLLHUP:表示挂断
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
EPOLLERR:表示对应的文件描述符发生错误; EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
ET和LT模式代码demo分析
#include <unistd.h>
#include <iostream>
#include <string>
#include <string.h>
#include <sys/epoll.h>
using namespace std;
void LT_read(void)
{
int epfd,nfds;
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd=epoll_create(1);//只需要监听一个描述符——标准输入
ev.data.fd=STDIN_FILENO;
ev.events=EPOLLIN; // 默认使用LT模式
/*
程序出现死循环,因为用户输入任意数据后,数据被送入buffer且没有被读出,
所以LT模式下每次epoll_wait都认为buffer可读返回读就绪。
导致每次都会输出”hello world!”。
*/
epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
if(events[i].data.fd==STDIN_FILENO)
cout<<"hello world!"<<endl;
}
}
}
程序出现死循环,因为用户输入任意数据后,数据被送入buffer且没有被读出,所以LT模式下每次epoll_wait都认为buffer可读返回读就绪。导致每次都会输出”hello world!”
void ET_read(){
int epfd,nfds;
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd = epoll_create(1);//只需要监听一个描述符——标准输入
ev.data.fd = STDIN_FILENO;
ev.events = EPOLLIN|EPOLLET;//监听读状态同时设置ET模式
epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
if(events[i].data.fd==STDIN_FILENO)
cout<<"hello world!"<<endl;
}
}
}
输入一组字符,这组字符被送入buffer,又因为buffer由空变为不空,所以ET返回读就绪,输出"hello,world",之后程序再次执行epoll_wait,此时虽然buffer有内容可以读,但是ET并不返回就绪,只有当buffer由空变成不空时,或者有新的数据到达的时候,导致epoll_wait阻塞,(底层原因是因为ET下就绪fd的epitem只被放入rdlist一次)
用户再次输入一组字符,导致buffer中的内容增多(有新数据到达),这将导致fd状态的改变,对应的epitem再次加入rdlist,从而使epoll_wait返回读就绪,再次输出“hello world”
void LT_read2(){
int epfd,nfds;
char buf[256];
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd = epoll_create(1);//只需要监听一个描述符——标准输入
ev.data.fd = STDIN_FILENO;
ev.events = EPOLLIN;//使用默认LT模式 监听可读事件
epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds = epoll_wait(epfd,events,5,-1);
for(int i = 0 ; i < nfds ; i++)
{
if(events[i].data.fd == STDIN_FILENO)
{
read(STDIN_FILENO, buf, sizeof(buf));// 将缓冲中的内容读出
cout << "buf = " << buf << endl;
// memset(buf,'\0',sizeof(buf));
cout<<"hello world!"<<endl;
}
}
}
}
这次,我们可以用read函数将buffer缓冲区中的内容读出来,所以下次调用epoll_wait时就会阻塞。
这里贴出了运行结果,输入sdws字符串,就相当于向缓冲区中写入了 sdws字符串,因为此时监听的文件描述符 fd 是STDIN_FILENO输入,缓冲区有数据了,所以触发条件满足了,我加了一个read函数,按下回车之后,执行read,read函数会把输入输出缓冲区中的数据读出来,读进buf中,这里还有一个小知识,就是我发现用read(STDIN_FILENO, buf, sizeof(buf)); 读取输入输出缓冲区的sdws字符串的时候,会把末尾的 \n 一起读入,所以终端输出buf=sdws的时候换行了两次。之后会执行 输出 hello,world!
因为是LT模式,监听的是输入fd,一旦输入缓冲区可读,就触发
这里的
if(events[i].data.fd == STDIN_FILENO)
也可以换成
if(events[i].events & EPOLLIN)
效果是一样的。
void ET_write(){
int epfd,nfds;
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd = epoll_create(1);//只需要监听一个描述符——标准输出
ev.data.fd = STDOUT_FILENO; // = 1 文件描述符是输出
ev.events = EPOLLOUT|EPOLLET;//监听可写 同时设置ET模式
epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
// if(events[i].events & EPOLLOUT){ 可以改成这个
if(events[i].data.fd==STDOUT_FILENO)
cout<<"hello world!"<<endl;
//去掉换行符
//程序变成挂起状态
//一次epoll_wait返回写就绪后,程序向标准输出的buffer中写入“hello world!”,
//但是因为没有输出换行,所以buffer中的内容一直存在,下次epoll_wait的时候,
//虽然有写空间但是ET模式下不再返回写就绪。这种情况原因就是第一次buffer为空,导致epitem加入rdlist,返回一次就绪后移除此epitem,之后虽然buffer仍然可写,但是由于对应epitem已经不在rdlist中,就不会对其就绪fd的events的在检测了。
}
}
}
对于上面代码分析,这是一个死循环,首先初始buffer是空,buffer中有空间可以写,这时候无论是ET还是LT都会将对应的rpitem加入rdlist,导致epoll_wait返回写就绪
程序标准输出(STDOUT_FILENO)输出"hello,world" 和换行符,因为标准输出为控制台的时候缓冲是 “行缓冲” ,所以换行符导致buffer中的内容清空,当有旧数据被发送走时,即buffer中待写的内容变少的时候会触发fd状态的改变,所以下次epoll_wait会返回写就绪,之后重复这个过程一直循环
当加上换行符 endl; 之后,我们看到程序是属于挂起的状态,因为第一次epoll_wait返回写就绪后,程序向标准输出的buffer中写入 “hello,world”,但是因为没有换行,所以buffer的内容一直存在,下次再epoll_wait的时候,虽然有写空间但是ET模式下不再返回写就绪,这种情况原因是因为第一次buffer为空,导致epitem加入rdlist,返回一次就绪后就移除此epitem,之后虽然buffer仍然可以写,但是由于对应的epitem已经不在rdlist中,就不会对其就绪fd的events进行检测了。
void LT_write(){
int epfd,nfds;
struct epoll_event ev,events[5];//ev用于注册事件,数组用于返回要处理的事件
epfd=epoll_create(1);//只需要监听一个描述符——标准输出
ev.data.fd=STDOUT_FILENO;
ev.events=EPOLLOUT;//使用默认LT模式 只要对应的文件描述符可以写 就一直写
epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);//注册epoll事件
for(;;)
{
nfds=epoll_wait(epfd,events,5,-1);
for(int i=0;i<nfds;i++)
{
if(events[i].data.fd==STDOUT_FILENO)
cout<<"hello world!";
}
}
}
对于上面这段代码分析,程序再次死循环 因为向buffer中写入 hello world时候,虽然 buffer 没有输出清空,但是LT模式下只要buffer有写空间就返回写就绪 ,所以会一直输出 hello world ,当 buffer 满的时候,buffer 会自动刷清输出 ,同样会造成epoll_wait 返回写就绪,这里我理解就是因为buffer一直可以写,所以LT模式下就一直满足条件 if(events[i].data.fd==STDOUT_FILENO)
同样,这一句代码可以换成
if(events[i].events & EPOLLOUT)
效果是一样的,可以表示 fd[i] 的事件现在可以写了
ET模式下的读写注意事项
经过前面的分析,我们可以知道,当epoll工作在ET模式下时,对于读操作,如果read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,造成buffer中已有的数据无机会读出,除非有新的数据再次到达。对于写操作,主要是因为ET模式下fd通常为非阻塞造成的一个问题——如何保证将用户要求写的数据写完。要解决上述两个ET模式下的读写问题,我们必须实现:
对于读,只要buffer中还有数据就一直读;
对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写。
解决方法(非阻塞模式)
读: 只要可读, 就一直读, 直到返回 0, 或者 errno = EAGAIN;
写: 只要可写, 就一直写, 直到数据发送完, 或者 errno = EAGAIN
多路IO复用accept为什么应该工作在非阻塞模式
如果accpet工作在阻塞模式,那么有时候会出现这种情况,TCP连接被客户端断开,在服务器accept之前,(此时select等已经返回连接到达读就绪)
客户端主动发送 RST 终止连接,导致刚刚建立的连接从就绪队列中移出,如果套接口被设置成阻塞模式,服务器就会一直阻塞在 accept 调用上,直到其他某个客户建立一个新的连接为止。但是在此期间,服务器单纯地阻塞在accept 调用上(实际应该阻塞在select上),就绪队列中的其他描述符都得不到处理。
解决办法是把监听套接口设置为非阻塞, 当客户在服务器调用 accept 之前中止某个连接时,accept 调用可以立即返回 -1。这是源自 Berkeley 的实现会在内核中处理该事件,并不会将该事件通知给 epoll,而其他实现把 errno 设置为 ECONNABORTED 或者 EPROTO 错误,我们应该忽略这两个错误。
LT模式下会不停触发socket可写事件,如何处理?
需要向socket写数据的时候才把socket加入epoll,等待可写事件。接受到可写事件后,调用write或者send发送数据。当所有数据都写完后,把socket移出epoll。
使用ET模式(边沿触发),这样socket有可写事件,只会触发一次。
在epoll_ctl()使用EPOLLONESHOT标志,当事件触发以后,socket会被禁止再次触发。
使用ET和LT的区别
LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。
ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。
参考链接:
epoll详解
ET和LT模式详解 写的很好,有代码分析,本文代码都是这里面的,不过在监听读 还是 监听写的注释会有一些小错误
彻底学会使用epoll(一)——ET模式实现分析 更加详细的解释了为什么ET只触发一次,LT可以一直触发
实例文字
实例文字
实例文字
实例文字
实例文字
实例文字
实例文字