问题引入: Redis为什么那么快?
- 首先,采用了多路复用io阻塞机制。单线程,避免了线程之间的切换开销。
- 然后,数据结构简单,操作节省时间。
- 最后,运行在内存中,自然速度快。
一、select函数
1、代码解析
- 1、在一个for循环中,根据accept函数创建文件描述符,放入fds数组中;
- 2、在一个while循环中,先调用FD_ZERO():
- 取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd
- 将给定的文件描述符先清空,set用位表示是0000,0000;
- 3、在一个for循环汇总,调用FD_SET函数,将文件描述符数组fds数组放入到rset中;
- rset底层是一个bitmap,比如有5个文件描述符,set变为0001,0011;
- 4、执行select(6,&set,0,0,0)阻塞等待
- 首先对标志位置0,表示没数据。
- 如果有数据,则标志位置1。
- 若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意:没有事件发生的fd=5被清空。
- 5、遍历文件描述符,并且判断该文件描述符的FD_SET标志位是否为1
- 如果为1,说明有数据返回,则进行read操作,并且将数据存入buffer中以待后面使用。
2、select 函数介绍
- select()用来等待文件描述词状态的改变。
int select(int maxfd, fd_set *rdset, fd_set *wrset, fd_set *exset, struct timeval *timeout);
-
参数介绍
- maxfd:
- 是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1;
- rdset:
- 可读文件描述符的集合;
- 是指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的读变化的;
- 即我们关心是否可以从这些文件中读取数据了,如果这个集合中有一个文件可读,select就会返回一个大于0的值,表示有文件可读;
- 如果没有可读的文件,则根据timeout参数再判断是否超时;
- 若超出timeout的时间,select返回0;
- 若发生错误返回负值;
- 可以传入NULL值,表示不关心任何文件的读变化。
- wrset:
- 可写文件描述符的集合;
- 是指向fd_set结构的指针,这个集合中应该包括文件描述符,我们是要监视这些文件描述符的写变化的;
- 即我们关心是否可以向这些文件中写入数据了,如果这个集合中有一个文件可写,select就会返回一个大于0的值,表示有文件可写;
- 如果没有可写的文件,则根据timeout参数再判断是否超时,
- 若超出timeout的时间,select返回0;
- 若发生错误返回负值;
- 可以传入NULL值,表示不关心任何文件的写变化。
- exset: 异常文件描述符的集合;
- struct timeval:
- 用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。
- 是select的超时时间,它可以使select处于三种状态:
- 第一,若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止;
- 第二,若将时间值设为0秒0毫秒,就变成一个纯粹的非阻塞函数,不管文件描述符是否有变化,都立刻返回继续执行,文件无变化返回0,有变化返回一个正值;
- 第三,timeout的值大于0,这就是等待的超时时间,即select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回,返回值同上述。
- maxfd:
-
返回值
- 负值:select错误
- 正值:某些文件可读写或出错 0:等待超时,没有可读写或错误的文件。
- 如果参数timeout设为NULL则表示select()没有timeout。
-
其他函数介绍
- FD_ZERO(fdset):
- 将指定的文件描述符集清空,在对文件描述符集合进行设置前,必须对其进行初始化,如果不清空,由于在系统分配内存空间后,通常并不作清空处理,所以结果是不可知的。
- FD_SET(fd_set, fdset)
- 用于在文件描述符集合中增加一个新的文件描述符。
- FD_CLR(fd_set, fdset)
- 用于在文件描述符集合中删除一个文件描述符。
- FD_ISSET(int fd, fdset)
- 用于测试指定的文件描述符是否在该集合中,
- 用来测试描述词组set中相关fd的位是否为真。
- FD_ZERO(fdset):
3、缺点
- &rset是一个bitMap数组,最多只能存放1024位,根据操作系统而定.
- FD_SET不可重用,每次都要进入for循环中重新赋值。
- 用户态-》内核态,还是有性能开销。
- select函数是堵塞等待的,而且是O(n)的查询复杂度。
二、poll函数
1、poll函数介绍
- select() 和 poll() 系统调用的本质一样,poll() 的机制与 select() 类似,与 select() 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll() 没有最大文件描述符数量的限制(但是数量过大后性能也是会下降)。
- poll() 和 select() 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
- 监视并等待多个文件描述符的属性变化
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
参数介绍
- fds:
- 指向一个结构体数组的第0个元素的指针,每个数组元素都是一个struct pollfd结构,用于指定测试某个给定的fd的条件。
- 每一个 pollfd 结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示 poll() 监视多个文件描述符。
- pollfd结构体
- int fd:文件描述符
- short events:定义事件的类型
- short revents:定义返回事件的类型
- events:指定监测fd的事件(输入、输出、错误),每一个事件有多个取值,如下:
- revents:revents 域是文件描述符的操作结果事件,内核在调用返回时设置这个域。events 域中请求的任何事件都可能在 revents 域中返回.
- 注意:每个结构体的 events 域是由用户来设置,告诉内核我们关注的是什么,而 revents 域是返回时内核设置的,以说明对该描述符发生了什么事件。
- nfds:用来指定第一个参数数组元素个数。
- timeout:指定等待的毫秒数,无论 I/O 是否准备好,poll() 都会返回.
- -1:永远等待,直到事件发生;
- 0:立即返回;
-
0:等待指定数目的毫秒数。
- fds:
-
返回值
- 成功时,poll() 返回结构体中 revents 域不为 0 的文件描述符个数;
- 如果在超时前没有任何事件发生,poll()返回 0;
- 失败时,poll() 返回 -1,并设置 errno 为下列值之一:
- EBADF:一个或多个结构体中指定的文件描述符无效。
- EFAULT:fds 指针指向的地址超出进程的地址空间。
- EINTR:请求的事件之前产生一个信号,调用可以重新发起。
- EINVAL:nfds 参数超出 PLIMIT_NOFILE 值。
- ENOMEM:可用内存不足,无法完成请求。
- 成功时,poll() 返回结构体中 revents 域不为 0 的文件描述符个数;
工作原理
- 1、同样,在一个for循环中,使用accept函数定义了一个文件描述符,这里直接赋给了一个pollfds数组
- 将POLLIN事件类型赋给events属性。
- 2、同样,在一个while循环中,执行poll函数
- poll函数中会对有数据的文件描述符进行置位,置的是pollfd结构体中的revents属性。
- 3、遍历文件描述符,判断是否有数据,如果有进行read操作
- 此时判断revents属性 和 事件类型
优化点
- 解决了&rset的存储上限问题,声明了一个pollfds数组,理论上无限制。
- 解决了&rset不可重用的问题,使用pollfd结构体中的revent属性来控制该文件描述符是否有数据达到。
三、epoll函数
3.1、函数介绍
3.1.1、epoll_create
int epoll_create(int size);
- 创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。
- 这个参数不同于select()中的第一个参数,给出最大监听的fd+1值。
- 当创建好epoll句柄后,它就是会占用一个fd值,在linux的/proc/process中可以查看到该fd值。
- 所以再使用完epoll后,必须调用close关闭,否则可能导致fd被耗尽。
- 当调用该方法时,还会创建一个evemtpoll结构体:
struct eventpoll {
...
/*
* 红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
* 也就是这个epoll监控的事件
*/
struct rb_root rbr;
/*
* 双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件
*/
struct list_head rdllist;
...
};
- 内核不仅在epoll文件系统里创建一个file节点句柄,还创建了一个以上的结构体。
- 在内核cache中创建一个红黑树用户存储以后epoll_ctl传来的socket。
- 还会再建立一个rdllist双向链表,用于存储准备就绪的事件。
- 当epoll_wait调用时,仅仅观察这个rdllist双向链表里是否有数据,如果有就返回,没有就sleep,等到timeout事件到后即使链表没有数据也返回。
3.1.2、epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- epoll的事件注册函数,不同于select()函数,是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
- 参数介绍:
- int epfd:epoll_create返回的epoll句柄对象。
- int op:表示注册的动作
- EPOLL_CTL_ADD: 注册新的fd到epfd中
- EPOLL_CTL_MOD: 修改已经注册到epfd中的fd。
- EPOLL_CTL_DEL: 从epfd中删除一个fd。
- int fd:需要监听的fd,一个fd文件描述符代表一个客户端socket。
- struct epoll_event: 告诉内核需要监听什么类型的事件。
- struct epoll_event结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
- events可以是以下几个宏的集合:
- EPOLLIN:表示对应的文件描述符可以读(包含对端socket正常关闭)
- EPOLLOUT:表示对应的文件描述符可以写
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读,表示有带外数据到来
- EPOLLERR:表示对应的文件描述符发生错误
- EPOLLHUP:表示对应的文件描述符被挂断
- EPOLLET:将EPOLL视为边缘触发模式(Edge Triggered),相对于水平触发模式(Level Triggered)。
- EPOLLONESHOT: 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到epoll句柄中。
- 事件发生后的回调
- 当监听的事件发生之后,会调用一个回调方法,叫做ep_poll_callback.
- 在epoll中对于每一个事件都会建立一个epitem结构体。
- 然后将发生事件的epitem放入rdllist双向链表中。
struct epitem {
...
//红黑树节点
struct rb_node rbn;
//双向链表节点
struct list_head rdllink;
//事件句柄等信息
struct epoll_filefd ffd;
//指向其所属的eventepoll对象
struct eventpoll *ep;
//期待的事件类型
struct epoll_event event;
...
}; // 这里包含每一个事件对应着的信息。
3.1.3、epoll_wait
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
-
等待事件的产生,类似于select()调用。
-
参数介绍:
- int epfd:epoll_create返回的epoll句柄对象
- struct epoll_event * events:从内核中得到的所有事件集合。
- int maxevents:告知内核最大可以处理的事件数量,不能大于创建时的size。
- int timeout:超时时间;
- 0:立即返回
- -1:将不确定
-
0 : 等待的时间数。
-
工作原理:
- 调用epoll_wait()时检查是否有发生事件的连接,去检查epoll_ctl()函数创建的eventpoll对象中的rdllist双向链表中是否有epitem元素而已。
- 如果rdllist链表不为空,则这里的事件复制到用户态内存中(使用共享内存提高效率),同时将事件数量返回给用户。
-
总结
- 一颗红黑树,一张准备就绪句柄链表rdllist,少量的内核cache,就帮我们解决了大并发下的socket处理问题
- 执行epoll_create()时,创建了红黑树和就绪链表。
- 执行epoll_ctl()时,如果增加socket句柄,则检查红黑树中是否存在,如果存在立即返回,不存在则添加到红黑树上。
- 然后向内核注册回调函数,用于当中断事件发生时,向就绪链表rdllist中插入数据。
- 指定epoll_wait()时,立即返回rdllist链表中的数据即可。
3.2、epoll的触发模式
3.2.1、EPOLLLT 水平触发
- 水平触发模式Level Triggered,默认模式。
- 在该模式下,只要这个文件描述符还有数据可读,每次epoll_wait都会返回它的事件,提醒用户程序去操作。
3.2.1、EPOLLET 边缘触发
-
边缘触发Edge Triggered
-
在该模式下,在它检测到有I/O事件时,通过epoll_wait调用会得到有事情通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让errno返回EAGAIN为止,否则下次的epoll_wait不会返回余下的数据,会丢失事件数据。
- 如果ET模式不是非阻塞的,那这个一直读或者一直写势必会在最后一次堵塞。
-
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
-
- epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
-
epoll为什么要有EPOLLET触发模式?
- 如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。
- 而采用EPOLLET这种边缘触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。
- 如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!
- 这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
-
总结
- ET模式(边缘触发)只有数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返回;
- LT 模式(水平触发,默认)只要有数据都会触发,缓冲区剩余未读尽的数据会导致epoll_wait返回。
总结
- epoll_create:redis服务器在启动时,创建事件循环,调用epoll_create方法创建epoll实例。
- epoll_ctl:当有新的客户端连接时,把新的连接描述符注册到epoll实例。
- epoll_wait:调用epoll_wait获取客户端产生的io事件。