IO的基本概念
什么是IO?
I/O(input/output)也就是输入和输出,在著名的冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫做输入,将数据从内存拷贝到输出设备就叫做输出。

- 对文件进行的读写操作本质就是一种IO,文件IO对应的外设就是磁盘。
- 对网络进行的读写操作本质也是一种IO,网络IO对应的外设就是网卡。
OS如何得知外设当中有数据可读取?
输入就是操作系统将数据从外设拷贝到内存的过程,操作系统一定要通过某种方法得知特定外设上是否有数据就绪。
- 并不是操作系统想要从外设读取数据时外设上就一定有数据。比如用户正在访问某台服务器,当用户的请求报文发出后就需要等待从网卡当中读取服务器发来的响应数据,但此时对方服务器可能还没有收到我们发出的请求报文,或是正在对我们的请求报文进行数据分析,也有可能服务器发来的响应数据还在网络中路由。
- 但操作系统不会主动去检测外设上是否有数据就绪,这种做法一定会降低操作系统的工作效率,因为大部分情况下外设当中都是没有数据的,因此操作系统所做的大部分检测工作其实都是徒劳的。
- 操作系统实际采用的是中断的方式来得知外设上是否有数据就绪的,当某个外设上面有数据就绪时,该外设就会向CPU当中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给CPU。
- 每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表叫做中断向量表,当CPU收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表执行该中断信号对应的中断处理程序,处理完毕后再返回原被暂停的程序继续运行。
需要注意的是,CPU不直接和外设打交道指的是在数据层面上,而外设其实是可以直接将某些控制信号发送给CPU当中的某些控制器的。
什么是高效的IO?
IO主要分为两步:
- 第一步是等,即等待IO条件就绪。
- 第二步是拷贝,也就是当IO条件就绪后将数据拷贝到内存或外设。
任何IO的过程,都包含"等"和"拷贝"这两个步骤,但在实际的应用场景中"等"消耗的时间往往比"拷贝"消耗的时间多,因此要让IO变得高效,最核心的办法就是尽量减少"等"的时间。
五种IO模型
阻塞IO
阻塞IO就是在内核将数据准备好之前,系统调用会一直等待。
图示如下:

阻塞IO是最常见的IO模型,所有的套接字,默认都是阻塞方式。
- 比如当调用recvfrom函数从某个套接字上读取数据时,可能底层数据还没有准备好,此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后recvfrom函数才会返回。
- 在recvfrom函数等待数据就绪期间,在用户看来该进程或线程就阻塞住了,本质就是操作系统将该进程或线程的状态设置为了某种非R状态,然后将其放入等待队列当中,当数据就绪后操作系统再将其从等待队列当中唤醒,然后该进程或线程再将数据从内核拷贝到用户空间。
非阻塞IO
非阻塞IO就是,如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。
图示如下:

非阻塞IO往往需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用。
- 比如当调用recvfrom函数以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么recvfrom函数会立马错误返回,而不会让该进程或线程进行阻塞等待。
- 因为没有读取的数据,因此该进程或线程后续还需要继续调用recvfrom函数,检测底层数据是否就绪,如果没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。
- 每次调用recvfrom函数读取数据时,就算底层数据没有就绪,recvfrom函数也会立马返回,在用户看来该进程或线程就没有被阻塞住,因此我们称之为非阻塞IO。
阻塞IO和非阻塞IO的区别在于,阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。
信号驱动IO
信号驱动IO就是当内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。
图示如下:

当底层数据就绪的时候会向当前进程或线程递交SIGIO信号,因此可以通过signal或sigaction函数将SIGIO的信号处理程序自定义为需要进行的IO操作,当底层数据就绪时就会自动执行对应的IO操作。
- 比如我们需要调用recvfrom函数从某个套接字上读取数据,那么就可以将该操作定义为SIGIO的信号处理程序。
- 当底层数据就绪时,操作系统就会递交SIGIO信号,此时就会自动执行我们定义的信号处理程序,进程将数据从内核拷贝到用户空间。
信号的产生是异步的,但信号驱动IO是同步IO的一种。
- 我们说信号的产生异步的,因为信号在任何时刻都可能产生。
- 但信号驱动IO是同步IO的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,因此当前进程或线程仍然需要参与IO过程。
判断一个IO过程是同步的还是异步的,本质就是看当前进程或线程是否需要参与IO过程,如果要参与那就是同步IO,否则就是异步IO。
IO多路转接(多路复用)
IO多路转接也叫做IO多路复用,能够同时等待多个文件描述符的就绪状态。
图示如下:

