目录
在进行网络编程或文件操作时,IO模型的选择对程序的性能和效率有着重要的影响。本文将介绍五种IO模型,并详细讨论非阻塞IO的相关内容。
IO有两个阶段:++数据准备、数据读写++,两个完整的阶段我们就成为一个IO过程。
通俗一点: IO = 等待 + 拷贝
一、五种IO模型
(一)阻塞IO
在内核将数据准备好之前,系统调用会一直等待。所有的套接字默认都是阻塞方式,这是最常见、也是之前使用最多的IO模型。
当调用IO接口时,本质就是在做用户层和内核层的数据拷贝,但是调用的时刻大概率是不一定满足就绪条件的,此时就会阻塞等待,直到读/写条件满足。
(二)非阻塞IO
如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK
错误码。
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对CPU来说是较大的浪费,一般只有特定场景下才使用。
系统调用接口:
fcntl
函数原型:int fcntl(int fd, int cmd,... /* arg */ );
,传入的cmd
的值不同,后面追加的参数也不相同。此处使用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞。
-
实现函数
SetNoBlock
:cppvoid SetNoBlock(int fd){ if(f1< 0){ int fl = fcntl(fd, F_GETFL); perror("fcntl"); return; } fcntl(fd, F_SETFL, fl | O_NONBLOCK); }
使用
F_GETFL
将当前的文件描述符的属性取出来(这是一个位图),然后再使用F_SETFL
将文件描述符设置回去,设置回去的同时,加上一个O_NONBLOCK
参数。
具体示例:
- 非阻塞轮询方式读取标准输入
示例代码:
cpp
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstdlib>
#include <cerrno>
// 对指定的fd设置非阻塞
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if(fl < 0)
{
std::cerr << "fcntl error" << std::endl;
exit(0);
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
SetNonBlock(0);
while(true)
{
char buffer[1024];
ssize_t s = read(0, buffer, sizeof(buffer)-1); // sizeof(buffer)-1
if(s > 0)
{
buffer[s] = 0;
std::cout << "echo# " << buffer << std::endl;
}
else if(s == 0)
{
std::cout << "end stdin" << std::endl;
break;
}
else
{
// 非阻塞等待, 如果数据没有准备好,返回值会按照出错返回, s == -1
// 数据没有准备好 vs 真的出错了 : 处理方式一定不是一样的。 s无法区分!
// 数据没有准备好,算读取错误吗?不算。read,recv以出错的形式告知上层,数据还没有准备好
if(errno == EWOULDBLOCK)
{
std::cout << "OS的底层数据还没有就绪, errno: " << errno << std::endl;
// 做其他事情了
}
else if(errno == EINTR)
{
std::cout << "IO interrupted by signal, try again" << std::endl;
}
else
{
std::cout << "read error!" << std::endl;
break;
}
}
sleep(1);
}
}
对于设置为非阻塞的文件描述符,读取的返回值 s :
- s > 0: 读取成功,实际读取了 s 字节
- s == 0: 读到结尾,正常结束。
- s < 0: 数据并未准备好 / 读取出错
- 数据并未准备好:设置错误码为 EWOULDBLOCK 或 EAGAIN (本质是同一个数字)
- 当正在读取数据时收到信号被打断,设置错误码为 EINTR
- 排除前两种情况之后,则是真的出错了。
所以对于返回值小于0的情况需要再进一步判断。
以上是对于非阻塞的fd读取的示例,写入也是同理,同样把写条件未就绪归并为返回值小于0,并设置错误码。
(三)信号驱动IO
内核将数据准备好的时候,使用SIGIO
信号通知应用程序进行IO操作。
在信号驱动 I/O 中,进程首先使用 sigaction
系统调用安装一个信号处理函数,并通过 fcntl
系统调用将文件描述符设置为非阻塞和信号驱动模式。
当内核中数据准备好时,会发送一个信号给进程。进程在收到信号后,再进行实际的 I/O 操作。
这种方式的优点在于:进程不必阻塞等待 I/O 操作完成,可以在等待数据准备好的同时执行其他任务,提高了 CPU 的利用率。
简单示例代码:
cpp
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/types.h>
void signalHandler(int signum) {
std::cout << "Signal received. Performing I/O operation." << std::endl;
}
int main() {
int fd = open("test.txt", O_RDONLY | O_NONBLOCK);
if (fd == -1) {
std::cerr << "Error opening file" << std::endl;
return 1;
}
// 设置文件描述符为信号驱动 I/O
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC);
// 安装信号处理函数
signal(SIGIO, signalHandler);
// 设置当前进程为接收信号的进程
fcntl(fd, F_SETOWN, getpid());
char buffer[1024];
while (true) {
std::cout << "Doing other work..." << std::endl;
read(fd, buffer, sizeof(buffer));
sleep(1);
}
close(fd);
return 0;
}
整个程序的目的是在执行其他工作的同时,能够响应与文件 I/O 相关的信号,并进行相应的处理。
例如,如果在文件有可读数据等情况下,会发送
SIGIO
信号,从而触发signalHandler
函数的执行。
(四)IO多路转接
虽然从流程图上看起来和阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。
多路转接:select、poll、epoll,三者在IO种的定位都是相同的,都是在等待一堆文件描述符的条件就绪,只是原理和具体接口的使用不同,epoll的效率是最优的,后续文章再具体讲解多路转接部分。
(五)异步IO
由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
也就是通过调用特殊的API,使得等待和拷贝的过程都由内核完成,进程只用坐享其成的使用数据即可,因为IO具体过程的两步(等待、拷贝)都没有参与,所以是异步IO。
以下是一个简单的 C++ 异步 I/O 示例代码,
使用了 std::future
和 std::async
来模拟异步文件读取操作:
cpp
#include <iostream>
#include <future>
#include <fstream>
// 异步读取文件的函数
std::string asyncReadFile(const std::string& filename) {
std::ifstream file(filename);
std::string content;
if (file.is_open()) {
std::string line;
while (std::getline(file, line)) {
content += line + '\n';
}
file.close();
}
return content;
}
int main() {
std::string filename = "example.txt";
// 启动异步任务
std::future<std::string> future = std::async(asyncReadFile, filename);
std::cout << "Doing other work while reading the file asynchronously..." << std::endl;
// 获取异步任务的结果
std::string fileContent = future.get();
std::cout << "File content: " << fileContent << std::endl;
return 0;
}
小结
任何IO过程中,都包含两个步骤:等待和拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让IO更高效,最核心的办法就是让等待的时间尽量少。
二、高级IO重要概念
(一)同步通信与异步通信
同步和异步关注的是消息通信机制。
- 同步:在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。
- 异步:调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
另外,我们在学习多进程多线程的时候,也提到同步和互斥。这里的同步通信和进程之间的同步是完全不相干的概念。
进程/线程同步也是进程/线程之间直接的制约关系,是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系,尤其是在访问临界资源的时候。
所以大家以后在看到"同步"这个词,一定要先搞清楚大背景是什么。这个同步,是同步通信异步通信的同步,还是同步与互斥的同步。
(二)阻塞与非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息、返回值)时的状态。
- 阻塞调用:指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞调用:指在不能立刻得到结果之前,该调用不会阻塞当前线程。
理解这四者的关系
可以用"妖怪蒸唐僧"的例子来理解这四者的关系:
- 同步阻塞:就像妖怪蒸唐僧时,一直守在蒸笼旁边,直到唐僧蒸熟(得到结果)才离开,这期间什么也不干,一直等待。
- 同步非阻塞:还是妖怪蒸唐僧,但是会时不时去看看唐僧熟了没(轮询),没熟就去干别的事,熟了就回来处理。
- 异步阻塞:这种情况比较奇怪,一般不太会出现,就像妖怪告诉别人,等唐僧熟了通知我,然后就在蒸笼旁边等着。
- 异步非阻塞:妖怪告诉别人,等唐僧熟了通知我,然后就去干别的事了,等收到通知再回来处理。