目录

一、五种IO模型

以网络为例

什么叫做IO?

什么叫做高效的IO呢?

为什么第二个大爷的效率很高呢?

五种IO模型

感性认识

这五个人,在钓鱼的时候,谁的效率是最高的?

阻塞IO

非阻塞IO

信号驱动IO

IO多路转接

异步IO

二、同步通信 vs 异步通信(synchronous communication/ asynchronous communication)

区分同步IO和异步IO

三、阻塞 vs 非阻塞

什么叫做等事件就绪?

为什么OS也有自己的缓冲区呢?

其他高级IO

四、非阻塞IO

fcntl

实现函数SetNoBlock

五、I/O多路转接之select

select

select函数原型

以读事件就绪为例

参数timeout

select的返回值

ft_set

理解select执行过程

你的程序,怎么知道你都有哪些fd呢?

准备编码

当我们写到这以后,应该直接进行accept新连接吗?

走到这里,我们知道至少有一个fd就绪了,你怎么知道哪一个fd的读事件就绪了呢?

但是读事件就绪,就一定可以read或者recv吗?

获取成功连接后,可以直接read或recv了吗?

什么时候数据到来呢?

接下来编写普通的socket读事件就绪

select的优缺点

优点

缺点

socket就绪条件

读就绪

写就绪

异常就绪

六、I/O多路转接之poll

poll函数接口

poll是干什么的?

struct pollfd的结构解析

select是有读事件,写事件,异常事件三种,对于poll如何体现出不同的事件呢?

一个fd关心事件时,只用了一个short,如果我这个fd想既关心读又关心写呢,是不是这一个fd就不能表示?

当返回时如何知道有哪些事件已经就绪了呢?

将我们之前的select_server代码改写成使用pool的代码

针对pool写一个简单的样例

poll和select有什么差别呢?

poll的优点

poll的缺点

七、I/O多路转接之epoll

epoll(extend poll)是干啥的?

epoll初识

epoll的相关系统调用

epoll_create

epoll_ctl

epoll_wait

epoll服务器

epoll的原理

背景引入

OS是怎么知道网卡上有数据的呢?

但是数据到了内存就会通知你这个进程数据已经就绪了吗?换句话说数据到了OS内的各种接收缓冲区里面,是否就相当于数据已经就绪了呢?

一旦事件就绪的时候,OS要不要还在去帮你检测下,你这个进程所关心的其他fd对应的缓冲区上有没有?

epoll的回调机制

epoll底层处理过程

OS是如何做到然你在这个就绪队列里等呢?

总结一下

红黑树节点里的键值是什么?

为什么要建立红黑树?

继续编写事件就绪以后的代码

epoll服务器目前存在的问题

epoll的原理总结

epoll的优点(和 select 的缺点对应)

注意!!

epoll的工作方式

感性认识

如何让epoll更改成边缘触发呢?

ET模式下的fd,必须是非阻塞的原因

我们现在处于ET模式,当事件就绪只会通知1次,当你准备通过read,recv,accept进行读取的时候,如何保证将本次的数据全部读取完呢?

循环读取的时候,什么时候代表读取完毕呢?

如何解决这种问题呢?

ET与LT的效率

epoll的使用场景

epoll中的惊群问题


所谓的读写文件或者读写网络,本质上叫做拷贝。实际上真正的把数据刷新到磁盘,从磁盘里把数据转移到内存,以及把数据刷新到网络,以及从网络中收到数据,这个过程是OS的事情。我们曾经学到的IO接口都可以称之为拷贝接口。

一、五种IO模型

以网络为例

传输层一定会存在两个缓冲区,一个是发送缓冲区,一个是接收缓冲区。你曾经在应用层调用send或者recv的时候,你以为你发送数据的时候是把数据发送到网络里,实际上只是你把数据直接拷贝到了TCP的发送缓冲区里,同时你自己通过recv或者read也自己定义一个缓冲区读取数据,读数据也是从接收缓冲区里拿的,有数据让你拿你就能拿到,没有数据你就等待。我们以接收为例,当对方给我发消息的时候,他所有的消息都相当于是入栈到缓冲区的,当缓冲区满的时候,我通知对方缓冲区满了,对方就不发了,我的缓冲区满的原因无非就是上层来不及取或者取的太慢了,所以最后由于数据来的太快,缓冲区满了。所以当缓冲区满的时候还想要对方尽快发送消息,只要上层把数据取走即可。所以这个就可以看做是一个生产消费者模型,生产方就是对方,消费的一方就是应用层,缓冲区就是交易场所。所以我们的流量控制,通告对方我的窗口大小,结合起来这个就是环形队列的生产消费模型。当生产者把数据生产满的时候,就不能生产了,必须等消费者消费。所以网络中处处有系统,系统中处处有网络。

什么叫做IO?

我们以读为例,当数据转备好的时候我们就可以读上来,当缓冲区可以让发送方发送的时候,就可以发送。我们现在重新认识下IO,比如说今天对方要给我发送数据,那上层就进行取数据,如果此时发送方一个数据也不发,那么此时接收缓冲区里没有数据,应用层也不知道你的缓冲区里没有数据,只能去调用read函数去看,发现没有数据,一般情况下,如果发现底层没有数据,那么应用层只能去等。我们所谓的IO过程,在计算机看来,如果底层没有数据,你就必须要等,对于接收缓冲区就是没有数据,对于发送缓冲区就是没有空间(上层一直拷贝数据,一直拷贝,导致没有空间了,发不出数据了),总之,我们一定会因为某些条件而导致无法立即发送和接收,在这种情况发生的条件下,你只能等。所以“等待”也属于IO的一个重要环节。

我们之前的理解:真正的IO就是把数据从系统发送到网络或者从网络读取到系统内部,或者从内存刷新到磁盘,执行这个动作的时候叫做IO。但是,实际上遇到的大部分IO=等+拷贝数据

就好比今天有个大爷去钓鱼,当他把鱼钩鱼饵撒向河里面的时候,他在那里静静等待的时候,你问他在干啥,大爷就说他在钓鱼。实际上,当大爷等了10分钟,直接把鱼钓上来。在刚刚大爷执行钓鱼动作的一刹那,你问大爷你干啥呢,大爷又说我在钓鱼。这两个钓鱼并不是一个维度的概念,第一个钓鱼可以理解成一个名词甚至是一个形容词,说的是我正在执行钓鱼的整个过程;当大爷把鱼真正的从河里钓上来的时候,这个才叫做真正的钓鱼,此时的钓鱼是一个动词。而我们实际上遇到的大部分IO,就好比你问正在等待鱼上钩的大爷,你在干哈,它是一个名词或者形容词,当大爷回答我在钓鱼的时候,他想表达的是我钓鱼的过程就包含了等和拷贝的问题。

什么叫做高效的IO呢?

在物理层面是就是把传送数据的带宽变大。但我们谈论的是在软件层面,就已经假定物理层面不在变化了。

今天你在河边又看见一个大爷,他一直在执行把鱼饵和鱼钩放下去,等待的时间不超过10秒钟,他就立即把鱼钓上来了;所以他一直在疯狂的挥舞着鱼竿,只要他把鱼饵和鱼钩撒下去,然后在往上一拉就能调到一条鱼...所以你就能看到这个大爷疯狂的挥舞着鱼竿,疯狂的钓着鱼。所以这个大爷就和上一位大爷形成鲜明的对比。因为这两个大爷年龄一样,在同一片河里调用,而且用的鱼饵和鱼杆都一样,甚至用的技术动作都是一样的,所以第二位的大爷效率非常的高。

为什么第二个大爷的效率很高呢?

IO=等+拷贝,本质上是因为第二位大爷在钓鱼(IO)过程中“等待”的比重非常的低,所以他效率就高。所以高效的IO,本质就是减少单位时间内,“等”的比重!

所以针对IO的话题:

  • 1.改变等待的方式
  • 2.减少等的比重

五种IO模型

故事背景:钓鱼的区域是一条河,对应我们的接收缓冲区;河中的鱼我们可以想象成一条条数据;钓鱼的时候一方面你得等,另外一方面当鱼咬钩了你得钓,真正钓鱼的过程就是把鱼从河里拉上来,这叫做从内核层把数据拷贝到用户层,相当于你把鱼钓上来放到桶里面,这叫做拷贝的过程。大部分情况下,我们在等鱼上钩,说白了就是在等数据就绪。

感性认识

  • 有一个钓友叫做张三,今天他来钓鱼,张三这个人做事一心一意,当他把鱼钩,鱼饵,鱼鳔放下去,他就死死地盯着鱼鳔,只要有鱼咬钩他就立马钓上来;如果不咬钩的时候,张三就死死盯着鱼鳔。此时,张三就在死死的盯着鱼鳔,死死地等着,忽然这个鱼鳔上下浮动了,此时他知道鱼上钩了,然后他就迅速的把鱼钓上来放在自己的桶里。
  • 另外一个钓友是李四,他性格开朗,认识张三,他钓鱼的时候就把鱼钩,鱼儿,鱼鳔放下去,然后他看了下鱼鳔当前没有动,所以他就转过头问张三,“今天钓了多少鱼?”意料之中,张三没有理他,就是死死盯着鱼鳔。然后李四自讨无趣,回头看了眼自己的鱼鳔,发现还是没动,然后李四一会看看手机,一会看看书,一会又去问张三钓了多少鱼...重复的干着这些事情,然后李四突然发现自己的鱼鳔动了,所以李四迅速的拉起杆来,他也钓上了一条鱼。张三是一心一意,李四就是三心二意。
  • 一位钓友叫做王五,他是个聪明人,也来钓鱼,王五看到一动不动的张三和一直在动的李四,很是不屑,他也坐在他俩跟前,他除了基本的钓鱼设备外还带了一个铃铛,然后他把铃铛挂在了鱼竿顶部(完全理想情况),然后开始钓鱼,他呢把鱼竿往那一放,然后就不管了,全心全意的做其他事情,但是王五始终没有没有离开自己的鱼竿,因为王五知道,虽然他在干其他事情,但是铃铛只要一响,他该干啥了。王五就做着自己的事情,但心里很清楚铃铛响了该干啥。突然,王五的铃铛响了,王五头也不抬,就把鱼竿吊起来,就钓上了一条鱼。张三,李四是主动去检测鱼鳔看是否有鱼上钩,王五就变成了以铃铛的方式通知他是否有鱼上钩。
  • 还有一位钓友叫做赵六,他是一个简单粗暴的有钱人,他也来钓鱼,但是赵六拉了500支鱼竿,他把所有的鱼竿都放上鱼饵,鱼鳔,插到岸边,开始钓鱼。然后赵六定期的在岸边轮询检测鱼竿,当他在检查的时候,突然发现有一个鱼鳔动了,有鱼咬钩了,然后他就把对应的鱼杆拎起来,把鱼钓上来。钓上鱼以后,又放置上鱼饵在继续钓。然后继续进行检测是否有鱼上钩。
  • 还有一位老板叫做田七,他是一个更有钱的人,他想吃鱼,但是他不想钓鱼,因为田七着急去开会,就让司机小王把车停在岸边,然后然司机小王拿上钓鱼相关的工具,水桶(放鱼的),以及电话。然后就告诉小王,我先开车去开会,你在这钓鱼,等你把这个桶钓满的时候,就给我打电话来接你。然后小王,就开始钓鱼了,钓满以后就给老板打电话说钓好了。

这五个人,在钓鱼的时候,谁的效率是最高的?

