目录
IO
五种IO模型
阻塞式IO
非阻塞式IO
信号驱动IO
多路转接
异步IO
阻塞IO VS 非阻塞IO
IO
网络的知识我们已经介绍完了,网络通信的本质就是IO,一方要发送数据,还要接收数据,这就是一次IO,所以我们原来说过的IO至少是在一个机器上进行的,虽然向磁盘写入的效率相比于CPU是很慢的,但是网络通信中的IO效率要比这些更低的。
为了解决IO低效的问题,我们得先知道它为什么低效。以读取为例:
- 当调用read/recv这样的拷贝函数时,如果缓冲区没有数据,那就会阻塞,说白了就是等。
- 如果缓冲区中有数据,那就把缓冲区中的数据拷贝到上层。
- 所以一次IO操作就可以理解为:等待 + 数据拷贝。
当要读取磁盘中的某个文件时,首先要打开该文件,打开文件就是为这个文件创建内核数据结构,此时可能并没有加载到内存,此时这个进程就只能阻塞等待,等操作系统把外设的数据换入到内存中,之后才能进行拷贝。
现在就可以知道,低效的IO就是:单位时间内,IO接口等待的比重高。所以提高IO效率就是想办法在单位时间内,IO接口等待的比重降低。
五种IO模型
先来说一下五种IO模型:
- 阻塞式IO
- 非阻塞式IO
- 信号驱动IO
- 多路转接
- 异步IO
IO模型说完,我们也先来说一下IO的相关概念,还是以读取数据为例:
- 是谁读取数据?肯定是一个执行流在读取,也就是进程或者线程。
- 从哪里读读取?从特定的文件描述符中读取。
- 要读取的数据放在哪里?放在了内核的缓冲区中。
- 读取后放到哪里?放到了用户缓冲区中。
之后我们都以读取为例来说明这五个IO模型。
阻塞式IO
阻塞式IO就是在内核缓冲区的数据准备好之前,系统调用会一直等待,改变进程的状态;数据准备好后,执行拷贝操作。
阻塞IO是最常见的IO模型,也是使用最多的,因为它简单。
- 在等待的过程中,操作系统也在不停的检测,检测条件是否就绪,这个条件就是某个文件描述符上是否有数据,如果没有数据,操作系统就会把进程状态设置为非运行状态(例如S状态),并把这个进程放到对应的等待队列中。
- 当条件就绪时,操作系统识别后,把该进程的状态调整为运行状态(R状态),放入运行队列。
阻塞式IO一定参与了此次IO,因为它不仅有等待的过程,还有拷贝的过程。
非阻塞式IO
非阻塞式IO就是如果内核缓冲区还未将数据准备好,系统调用会直接返回,并且返回EWOULDBLOCK错误码,也就是在数据未准备好时,操作系统不将进程阻塞,让进程自行处理,此时进程可以先处理其他事,所以通常在检测条件就绪时采用循环的方式,这也叫做非阻塞轮询式。
阻塞IO和非阻塞IO的区别在于,两种方式都进行了等待,但是等的方式不一样,最后两种方式都要从内核缓冲区拷贝到用户缓冲区。
信号驱动IO
信号驱动IO就是当内核将数据准备好的时候,操作系统向目标进程发送SIGIO信号,将信号集中的位图结构的第29号位置由0置1,通知进程进行拷贝操作。
- 信号的产生是异步的,因为信号在任何时刻都可能产生。
- 信号驱动IO是同步,因为当底层数据就绪时,执行流需要停下正在做的事情,进行数据拷贝,所以当前执行流仍然需要参与IO过程。
如何区分一个IO过程是同步还是异步,其实就是看当前进程或线程是否亲自参与IO,如果参与了IO,那就是同步的,反之就是异步。
多路转接
多路转接也叫多路复用,能够同时检测多个文件描述符的状态,支持多路转接的操作系统都要提供一些接口,比如下面的select,它的工作就是等待,运行向其中添加多个描述符,一次就可以等待多个文件描述符。
- 一次IO操作需要等待+数据拷贝,但是使用read/recvfrom这样的系统调用接口一次只能等待一个文件描述符,但是这样IO效率太低。
- 所以系统提供了三组接口,分别是select、poll 和 epoll,他们的工作就是等待。
- 这些多路转接的接口一次性等待多个文件描述符,将等待的时间重叠,当数据就绪时就可以调用recvfrom等函数进行数据拷贝,就不需要再等了。
异步IO
异步IO会在内核数据拷贝完成后,通知应用程序,这是不同于信号驱动的。
- 进行异步IO需要调用异步IO接口,比如aio_read,调用时预先给操作系统提供一块缓冲区,调用后会立刻返回。
- 当数据就绪时不需要告知进程,直接包内核缓冲区的数据拷贝到用户缓冲区,之后通过某种特定的信号告知该进程。
最后我们再来总结一下,这五种IO模型哪种的效率是最高的呢?前面也说过,只要等待的比重低,那么它的效率一定高,所以一定是多路转接这种方式的效率是最高的。
阻塞IO VS 非阻塞IO
系统中大部分的接口都是阻塞式的接口,我们之前使用的read或者是recvfrom,这些都是阻塞式的,所以我们下面就来谈一谈非阻塞IO。
我们之前使用系统调用打开文件时使用的open函数,函数的参数可以设置选项,其中就有打开方式,可以设置O_CREAT、O_RDONLY、O_WRONLY、O_APPEND 和 O_TRUNC等选项,还可以设置O_NONBLOCK 或 O_NDELAY选项,设置为非阻塞打开。
在套接字编程篇,设置socket也可以设置为非阻塞的,上面两个就是设置为字节流还是数据报。
所以在进行IO的时候,打开文件的时候就可以设置阻塞或非阻塞,但是我们不这样做,我们使用统一的方式来进行设置,使用 fcntl()函数。
一个文件的属性中一定有这个文件是否是阻塞的,当使用系统调用时,操作系统要检查struct_file中的属性,如果是阻塞,那么就会直接挂起;如果当前设置非阻塞,操作系统不挂起,那就直接返回。
我们使用的函数就是fcntl。
int fcntl(int fd, int cmd, ... /* arg */ );
参数:
- fd:已经打开的文件描述符。
- cmd:需要进行的操作。
- …:可变参数,传入的cmd值不同,后面追加的参数也不同。
fcntl函数常用的5种功能与其对应的cmd取值如下:
- 复制一个现有的描述符 (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)
其中可以设置cmd为 F_GETFL 或 F_SETFL 来获取和设置文件读写标志位,这些大写字母就是使用位图方式,最后的可变参数可以使用按位或(|)来传参。
返回值:
- 调用成功,则返回值取决于具体进行的操作。
- 调用失败,则返回-1,同时错误码会被设置。
我们使用的标准输入本来就是一个阻塞式,当我们调用read,从0号文件描述符中读取数据,如果不输入,那就会阻塞住,原因就是底层数据不就绪,read需要阻塞等待。
下面我们就实现一个函数,向该函数传入指定的文件描述符,设置为非阻塞状态。
#include <unistd.h> #include <fcntl.h> // 对指定的文件描述符设置为非阻塞 bool SetNonBlock(int fd) { int fl = fcntl(fd, F_GETFL); // 在底层获取当前fd对应的文件读写标志位 if (fl < 0) { std::cout << "fcntl error" << std::endl; return false; } fcntl(fd, F_SETFL, fl | O_NONBLOCK); // 要设置一个新的读写标志位,而且还要将非阻塞的选项传入 }
#include <iostream> #include <unistd.h> int main() { SetNonBlock(0); // 只要设置一次,0号文件描述符就是非阻塞的了 char buffer[1024]; while (true) { ssize_t s = read(0, buffer, sizeof(buffer) - 1); if (s > 0) { buffer[s - 1] = 0; std::cout << "echo# " << buffer << std::endl; } else { std::cout << "read \"error\": " << s << std::endl; } sleep(1); } return 0; }
我们看到的现象就是,如果我们不输入,read的返回值一直是错误,所以非阻塞的时候是以出错的形式返回,告知上层数据没有就绪;如果数据就绪,那就正常读取。
但是如何甄别是真的出错了,还是没有数据就绪呢,这时就要使用cerrno这个库中的errno,出错了,返回错误码,并且errno被设置,标明出错原因,使用strerror就可以打印错误信息。
int main() { SetNonBlock(0); // 只要设置一次,0号文件描述符就是非阻塞的了 char buffer[1024]; while (true) { ssize_t s = read(0, buffer, sizeof(buffer) - 1); if (s > 0) { buffer[s - 1] = 0; std::cout << "echo# " << buffer << ", erron: " << errno << ", errorstring: " << strerror(errno) << std::endl; } else { std::cout << "erron: " << errno << ", errorstring: " << strerror(errno) << std::endl; } sleep(1); } return 0; }
这里不管成功与否,errno都是11,原因就是如果数据就绪,errno没有被设置,如果想要看到errno为0就在循环开始设置errno为0即可。
所以errno被设置为11就不能叫出错,所以前面我们说过非阻塞如果数据未准备好返回的是EWOULDBLOCK。
#define EAGAIN 11 /* Try again */ #define EWOULDBLOCK EAGAIN /* Operation would block */
还有一种也不能叫做错误,就是返回的是以EINTR,这个就是当阻塞式读取的时候,进程收到一个信号,此时该进程就要被操作系统唤醒处理信号,处理完信号后就不是挂起状态了,因为没有再调用read,所以这个也要处理一下。
int main() { SetNonBlock(0); // 只要设置一次,0号文件描述符就是非阻塞的了 char buffer[1024]; while (true) { sleep(1); errno = 0; ssize_t s = read(0, buffer, sizeof(buffer) - 1); if (s > 0) { buffer[s - 1] = 0; //std::cout << "echo# " << buffer << std::endl; std::cout << "echo# " << buffer << ", erron: " << errno << ", errorstring: " << strerror(errno) << std::endl; } else { if (errno == EWOULDBLOCK || errno == EAGAIN) { std::cout << "当前0号fd数据没有就绪, 请再试一次" << std::endl; continue; } else if (errno == EINTR) { std::cout << "当前IO可能被信号中断, 请再试一次" << std::endl; continue; } else { // 差错处理 } } } return 0; }
所以底层没有数据就绪的时候就是非阻塞式,如果有数据就依次读取。