目录
[1.1、OS 如何得知外设中有数据可读取?](#1.1、OS 如何得知外设中有数据可读取?)
[1.2、OS 如何处理从网卡中读取到的数据包?](#1.2、OS 如何处理从网卡中读取到的数据包?)
[2.3、信号驱动 IO](#2.3、信号驱动 IO)
[2.4、IO 多路转接](#2.4、IO 多路转接)
[2.5、异步 IO](#2.5、异步 IO)
[3.1、同步通信 VS 异步通信](#3.1、同步通信 VS 异步通信)
[3.2、阻塞 VS 非阻塞](#3.2、阻塞 VS 非阻塞)
[3.3、其他高级 IO](#3.3、其他高级 IO)
[四、阻塞 IO](#四、阻塞 IO)
[五、非阻塞 IO](#五、非阻塞 IO)
[5.2、实现函数 SetNoBlock](#5.2、实现函数 SetNoBlock)
一、I/O概念
I/O(input/output)也就是输入和输出,在冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫作输入,将数据从内存拷贝到输出设备就叫作输出
- 对文件进行的读写操作本质就是一种 IO,文件 IO 对应的外设就是磁盘。
- 对网络进行的读写操作本质也是一种 IO,网络 IO 对应的外设就是网卡。
1.1、OS 如何得知外设中有数据可读取?
输入就是操作系统将数据从外设拷贝到内存的过程,操作系统一定要通过某种方法得知特定外设上是否有数据就绪。
并不是操作系统想要从外设读取数据时,外设上就一定有数据可以被读取。
例:
用户正在访问某台服务器,当用户的请求报文发出后就需要等待从网卡中读取服务器发来的响应数据,但此时服务器可能还没有收到请求报文,或者是正在对请求报文进行数据分析,也可能是服务器发来的响应数据还在网络中路由。但操作系统不会主动去检测外设上是否有数据就绪,这种做法会降低操作系统的工作效率,因为大部分情况下外设中都是没有数据的,所以操作系统所做的大部分检测工作其实都是徒劳的。
操作系统实际上采用的是中断的方式来得知外设上是否有数据准备就绪了,当某个外设上面有数据就绪时,该外设就会向 CPU 中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给 CPU。
每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表被称为中断向量表,当 CPU 收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表来执行该中断信号对应的中断处理程序,处理完毕后再返回原先被暂停的程序继续运行。
注意:CPU 不直接和外设交互指的是在数据层面上,而外设其实是可以直接将某些控制信号发送给 CPU 中的某些控制器的。
IO 最主要的问题就是效率问题,IO 的效率极为低下,以读取数据为例:
- 当 read/recv 时,如果底层缓冲区中没有数据,read/recv 就会阻塞等待。
- 当 read/recv 时,如果底层缓冲区中有数据,read/recv 就会进行拷贝(学习 TCP 时,就知道了 read/recv 等一系列接口本质就是拷贝函数)
所以,IO 的本质就是:等待 (等待 IO 条件就绪)+ 数据拷贝(当 IO 条件就绪后将数据拷贝到内存或外设)。
只要缓冲区中没有数据,read/recv 就会一直阻塞等待,直到缓冲区中出现数据,然后进行拷贝,所以 read/recv 就会花费大量时间在等这一操作上面,这就是一种低效的 IO 模式。
任何 IO 的过程,都包含 "等" 和 "拷贝" 这两个步骤,但在实际的应用场景中 "等" 消耗的时间往往比 "拷贝" 消耗的时间多。
1.2、OS 如何处理从网卡中读取到的数据包?
操作系统任何时刻都可能会收到大量的数据包,因此操作系统须将这些数据包管理起来。所谓的管理即 "先描述,再组织",在内核中有一个结构 sk_buff,该结构就是用来管理和控制接收或发送数据包的信息。
简化版的 sk_buff 结构:
当操作系统从网卡中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户。
那对应到这个 sk_buff 结构来说具体是如何进行数据包的解包和分用的呢?
1、当操作系统从网卡中读取到一个数据包后,就会定义一个 sk_buff 结构,然后用 sk_buff 结构中的 data 指针指向这个读取到的数据包,并将这个 sk_buff 结构与其他 sk_buff 结构以双链表的形式组织起来,此时操作系统对各个数据包的管理就变成了对双链表的增删查改等操作。
2、接下来需要将读取上来的数据包交给最底层的链路层处理,进行链路层的解包和分用,让 sk_buff 结构中的 mac_header 指针指向最初的数据包,然后向后读取链路层的报头,剩下的就是要交给网络层处理的有效载荷了,此时便完成了链路层的解包。
3、这时链路层就需要将有效载荷向上交付给网络层进行解包和分用了,所谓的向上交付只是形象的说法,实际向上交付并不是要将数据从链路层的缓冲区拷贝到网络层的缓冲区,只需让sk_buff结构中的 network_header 指针,指向数据包中链路层报头后的数据即可,然后继续向后读取网络层的报头,便完成了网络层的解包。
4、紧接着就是传输层对数据进行处理,同样的道理,让 sk_buff 结构中的 transport_header 指针,指向数据包中网络层报头后的数据,然后继续向后读取传输层的报头,便完成了传输层的解包。
5、传输层解包后就可以根据具体使用的传输层协议,对应将剩下的数据拷贝到 TCP 或 UDP 的接收缓冲区供用户读取即可。
发送数据时对数据进行封装也是同样的道理,即依次在数据前面拷贝上对应的报头。应用层以下,数据包在进行封装和解包的过程中,本质数据的存储位置是没有发生变化的,实际只是在用不同的指针对数据进行操作。
但内核中的 sk_buff 并不像上面那样简单:
- 一方面,为了保证高效的网络报文处理效率,要求 sk_buff 的结构必须是高效的。
- 另一方面,sk_buff 结构需要被内核协议中的各个协议共同使用,因此 sk_buff 必须能够兼容所有网络协议。
如何提高 IO 效率?
想办法在单位时间内,让等待的比重降低,这样 IO 的效率就提高了。
二、五种I/O模型
IO 的过程跟钓鱼的过程是非常相似的。
- 钓鱼的过程同样分为 "等" 和 "拷贝" 两个步骤,只不过这里的 "等" 指的是等鱼上钩,"拷贝" 指的是当鱼上钩后将鱼从河里 "拷贝" 到鱼桶中。
- IO 时 "等" 消耗的时间往往比 "拷贝" 消耗的时间多,钓鱼也符合这个特点。在钓鱼时大部分的时间都在等待鱼上钩,而当鱼上钩后只需要一瞬间就能将鱼 "拷贝" 上来。
五个人的不同钓鱼方式:
- 张三:用 1 根鱼竿,将鱼钩抛入水中后,就一直盯着浮标一动不动,不理会外界的任何动静,直到有鱼上钩,就挥动鱼竿将鱼钓上来。(阻塞)
- 李四:用 1 根鱼竿,将鱼钩抛入水中后,就可以去做其它事情了,然后定期观察浮标的动静,如果有鱼上钩就将鱼钓上来,否则就继续做其它事情。(非阻塞轮询式)
- 王五:用 1 根鱼竿,将鱼钩抛入水中后,在鱼竿顶部绑一个铃铛,就可以去做其它事情了,如果铃铛一响就知道有鱼上钩了,于是挥动鱼竿将鱼钓上来,否则就不管鱼竿。(信号驱动)
- 赵六:用 100 根鱼竿,将 100 个鱼钩抛入水中后,就定期观察这 100 个浮漂的动静,如果某个鱼竿有鱼上钩就挥动对应的鱼竿将鱼钓上来。(多路复用、多路转接)
- 田七:田七是一个公司的领导,带了一个司机,此时田七也想钓鱼,但他因为要立刻回公司开会,所以他拿来一根鱼竿,让自己的司机去钓鱼,当司机将鱼桶装满时再打电话告诉他。(异步 IO)
张三,李四和王五钓鱼的效率一样吗?
张三,李四和王五钓鱼的效率本质上是一样的。
- 因为他们的钓鱼方式都是一样的,都是先等鱼上钩,然后再将鱼钓上来。
- 其次,因为他们每个人都是拿的一根鱼竿,在等待鱼上钩,当河里有鱼来咬鱼钩时,这条鱼咬哪一个鱼钩的概率都是相等的。
因此,张三、李四、王五三个人的钓鱼的效率是一样的,只是等鱼上钩的方式不同而已。张三是静静的等待,李四是定期观察浮漂动静,而王五是通过铃铛来判断是否有鱼上钩。
谁的效率更高?
因为赵六减少了等待的概率发生,增加了拷贝的时间,所以他的效率是最高的。(多路复用、多路转接)
高效的钓鱼就是要减少单位时间内 "等" 的时间,增加 "拷贝" 的时间,所以说赵六的钓鱼效率是这四个人中最高的。赵六的钓鱼效率之所以高,是因为赵六一次等待多个鱼竿上的鱼上钩,可以将 "等" 的时间进行重叠。
其中,这个钓鱼场景中的各个事物都能与 IO 中的相关概念对应起来,鱼对应的是数据,钓鱼的河对应的是内核,浮标对应的文件描述上是否有事件就绪,每一个人对应的是执行流(进程或线程),司机对应的是操作系统,鱼竿对应的是文件描述符或套接字,装鱼的桶对应的就是用户缓冲区。
对于张三、李四、王五、赵六而言,他们都要自己等待鱼上钩,当鱼上钩后又需要自己把鱼从河里钓上来,对应到 IO 中就是需要自行进行数据的拷贝,因此他们四个人的钓鱼方式就叫作同步 IO。
田七本人并没有参与整个钓鱼的过程,他只是给司机派发了钓鱼的任务,所以真正钓鱼的人的是司机,所以田七在司机钓鱼的期间,可以做任何其他事情。如果将钓鱼看作是一种 IO 的话,那么田七的这种钓鱼方式就叫作异步 IO。
2.1、阻塞I/O
在内核将数据准备好之前,系统调用会一直等待。
阻塞 IO 是最常见的 IO 模型,所有的套接字,默认都是阻塞方式
调用 recvfrom 函数来从某个套接字上读取数据时,可能底层的数据还没准备好,那么此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后 recvfrom 函数才会返回。
在 recvfrom 函数等待数据就绪期间,在用户看来该进程或线程就阻塞了,本质就是操作系统将该进程或线程的状态设置为了某种非 R 状态,然后将其放入等待队列中,当数据就绪后操作系统再将其从等待队列中唤醒,然后该进程或线程再将数据从内核拷贝到用户空间。
以阻塞方式进行 IO 操作的进程或线程,在 "等" 和 "拷贝" 期间都不会返回,在用户看来就是阻塞了,因此被称为阻塞 IO。
2.2、非阻塞I/O
如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码。
非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。
当调用 recvfrom 函数,以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么 recvfrom 函数会立马错误返回,而不会让该进程或线程进行阻塞等待。
因为没有读取的数据,所以该进程或线程后续还需要继续调用 recvfrom 函数,检测底层数据是否就绪,若没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。
每次调用 recvfrom 函数读取数据时,就算底层数据没有就绪,recvfrom 函数也会立马返回,在用户看来该进程或线程就没有被阻塞,因此被称为非阻塞 IO。
阻塞 IO 和非阻塞 IO 的区别
阻塞 IO 当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞 IO 当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的
2.3、信号驱动 IO
内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作。
当底层数据就绪的时候会向当前进程或线程递交 SIGIO 信号,因此可以通过 signal 或 sigaction 函数将 SIGIO 的信号处理程序自定义为需要进行的 IO 操作,当底层数据就绪时就会自动执行对应的 IO 操作。
比如需要调用 recvfrom 函数从某个套接字上读取数据,那么就可以将该操作定义为 SIGIO 的信号处理程序。
当底层数据就绪时,操作系统就会递交 SIGIO 信号,此时就会自动执行定义的信号处理程序,进程将数据从内核拷贝到用户空间。
信号的产生是异步的,但信号驱动 IO 是同步 IO 的一种。
信号的产生异步的,因为信号在任何时刻都可能产生。但信号驱动 IO 是同步 IO 的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,当前进程或线程仍然需要参与 IO 过程。
判断一个 IO 过程是同步还是异步的,其本质就是看当前进程或线程是否需要参与 IO 过程,若参与即为同步 IO,否则为异步 IO。
2.4、IO 多路转接
虽然从流程图上看起来和阻塞 IO 类似,IO 多路转接也被称为 IO 多路复用,实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。
IO 多路转接的思想:
因为 IO 过程分为 "等" 和 "拷贝" 两个步骤,因此使用的 recvfrom 等接口的底层实际上都做了两件事,第一件事是数据不就绪时需要等,第二件事是数据就绪后需要进行拷贝。
虽然 recvfrom等接口也有 "等" 的能力,但这些接口一次只能 "等" 一个文件描述符上的数据或空间就绪,IO 效率太低。
因此系统提供了三组接口,即 select、poll 和 epoll,这些接口的核心工作就是 "等",可将所有 "等" 的工作都交给这些多路转接接口。
因为这些多路转接接口是一次 "等" 多个文件描述符的,因此能将 "等" 的时间重叠,数据就绪后再调用对应的 recvfrom 等函数进行数据的拷贝,此时这些函数就能够直接进行拷贝,而不需要 "等" 了。
IO 多路转接就像是帮人排队的黄牛,因为多路转接接口实际并没有进行数据拷贝。排队黄牛可以一次帮多个人排队,此时就将多个人排队的时间进行了重叠。
2.5、异步 IO
由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
进行异步 IO需调用一些异步 IO 接口,异步 IO 接口调用后会立马返回,因为异步 IO 不需要发起者进行 "等" 和 "拷贝" 的操作,都由操作系统来完成,只需发起 IO。
当 IO 完成后操作系统会通知应用程序,因此进行异步 IO 的进程或线程并不参与 IO 的所有细节。
总结:
任何 IO 过程中,都包含两个步骤:等待和拷贝
在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是在单位时间内让等待的时间尽量少。
三、高级I/O
3.1、同步通信 VS 异步通信
同步和异步关注的是消息通信机制。
- 所谓同步,就是在发出一个调用 时,在没有得到结果之前,该调用 就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者 主动等待这个调用的结果。
- 异步则是相反,调用 在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用 发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
为什么非阻塞 IO 在没有得到结果之前就返回了?
- IO 是分为 "等" 和 "拷贝" 两步的,当调用 recvfrom 进行非阻塞 IO 时,若数据没有就绪,那么调用会直接返回,此时这个调用返回时并没有完成一个完整的 IO 过程,即便调用返回了也是属于错误的返回。
- 因此该进程或线程后续还需继续调用 recvfrom,轮询检测数据是否就绪,当数据就绪后再把数据从内核拷贝到用户空间,这才是一次完整的 IO 过程。
- 因此,在进行非阻塞 IO 时,在没有得到结果之前,虽然这个调用会返回,但后续还需要继续进行轮询检测,因此可以理解成调用还没有返回,而只有当某次轮询检测到数据就绪,并且完成数据拷贝后才认为该调用返回了。
在学习多进程多线程的时候,也提到过同步和互斥,但这里的同步通信和进程之间的同步是完全不想干的概念
- 进程 / 线程同步:指的是在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
- 同步 IO:指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与 IO 过程。
注意:尤其是在访问临界资源的时候,一定要弄清楚这个 "同步",是同步通信异步通信的同步,还是同步与互斥的同步。
3.2、阻塞 VS 非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
- 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
3.3、其他高级 IO
非阻塞 IO、 纪录锁、系统 V 流机制、 I/O 多路转接(也叫 I/O 多路复用), readv 和 writev 函数以及存储映射 IO( mmap ),这些统称为高级 IO。
四、阻塞 IO
系统中大部分的接口都是阻塞式接口,如使用 read 函数从标准输入中读取数据
cpp
#include<iostream>
#include<unistd.h>
#include<fcntl.h>
#include<cstdlib>
#include<cerrno>
using namespace std;
int main()
{
while(true)
{
char buffer[1024];
ssize_t s = read(0,buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
cout<<"echo#"<<buffer<<endl;
}
else if(s==0)
{
cout<<"end stdin"<<endl; //读到结尾了 ctrl+d
break;
}
else
{
//非阻塞等待,如果没有数据准备好没返回值会按照出错返回 s==-1;
//数据没有准备好 vs 真的出错了 :处理方式不一定一样的,s无法区别
//数据没有准备好不算读取错误,read,recv以出错的形式告知上层,数据还没有准备好
if(errno==EWOULDBLOCK)
{
cout<<"OS底层数据还没有就绪,errno:"<<errno<<endl;
}
else if(errno == EINTR)
{
cout<<"IO insterrupted by signal, try again"<<endl;
}
else
{
cout<<"read error"<<endl;
break;
}
break;
}
sleep(1);
}
return 0;
}
程序运行后,若不进行输入操作,该进程就会阻塞。根本原因就是因为此时底层数据不就绪,所以 read 函数需要进行阻塞等待。
一旦进行输入操作,此时 read 函数就会检测到底层数据已经就绪,然后将数据读取到从内核拷贝到程序员传入的 buffer 数组中,并且将读取到的数据输出到显示器上面,最后就可以看到输入的字符串了。
五、非阻塞 IO
打开文件时,都是默认以阻塞的方式打开的。如果要以非阻塞的方式打开某个文件,需要在使用 open 函数打开文件时携带 O_NONBLOCK 或 O_NDELAY 选项,那么此时就可以以非阻塞的方式打开文件。
5.1、fcntl
一个文件描述符, 默认都是阻塞 IO。
- fd:已打开的文件描述符
- cmd:需要进行的操作
- ...:可变参数,传入的 cmd 值不同,后面追加的参数也不同
传入 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)
若函数调用成功,则返回值取决于具体进行的操作;若函数调用失败,则返回 -1,同时错误码被设置。
5.2、实现函数 SetNoBlock
基于 fcntl,下面实现一个 SetNoBlock 函数,该函数用于将指定的文件描述符设置为非阻塞状态。
- 先调用 fcntl 函数获取该文件描述符对应的文件状态标记(位图结构),此时调用 fcntl 函数时传入的 cmd 值为 F_GETFL。
- 在获取到的文件状态标记上添加非阻塞标记 O_NONBLOCK,再次调用 fcntl 函数对文件状态标记进行设置,此时调用 fcntl 函数时传入的 cmd 值为 F_SETFL。
cpp
#include<iostream>
#include<unistd.h>
#include<fcntl.h>
#include<cstdlib>
#include<cerrno>
using namespace std;
bool SetNonBlock(int fd)
{
int f1=fcntl(fd,F_GETFL);
if(f1<0) return false;
fcntl(fd,F_SETFL,f1|O_NONBLOCK);
return true;
}
int main()
{
SetNonBlock(0);
while(true)
{
char buffer[1024];
ssize_t s = read(0,buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
cout<<"echo#"<<buffer<<endl;
}
else if(s==0)
{
cout<<"end stdin"<<endl; //读到结尾了 ctrl+d
break;
}
else
{
//非阻塞等待,如果没有数据准备好没返回值会按照出错返回 s==-1;
//数据没有准备好 vs 真的出错了 :处理方式不一定一样的,s无法区别
//数据没有准备好不算读取错误,read,recv以出错的形式告知上层,数据还没有准备好
if(errno==EWOULDBLOCK)
{
cout<<"OS底层数据还没有就绪,errno:"<<errno<<endl;
}
else if(errno == EINTR)
{
cout<<"IO insterrupted by signal, try again"<<endl;
}
else
{
cout<<"read error"<<endl;
break;
}
break;
}
sleep(1);
}
return 0;
}
-
当 read 函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么 read 函数就会立即返回,并且是以出错的形式返回的,此时的错误码会被设置为 EAGAIN 或 EWOULDBLOCK。
-
因此在以非阻塞方式读取数据时,如果调用 read 函数时得到的返回值是 -1,此时还需通过错误码进一步进行判断,如果错误码的值是 EAGAIN 或 EWOULDBLOCK,说明本次调用 read 函数出错是因为底层数据还没有就绪,因此后续还应该继续调用 read 函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。
-
调用 read 函数在读取到数据前可能被其他信号中断,此时 read 函数也会以出错的形式返回,此时的错误码会被设置为 EINTR,应重新执行 read 函数进行数据的读取。
因此在以非阻塞的方式读取数据时,若调用 read 函数读取到的返回值为 -1,此时并不应该直接认为 read 函数在底层读取数据时出错,而应该继续判断错误码,若错误码的值为 EAGAIN、EWOULDBLOCK 或 EINTR 则应该继续调用 read 函数再次进行读取。