钓鱼=等+钓。谁等的比重低,谁的效率高。这五个人,除了赵六都是只有一个鱼竿,站在鱼的角度,发现水面上有504个鱼竿,其中500个都是赵六的,鱼咬饵的时候,咬到赵六的杆的概率最大。所以,在特定时间内,钓鱼效率最高的是赵六。赵六的这种钓鱼方式就叫做多路转接也可以称之为多路复用,这个鱼竿就等价于fd(文件描述符)。所以你可能看到的情况,就是另外四个人在河边静静的坐着,但是赵六一直忙着钓鱼。单纯在IO的效率上,另外四个人的效率是没有差别的,因为鱼上钩的概率都是1/504。

先不考虑田七,对于张三和李四,钓鱼的效率是一样的,但是张三在一心一意的坐着检查,这种钓鱼方式叫做阻塞等待,如果鱼鳔不动也就是就绪条件不满足,我就一直卡在那里知道条件满足。

李四虽然也在等,但他是定期检查一下,他看到鱼鳔不动也就是就绪条件不满足,他不会一直在这等鱼鳔就绪,而是立马返回,返回之后,以非阻塞的方式做其他事情。这种等待方式叫做非阻塞等待。李四和张三虽然在钓鱼的效率上是一样的,但是李四却多做了一些其他事情,虽然李四无法决定数据什么时候来让我去拷贝,也就是无法决定鱼什么时候咬钩,但是他可以决定在等的时候,不要白等,而是可以做些其他的事情。

阻塞等待和非阻塞等待两中IO都需要自己去参与,自己去等,但是等待的方式是不一样的。

对于王五,虽然挂上了铃铛,但他也是参与了钓鱼的,他最大的特点就是不主动的对就绪条件做检测,而是通过设置铃铛保证底层一旦有数据就绪时,就通知他。他虽然参与了,但是他改变了等待的方式,不主动去等。这种方式就叫做信号驱动。

对于田七,他没有等,也没有参与钓鱼,他只是发起了钓鱼,然后把事情交给了小王,小王完成事情后电话通知田七,田七就拿走了所有的鱼(桶是田七的)。田七只是发起了钓鱼的事件,小王钓鱼过程中的细节,田七一点也不关心。田七压根不参与钓鱼的IO过程,我们称作异步IO。田七只关心最终数据有没有准备好,至于什么时候钓的,多少时间钓的,一点也不关心。

张三,李四,王五,赵六都要整体或者局部的参与钓鱼的过程。王五看似没等,但是他也要参与把鱼钓上来这一步骤。信号驱动本来就是异步的,但是整个异步只是告诉他有鱼上钩了,但是真正钓鱼还得王五自己来。所以这四种IO都可以称为同步IO。

我们说过所有针对IO的话题:1.改变等待的方式 2.减少等的比重  围绕这两点展开。张三,李四,王五改变的都是等待的方式。尤其是李四和王五,他可以把等待的时间利用起来,从而提高服务器的效率,但并不是真正意义上IO效率的提高,改变的是等待的方式。赵六就是减少等的比重。

几乎所有的IO函数,它的核心工作其实是两类:1.等(等数据就绪) 2.拷贝(从内核层把数据拷贝到用户层)。

阻塞IO

阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式.
阻塞 IO 是最常见的 IO 模型 .

对于recvfrom,应用程序通过系统调用检查OS内有没有把数据准备好(数据要么从网络来,要么从外设来),数据没来只能等待,在你看来,这个recvfrom就是卡住了。在系统角度就是调用recvfrom的这个进程放在了等待队列里,状态设置成了S状态。数据来了,OS就把你从S状态设置成R状态,把进程设置到运行队列,然后你继续调用recvfrom的时候就执行你的拷贝功能。拷贝的时候就把数据从内核拷贝到用户空间。

非阻塞IO

非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符 , 这个过程称为 轮询 . 这对 CPU 来说是较大的浪费 , 一般只有特定场景下才使用

我今天调用recvfrom,问内核有没有把数据准备好,如果没有recvfrom就返回,返回之后你可以在OS准备数据的时候干自己的事情。如果数据没准备好就返回,这就是非阻塞等待。基于非阻塞接口不断的去检查,这个就叫做非阻塞轮询。数据准备好后,就把数据从内核拷贝到用户。这就是IO分为等和拷贝。

信号驱动IO

信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作

这个SIGIO信号不建议使用(使用成本高)。针对真正方式,首先把SIGIO信号自定义捕捉一下,然后我就干其他事情,啥都不管,当底层的数据准备好以后,此时OS直接向目标进程递交SIGIO信号,然后你就会执行当初用SIGIO信号注册的回调方法,注册好之后它这个内部方法里面会调用recvfrom,这个时候recvfrom就直接进行拷贝数据,这种方法就是真正的只使用了recvfrom的拷贝方法。这个在等待数据期间一定是异步的过程,因为什么时候数据就绪我们并不清楚,但是数据一旦就绪时,依然需要你这个执行流调用信号处理方法,把数据从内核拷贝到用户,你是需要参与IO的,虽然你参与的只是最后一步。

IO多路转接

IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态

所有的IO接口都做了两件事情等和拷贝,OS就提供一个接口select,select就给recvfrom说,你这个接口以后不要等了,所有的fd的等待你就交给我,select接口的特点是一次允许等待上几百个甚至上千个接口的,我select的工作只负责等,你的工作只负责拷贝。相当于所有的fd的等待工作交给select,哪个好了,我就告诉你。这个就像赵六不想自己去轮询检查了,所以他花钱雇了一个小张, 让小张去做轮询的检查,赵六就在岸边想干哈干啥,但是一旦有鱼上钩的时候,小张就通知赵六,让赵六亲自把鱼钓上来。select就相当于赵六雇佣的小张,recvfrom就相当于赵六。小张就托管了大量的fd,只要有一个fd就绪就通知赵六,赵六就用recvfrom把数据拷贝上来。阻塞和非阻塞都用的是recvfrom一个接口即等又拷贝,多路转接这里就是让select去等,让recvfrom去拷贝,把IO的两个步骤拆分,让不同的函数去完成。这个select就叫做多路复用,一旦数据就绪让recvfrom读就可以。

异步IO

异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).

PS:异步IO可能要被更好的方案去解决了,所以不推荐用

异步IO就是,我要读数据,读数据的时候,我的应用层进程就调用对于的异步IO调用,我的应用层进程就是田七,这个内核就是小王。这个异步IO的函数是需要传入一个内存缓冲区的,相当于田七给小王的那只桶,田七告诉小王桶的位置,数据就绪之后,要求小王把数据放在桶里面。至此,应用层进程就再也不管了,它发起了IO。然后等待数据的过程就是OS帮我做,当数据准备好了,OS会自动把数据从内核拷贝到用户空间,拷贝完成之后就给田七打电话,告诉田七你现在可以处理数据了,因为数据已经放到了田七曾经给你的缓冲区中,田七只需要处理数据,不需要参与任何IO的过程。

任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝. 而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少.

二、同步通信 vs 异步通信(synchronous communication/ asynchronous communication)

区分同步IO和异步IO

只要在IO过程中(等和拷贝),有一方你是参与的,你就不是异步的。你只要完全不参与IO,给我一个缓冲区你就是异步。

另外 , 在 多进程多线程中 , 也提到同步和互斥 . 这里的同步通信和进程之间的同步是完全不相干的概念.

多进程或者线程中的同步本质上是让多执行流在执行的时候按照一定的顺序进行协调访问我们的临界资源。同步可以有效的解决饥饿问题。

线程和进程同步与同步IO(同步通信)两个是完全不一样的概念。一个是处理IO的过程,一个谈的是进程/线程间协作的事情,是不同的两个概念。

三、阻塞 vs 非阻塞

阻塞和非阻塞共同点是他们都叫做同步IO,他们两个唯一的区别在于等的方式不一样,拷贝都是一样的,都是得他们自己去拷贝。

什么叫做等事件就绪?

等事件就绪我们也称之为IO事件就绪。第一点就是读事件就绪。第二点就是写事件就绪。

写事件就绪:只要对应的发送缓冲区里有空间,比如我要发100个字节,发送缓冲区里还有200个字节空间,那么空间够用,我就叫做写事件就绪,也就是可以拷贝。这就叫做写事件就绪。所以当我们发送的时候,我们对应的发送条件是满足的,比如发送缓冲区有足够的空间承装我们对应的数据,这就叫做写事件就绪,可以拷贝。

读事件就绪:当接收缓冲区里面有数据的时候,我们就叫读事件就绪。不过,每一次拷贝都是从用户到内核,从内核到用户的一次切换和状态变化,这种用户到内核,内核到用户的那种状态变化实际是一种低效的表现,所以实际上(以接收缓冲区为例)当我们接收的时候,比如缓冲区里有10个,20个字节的时候,OS可能并不会通知应用层,因为上层一次拿的数据太少了,所以OS可能是让接收缓冲区积累上一些数据,然后再通知上层去读,这样就只需要经过一次的从用户到内核,从内核到用户的状态切换,从而拿到更多的数据,其次这里的通知还是不通知我们称之为水位线的概念,低于水位线就暂时不通知,高于水位线立马通知可以读了。发送缓冲区一样有水位线的概念,超过水位线我们就可以发了,没超过可以暂时不发...

所以读事件就绪和写事件就绪就是有数据还是有空间的问题。正因为有水位线的概念,所以TCP中的psh就是让OS尽快将数据交付给上层,psh就是强制的告诉OS不要管水位线了,赶紧通知上层可以拿数据了。所以OS能做的仅仅是告知上层数据OK了,如果OS通知了,你上层就是故意不取数据,这就是程序员的问题。

再次理解曾经的文件操作 ,我们读写文件的时候,是先把文件读到OS的缓冲区里面,OS把数据从磁盘换入到系统当中,再从系统中把数据拷贝到用户。我们常说,用户层语言给我们提供了什么缓冲区,OS实际上也有自己的缓冲区。

为什么OS也有自己的缓冲区呢?

因为OS内核中可以预读数据除了有效率方面的提升之外(IO过程就是要等待和拷贝,所以当我们把数据换入到内存中的时候,我们经常read/write文件,数据写到缓冲区里,主要考量的就是效率);当你读写的时候你会发现你的读写要么条件满足,要么条件不满足,本质上就是缓冲区里面的数据有么有就绪,如果缓冲区不足,你就继续等。如果底层没有数据,此时OS就从外设(磁盘)把数据加载到内存当中,这个就是在系统层面和应用层面进行解耦的一种做法。因为有缓冲区的存在,OS可以安心做自己换入或者刷盘的操作,而你就可以安心的做自己从应用层拷贝到内核,从内核拷贝到应用层的操作。因为有缓冲区的存在在逻辑上解耦了,所以会导致OS的系统接口设计和OS内核的具体实现比较简单。要不然我这个系统调用read和write就必须从头到尾一路干下去,从用户层对应的缓冲区数据必须直接刷新到硬盘上直到调用台再完成。那么就不可能把用户解放出来,因为这等磁盘,寻道的过程就全部记录在了用户的脑袋上;OS也不可能有预读的策略,那么整个IO的过程就全取决于硬件的效率了,所以效率一定是非常低。 因为有内核缓冲区的存在,所以OS可以从中进行优化,比如你把数据写到缓冲区里,你的任务就完了,你认为你写完了,实际上OS后面还要给你发呢,就好比你自己在网络里发数据的时候,你以为你把数据发送出去了,实际上你的数据还在计算机里,什么时候发,发多少完全由网络决定。什么时候刷,刷多少,这完全由OS决定。

其他高级IO

非阻塞 IO ,纪录锁,系统 V 流机制, I/O 多路转接(也叫 I/O 多路复用) ,readv 和 writev 函数以及存储映射 IO( mmap ),这些统称为高级 IO

四、非阻塞IO

fcntl

函数原型如下.
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

