目录
什么是IO?
IO是一个很宽泛的概念,指input/output。同时IO也是一个效率极其低下的过程,因为IO不仅仅是简单的读取数据,同时还有漫长的等待,等待数据的时间严重影响了IO的效率
IO = 等待数据 + 读取数据
因此减少等待时间所占的比重,能够有效的提升IO效率
接下来介绍五种常见的IO模型
五种IO模型
通过一个例子引入
钓鱼的故事:
张三:掉死死盯着鱼竿,鱼一上勾就拿,阻塞式
李四:边玩手机边掉,过一会就看一下鱼竿,轮询式
王五:鱼竿上挂个铃铛,铃铛一响就代表鱼上钩了,信号驱动
赵六:多支鱼竿,不断查询是否有鱼上钩,多路复用,多路转接
老板田七和秘书小王:小王钓,田七只管吃鱼,异步IO
阻塞IO
阻塞IO指当进程申请数据,但数据没有就绪时,cpu会将当前进程加入到等待队列中直到资源就绪,然后再唤醒进程进行读取数据
非阻塞IO
而非阻塞IO与阻塞IO是相反的,当发起数据申请,但资源未就绪时通过返回EWOULDBLOCK
来告知,进程通过不断轮询的方式查询,一旦资源就绪就拷贝
多路转接IO
上面说提升IO效率关键在于减少等待时间所占比重,那么如果我一次性监控多个fd,那么每一刻有资源就绪的概率就会大大提升,换句话说,每一刻只能干等着资源的概率就大大减小,因此就减少了等待时间所占的比重。这种模式提升效率是比较明显的,也是后面的重点
信号驱动IO
当资源就绪时进程会收到一个SIGIO信号,因此可以通过捕捉SIGIO来实现信号驱动IO,因此当发送了数据请求之后就只需要等待信号即可,可以避免大量无效的数据状态轮询操作。
异步IO
上述IO操作都只解决了等待数据这一部分,而对于从内核空间读取数据这件事是需要我们自己完成的,那么既然要做,为什么不全部都帮我做完呢?诶,这种等待和读取全部一肩挑的模式,就是异步IO。 当所有操作完成之后,内核会发起一个通知告知进程:活我干完了!
异步IO的优化思路是解决了应用程序需要先后发送询问请求、发送接收数据请求两个阶段的模式,在异步IO的模式下,只需要向内核发送一次请求就可以完成状态询问和数拷贝的所有操作。
IO之多路转接
select
select相关接口
//nfds: 要监控的最大fd + 1, 方便遍历 //fd_set: 是一个位图结构,属于内核,类似于进程信号中的sigblock、sigpending等 //readfds: 表示需要监控读事件的fd集 //writefds: 表示需要监控写事件的fd集 //exceptfds: 表示需要监控异常事件的fd集 //timevout: 是一个结构体,表示需要阻塞的时间,如果设为nullptr则代表永久阻塞 //返回值:-1代表出错,0代表超时,1代表有资源就绪 int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select底层原理
select通过用户设定等待队列,也就是上面的readfds/writefds/exceptfds,当进程调用到select接口时会将当前进程添加到这些sock的等待队列中(默认阻塞式,如果是非阻塞就是发送EWOULDBLOCK
信号),当有资源就绪时,os通过遍历获悉哪些fd资源已经就绪,然后将其拷贝至用户之前定义的等待队列中,所以用户定义的等待队列其实也是就绪队列
细节点
细节点1:在select中除了nfds其余的参数全部都是输入输出型参数
其中输入的readfds/writefds/exceptfds都代表要监控对应事件的fd集合
而输出的readfds/writefds/exceptfds都代表对应事件已经就绪的fd集合
而输入的timeout代表最多阻塞的时间
输出的timeout代表实际阻塞的时间
细节点2:select中的fd_set是内核数据结构,只能通过os给出的以下接口进行更改
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位 int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真 void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位 void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
细节点3:select是通过fd_set来保存需要监控的fd的,而sizeof(fd_set) = 128字节,也就
是1024bit,因此select一次最多可以监控的fd是1024个
细节点4:select中的等待队列和就绪队列是同一个参数,简单点说就是输入表示需要监控 的,输出表示实际就绪的。但是这样处理会带来一个问题:看下面这段代码
void start() { fd_set rfds; FD_ZERO(&rfds); FD_SET(listen_sock, &rfds); int max_fd = listen_sock; while (true) { int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr); // 阻塞 if (n == 0) { //超时 //logMessage会打印日志消息 logMessage(DEBUG, "time out..."); } else if (n == -1) { //错误 logMessage(ERROR, "%d:%s", errno, strerror(errno)); } else { //成功 //说明有资源就绪 //handler表示对就绪的资源进行处理 handler(rfds); } } }
这段代码的逻辑是,先将监听sock加入等待队列,然后进行select,但是由于select中的等待队列和就绪队列是同一个参数,到下一次循环的时候,这个等待队列就已经改变了。
所以说,在select中需要额外开辟一个数组保存要监听的fd,以便在每一次循环开始对等待队列做重置,同时还能够结合返回后的fd_set进行FD_ISSET操作判断资源是否就绪
select优缺点总结
优点:
- 同时能够监控多个fd,效率大大提升
- 支持微秒级别的阻塞等待
缺点:
- 由于输入输出是没有分离的,因此每一次循环开始之前,都要对等待队列进行重置,并且要将其从用户态拷贝到内核态,fd较多时花销比较大
- 一次能监控的fd是有上限的
- 在资源就绪时,os要对其进行遍历,同样的fd较多时花销也会较大
select Echo服务器代码
poll
poll相关接口
//pollfd: 结构体对象,其中包含fd,监控事件集和就绪事件集 //fds: pollfd结构体数组的第一个元素的地址 //nfds: 要监控的最大fd + 1 //timeout: 阻塞等待的时间, -1表示永久阻塞 //返回值: >0表示有资源就绪,0表示超时,-1表示出错且错误码被设置 int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; /* 文件描述符 */ short events; /* 用户定义的需要监控的事件 */ short revents; /* 实际就绪输出的事件 */ };
poll底层原理
同select
poll优缺点总结
优点:
- poll做了输入输出相分离,也就是上述结构体中的events和revents,因此它可以避免每次循环都要对等待队列进行重置
- 相比于select用三个位图结构表示等待队列,poll使用一个结构体包含所有事件,接口调用更方便
- poll中能监控的fd数量没有上限(只要服务器能抗住)
缺点:
- poll本质上还是要通过轮询来检测具体是哪一个资源就绪
- 每次调用都需要拷贝大量的pollfd数据到内核
- 在大量的fd中只有少数是会处于就绪状态的,因此很多遍历其实是不必要且拉低效率的
poll Echo服务器代码
epoll
epoll相关接口
//epoll_create的参数已经废弃,返回值是一个fd, //fd指向的stuct file中包含指向整个epoll模型的指针 int epoll_create(int size);
//epfd: epoll_create返回的fd //op: 代表不同的操作,支持增改删,分别对应EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL //fd: 代表待被操作的fd //event: 代表需要被操作的事件,可以是以下几个宏的集合 EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭); EPOLLOUT : 表示对应的文件描述符可以写; EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来); EPOLLERR : 表示对应的文件描述符发生错误; EPOLLHUP : 表示对应的文件描述符被挂断; EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的. EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需 要再次把这个socket加入到EPOLL队列里 //返回值: 0代表成功,-1代表出错且错误码被设置 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epfd: epoll_crate返回的fd // events: 数组, 存储从就绪队列中提取上来的fd // maxevents: 数组最多能容纳的fd数量 // timeout: 设置的阻塞的时间,-1表示一直阻塞 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll底层原理
创建一个epoll模型os需要做的事:
- 创建一颗红黑树用来存储用户需要监控的fd以及对应事件等等
- 维护一个就绪队列,用来存放已经就绪的fd
- 向中断处理程序中注册一个回调函数,当有fd资源就绪引起中断时,直接构建节点,并将其插入到就绪链表中
os底层会维护一个红黑树以及就绪队列(链表实现),每一个红黑树节点中就包含了要监控的fd以及要监控的事件,就绪队列中存储资源已经就绪的fd。当网卡中有数据到来时,会向cpu发送中断,cpu收到中断后就知道有数据已经就绪以及资源就绪的fd(回调机制),然后会去红黑树中查找对应fd需要关心的事件,构建节点,插入队列,用户只需要判断队列是否为空即可判断是否有资源就绪,避免了遍历操作。
细节点
一、epoll_create的返回值
上面所讲的红黑树以及就绪队列,都是归属于一个epoll模型的,一个进程可以有很多的epoll模型,在struct file中存在指向一个epoll模型的指针,因此需要单独创建一个fd以供调用epoll模型。
二、epoll为什么高效呢?(和poll,select相比)
- 在epoll中,不需要每次都去更新等待队列,减少了许多不必要的遍历和用户到内核的拷贝
- epoll维护了一个就绪列表,因此就算等待队列中要监控的fd成千上万,用户也不关心,用户只需要检测就绪队列是否为空,而不是像epoll和select一样仍然需要遍历
- epoll采用了回调机制,当资源就绪时,结合红黑树,中断程序会直接通过回调函数将资源就绪的fd及对应events构建节点插入就绪队列。
epoll工作模式
epoll有两种工作模式,分别是LT(水平触发)和ET(边缘触发)
寄快递例子:
有张三和李四两个快递员,他们都会打电话通知客户下楼取快递,但是今天他们的客户正在有事,打完一个电话后客户虽然嘴上答应立马下来,但是挂完电话后仍然是岿然不动
此时,张三会每隔一段时间重新拨打电话提醒客户(LT),而李四拨打一次电话后就不会再去联系这名客户(ET)
ET是一次事件只会触发一次,如一次客户端发来消息,fd可读,epoll_wait返回.等下次再调用epoll_wait则不会返回了
LT是一次事件会触发多次,如一次客户端发消息,fd可读,epoll_wait返回,不处理这个fd,再次调用epoll_wait,立刻返回
水平触发 Level Triggered 工作模式epoll 默认状态下就是 LT 工作模式 .
- 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
- 如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait
- 仍然会立刻返回并通知socket读事件就绪.
- 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
- 支持阻塞读写和非阻塞读写
边缘触发 Edge Triggered 工作模式如果我们在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志 , epoll 进入 ET 工作模式 .
- 当epoll检测到socket上事件就绪时, 必须立刻处理.
- 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候,epoll_wait 不会再返回了.
- 也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
- ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
- 只支持非阻塞的读写
细节点
- ET模式会倒逼程序员当数据就绪时,必须一次将全部数据读取完毕
- 为什么我必须一次把所有数据全部读取完?因为如果没有读取或者没有一次性读取完,就必须等到有新数据到来之后epoll_wait才会返回,而有些时候如果请求读取不完整是不会构建响应的,也自然不会有新数据到来,所以数据也就会变相丢失。
- 为什么ET只支持非阻塞读取? 因为我必须保证一次性能够将所有数据读取完毕,而每一次read是有上限的,所以我必须循环进行读取。一旦使用了循环就必须考虑循环终止的条件,在这里,只要一直读取,直到文件缓冲区中没有数据就代表读完了,而如果我们设置的是阻塞读取,那么这个进程/线程就堵塞在这里了,这显然是不合理的。因此要设置成非阻塞读取。
- 为什么ET比LT性能更高
1. ET只需要调用一次epoll_wait就能保证把所有数据读取完毕,而LT调用系统接口的次数可能会较多,因此ET效率会更高
2.ET保证数据会被及时读取,相比LT,接收方的接受能力会更强,能够有效提升网络吞吐量,提升通信的效率
ET本质上仅仅是倒逼程序员必须一次将数据全部读取而已,如果代码采用LT模式,但是
同样也实现一次把数据全部读完,那么ET和LT就没有什么差别了
epoll Echo服务器代码
基于ET模式编写
Reference
EPOLL原理详解(图文并茂) - Big_Chuan - 博客园 (cnblogs.com)