首先,我们想一个问题,我们在调用read或者recv时,我们做了哪些事情?
首先,我们要等TCP缓冲区里有数据,换句话说,就是等待IO事件就绪。这里面含有检测功能。当有数据时,从内核层拷贝到应用层。
那么IO=等+拷贝数据。
什么叫做高效的IO呢?
单位事件,等的比重越小,IO效率越高。
下面有5种IO模型:
阻塞IO:在内核将数据准备好之前,系统调用会一直等待。所有的套接字默认都是阻塞方式。
非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询。这对CPU来说是较大的浪费,一般只有特定场景下才使用。
信号驱动IO: 内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。
IO多路转接:IO多路转接能够同时等待多个文件描述符的就绪状态。
异步IO:由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
在这5种IO模型中,效率最高的其实是IO多路转接这种模式。
同步通信 vs 异步通信
同步和异步关注的是消息通信机制。
所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。
异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用
者,或通过回调函数处理这个调用。
总而言之,同步和异步主要就看它有没有参与IO细节。
另外, 我们回忆在讲多进程多线程的时候, 也提到同步和互斥,这里的同步通信和进程之间的同步是完全不想干的概念。
进程/线程同步也是进程/线程之间直接的制约关系,是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待,传递信息所产生的制约关系,尤其是在访问临界资源的时候。
阻塞 vs 非阻塞
阻塞和非阻塞关注的是程序在等待事件就绪。
阻塞调用是指事件就绪之前,当前线程会被挂起,调用线程只有在事件就绪后才进行拷贝。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
一个文件描述符,默认都是阻塞IO。但是我们可以用fcntl函数进行设置:
传入的cmd的值不同,后面追加的参数也不相同。我们主要使用的是这种方式:获得/设置文件状态标记(cmd=F_GETFL或F_SETFL),就可以将一个文件描述符设置为非阻塞。
运行结果如下:
我们可以看到标准输入变成非阻塞了,当我们输入的时候也能获取到。
上面我们说过,IO=等+数据拷贝。等,等的是文件描述符状态的变化。文件描述符状态的变化有3种:1.可读。2.可写。3.异常。
系统提供select函数来实现多路复用输入/输出模型,select系统调用是用来让我们的程序监视多个文件描述符的状态变化的,程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。
select的函数原型如下: #include
参数nfds是需要监视的最大的文件描述符值+1。
后面几个参数都是输入输出型参数,readfds作用是读取文件描述符集。
输入时:用户告诉OS,你帮我关心一下我所设置的多个fd中的读事件是否就绪。输出时:OS告诉用户,你让我关心的多个fd中,有哪些已经就绪了。
那么fd_set结构是什么呢?
其实这个结构就是一个 “位图”。使用位图中对应的位来表示要监视的文件描述符。
比特位的位置代表fd的编号,比特位的内容代表"是否就绪"的概念。
举个例子:
假设我们要传的位图结构原本是0100 1110,我们可以告诉OS位图为1的帮我关注一下,位置为0的不需要关注。当有事件就绪的时候,把就绪位置上的比特位设置成1,其余的覆盖成0。比如说,结果是0000 1000,就说明位置3的比特位上事件就绪了。
OS提供了一组操作fd_set的接口,来比较方便的操作位图:
第二个参数就是代表写事件,第三个参数代表的是异常事件。
参数timeout用来设置select()的等待时间:
参数timeout取值:
NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件。
0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
特定的时间值:在指定的时间内,阻塞,如果在指定的时间段里没有事件发生,select将超时返回。
下面我们来看一下参数timeout类型timeval:
timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0,如果有事件发生,就返回特定的时间值的剩余时间。
第一个是秒,第二个是微秒。
函数返回值:
执行成功则返回文件描述词状态已改变的个数。
如果返回0代表在描述词状态改变前已超过timeout时间,没有返回。
当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。
错误值可能为:
EBADF 文件描述词为无效的或该文件已关闭
EINTR 此调用被信号所中断
EINVAL 参数n 为负值。
ENOMEM 核心内存不足
我们将创建套接字封装了,这样以后再创建套接字就直接使用。
我们可以先测试一下这个类型多大,可以放多少个fd。
可以看出fds的大小是128字节,因为是位图是比特位,需要乘8,也就是1024比特位。
我们在这里可以直接用accept去接受监听套接字吗?
如何看待listensocket:获取新连接的,本质需要先三次握手,前提给我发送syn。建立连接的本质,其实也是IO,一个建立好的连接我们称之为读事件就绪,listensocket 只(也)需要关心读事件就绪。
accept: 等 + “数据拷贝”(链接拿到应用层),编写多路转接代码的时候,必须先保证条件就绪了,才能调用IO类函数。所以我们不能让accept去等,应该让select去等。
1. 刚启动的时候,只有一个fd,listensock
2. server 运行的时候,链接会越来越多,sock才会慢慢变多
3. select 使用位图,采用输入输出型参数的方式,来进行 内核<->用户 信息的传递, 每一次调用select,都需要对历史数据和sock进行重新设置。
4. 因为在select等待成功后,其它符号会被清空listensock也就没了,但是我们需要永远把listensock设置进readfds中
5. select 就绪的时候,可能是listen 就绪,也可能是普通的IO sock就绪了。
所以,我们每次要重新更新符号集,我们可以用数组来设置:
因为文件描述符是从0开始的整数,我们一开始都设置-1,这样当我们遍历符号集时,就可以判断fd是否合法。我们固定下标0为监听套接字。
这样我们每次使用的符号集就更新出来了。
当等待成功后,就说明有事件就绪了,我们就需要进行处理。
这里处理套接字有2种情况,一个是监听套接字,一个是普通套接字。
当我们获取链接成功后,我们就能直接进行读取了吗?
不能,因为你read不知道对方什么时候给你发送数据,但是select知道!我们要想办法把新的fd托管给select?
这样我们在数组里就找到了空余位置。
但是这里有一个bug,你怎么知道网络发送的报文是完整的呢?
这个问题以后再解决。
Select的编码特征:
a. select之前要进行所有参数的重置,之后要遍历所有的fd进行事件检测。
b. select需要用户自己维护第三方数组,来保存所有的合法fd,方便select进行批量处理。
c. 一旦特定的fd事件就绪,本次读取或写入不会被阻塞。
Select的优缺点:
优点:占用资源少,并且高效,对比之前的多线程,多进程。
缺点:
a. 每一次都要进行大量的重置工作,效率比较低。
b. 每一次能够检测的fd数量是有上限的。
c. 每一次都需要内核到用户,用户到内核传递位图参数,较为大量的数据拷贝工作。
d. select编码特别不方便,需要用户自己维护数组。
e. select底层需要遍历的方式,检测所有需要检测的fd。