第一个参数就是文件描述符,第二个参数是代表对文件描述符做的什么操作,第三个是可变参数

传入的cmd的值不同, 后面追加的参数也不相同.
fcntl函数有5种功能:

  • 复制一个现有的描述符(cmd=F_DUPFD).
  • 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
  • 获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
  • 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
  • 获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW).

我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞。

实现函数SetNoBlock

基于fcntl, 我们实现一个 SetNoBlock 函数 , 将文件描述符设置为非阻塞
void SetNoBlock(int fd) { int fl = fcntl(fd, F_GETFL); if (fl < 0) { perror("fcntl");return; }fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
使用F_GETFL将当前的文件描述符的属性取出来(这是一个位图).
然后再使用F_SETFL将文件描述符设置回去. 设置回去的同时, 加上一个O_NONBLOCK参数

PS:我们之前在open文件的时候就可以设置各种选项,甚至设置O_NONBLOCK or O_NDELAY这样的选项,它就可以以一个非阻塞的方式打开。

对于fcntl即便它已经打开fd了,我们也可以设置它的状态标志位,把它设置成非阻塞的。这样我们就完成了一个非阻塞操作。

eg:阻塞等待

我们用read读数据,我们从0(标准输入)读数据,将数据读到buffer中。如果读成功了,就把信息写出来。我们就写了一个基本的一直进行的读写程序,先读,读完之后就写出去。

我们运行起来始终是这个界面,说明和这个进程在“等” ,等数据就绪(我是从0里读的,0号数据描述符里此时没数据,因为我没有从键盘上写),这就叫做阻塞等待,当我从键盘输入hello+回车(代表条件就绪了),此时就可以读到我的hello,并显示出来。完成以后发现又开始阻塞了...

这个进程也一定处于sleep状态

eg:非阻塞等待

  void SetNonBlock(int fd){int fl = fcntl(fd, F_GETFL);if(fl<0){perror("fcntl");return;}fcntl(fd, F_SETFL, fl | O_NONBLOCK);}int main(){SetNonBlock(0);while(1){ char buffer[1024];ssize_t s = read(0,buffer,sizeof(buffer)-1);if(s>0){buffer[s]=0;write(1,buffer,strlen(buffer));                                                                                                    printf("read success, s: %d, errno: %d\n", s, errno);}else{printf("read failed, s: %d, errno: %d\n", s, errno);}sleep(1);}return 0;}

SetNonBNlock函数的功能就是设置fd为非阻塞。我们在主函数中,先将0号fd设置成非阻塞。 在read成功的时候,输入下read的返回值(返回字节数),再输出下errno(errno是C语言提供的变量,标识的是最后的报错,意味着当你调用某些调用时会默认把这个变量设置好,设置好之后你就可以获取到这个值,然后根据这个值判定是什么原因出错了)。失败了同样输出一下。

系统调用返回值为什么会设置C语言当中errno的变量呢?

原因就是linux是C语言写的。

执行结果:

什么都不输入,就一直输出读取失败,疯狂的检查数据有没有就绪

当输入数据以后,就检查到,读取成功。

在非阻塞情况下,我们读取数据,如果数据没有就绪,系统是以出错的形式返回的(但并不是错误),没有就绪和和真正的出错使用的都是同样的方式标识,如何进一步区分呢?

就需要通过检测errno进行区分。我们可以看出我们执行出来的errno值是11。我们再看一下read介绍

如果这个文件描述符或者是套接字,已经被O_NONBLOCK标识了(标识成了非阻塞),当他被读取的时候将会被阻塞(意思就是如果你的fd被标识了非阻塞,那么读取数据,数据没有就绪的话,最后给你返回EAGAIN),那么这errno就会被设置成EAGAIN,这个EAGAIN的值就是11。

所以我们可以将代码改进一下,在read失败的时候,多加一个判断,明确这个出错是由数据没就绪引起的,还是read真的失败了

但是我们发现当成功读取的时候,返回的errno还是11,正常情况下,如果把缓冲区给满。errno大概率是置成0的,但我们实际发现还是11。原因就是read在读取成功的时候不会去设置errno,那这个errno是上次的结果,就一直都是11,读取成功了,也就没人关心errno了。

PS:我们设置0号fd,就是因为0号fd是需要用户参与的。

五、I/O多路转接之select

linux中最常见的多路转接的方案就是三种,分别是select,poll,epoll。select是出现最早的,但是实现起来是最麻烦的。

select

多路转接只负责一件事情,只负责等的过程!在我们进行IO的时候,任何一次IO都只是通过fd做IO,但是多路转接(比如你调用read或者write都必须一定要有文件描述符,没有就绪的时候,就自己等,拷贝的时候自己拷贝)只做一件事情,就是等,只要等就绪了,就把事件通知上层,让上层进行读取

select定位:只负责等待,等到fd就绪,通知上层进行读取或者写入。select是没有所谓的读取和写入数据的功能的。

read,write,recv,send本身也有等待的功能,但是这些接口只能传入1个fd。而select能够同时等待多个fd,也就意味着至少有一个fd就绪的概率就大大增加了,这样我们就可以保证哪个fd需要被读取时就直接read,这个时候read就不会被卡住,而是直接去读取你想读取到的数据。

select函数原型

select的函数原型如下:

 #include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout); 

参数解释:
参数nfds是需要监视的最大的文件描述符值+1;

fd_set是一个位图结构,因为fd是从0开始的整数。位图结构是可以用比特位的位置来标识哪一个文件描述符。比特位的位置和比特位的内容是两码事。

当我们进行读写文件描述符的时候,最关心的就是:读就绪,写就绪,异常就绪(一般不考虑)。能不能读就叫做读就绪,能不能写就叫做写就绪。我们专业化描述下就叫做读就绪事件和写就绪事件。读就绪事件就是你的底层已经把数据拿到了系统中可以供上层读取了;所谓的写事件就绪就是我的发送缓冲区的剩余空间可以让用户把数据从用户层拷贝到内核层 ,然后让TCP协议发送。所谓的异常就比如我正要进行读写的时候,对方连接关了,对方网络崩溃了,这就是异常。

因为事件分为3类,所以rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合;它们3个只关心对应的读,写,异常。

以读事件就绪为例

select能等多个fd,但是它不知道自己要等哪些fd。所以我们要使用select就要明白两点:

1.用户告知内核(因为select是系统调用,所以一定是用户和OS交互),你(OS)要帮助我关心哪些fd上的读事件就绪。

2.内核告知用户,你(用户)所关心的哪些fd上的读事件已将就绪。这就是select的核心功能!!!

而且我们发现这个参数是一个位图结构的指针fd_set *readfds,这个参数是一个输入输出型参数。当他输入时是用户在输入,含义就是用户告知内核,你要帮助我关心哪些fd上的读事件就绪;当他输出时是内核输出,含义就是内核告知用户,你所关心的哪些fd上的读事件已将就绪。

当OS进行返回的时候,还会进行修改这个位图,比如:发现3号就绪了,1,2,4都没就绪,那么此时OS就把除3号以外的其余几号都置0。

所以:

  • 比特位的位置代表是哪一个fd。
  • 比特位的内容:输入的时候是:用户告诉内核,你要帮我关心的fd集合;输出时:内核告诉用户,你关心的哪些fd上面的事件已经就绪。写事件和异常事件都是一个道理,一毛一样的。

参数timeout

参数timeout为结构timeval,用来设置select()的等待时间。

比如:你今天让我等1,2,3,4号文件描述符,可是等了很长时间没有一个就绪的,select本身也在等,select的等待策略分别由3个:

  1. 只要不就绪,我就不返回(阻塞式等)。
  2. 只要不就绪,立马就返回(非阻塞等)。
  3. 设置好deadline,deadline之内,deadline之外。比如:我把deadline设置成5s,5s之内遵守规则1(只要不就绪,我就不返回),5s之后遵守规则2(只要不就绪,立马就返回)。

但是针对这三种方式,都是只要就绪,我就立马返回。

timeval的结构

第一个参数就是秒,第二个参数就是毫秒。

这个timeout也是一个输入输出型参数,比如你传入5s钟,如果5s超时了,最后返回timeout的时间就只剩0s了;如果在3s时候就已经就绪了,那么剩余的时间就还剩2s,所以timeout就代表里面还剩多少时间,这就叫做它的输入输出。

参数timeout取值:

  • NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件,就是阻塞。
  • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生,就是非阻塞。
  • 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。

所以select的参数只有第一个是输入型的,剩下的都是输入输出型参数。

select的返回值

  • 执行成功则返回文件描述符状态已改变的个数
  • 如果返回0代表在描述符状态改变前已超过timeout时间,没有返回
  • 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。

错误值可能为:

  • EBADF 文件描述词为无效的或该文件已关闭
  • EINTR 此调用被信号所中断
  • EINVAL 参数n 为负值。
  • ENOMEM 核心内存不足

ft_set

ft_set其实这个结构就是一个整数数组 , 更严格的说 , 是一个 " 位图 ". 使用位图中对应的位来表示要监视的文件描述符.提供了一组操作 fd_set 的接口 , 来比较方便的操作位图 .
PS:你自己是不能进行位图级别的设置的,不同平台的位图结构的是不一样的,所以你自己不能进行按位与,或,异或这样的操作的。
 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的全部位

所以以后操作一个fd_set禁止使用位操作,必须用OS提供的接口。

fd_set的大小

fd_set是一种类型,那么它就要在计算机里开辟空间,那么在特定的环境里面这个fd_set就是特定的大小,知道fd_set的大小,我们就可以推测出这个fd_set可以容纳多少fd。

所以一个fd_set在我的平台里,最多能监听的fd是1024个。PS:不同平台和系统是不一样的。

理解select执行过程

理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd.
(1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。

(2)若fd=5,执行FD_SET(fd,&set);后set变为0001,0000(第5位置为1) *(注意:实际上第一个位置是0号的fd,这里是为了方便理解,直接用位置顺序代表fd)

(3)若再加入fd=2,fd=1,则set变为0001,0011

(4)执行select(6,&set,0,0,0)阻塞等待(这个就是用户告诉内核)

(5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011(这个就是内核告诉用户)。注意:没有事件发生的fd=5被清空。现在1,2,已经就绪了,接下来你调用read,recv进行读取就行了。

上面的过程我们只是模拟了1次,最多让你读一次1和读一次2。但是只读一次1,2就能保证能把对端发过来的报文完整读完吗?万一对方只发了半个报文呢?万一对方只发了几十个字节,还有大量数据没发完呢,1,2号只是读了一部分,没读全。

第二:5号被清空了,不代表下一次我不想关心5号了,只是这次5号没就绪,如果下次我还要关心5号呢,这次5号没就绪,下次5号就绪了怎么办?

所以,select因为使用输入输出型参数标识不同的含义,意味着后面每一次,都需要对fd_set进行重新设置!!!对应到这个例子,我今天关心1,2,5。1,2就绪了,我就读1,2号。下一次,根据上层协议判断(比如是http),我读了上层发现http只发送了请求行,正文还没发过来,此时我还要关心1,2号,这个时候5号也要被关心,虽然这次没就绪,但是下次就可能就绪,因为上次清空了5号,所以就要重新设置5号。所以select就要每次使用的时候进行对fd_set的重新设置,每次都要把曾经关心的fd重新做检测,归类一下哪些是需要关心的,哪些是需要不关心的,然后让select重新设置。所以select的操作就很复杂,万一1号读完了,你下次就只需要设置2和5,所以设置前要做相关检查。

你的程序,怎么知道你都有哪些fd呢?

之前的服务器是accept上来就创建一个线程,所以一个fd对应一个线程,所以每一个进程或者线程内部可以以一个变量的方式把这个fd保存起来。但是对于select,每次select都要把众多fd设置一下,可是每次select返回之后,所有的fd都被清空了,甚至超时之后没有一个关心的,所以哪些fd需要关心你也就不知道了。所以用户必须定义数组或者其他容器结构,来把历史fd保存起来。比如:我accept获取了1~10的fd,你就必须先把这些fd保存起来,之后你想关心哪些fd,你就把这些fd遍历,设置进fd_set,如果你不保存,一旦select就把曾经设置的fd清空了,也就是位图中没有这些fd了,所以下次在select就不关心fd了,在应用层上就叫做fd泄露,在网络层面上就叫做这个连接变成了一个无效的长连接。因此,如果要编写select代码是需要第三方数组的。

准备编码

写一个客户端给服务端发送消息的服务(只关心读取)。select代码的编写不需要多进程或者多线程,但是并不代表他不能,select单进程就可以响应多用户请求而且是同时的。

当我们写到这以后,应该直接进行accept新连接吗?

不应该,因为accept的本质叫做通过listen_sock获取新连接,前提是listen_sock上面有新连接,但是accept不知道是否有新连接,所以accept就是阻塞式等待。站在多路转接的视角,连接到来,对于listen_sock就是读事件就绪。读事件就绪不一定是用户数据来了,底层把连接建立好了也叫作读事件就绪。 底层把连接建立好了,双方是有报文的交互的,至少客户端得发送SYN,所以底层把连接建立好也有数据交互,只不过这部分数据不需要被用户读走,而代表了新连接的到来,新连接到来在listen当中就叫做读事件就绪。

对于所有的服务器,最开始的时候只有listen_sock,所以你只能把listen_sock添加到select里。我们不想调用accept就是因为它是阻塞式等待,会影响服务器的效率。accept的等只能等一个fd,select的等一次可以等多个fd,我们的服务器上的所有fd(包括listen_sock),都要交给select检测。因为select等待的fd越多,至少有一个就绪的概率就越高,就可以保证我们的服务器造大部分情况下有很高的概率一直做IO,而且一旦我们把等待的工作交给select,那么recv,read,write,send,accept只负责自己最核心的工作:真正的读写(对于listen_sock就叫做真正的accept,真正的accept可不是等,而是把连接从底层获取上来)

1.因为目前只有一个fd,一旦就绪了,就是listen_sock就绪了,listen套接字就绪了我们就可以读取了,只不过这次读取我们不是用read,recv,而叫做accept,而且listen套接字就绪了accept也不会阻塞,然后accept就获取新连接,可是一旦accept把新连接获取上来,那么这个新连接怎么被设置进rfds里呢?也就是说,一旦有新的套接字获取上来,这个套接字该怎么被添加呢?

2.你今天在select里定义的是listen_sock+1,但是我们知道,fd越往后越大,所以之后有新的连接到来的时候,我们一定获取新连接,新连接也一定比listen_sock大,所以以后select的第一个参数该如何更新呢?

3.随着fd越增越多,之后不仅仅有listen_sock就绪,新的fd也可能就绪,哪些fd就绪了也要进行判断和读取,所以这份代码是不完整的,归根结底就是我们要把我们获取上来的fd想办法暂时保存起来,所以我们得用一个数组。

然后我们先把数组定义出来

这个时候我们已经把前半部分写出来了,然后可以测试下timeout,5s之内阻塞等待,5s之后返回一次,这下这个服务器启动以后就可以周期性的帮助我们等待套接字了。

 此时我们就可以看到每个5s就返回一次。

我们把5改成0,就是不断的再进行轮询检测。

设置成nullptr就是在阻塞等

现在我们阻塞启动,虽然没有accept,但是我们可以telnet连接一下,连接一下以后,我这个连接确实不会被获取上去,但是select就是负责等的,就是负责监听一个事件就绪,今天我只添加了一个listen套接字,一旦有连接到来,底层3次握手成功,虽然我压根没有调用accept,但你的select要告诉我底层有数据就绪了,要不然accept怎么能被调用呢。

所以我们看到我们的服务器一直告诉我们有fd就绪了。为什么一直循环告诉我fd就绪了,当前底层连接已经建立好了,3次握手已经成了,但是上层没有调用accept,也就是这个连接没有被取走,也就意味着listen套接字上一直有事件在就绪,你不读就一直通知你,你虽然设置的nullptr,按理应该阻塞,但是每次调用都会通知你fd上事件就绪了,你不读我就一直通知。也就是说,如果一个事件在select这就绪了,那么你不把事件拿走,下层就会一直通知你,这就是TCP中的PSH。(如果你就是那个一直不读的程序员,那么你就应该被开掉了)

接下来我们编写fd就绪以后的代码

走到这里,我们知道至少有一个fd就绪了,你怎么知道哪一个fd的读事件就绪了呢?

以及是读事件还是写事件,因为我们今天的服务器只关心读,所以它只能是读事件。

我们不确定,只能一个一个检测,好在select的这个rfds参数是输入输出型参数,返回的时候告诉用户哪些fd上的读事件就绪了。你怎们知道你有哪些文件描述符呢?别忘了我们之前设置了一个保存fd的数组,我们可以通过遍历这个数组做检测,

如果数组里面的值等于-1,就是不合法的,就证明历史上没有设置过。之后的fd都是合法的fd,但是合法的fd不一定是就绪的fd,比如:我一共有5个fd:1,2,3,4,5,你全设置进去了,select就帮你等,可是等的时候不一定是5个都就绪了,有可能是1,2,有可能是3,4...所以需要在rfds返回的参数中确定下哪些合法的fd就绪了。

如果FD_ISSET这个条件满足了,一定是读事件就绪了,就绪的fd就在fd_array[i]保存,然后我们就可以开始通过read,recv读了,这次我们可以预料到在读的时候这些read,recv接口一定不会被阻塞。因为走到这fd一定就绪了。

但是读事件就绪,就一定可以read或者recv吗?

不一定。因为读事件就绪,除了底层的某一个fd可以被读取了,还有一个listen套接字,而且你现在数组里也只有listen套接字,listen套接字也是以读事件就绪的方式告知我们有新的连接到来。所以我们还要甄别listen套接字和普通fd。

如果是listen套接字,这个时候是读事件就绪了,但我们应该accept套接字。如果是普通fd就绪,你才可以read或者recv。

我们先写这个fd是listen套接字:

listen套接字读事件就绪,直接进行accept。对应accept出错我们就不考虑了。sock大于等于0就是获取连接成功。失败了什么也不做

获取成功连接后,可以直接read或recv了吗?

以前我们写套接字的时候,一旦有新连接到来,我们就直接调用read或者recv了。但是这次绝对不能,新连接到来,不意味着有数据到来!比如:我有一个客户端,我就连着你,就不给你发数据,此时你直接读就会被阻塞。PS:我们目前写的代码都是单进程,如果你今天立马读了,我只要写个客户端,连接上你,从此以后不给你发消息,那么你这个进程就因为直接读而导致直接被挂起,被挂起就没办法执行其他代码,所以我们绝对不能读。

什么时候数据到来呢?

很遗憾不知道。但select可以最清楚的知道哪些fd上面可以进行读取了,大思路就是把这些fd交给select就行了,可是我们这个代码是在select之后的,我怎么把新的fd交给select呢?

所以无法将这个fd设置进select,但是我们有fd_array[]这个数组,我可以暂时把fd设置进这个数组里,一旦放到数组里,这个for循环一旦执行完了,下一次在进行循环,重新执行时就会把所有的fd重新添加到rfds,下一次就可以监测这个文件描述符。PS:这个下一次对于计算机一点也不慢,非常快。

所以接下来就进行for循环,从1开始,0我们不关心,因为0本来就是设置给listen套接字的。如果此时fd_array[pos] == -1,说明这个位置没有被使用,然后就跳出。跳出后无非就两种情况:

  • 1.找到了一个位置没有被使用。
  • 2.找完了所有的fd_array[],都没有找到没有被使用的位置。

当找到没用的位置后,我们就可以把fd加进数组里,下一次就会把不合法的continue掉,合法的添加到rfds,然后交给select让他去管理。select就告诉我们什么时候fd可以被读取了;如果没用位置了,说明服务器已经满载,没法处理新的请求了,我们就把获取上来的这个fd直接close。用户感受到的就是没连上,在试试,因为服务器是动态的,当你再次刷新尝试,有可能别人离开了,你就连上了,所以多刷新几次就是竞争。

我们写到这就可进行测试一下:

ps:之前我们初始化fd_array是初始化的0,但是这就不符合我们制定的规则了,所以更改成初始化成-1。

 测试结果:符合预期

而且我们发现,退出的时候也会有读事件就绪,之后我们解释

接下来编写普通的socket读事件就绪

这次我们可以调用read或者recv进行读取了,这次读取是一定不会出错或者阻塞的。就好比select告诉listen套接字就绪了,那么在accept的时候也一定不会被阻塞。然后就进行读。

可是本次读取就一定能读完吗?读完,就一定没有所谓的数据包粘包问题吗?

我们可以说这一次读肯定不会被阻塞,但是这一次就一定能保证把对方的内容都读完吗?

虽然大部分情况下,确实对方会把完整的报文发过来,但是TCP是有流量控制...的,对方发送数据完全由TCP说了算,可能对方就给你发了一条数据,不能保证一次就把数据读完。即便是你这次把缓冲区里的全部数据读完了,你也解决不了粘包问题。但是,今天我们没法解决!因为我们今天没有场景(没有办法针对场景定制协议)!我们的代码仅仅用来测试(就是有bug的)。(在博主之后的博客进行解决这些问题)。

我们还是用个buffer接收数据,用recv读。recv返回值大于0,就是读取成功;等于0就代表对端关闭了连接,这个时候我也把对端的连接关闭,因为此时这个fd还在这个fd_array数组里,下次循环select还会对他做检测,select一检测发现这个fd被关了,就直接报错。所以我们要从fd_array把这个fd去掉。返回值小于0,就是读取失败,同样是关闭fd,把fd从fd_array去除。

这次代码完毕,我们进行测试:

模拟3个客户端连接我的服务器进行发送信息。

模拟集体进行退出

当大家退出后,再次连接服务器

新的连接从4开始,就证明曾经的连接关闭是成功的。

我们的服务器是单进程+多路转接,实际上大型的服务器都是多进程或者线程+多路转接共同完成。

select的优缺点

优点

select是对比多线程或者进程的,可以一次等待多个fd,在一定程度,提高IO的效率。解释:如果我是多线程,每个线程一个fd,每个线程虽然是阻塞的,但是多线程都在等,也是同时在等多个fd,但是select的最大的特点是将这些需要等多个fd的代码转成了单进程。而且线程是受调度约束的,即便是阻塞着,即便是底层已经好了,你的线程不是立马获取到的,而是你的线程一定在OS进行调度,也就是进行排队,即便已将好了,你也得先排队,排完队,运行的时候,你才能检测到它好了;而select是单进程,排队的成本比多进程低的多。

缺点

  • 1.每次都要重新设置,每次完成之后,需要遍历检测。因为:每次都要设置意味着每次都要调用轮询,哪怕只有1个就绪了,也要重新设置,每次轮询就是遍历,数组虽然不大(1024bit),但是触发的次数多了(比如直接串行触发fd),那么你的服务器一直都在轮询,所以一定导致效率降低。而且完成之后,还要遍历检测,效率都很低。
  • 2. fd_set,它能够让select同时检查的fd是有上限的。因为fd_set就是确定的128字节。虽然一个进程能打开的fd是有上限的,但是这个事和fd_set有上限是两码毫不相关的事,这只是进程的问题和fd_set设计的fd有上限没有半毛钱关系。而且内核里是支持扩展fd的,一般在生成环境下OS能打开的fd是10万个。
  • 3.select底层(就是OS)需要轮询式的检测哪些fd上的哪些事件就绪了!这也就是为什么select的第一个参数传的是max_fd+1。因为变量的时候OS用的是前闭后开的方式
//[0,6) 实际遍历的就是[0~5]
for(int i=0; i<6; i++)
{//...
}
  • 4.select可能会较为高频率的进行用户到内核,内核到用户的频繁拷贝问题。select是位图做的,虽然传的是指针,指针把数据填好告诉内核,内核就知道你关心哪些fd了,但是当底层有fd就绪时,内核就修改这个位图,双方是一定要有数据交互的,这个数据交互最本质就是进行用户到内核,内核到用户的频繁拷贝。所以在多路转接中,出现频繁的就绪,就会出现频繁的拷贝。

socket就绪条件

读就绪

  • socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
  • socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
  • 监听的socket上有新的连接请求;
  • socket上有未处理的错误;

写就绪

  • socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记
  • SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
  • socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号;比如:我今天想往一个套接字里写,我没关,可是对端把连接关了,此时我写就是向对方发报文,对我们来讲,对方就会给我发reset,告诉我对端连接异常了,OS也就识别到这个问题,触发SIGPIPE信号,终止你。
  • socket使用非阻塞connect连接成功或失败之后;
  • socket上有未读取的错误;

异常就绪

socket上收到带外数据. 关于带外数据, 和TCP紧急模式相关(回忆TCP协议头中, 有一个紧急指针的字段)

六、I/O多路转接之poll 

poll函数接口

poll是干什么的?

poll也是只负责等的。通过等多个fd的方式:
1.用户告诉内核哪些fd上的哪些事件你应该帮我关心;
2.哪些fd上的哪些事件已经就绪,其实和select的定位一样,没有差别。 唯一区别就是在接口上:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */
};

poll的参数有三个,第三个参数timeout除了单位不一样,在含义上是和select的timeout是一模一样的,这个timeout以毫秒为单位

  • 为0就是非阻塞等待;
  • 大于0,比如是10,意思就是10ms以内阻塞式等待,10ms过了,没有就绪,我就直接返回一次;
  • 等于-1,就是永久阻塞式等待。

第一个参数代表的是一堆struct pollfd结构。第二个参数代表的是有几个struct pollfd。第一个参数和第二个参数往往合起来称为一个数组,也就是说fds是数组起始元素的地址,nfds代表的就是这个数组中元素的个数。

struct pollfd的结构解析

多路转接的两个重大作用:

  • 1.用户告知内核,你要帮我关心哪些fd上的哪些事件,对应的就是events.
  • 2.内核告诉用户你所关心的这些fd中哪些fd上的事件已经就绪了,对应的就是revents.

fd代表的就是你想要哪些fd.

select是有读事件,写事件,异常事件三种,对于poll如何体现出不同的事件呢?

一个fd关心事件时,只用了一个short,如果我这个fd想既关心读又关心写呢,是不是这一个fd就不能表示?

答案是:这一个short就可以表示,因为一个short是16位的,可以传入16个比特位,每个比特位是0还是1就可以表示我们所关心事件的类型。这些大写的标识符就是宏,而且毫无疑问这些宏的特点是在众多的比特位中,一定有一个比特位为1,而且这些宏唯一的比特位是不会重复的。

eg: 通过按位或设置events

events = 0; //起初events为0;
events |= POLLIN; //设置读事件到events
//还想继续添加写事件到events中,让fd也关心写,就继续按位或即可。
events |= POLLOUT;//如果一开始就像设置events,直接把宏赋值过去即可
events = POLLIN;

当返回时如何知道有哪些事件已经就绪了呢?

通过按位与

if(revents & POLLIN)
{//检测到了读事件就绪
}if(revents & POLLOUT)
{//检查到了写事件就绪
}

将我们之前的select_server代码改写成使用pool的代码

#include<iostream>
#include<sys/select.h>
#include<string>
#include<poll.h>
#include"Sock.hpp"#define NUM 128// 规定:数组里的内容>=0,就是合法的fd;如果是-1,该位置没有fd;
struct pollfd fd_array[NUM]; // 不想固定值栈上们可以放在堆上
static void Usage(std::string proc)
{std::cout << "Usage: " << proc << "prot" << std::endl;
}
// ./select_server 8080
int main(int argc, char *argv[])
{if(argc != 2){Usage(argv[0]);exit(1); //我们用的是exit,如果任何一个出错了,就立即终止该进程}u_int16_t port = (uint16_t)atoi(argv[1]); //端口号int listen_sock = Sock::Socket(); //创建Sock::Bind(listen_sock, port); //绑定Sock::Listen(listen_sock);  //监听for (int i = 0; i < NUM; i++){fd_array[i].fd = -1;fd_array[i].events = 0;fd_array[i].revents = 0;}//fd_set rfds;           // 位图//fd_array[0] = listen_sock; //这个fd后面我们不动fd_array[0].fd = listen_sock;fd_array[0].events = POLLIN;fd_array[0].revents = 0;// 事件循环for (;;){int n = poll(fd_array, NUM, 1000);switch (n){case -1:std::cerr << "poll error" << std::endl;break;case 0:std::cout << "poll timeout" << std::endl;break;default:std::cout << "有fd对应的事件就绪啦!" << std::endl;//到这,至少有一个fd就绪了,你怎么知道哪一个fd就绪了呢?//我们不确定,只能一个一个检测for (int i = 0; i < NUM; i++){if(fd_array[i].revents & POLLIN) {std::cout << "sock: "<< fd_array[i].fd << "上面有了读事件,可以读取了" << std::endl;// 一定是读事件就绪了// 就绪的fd就在fd_array[i]保存// read,recv此时就一定不会被阻塞,因为fd已经就绪了// 读事件就绪,就一定可以read或者recv吗?不一定// 因为读事件就绪,除了底层的某一个fd可以被读取了,还有一个listen套接字// listen套接字也是以读事件就绪的方式告知我们有新的连接到来。所以我们还要甄别if(fd_array[i].fd == listen_sock){std::cout << "listen_sock: " << listen_sock << "有了新的连接到来" << std::endl;// acceptint sock = Sock::Accept(listen_sock);if(sock >= 0){std::cout << "listen_sock: " << listen_sock << "获取新的连接成功" << std::endl;//获取成功连接后,可以直接read或recv了吗?//绝对不能,新连接到来,不意味着有数据到来!比如:我有一个客户端,我就连着你//就不给你发数据,此时你直接读就会被阻塞。//什么时候数据到来呢? 很遗憾不知道//但是select可以最清楚的知道哪些fd上面可以进行读取了。大思路就是把这些fd交给select//可是这个代码是在select之后的,我怎么把新的fd交给select呢?//所以无法将这个fd设置进select,但是我们有fd_array[]这个数组,我可以暂时把fd//设置进这个数组里,一旦放到数组里,这个for循环一旦执行完了,下一次在进行循环重新//执行时就会把所有的fd重新添加到rfds,下一次就可以监测这个文件描述符。int pos = 1; //这个pos后面还用,所以就不放在for里面for (; pos < NUM; pos++){if(fd_array[pos].fd == -1) //说明这个位置没有被使用,然后就跳出{break;}}if (pos < NUM) // 1.找到了一个位置没有被使用{std::cout << "新连接: " << sock << "已经被添加到了数组[" << pos << "]的位置" << std::endl;fd_array[pos].fd = sock;fd_array[pos].events = POLLIN;fd_array[pos].revents = 0;}else // 2.找完了所有的fd_array[],都没有找到没有被使用的位置{//说明服务器已经满载,没法处理新的请求了std::cout << "服务器已经满载了,关闭新的连接" << std::endl;close(sock);}}}else{//这里才是普通fd就绪了,这个时候才能read或者recv//可以进行读取了,read或者recvstd::cout << "sock: " << fd_array[i].fd << "上面有普通读取" << std::endl;char recv_buffer[1024] = {0};ssize_t s = recv(fd_array[i].fd, recv_buffer, sizeof(recv_buffer) - 1, 0);//虽然设置为0,但是不会阻塞的,因为已经就绪if(s > 0){//读成功了recv_buffer[s] = '\0';std::cout << "client[" << fd_array[i].fd << "]#" << recv_buffer << std::endl;}else if(s==0){//代表对端关闭了连接std::cout << "sock: " << fd_array[i].fd << "关闭了,client退出了!" << std::endl;close(fd_array[i].fd); std::cout << "已将在数组fd_array[" << i << "]"<< "中,去掉了sock: " << fd_array[i].fd << std::endl;fd_array[i].fd = -1;fd_array[i].events = 0;fd_array[i].revents = 0;}else{//读取失败close(fd_array[i].fd);std::cout << "已将在数组fd_array[" << i << "]"<< "中,去掉了sock: " << fd_array[i].fd << std::endl;fd_array[i].fd = -1;fd_array[i].events = 0;fd_array[i].revents = 0;}}}}break;}}return 0;
}

针对pool写一个简单的样例

用pool监控标准输入

执行结果:我们发现没输入就time out了,而且只有1次,将来我们的服务器是让他一直进行下去的

执行结果:没从键盘输入就开始一直检测

从键盘输入后,发呢疯狂打印有事件发生,为什么呢?

就是因为你输入数据后没有人读,这个数据就在你的输入缓冲区当中,数据一直在没人读,poll就会一直通知,只要把数据读了,poll就不会通知了。

所以我们可以读一下,因为我们已明确fd只有0,所以就不用循环检查了

执行结果:

poll和select有什么差别呢?

poll的优点

  • 不同于select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现。位图的优点就是简单,修改数据量比较小;缺点就是有上限。
  • pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比select更方便.
  • poll并没有最大数量限制换句话说就是解决了select中位图有上限的问题 (但是数量过大后性能也是会下降,因为poll底层和select底层一样依旧是采用遍历的方案去轮询检测每个fd及其事件是否关系).

poll的缺点

  • poll中监听的文件描述符数目增多时和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符.
  • 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
  • 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降(fd变多了,遍历的周期也就变长了).

七、I/O多路转接之epoll

epoll(extend poll)是干啥的?

同select,poll一样,只负责等,等是手段,但不是目的。目的是通过用户设置的某些fd及其事件,告知内核,让内核关心,一旦就绪,通知上层。

epoll初识

按照man手册的说法: 是为处理大批量句柄而作了改进的poll. 
它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44) 
它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法.

epoll的相关系统调用

epoll 有 3 个相关的系统调用 .

epoll_create

int epoll_create(int size);

创建一个epoll的句柄(说人话就是创建一个fd)

自从linux2.6.8之后,size参数是被忽略的(但是最好还是写成128,256这样的,主要是向前兼容).
用完之后, 必须调用close()关闭.

eg: 查看建立epfd

epoll_ctl

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

无论是select还是poll都会说用户告诉内核哪些fd上的事件需要关心;内核告诉用户,哪些fd上的哪些事件就绪了。这一套功能以前是用一个接口完成的,就是select和poll,select采用的是输入输出型参数拿到的,poll是通过event和revent参数分离拿到的。epoll毫无疑问也是能做到这件事情的,但是epoll是用两个接口将这件事情分离了。

这个epoll_ctl就叫做用户告诉内核,用户告诉OS你要帮我关心哪些fd上的哪些事件。

  • 第一个参数是epoll_create()的返回值(epoll的句柄).
  • 第二个参数表示你要进行的操作种类,用三个宏来表示.

第二个参数的取值:

  • EPOLL_CTL_ADD :注册新的fd到epfd中(相当于把新的fd添加到OS里);
  • EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
  • EPOLL_CTL_DEL :从epfd中删除一个fd
  • 第三个参数是需要监听的fd(也就是你要向这个epfd模型中添加/删除/修改哪个fd).
  • 第四个参数是告诉内核需要监听(关心)什么事。

events可以是以下几个宏的集合(与poll用法一致):

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

epoll_data_t这个字段也挺重要的,只不过现在我们不用管。

PS:在select中,用户告诉内核,下一次调用select还需要重新设置;如果用poll,虽然在数组里保存着呢,但是实际上每次调用poll还得把数组传进去,相当于每次也在重新告诉内核,只不过不用像select每次都进行修改,但是你还是需要同select一样,用户到系统需要传参。epoll_ctl只要调用1次,内核就永远记住了,也就是说,你告诉内核你要帮我关心3号fd上的读事件,只要你给内核说一次,内核就永久记住了,以后就再也不用告诉内核了。如果你要删除和修改,你就要继续调用epoll_ctl接口进行删除或修改。

epoll_wait

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

epoll_wait就是内核告诉用户哪些fd上的哪些事件就绪了。 收集在epoll监控的事件中已经发送的事件.

  • 参数events是分配好的epoll_event结构体数组.;epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
  • maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.

第二个第三个参数配合使用是一个数组缓冲区,你在等的时候你想知道哪些fd上的哪些事件就绪了,其中缓冲区就得提供好。哪些fd就绪了,有几个就绪了,最终就会填写在这个数组缓冲区中.所以events是一个输出型参数。

  • 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).

返回值

如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败.

PS:比如你和OS说有5个fd需要关心,分别是1,2,3,4,5,如果是1,2,3就绪了返回值就是3;而且它不像select和poll通过遍历自己的数组来检测哪些fd就绪了,epoll_wait会把就绪的3个事件,封装成对应的events结构返回给你,而且按照顺序给你写好,换句话说,epoll_wait以后想要遍历,就直接遍历返回值的个数,就一定是就绪的事件。

epoll服务器

我们先写一个epoll服务器的大体逻辑,然后进行解析

测试: 直接运行起来就进行检测

连接一下服务器,有事件就一直通知我,因为你没有把事件拿走,所以就一直通知

epoll的原理

再写接下来的代码之前,我们先解释下epoll的原理

对于OS而言,调用epoll_create,epoll_ctl,epoll_wait的时候一定是一个进程在调用。进程在创建出来一定有一个task_struct,而且task_struct还有一个指针指向fd数组。

实际上当我们调用epoll_create的时候:

首先epoll模型里面会维护一颗红黑树(红黑树就是一颗近似平衡的搜索二叉树),最开始的时候红黑树肯定是个空树,红黑树里面的几点放的主要是fd和events。

这个红黑树的特点:

1.凡是在红黑树中的节点,对应的fd和事件,就是OS需要关心的!

背景引入

OS之下还有驱动程序,驱动程序下面还有硬件,与网络最强相关的就是网卡硬件。底层的数据一旦到来或者就绪的时候,在OS层面上,我这个进程是怎么知道底层有数据了呢?也就是底层硬件有数据了,你这个进程是怎么知道的?

在系统角度,当一个进程进行套接字read,write的时候,这个网卡本身在OS层面上是会给我们维护一个叫做等待队列的东西,其实就是把每个进程的PCB连起来放到这。当调用read的时候,我们经常说,你这个进程就被阻塞了,然后你这个进程当前就无法访问了,只有当底层设备就绪时才把你唤醒。当我们今天想要读取磁盘或者外设的时候,如果数据没就绪,那么此时你这个进程就相当于被阻塞掉,你就以等待队列的方式在这里;当一旦底层数据就绪时,并不是进程拿到第一手数据,因为进程在等待,当底层有数据时,OS才把进程唤醒,然后让进程做相关操作。

OS是怎么知道网卡上有数据的呢?

硬件是通过中断的方式来告知我们的OS的,比如说你在键盘上输入a,b,c,d,就像我们刚刚的poll代码,我把数据写了,我没读,但数据还是写了,一按回车,poll就告诉我数据已经就绪了,实际上那个数据还没有被读到程序当中,而是直接在系统层面上就绪了。OS就是因为你按了回车才知道数据就绪的,你按回车这些键盘的按键的时候,通过中断的方式来通知我们的CPU说有一个硬件设备已经准备好了。网卡或者键盘这样的设备(外设)是通过中断的方式向我们CPU的针脚发送特定的中断号,这个中断号也并非所谓键盘,网卡这样的设备发起的,而是由硬件中的一些设备,比如说8259这样的硬件,它直接和像网卡,键盘这样的慢设备,通过8259用对应的方式桥接在硬件上和CPU的针脚直接关联,中断只是第一步,每个针脚都有编号,中断也有编号,CPU收到之后,在OS加载时,每一个OS以及CPU都会配上一个与中断号相关的中断向量表(其实就是哈希用中断号找对应的中断方法) 不同的外设对应不同的方法,此时就可以先把外设的数据,拷贝到所谓的内核当中,所以数据就通过这样的方式就搬到了我们的内存里面。

但是数据到了内存就会通知你这个进程数据已经就绪了吗?换句话说数据到了OS内的各种接收缓冲区里面,是否就相当于数据已经就绪了呢?

不是,因为进程在这个环节当中扮演的是一个被动的角色,它压根就不知道OS做了这样的事情,OS把数据搬到内存里,然后此时把数据交付给特定的进程,然后让进程相关的通知去它操作。所以,所谓的就绪本质上就是把你这个进程把数据给你拷贝好了。你以为select做检测的时候,当底层数据就绪时,上来读就不阻塞了,就是因为OS早就把数据给你准备好了。所以当把数据直接从外设拷贝到内存里,这时候是在内存的各种缓冲区里,一个进程里有很多文件,每个文件都有各种对应的缓冲区。所以现在就变成了当哪一个fd就绪的时候,应该怎么办?

现在就是两件事:1.OS要不要帮你去关心,其他fd上有没有事件就绪,OS说我把数据给你放在缓冲区里了,当然这个网卡就绪的时候,可能有很多fd上的数据都拷贝进来了。

一旦事件就绪的时候,OS要不要还在去帮你检测下,你这个进程所关心的其他fd对应的缓冲区上有没有?

如果有,就相当于可以把这个进程从等待队列里唤醒了,此时唤醒之后,然后再进行通知上层用户哪些fd就绪了。这种策略是select和poll做的。相当于,只要有数据就绪,当OS准备进行数据读取,识别到你这个进程对应的fd有就绪(进程可是打开了很多fd的),或者是需要一定策略让OS周期的检测对应的fd的时候,OS此时就可以轮询式的去检测对应的fd。这是由select和poll的特点决定的,OS必须这么干,select也要求OS这么干,因为select一次等了多个fd,所以当你调一次select或者是当你正在调用select,OS就帮你关心所有的fd(采取遍历的方式)。注意,刚刚说的和底层把数据从外设拷贝到内存里是两个阶段,第一阶段是当硬件有数据就绪就通知上层,第二阶段是OS底层一旦有数据了,其实指的是特定的某些fd上有数据了,OS就可能要唤醒这个进程了,但因为OS现在是在select的上下文,OS在调用select的时候,一旦没fd就绪,就会把select的代码在select中挂起,一旦特定fd就绪,OS依旧需要帮你遍历你所关心的其他fd,就设计好位图然后就告诉你了,当你上层知道后就直接调用接口读就可以了。

从硬件把数据搬到软件上,通知就绪,通过中断的方式从外设告知CPU,CPU直接由内核上下文切到中断向量表,然后将数据从外设搬迁到内存中。到了内存中以后,接下来就是OS的策略了。

我们现在假设这个数据已经从外设拷贝到内存了,比如:网卡把数据拷贝了,你上面有10几个fd,可能有一些fd的缓冲区好了,有些没好。当OS发现有一个fd就绪了,OS需要把一个进程唤醒的时候,如果是select还要继续遍历后面的,把后面的数据做统计;但是在驱动和OS结合的地方是epoll,OS做了更多的工作。

epoll的回调机制

其实当我们向内核中告诉内核你要帮我关心哪些fd的哪些事件的时候,epoll底层支持一个回调机制,说白了:以前是OS中有数据就绪了,OS要再去检测其他的这些就绪的话,OS此时就只能一个一个去轮询检测。OS不想在这样遍历了,所以OS建立了一种回调机制,在网卡,驱动和OS的共同结合,甚至可以理解成是修改它的中断向量的中断方法(不太准确)。

回调机制:当底层有数据来,放到了缓冲区里,数据已经就绪了,这个回调机制除了回调之外还要维护一个就绪队列,这个就绪队列里面填的是fd和revents,当我们向红黑树中添加一个节点的时候,比如说3号和in,除了让OS永远记住关心的是3号的in事件,同时还会给3号in事件建立一个回调函数,一旦底层数据就绪,OS通过硬件中断的方式拷贝到缓冲区里,但这还没完,还多做一个事情,就是你把数据拷贝完成后,在帮我多做一件事情。理论上这个事情做完,OS就回去了,现在OS先别回,因为这个缓冲区对应的文件可能是3号fd,OS在帮忙新建立一个节点,然后把这个节点放在就绪队列里,这个就是回调函数所做的事情。

意味着现在就变成了底层有数据就绪,此时OS就自动把数据拷贝到缓冲区里,并且把已经就绪的事件生成一个事件节点,放在就绪队列里,然后OS的事就干完了,当底层不断有数据就绪的时候,OS就可以向这个就绪队列里再放数据,这个就与OS有关了。这个回调工作是中断触发的,然后让节点投入到就绪队列中。OS一旦识别到就绪队列里一旦有节点上来了,OS就会把进程唤醒(刚刚OS中断,拷贝数据,通过回调机制放节点,这样进程没有任何关系,这是OS自己做的,你这个进程在等)。进程作为上层压根就不关心回调,不关心红黑树..只关心这个就绪队列里有没有节点,如果有,就意味着曾经对应的fd及其事件就绪了,我直接把他读取就可以了。

所以有一个红黑树表明的是OS关系哪些fd及其哪些事件;当我们新增红黑树节点的时候,系统调用接口也会直接在驱动和OS临界区域帮我们设置回调机制,OS做的就是不断把数据从外设拷贝到内存,当OS拷贝完后又多做了一步,建立了一个fd及其事件就绪的节点放在就绪队列上,以后进程检测这个队列就可以了。

中断的过程以前就是把数据从外设拷贝到内存里就完了,缓冲区数据有没有就绪由OS自己去检查(轮询,遍历等)处理完后,把进程唤醒;现在因为有了回调机制,除了把数据从外设拷贝到内存,而且还新生成一个对应fd及其事件的节点链接到就绪队列这里,然后OS就干自己的。这个进程是epoll的进程,最后这个就绪队列不为空了,我们就可以让epoll返回了,所谓的epoll_wait返回,就是把这个进程在唤醒,唤醒之后,这个进程只关心这个就绪队列就可以了。

所以对于epoll当底层硬件就绪是以中断的方式告诉OS数据就绪了(根据中断向量表把数据从外设拷贝到内存),然后还进行调用了回调机制,形成了一个节点。之后OS自己忙自己的,当他发现这个进程需要被检测一下的时候,发现这个就绪队列上有节点了,然后再把进程唤醒,唤醒之后OS就知道有哪些数据就绪了。

所以当我们创建epoll模型的时候有3个结构,红黑树,就绪队列,回调机制(这个回调机制是OS内核支持的)。红黑树,就绪队列,回调机制这三个合起来就叫做epoll模型,我们调用epoll_create就是在内核中创建红黑树,创建就绪队列,然后告知OS我要使用回调机制。

PS:红黑树,就绪队列,回调机制里的回调方法,这些东西在内核里都是数据结构,所以这些数据结构是被整合到struct file当中的,然后让我们用fd直接指向这个struct file,只要找到了struct file相关的结构,就找到这个epoll模型了。对我们来讲,就相当于上层可以通过fd找到这个epoll模型。而且如果你愿意,你可以调用多次epoll_create创建多个epoll模型。

epoll底层处理过程

当我们把这些结构刚创建好,红黑树肯定是空的,就绪队列没节点,回调机制只是设定好(哪些fd上的哪些事件要进行回调并没有告诉我),所以第二步是调用epoll_ctl,所以调用epoll_ctl时一方面是你在底层向特定的epoll模型(epoll_ctl的第一个参数)中让红黑树新增一个节点(表明让OS关心的fd及其事件),第二步建立该fd对应的回调策略;接下来就是自动的,当底层有数据,外设告诉CPU有数据来了,CPU把数据拷贝进来后因为你还设置了回调,所以OS跳转到回调策略这里帮你生成节点(就绪队列的)。回调机制说白了就是在队列中新增个节点。总之一旦调用了epoll_ctl,底层的这一套规则就是自动的了。

所以当我们调用epoll_wait时,OS就再也不去遍历所有的fd了,它就成了只要底层有数据就会建立就绪节点放到就绪队列里,所以epoll_wait就以O(1)的时间复杂度,检查到是否有事件就绪。比如:节点有100个,我们就检查一下是否有事件就绪(只需要检查队列是否为空),其实就是决定了epoll要不要返回,如果就绪队列为空epoll_wait就进行等待。

OS是如何做到然你在这个就绪队列里等呢?

struct event_queue
{read_events* head; //有队列就指向第一个节点task_struct* q;}

我们以这份伪代码介绍,这个队列里一定包含head,有队列就指向第一个节点,没有就指向空。当我们调用多路转接的时候,我们的进程并没有在epoll_create,epoll_ctl那阻塞住,我们是在epoll_wait阻塞住的,说白了就是在检测底层就绪队列,你是怎么在那阻塞住的,OS又是怎么把你唤醒的?

当你的进程要去访问这个队列时,因为是系统调用,OS发现这个队列为空,就直接把你的进程转态设置成S转态,然后把你这个进程连接到这个event_queue队列里,接下来就等,当OS发现就绪队列里有很多节点了,也就是event_queue中的head不为空,然后就把在event_queue队列里连接的这个进程唤醒(直接就找到了这个进程的PCB,因为就在event_queue里连接着),然后把这个进程的状态设置成R,然后该进程就拿到了后序的就绪队列里的节点。

所以我今天要在某个资源下等待,在OS层面上翻译过来就是你要在某个资源下等待,前提条件是这个资源一定被OS管理,要管理这个资源一定有对应的内核数据结构,且这个内核数据结构一定存在一个进程队列,你要在这个资源下等,本质就是把你这个进程的PCB放到描述该资源的结构体当中,所以当OS一旦检测到这个资源,上面有数据就绪了,把你唤醒就直接能找到你,然后把你的状态由S设置成R,然后把你放到运行队列里,因为是先资源就绪,OS识别到资源就绪,然后再把你这进程从这个资源结构体当中找到,然后再把你这个进程状态设置成R,放到运行队列,然后你才会被唤醒,拿到对应的资源。

所以所谓的epoll_wait,在epoll模型里有一个就绪队列,一个进程的PCB就被维护到了这个就绪队列里,相当于你去等的时候没有就绪,就把PCB放到这个就绪队列里了。

OS可能有一个运行队列,一个CPU有一个运行队列,但是就绪队列可是有多个,如果你是等待队列看你是等待什么资源,比如我们等到磁盘,实际上并不是等待磁盘,如果你读写文件卡住了,你一定是在这个文件的队列当中等待的。像磁盘,网卡,键盘,显示器等所有设备,一旦有了对应的描述结构体,一旦特定的一个进程想访问它的资源,需要去等,就会在描述该设备的描述结构体里把自己的PCB放进去。OS就像一个大超市,你去等某种资源就绪时,你就去对应的超市等就可以了。

epoll_wait说白了就是做这方面的检测的,需要返回,epoll_wait内部依旧需要拷贝告诉上层,所以就会调用epoll_event接收返回值。revs这个数组本质上就是从底层把数据拷贝到用户区,拷贝的数据就放在revs数组里。PS:这个revs里面只有事件,意味着还有个data字段是需要用户维护的。而且OS向就绪队列里放的时候是按照顺序放的,所以epoll_wait的返回值就很有价值,我们后序做任务处理时只需要遍历到返回值就可以了(数组比如有64个,可是只有2个就绪了,后序你只需要遍历2次就可以了),也就是epoll_wait做检测的时候,只需要关心已经就绪的事件,不会对没有就绪的事件做任何精力上的浪费。

总结一下

所以当我们创建epoll的时候,就相当于帮助我们维护三个东西,红黑树,就绪队列,回调机制(当数据已经就绪,外设会向CPU的针脚发送中断,CPU执行中断向量表,然后执行中断上下文把数据从外设拷贝到内核,当然这个中断方法也有讲究,老式的计算机是由CPU参与IO的,CPU要把数据从外设搬到内存里,新的计算机也有独立的芯片,比计算机的等级低一点,我们称为DMA,当外设就绪之后,告诉CPU,CPU就让DMA把数据从外设搬到内存里)

红黑树节点里的键值是什么?

fd本身就有唯一性,是构建红黑树节点完美的键值,所以红黑树节点是通过fd构建的

为什么要建立红黑树?

1.红黑树可以让fd和该fd所关心的事件明确的在内核中呈现出来,这有利于更好的去设计接口,因为epoll_ctl中是有创建,删除和修改的,所以创建,删除和修改就可以通过直接修改红黑树来进行。

2.红黑树的存在就是在时刻告诉系统哪些fd和fd上的事件需要你OS关心。

3.有人说,直接把红黑树这个机制做到回调机制中,比如:你要删除就删除对应的回调机制。但是,因为回调机制是策略,红黑树表征的是对应的哪些fd和哪些事件需要关心,而且回调机制更靠近底层,越靠近底层就尽量不要去修改它,我们的回调机制可以理解成一旦底层有数据,回调机制就拿着底层就绪的数据的相关信息,然后在红黑树节点中去找。比如:底层有读事件,写事件...通过回调机制就会形成读事件和写事件的节点,假如我们就需要读事件,我们就需要把不需要的事件忽略,就是通过红黑树节点中每一个fd所关心的事件对比处理的。这个数据如果不在红黑树里维护,你就得在回调机制中维护,可回调机制更强调逻辑,数据维护不强调,所以需要有红黑树。

最后你可以理解成这个红黑树就等价于select中的数组,只不过以前的数组是你需要维护的,现在是OS给你维护。

继续编写事件就绪以后的代码

epoll的高效体现在这,因为我把所以的就绪事件都放在队列里了,所以就不像poll,select就绪和没就绪的事件是穿插在一起,需要你自己识别遍历,现在不用了,用队列把所有的就绪事件放在一起了,所以n就是这个数组的上限。

第一次执行这个程序的时候,因为目前只有listen套接字添加进去了,所以只要有读事件就绪一定是listen套接字。可是当第二次,第三次...执行的时候,此时已经就绪的fd,除了listen还有其他的fd,你怎么知道是listen套接字,还是别的fd?

epoll本身的就绪队列实际上并没有给我们返回是哪个fd就绪了,只告诉哪个事件就绪了,可这个就绪事件对应的fd你并不知道。这个时候就需要用到epoll_event中的data参数,这个事件和fd的对应关系,OS不关心,需要用户自己维护(因为data里有多个字段,目前我们关心的就是data里的fd字段)

所以我们在之前先设置一下data字段。

通过这个data字段里的提前保存的fd进行判断是listen套接字还是其他的fd。  

接着我们进行处理listen套接字就绪

accept了listen套接字以后,同样不能立即读取,因为有连接到来不代表连接上有数据,因为我们是单进程代码,一旦读了,数据不就绪,此时你就被阻塞了,这个进程就被挂起了。又因为epoll最清楚fd上的数据数据有没有就绪,然后将获取到的新fd添加到epoll中。

我们的代码至此基本完成。

测试:

因为fd0,1,2已经被占了,listen套接字是3,epoll_create的返回值是4,所以客户端来的套接字就从5开始。

客户端输入内容后,服务端疯狂刷屏,就是因为我们当前的代码并没有读取内容,所以OS一直提醒上层去读取数据。

接着我们将我们代码处理普通读事件的部分补充完整。

epoll服务器目前存在的问题

我们就完成了epoll读取的编写,但是这里与之前select的读取一样,存在很多的问题:

1.你不能保证recv到的报文,就是客户端想给你发的完整报文;如果你今天读取的时候,你没有把数据读完,这次读完,你就不能再读了,你就得等待下次事件就绪继续读,可你不能保证下次就绪的fd就是你自己,而且我们今天的代码,所有事件就绪用的是同一个buffer,也就是说在下一次调用的时候这个buffer早就被释放了,也就意味着你数据还没读完呢,数据就已经被释放了,因为buffer是临时空间,读完后循环结束就开始下一次循环了。所以这里要做好buffer数据的报错是很有讲究的,但我们仅仅是测试,大部分情况下是不会出错的。

2.假设读完数据之后,我们还想处理写事件。

我们一般不要轻易的把fd关系的事件将EPOLLOUT添加进去,EPOLLOUT就绪的本质就是底层区域当中有没有缓冲区空间,可我们当前的业务只有你一个人发,发送缓冲区被打满这样的情况不存在,所以你设置了EPOLLOUT,这个事件就会一直触发,所以我们暂时不要关心。但始终是有漏洞的,所以我们目前并不打算更改。

测试:

epoll的原理总结

上面具体分析了epoll的原理,这里再次总结一下

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关.

struct eventpoll{ .... /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/ struct rb_root rbr; /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/ struct list_head rdlist; ....
};
  • 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件.
  • 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是O(lgN),其中n为树的高度).
  • 而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法.
  • 这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中.
  • 在epoll中,对于每一个事件,都会建立一个epitem结构体.
struct epitem{ struct rb_node rbn;//红黑树节点 struct list_head rdllink;//双向链表节点 struct epoll_filefd ffd; //事件句柄信息 struct eventpoll *ep; //指向其所属的eventpoll对象 struct epoll_event event; //期待发生的事件类型
}
  • 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可.
  • 如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户. 这个操作的时间复杂度是O(1).

总结一下, epoll的使用过程就是三部曲:

  • 调用epoll_create创建一个epoll句柄;
  • 调用epoll_ctl, 将要监控的文件描述符进行注册;
  • 调用epoll_wait, 等待文件描述符就绪

epoll的优点(和 select 的缺点对应)

  • 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
  • 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
  • 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
  • 没有数量限制: 文件描述符数目无上限.

注意!!

网上有些博客说, epoll中使用了内存映射机制
内存映射机制: 内核直接将就绪队列通过mmap的方式映射到用户态. 避免了拷贝内存这样的额外性能开销.

这种说法是不准确的. 我们定义的struct epoll_event是我们在用户空间中分配好的内存. 势必还是需要将内核的数据拷贝到这个用户空间的内存中的.

epoll的工作方式

epoll对文件描述符有两种操作模式–LT(level trigger水平模式)和ET(edge trigger边缘模式)

感性认识

我们以故事切入,你在在淘宝上买了5件东西,卖家给你打包成5个快递发给你,你家附近有一个快递点,快递点值班的人的张三,他是专门负责打电话的(来了快递就给对应的人打电话让取快递) ,当你的快递到了以后,张三就给你打电话让你去取,你说好的,一会去取,但是你却没有去取,所以张三又给你打电话,只要你不取快递,张三就一直给你打电话,你顶不住电话轰炸,你就去把快递都取走了,张三也就给你打电话了。 第二天值班的人是李四,你又买了5个包裹,目前快递点收到了3个你的快递,李四给你打电话说,同学你的3个快递到了,你如果不来的话,我就不给你打电话了。你就是不去,一个礼拜以后你想起了你还有3个快递没拿,你给李四打电话,李四接也不接。你呢,最后经过寻找终于找到了。此时又来一个快递,李四就打电话说你的快递到了,你过来拿。你照样不拿,李四就再也不给你打电话了。当第5个包裹到的时候,李四又给你打电话让你去拿,此时你就去取了,可你只拿了1个包裹,李四也不管你,等你回去后发现你还有个快递没拿。反正李四就是我通知你来拿,要拿你就拿走,你不拿之后找不到了你也别问我。

张三:底层只要有你的包裹,就会一直通知你,不管这个包裹是新来的,还是历史积压的,只要有就给你一直打电话。

李四:只有当快递点中,你的快递从无到有,从有到多的时候,会给你打一个电话,除此之外,不会给你多打一个电话。比如:如果今天来1个快递,李四给你打一个电话,如果你不取,李四就再也不理你了;如果你来了3个快递,李四也只给你打一个电话,如果你全拿走就最好,如果你没拿完,李四也不理你,反正我李四是通知你了,没拿走就是你自己的事情。除非你再来一个包裹,李四才再给你打一个电话。

我们把快递点比作内核区,你就是用户进程,包括了读取逻辑和缓冲区。张三和李四对应两种不同的通知策略。

在IO这件事情上,IO效率的提高可不是OS一个人完成的,一个快递点派发快递的效率不仅仅是由快递点决定的,它也是由用户拿快递的效率决定的,好比TCP发送数据不仅仅受双方发送和接收数据能力决定,也与用户读取数据的效率有关。所以效率提升一定是由双方配合的。

这两种方式,张三会在我遗忘或者来不及处理数据的时候一直通知,对我来讲,我也就不担心数据丢失的问题,如果是张三让我进行数据读取,我读一次,我不担心,如果下次底层还有数据,张三会通知我,我就继续读取。而李四这种通知策略最大的优势在于通知你1次以后你爱读不读,这样的话,如果李四给你打电话了,你没读,可能你下次就没有机会再读了,换句话说就可能存在一批数据在底层永远不会通知你了,对我们来讲底层数据就有可能无法读取了,造成数据丢失。李四的工作方式最大的优点通过它的这种通知策略,倒逼程序员一旦开始读取数据,就要一直读完!!

张三和李四是两个人,一天最多打几百个电话,总之打电话的次数是确定的。可是张三打了很多的重复电话,李四就能保证他打的电话都是有效的都是给不同的人打的。所以李四的通知范围就更广了,通知效率就很高。

其中张三这种方式就是水平触发,李四就是边缘触发。我们之前写的select,poll,epoll默认的方式都是LT,而epoll是可以被设置成ET模式的。

如何让epoll更改成边缘触发呢?

我们可以用宏EPOLLET,就是将epoll设置成边缘触发。

eg:对于我们上面写的epoll代码,如果把这部分屏蔽了,默认情况下,服务器就会疯狂提示我们数据就绪了,让我们读取,这也符合我们的预期,这就是水平触发

我们的listen套接字的事件原来是EPOLLIN,我想改成ET模式,就添加一个标记位(其他代码不用改变),这个时候读事件就绪了,通知方式就变成了边缘触发。

 当前读事件就绪了,就不向之前的水平触发一直进行刷屏。变成了边缘触发

ET模式下的fd,必须是非阻塞的原因

我们现在处于ET模式,当事件就绪只会通知1次,当你准备通过read,recv,accept进行读取的时候,如何保证将本次的数据全部读取完呢?

不能保证,但是我可以循环读取。

循环读取的时候,什么时候代表读取完毕呢?

比如:你爸有500块但是你并不清楚,你就问你爸要零花钱,今天要100,你爸给你了,第二天又要了100,你爸给你了,第三天由要100,你爸说就剩50了,没钱了,就给你了50.你第四天就不会在向你爸要钱了,因为你知道你爸没钱了。

上层用户想通过read,recv,accept进行读取底层的连接或者数据,我期望读100字节,如果对方一直给我返回100字节,可是有1次,它给我返回了20字节,我就认为我把本次的数据全部读完了,这就是理想情况。

还有一种情况,还是你向你爸要钱,第一天要100,你爸给了你;第二天要100,你爸给了你;所以第三天你就还想问你爸要钱,但是你爸刚好只有200,所以当你第二天要玩以后,你爸就没钱了,但是你还会向你爸要。所以,循环读取的最后一次可能会卡住(本质就是阻塞住了)。

如果在进行数据读取的时候,每次期望读100,最后它给我返回20,这还好,我就会认为对方没数据了。但是如果对方数据刚好有200,我读了2次,肯定是会读第三次的,但是第三次已经没数据了,所以就阻塞住了。我们的epoll代码又是单进程,如果阻塞住了,相当于进程就挂起了,而且之后这个连接就再也不给你发数据了,也就代表这个服务器无法在向外提供服务了。

如何解决这种问题呢?

ET模式下的所有fd,必须将该fd设置成非阻塞。一旦设置成非阻塞,就相当于你可以进行循环读取了,循环读我也可以保证最后一次哪怕没有数据 ,只要你一直在进行循环读 ,最后就会以出错的方式返回,底层没数据,也就能读干净,并且不会被阻塞。PS:如果是LT模式,我们也可以将fd设置成非阻塞。

ET与LT的效率

所以所谓高效的IO是减少等的比重,减少等的比重,不仅仅是减少自己等的比重,同时也要尝试减少别人等的比重,所以IO这件事情,除了内核帮你做各种优化和策略,上层也要尽快的把数据取走。

eg:TCP上层数据取的足够快,底层进行通信的时候更新的窗口大小就变得越大,越有利于对方向我发消息。所以LT和ET谁更高效的话题,如果只是拘泥于LT和ET谈论的话,是谈不清楚的;

比如:LT通知上层取数据,上层也是取走一部分,底层缓冲区剩余窗口大小就不会特别大;如果是ET模式,倒逼程序员全部取走,一次向对方更新的窗口大小就变的更大了,这样的话就能够在传输层协议中尽可能的扩大对方滑动窗口大小,进而可以一次向我发送更大的数据。当然这只是大部分情况,有些情况下ET也不会很高效,但至少ET模式下的效率不差于LT模式。

epoll的使用场景

epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.
对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根据需求和场景特点来决定使用哪种IO模型

epoll中的惊群问题

参考资料

epoll的惊群效应_epoll惊群效应_fsmiy的博客-CSDN博客https://blog.csdn.net/fsmiy/article/details/36873357 epoll详解-chaohona-ChinaUnix博客http://blog.chinaunix.net/uid-24517549-id-4051156.html Apache与Nginx网络模型_apache和nginx网络模型_席飞剑的博客-CSDN博客https://blog.csdn.net/xifeijian/article/details/17385831 

Linux之高级IO相关推荐

  1. 【Linux练习生】高级IO

    本文收录于专栏:Linux 关注作者,持续阅读作者的文章,学习更多知识! https://blog.csdn.net/weixin_53306029?spm=1001.2014.3001.5343 高 ...

  2. Linux多线程编程----IO【select、poll、epoll】

    IO操作多   速度就下降 IO数据的 读和写 IO的完成 必须等到 读事件(如磁盘 拷贝  每次要从磁盘查找数据) 和 写事件 (允许写 如写太快 写满就要马上阻塞)的就绪 IO是否高效 :主要看一 ...

  3. linux的文件io操作(转)

    linux文件IO操作有两套大类的操作方式:不带缓存的文件IO操作,带缓存的文件IO操作.不带缓存的属于直接调用系统调用(system call)的方式,高效完成文件输入输出.它以文件标识符(整型)作 ...

  4. Unix环境高级编程-高级IO

    高级IO 非阻塞IO -- 对比阻塞IO 非阻塞:能做就做,不能做也不等待 因为速度不匹配,有些IO函数会出现假错, 不是因为函数报错,而是阻塞IO在读取或者写入的时候速度太慢.有限状态机编程思想 1 ...

  5. 树莓派 4B 下 Linux 系统高级命令行

    2021SC@SDUSC 现在我们已经将 ubuntu 系统安装到了树莓派上面,并且实现了两种连接树莓派的方式,同时对 Linux 系统中常见的命令有了一定的了解,接下来就是对一些 Linux 系统高 ...

  6. 深入聊聊Linux五种IO模型

    一.相关概念讲解 1.同步与异步 同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列.要么成功都成功,失败都失败,两个任务的状态可以 ...

  7. Netty框架-IO模型(Linux五大网络IO模型)

    一. IO读写的基础原理:read.write 1.编程模型一致性以及底层系统调用的理解(缓冲区与直接调用): 1.1.无论是Socket的读写还是文件的读写,在Java层面的应用开发或者是linux ...

  8. linux系统下io的过程,Linux系统基础知识:IO调度

    Linux系统基础知识:IO调度 IO调度发生在Linux内核的IO调度层.这个层次是针对Linux的整体IO层次体系来说的.从read()或者write()系统调用的角度来说,Linux整体IO体系 ...

  9. linux 磁盘并发io,Linux系统 磁盘IO过高排查总结

    最近做的一个电商网站因为磁盘 I/O 过高导致访问速度奇慢,问题存在两个月有余未得到解决办法.此次排查原因的经验可以作下次问题的参考. 1.会看懂 top 系统命令出来的各项参数.此次是无意中发现 u ...

最新文章

  1. qt5 中文乱码解决
  2. 将关闭窗口的按钮放在窗口右边
  3. android onscrolllistener判断到底部,判断RecyclerView是否滑动到底部
  4. Java-逻辑运算符、位运算符
  5. 通用业务平台设计(二):扩展多国家业务
  6. Windows C盘格式化或者同平台迁移oracle数据库
  7. 计算机二级vf上机考试题库,计算机等级考试二级VF上机题库
  8. 使用Python将PDF转换成图片
  9. 阿里云Aliplayer视频播放2(断点续播--根据上次播放记录实现续播功能)
  10. 客户端到服务器端的通信过程及原理
  11. 关于GHO文件怎么安装,GHO文件怎么打开等问题解答
  12. Elastic-Job (二)实现Dataflow作业
  13. layui做折线图_详解layuiAdmin单页版根据后台json数据动态生成左侧菜单栏
  14. 区块链数据服务 - BDS
  15. centos7离线安装软件和软件包组
  16. 看顶级渣男如何邀约100个女朋友(一)
  17. 还在使用@Autowired 吗?@Autowired和@Resource有啥区别
  18. MySql数据库入门
  19. 程序员加薪升职之成长金字塔
  20. 面试题整理 !=!=未看 *****面试题整理最全 有用

热门文章

  1. 第一次使用github
  2. 平台优势突出!科东软件被评定为广州开发区2020年工业互联网服务商
  3. 小程序canva手写板
  4. Log4j maven依赖配置
  5. 给你五个理由,在2023年获得CISA认证
  6. 通过ID 删除DIV
  7. 格力明年要用上自家的芯片?这似乎有点脱离现实
  8. 台式机计算机在哪里看,在哪里查看电脑配置?多种方法介绍
  9. HDU 5468 Puzzled Elena 莫比乌斯反演
  10. 近百元受让老板娘股份 汉威电子员工持股计划亏损