IO多路转接的思想:
- 因为IO过程分为"等"和"拷贝"两个步骤,因此我们使用的recvfrom等接口的底层实际上都做了两件事,第一件事就是当数据不就绪时需要等,第二件事就是当数据就绪后需要进行拷贝。
- 虽然recvfrom等接口也有"等"的能力,但这些接口一次只能"等"一个文件描述符上的数据或空间就绪,这样IO效率太低了。
- 因此系统为我们提供了三组接口,分别叫做select、poll和epoll,这些接口的核心工作就是"等",我们可以将所有"等"的工作都交给这些多路转接接口。
- 因为这些多路转接接口是一次"等"多个文件描述符的,因此能够将"等"的时间进行重叠,当数据就绪后再调用对应的recvfrom等函数进行数据的拷贝,此时这些函数就能够直接进行拷贝,而不需要进行"等"操作了。
IO多路转接就像现实生活中的黄牛一样,只不过IO多路转接更像是帮人排队的黄牛,因为多路转接接口实际并没有帮我们进行数据拷贝的操作。这些排队黄牛可以一次帮多个人排队,此时就将多个人排队的时间进行了重叠。
异步IO
异步IO就是由内核在数据拷贝完成时,通知应用程序(用户给操作系统一部分缓冲区由OS去等待IO然后将数据放到缓冲区)。

