五种IO模型

1.IO的本质

IO的本质就是等+拷贝。

  • 等:等待IO条件就绪,比如recv函数,当输入缓冲区中没有数据时阻塞等待。
  • 拷贝:当IO条件就绪后,将数据拷贝到内存或外设。

任何IO的过程,都包含“等”和“拷贝”这两个步骤,但在实际的应用场景中“等”消耗的时间往往比“拷贝”消耗的时间多,因此要让IO变得高效,最核心的办法就是尽量减少“等”的时间

2.五种IO模型

2.1阻塞IO

阻塞IO就是在数据准备好之前,系统调用会一直等待。

image-20240811103651386

阻塞IO是最常见的IO模型,所有的套接字,默认都是阻塞方式。

比如当调用recv函数从某个套接字上读取数据时,可能底层数据还没有准备好,此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后recv函数才会返回。

以阻塞方式进行IO操作的进程或线程,在“等”和“拷贝”期间都不会返回,在用户看来就像是阻塞住了,因此我们称之为阻塞IO。

2.2非阻塞IO

非阻塞IO就是,如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。

image-20240811104148905

非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对CPU来说是较大的浪费,一般只有特定场景下才使用。

比如当调用recvfrom函数以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么recvfrom函数会立马错误返回,而不会让该进程或线程进行阻塞等待。

阻塞IO和非阻塞IO的区别在于,阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。

实现方式

实现非阻塞式IO的方式有很多,包括可以设置sendrecv函数的flags参数为MSG_DONTWAIT ,但我们最推荐的还是一劳永逸的做法,比如以下两种方式。

(1)在打开文件时设置非阻塞读取

打开文件时默认都是以阻塞的方式打开的,如果要以非阻塞的方式打开某个文件,需要在使用open函数打开文件时携带O_NONBLOCKO_NDELAY选项,此时就能够以非阻塞的方式打开文件。

(2)将已经打开的某个文件或套接字设置为非阻塞读取

需要用到fcntl函数:

int fcntl(int fd, int cmd, ... /* arg */);

参数说明:

  • fd:已经打开的文件描述符。
  • cmd:需要进行的操作。
  • …:可变参数,传入的cmd值不同,后面追加的参数也不同,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)。

返回值说明:

  • 如果函数调用成功,则返回值取决于具体进行的操作。
  • 如果函数调用失败,则返回-1,同时错误码会被设置。

(3)封装SetNonBlock函数

我们可以封装一个函数,该函数就用于将指定的文件描述符设置为非阻塞状态。

  1. 先调用fcntl函数获取该文件描述符对应的文件状态标记(这是一个位图),此时调用fcntl函数时传入的cmd值为F_GETFL。
  2. 在获取到的文件状态标记上添加非阻塞标记O_NONBLOCK,再次调用fcntl函数对文件状态标记进行设置,此时调用fcntl函数时传入的cmd值为F_SETFL。

代码如下:

bool SetNonBlock(int fd)
{
	int fl = fcntl(fd, F_GETFL);
	if (fl < 0){
		std::cerr << "fcntl error" << std::endl;
		return false;
	}
	fcntl(fd, F_SETFL, fl | O_NONBLOCK);
	return true;
}

需要注意的是,当read函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么read函数就会立即返回,但当底层数据不就绪时,read函数是以出错的形式返回的,此时的错误码会被设置为EAGAINEWOULDBLOCK

因此在以非阻塞方式读取数据时,如果调用read函数时得到的返回值是-1,此时还需要通过错误码进一步进行判断,如果错误码的值是EAGAIN或EWOULDBLOCK,说明本次调用read函数出错是因为底层数据还没有就绪,因此后续还应该继续调用read函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。

此外,调用read函数在读取到数据之前可能会被其他信号中断,此时read函数也会以出错的形式返回,此时的错误码会被设置为EINTR,此时应该重新执行read函数进行数据的读取。

因此在以非阻塞的方式读取数据时,如果调用read函数读取到的返回值为-1,此时并不应该直接认为read函数在底层读取数据时出错了,而应该继续判断错误码,如果错误码的值为EAGAINEWOULDBLOCKEINTR则应该继续调用read函数再次进行读取。

2.3信号驱动IO

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

image-20240811104229202

当底层数据就绪的时候会向当前进程或线程递交SIGIO信号,因此可以通过signalsigaction函数将SIGIO的信号处理程序自定义为需要进行的IO操作,当底层数据就绪时就会自动执行对应的IO操作。

信号的产生是异步的,但信号驱动IO是同步IO的一种。

  • 我们说信号的产生异步的,因为信号在任何时刻都可能产生。
  • 但信号驱动IO是同步IO的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,因此当前进程或线程仍然需要参与IO过程。
  • 判断一个IO过程是同步的还是异步的,本质就是看当前进程或线程是否需要参与IO过程,如果要参与那就是同步IO,否则就是异步IO。

2.4多路转接

IO多路转接也叫做IO多路复用,能够同时等待多个文件描述符的就绪状态。

image-20240811104453647

IO多路转接与阻塞IO的区别在于:IO多路转接能够同时等待多个文件描述符的就绪状态。

  • 阻塞IO一次只能“等”一个文件描述符上的数据或空间就绪,这样IO效率太低了。
  • 因此系统为我们提供了三组接口,分别叫做select、poll和epoll,这些接口的核心工作就是“等”,我们可以将所有“等”的工作都交给这些多路转接接口。
  • 因为这些多路转接接口是一次“等”多个文件描述符的,因此能够将**“等”的时间进行重叠**,当数据就绪后再调用对应的recv等函数进行数据的拷贝,此时这些函数就能够直接进行拷贝,而不需要进行“等”操作了。

2.5异步IO

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

image-20240811105028740

异步IO不需要你进行“等”和“拷贝”的操作,这两个动作都由操作系统来完成,你要做的只是发起IO,当IO完成后操作系统会通知应用程序。

使用异步IO可能会造成操作系统压力大、编程难度、程序调试难度大、资源竞争和数据一致性问题,所以一般不适用异步IO。

3.进程同步与IO同步

在多进程和多线程当中有同步与互斥的概念,但是这里的IO同步和进程或线程之间的同步是完全不相干的概念。

  • 进程/线程同步指的是,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
  • 而同步IO指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与IO过程。

你与自己的关系,会奠定下你与其他所有关系的基石。 —罗伯特·霍尔登

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