- 进行异步IO需要调用一些异步IO的接口,异步IO接口调用后会立马返回,因为异步IO不需要你进行"等"和"拷贝"的操作,这两个动作都由操作系统来完成,你要做的只是发起IO。
- 当IO完成后操作系统会通知应用程序,因此进行异步IO的进程或线程并不参与IO的所有细节。
小结
任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝. 而且在实际的应用场景中, 等待消耗的时间往 往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少.
高级IO重要概念
同步通信 VS 异步通信
一、同步通信:"你说完我才说,必须等回应"
核心逻辑:像 "打电话",一方说、另一方听,必须等对方回应,流程才能继续。
举个例子 :
你给朋友打电话借钱(发送请求),必须等朋友说 "借 / 不借"(返回结果),你才知道下一步咋做(挂电话或继续聊)。中间你不能干别的,得一直 "等回应"。
对应技术场景:
- 比如早期浏览器发请求,点按钮后页面 "卡死",必须等服务器返回数据,才能继续操作(典型同步通信,程序被 "堵住" 等结果)。
二、异步通信:"发完就去忙,结果自己来"
核心逻辑:像 "发微信",消息发出去就完事,不用等对方秒回。你该干啥干啥,对方啥时候回,你收到通知再处理。
举个例子 :
你给朋友发微信借钱(发送请求),发完直接刷抖音、打游戏(程序继续干别的)。等朋友回 "借你" 或 "不借"(结果通过通知 / 回调触发),你再处理后续(感谢或换个人借 )。
对应技术场景:
- 比如手机 App 下拉刷新,点刷新后,App 继续让你滑动看内容(不阻塞),等服务器数据回来,自动更新列表(异步通信,后台默默等结果,不耽误你用 App)。
同步和异步关注的是消息通信机制。
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所有没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
为什么非阻塞IO在没有得到结果之前就返回了?
- IO是分为"等"和"拷贝"两步的,当调用recvfrom进行非阻塞IO时,如果数据没有就绪,那么调用会直接返回,此时这个调用返回时并没有完成一个完整的IO过程,即便调用返回了那也是属于错误的返回。
- 因此该进程或线程后续还需要继续调用recvfrom,轮询检测数据是否就绪,当数据就绪后最后再把数据从内核拷贝到用户空间,这才是一次完整的IO过程。
因此,在进行非阻塞IO时,在没有得到结果之前,虽然这个调用会返回,但后续还需要继续进行轮询检测,因此可以理解成调用还没有返回,而只有当某次轮询检测到数据就绪,并且完成数据拷贝后才认为该调用返回了。
同步通信 VS 同步与互斥
在多进程和多线程当中有同步与互斥的概念,但是这里的同步通信和进程或线程之间的同步是完全不相干的概念。
- 进程/线程同步指的是,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
- 而同步IO指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与IO过程。
因此当看到"同步"这个词的时候,一定要先明确这个同步是同步通信的同步,还是同步与互斥的同步。
阻塞 VS 非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息、返回值)时的状态。
- 阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
其他高级IO
非阻塞IO,记录锁,系统V流机制,I/O多路转接(也叫I/O多路复用),readv和writev函数以及存储映射IO(mmap),这些统称为高级IO。
阻塞IO
系统中大部分的接口都是阻塞式接口,比如我们可以用read函数从标准输入当中读取数据。
cpp
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
int main()
{
char buffer[1024];
while (true){
ssize_t s = read(0, buffer, sizeof(buffer)-1);
if (s < 0){
std::cerr << "read error" << std::endl;
break;
}
buffer[s] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
return 0;
}
程序运行后,如果我们不进行输入操作,此时该进程就会阻塞住,根本原因就是因为此时底层数据不就绪,因此read函数需要进行阻塞等待。

一旦我们进行了输入操作,此时read函数就会检测到底层数据就绪,然后立马将数据读取到从内核拷贝到我们传入的buffer数组当中,并且将读取到的数据输出到显示器上面,最后我们就看到了我们输入的字符串。

说明一下:
- C++当中的cin和C语言当中的scanf也可以读取从键盘输入的字符,但是cin和scanf会提供用户缓冲区,为了避免这些因素的干扰,因此这里选择使用read函数进行读取。
非阻塞IO
打开文件时默认都是以阻塞的方式打开的,如果要以非阻塞的方式打开某个文件,需要在使用open函数打开文件时携带O_NONBLOCK
或O_NDELAY
选项,此时就能够以非阻塞的方式打开文件。

这是在打开文件时设置非阻塞的方式,如果要将已经打开的某个文件或套接字设置为非阻塞,此时就需要用到fcntl函数。
fcntl函数
fcntl函数的函数原型如下:
cpp
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)。
返回值说明:
- 如果函数调用成功,则返回值取决于具体进行的操作。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
实现SetNonBlock函数
将文件描述符 fd
设置为非阻塞模式,成功返回 true
,失败返回 false。
- 先调用fcntl函数获取该文件描述符对应的文件状态标记(这是一个位图),调用fcntl函数时传入的cmd值为F_GETFL(获取文件描述符的状态标志)。
- 在获取到的文件状态标记上添加非阻塞标记O_NONBLOCK,再次调用fcntl函数对文件状态标记进行设置,此时调用fcntl函数时传入的cmd值为F_SETFL(获取文件描述符的状态标志)。
cpp
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL); //F_GETFL 获取文件描述符状态标记
if(fl < 0)
{
perror("fcntl::SetNonBlock");
return false;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK); //F_SETFL 设置文件描述符状态标记
return true;
}
此时就将该文件描述符设置为了非阻塞状态。
以非阻塞轮询方式读取标准输入
此时在调用read函数读取标准输入之前,调用SetNonBlock函数将0号文件描述符设置为非阻塞就行了。
代码如下:
cpp
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <cstring>
#include <cerrno>
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;
}
int main()
{
SetNonBlock(0);
char buffer[1024];
while (true){
ssize_t size = read(0, buffer, sizeof(buffer)-1);
if (size < 0){
if (errno == EAGAIN || errno == EWOULDBLOCK){ //底层数据没有就绪
std::cout << strerror(errno) << std::endl;
sleep(1);
continue;
}
else if (errno == EINTR){ //在读取数据之前被信号中断
std::cout << strerror(errno) << std::endl;
sleep(1);
continue;
}
else{
std::cerr << "read error" << std::endl;
break;
}
}
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
return 0;
}
当read函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么read函数就会立即返回,但当底层数据不就绪时,read函数是以出错的形式返回的,此时的错误码会被设置为EAGAIN
或EWOULDBLOCK
。

因此在以非阻塞方式读取数据时,如果调用read函数时得到的返回值是-1,此时还需要通过错误码进一步进行判断,如果错误码的值是EAGAIN或EWOULDBLOCK,说明本次调用read函数出错是因为底层数据还没有就绪,因此后续还应该继续调用read函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。
此外,调用read函数在读取到数据之前可能会被其他信号中断,此时read函数也会以出错的形式返回,此时的错误码会被设置为EINTR,此时应该重新执行read函数进行数据的读取。

因此在以非阻塞的方式读取数据时,如果调用read函数读取到的返回值为-1,此时并不应该直接认为read函数在底层读取数据时出错了,而应该继续判断错误码,如果错误码的值为EAGAIN、EWOULDBLOCK或EINTR则应该继续调用read函数再次进行读取。
运行代码后,当我们没有输入数据时,程序就会不断调用read函数检测底层数据是否就绪。

一旦我们进行了输入操作,此时read函数就会在轮询检测时检测到,紧接着立马将数据读取到从内核拷贝到我们传入的buffer数组当中,并且将读取到的数据输出到显示器上面。
