🌠 作者:@阿亮joy.
🎆专栏:《学会Linux》
🎇 座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
目录
- 👉IO的基本概念👈
- 👉五种IO模型👈
- 👉高级IO重要概念👈
- 同步通信 VS 异步通信
- 阻塞IO VS 非阻塞IO
- 👉其他高级IO👈
- 👉非阻塞IO👈
- 👉总结👈
👉IO的基本概念👈
什么是 IO
IO,全称 Input / Output,是计算机系统中的一种重要的操作,指的是系统的输入输出操作。简单来说,就是计算机系统与外界设备(如硬盘、键盘、鼠标、网络等)之间的数据交换过程。
IO 的分类
IO 通常分为阻塞 IO、非阻塞 IO、信号驱动 IO、多路复用 IO 和异步 IO 四种方式。
-
阻塞 IO(Blocked IO):在阻塞 IO 模型下,当应用程序发起 IO 请求后,应用程序会一直阻塞等待,直到操作系统完成IO操作并将结果返回给应用程序。
-
非阻塞 IO (Non-Blocked IO):在非阻塞 IO 模型下,当应用程序发起 IO 请求后,应用程序可以继续执行其他操作,而不需要等待操作系统完成 IO 操作。应用程序需要不断地轮询 IO 操作是否完成,这样会带来额外的 CPU 消耗。
-
多路复用 IO(IO Multiplexing):在多路复用 IO 模型下,应用程序通过调用 select / poll / epoll 等函数,将多个 IO 操作注册到一个函数中,然后等待这些 IO 操作中的任意一个操作完成后,再去处理已完成的 IO 操作。
-
信号驱动IO(Signal Driven IO):在信号驱动 IO 模型下,当应用程序发起 IO 请求后,应用程序会给操作系统注册一个信号处理函数,然后应用程序继续执行其他操作,当操作系统完成 IO 操作后,操作系统会向应用程序发送一个信号,然后应用程序在信号处理函数中处理已完成的 IO 操作。
-
异步 IO(Asynchronous IO):在异步 IO 模型下,应用程序发起 IO 请求后,应用程序继续执行其他操作,当操作系统完成 IO 操作后,操作系统会向应用程序发送一个通知,通知应用程序已完成 IO 操作,并将结果返回给应用程序。
网络通信的本质就是 IO
进行网络通信的双方从网络中获取数据或将数据发送到网络中,本质就是输入和输出数据,也就是 IO,根据冯诺依曼体系就是将数据从内存中的数据放到网卡或从网卡中读取数据。
IO 的效率问题
IO 操作对系统的性能和效率有着很大的影响,因为 IO 操作需要涉及到磁盘或网络等慢速设备的读写,而这些操作会比 CPU 计算要慢得多。因此,如果 IO 操作不能高效地完成,就会导致应用程序的性能下降,甚至出现瓶颈。
那为什么 IO 操作就抵消呢?以读取数据(Input)为例:
- 当调用 read / recv 函数时,如果底层缓冲区中没有数据,那么进程就会阻塞在 recv / recv 函数中,也就是等缓冲区中有数据。
- 当调用 read / recv 函数时,如果底层缓冲区中有数据。那么今后才能就会将数据拷贝到应用层缓冲区中。
所以,IO操作 就等于 “等” 加 “数据拷贝”。read、recv、write 和 send 等 IO 函数需要等 IO 时间就绪,IO 时间就绪后就可以进行数据拷贝了。在实际的应用场景中,“等” 消耗的时间往往比 “数据拷贝” 消耗的时间要多得多,因此想让 IO 变得高效,最重要的就是让 “等” 的时间尽量减少。
👉五种IO模型👈
钓鱼的过程和 IO 的过程是非常地类似的,我们那通过一个钓鱼的例子来引出五种 IO 模型。
- 钓鱼的过程同样分为 “等” 和 “数据拷贝” 两个步骤,这里的 “等” 就是等于上钩,“数据拷贝” 就是将上钩的鱼从河里放入到我们的桶里。
- 进行 IO 操作时,“等” 的时间往往要比 “数据拷贝” 的时间长,钓鱼恰好也是符合这个特点。一个人在钓鱼的时候,大部分的时间都是在等鱼上钩,而将鱼从河里钓上来放到桶里只是十几秒钟的时间。
- 那么在单位时间内,一个人等待鱼上钩所占的时间比例越低,那么这个人的钓鱼效率就越高。
钓鱼五人组
- 张三:拿着一根鱼竿,将鱼钩抛入到河里就死死地盯住鱼漂,在这个过程中,他什么都不做,直到有鱼上钩才挥动鱼竿将鱼钓上来。
- 李四:拿着一根鱼竿,将鱼钩抛入到河里就去做其他事情了,然后定期地观察鱼漂,如果有鱼上钩就将其钓上来,如果没有就继续去做其他时间。
- 王五:在鱼竿的顶部放一个铃铛,将鱼钩抛入到河里然后去做其他的事情。如果铃铛响了,则说明有鱼上钩了就挥动鱼竿将它钓上来;否则就继续干其他事根本需要管鱼竿。
- 赵六:拿着一百根鱼竿,将一百个鱼钩都抛入到河里,然后就观察鱼漂。只要鱼漂懂了,就说明有鱼上钩了则挥动鱼竿将鱼钓上来。
- 田七:田七是个有钱的老总,他有个助手,他给了自己的助手一个手机、一个电话和一个桶,说等桶中装满了鱼就打电话告诉我来拿鱼。说完,田七开着自己的车子去干别的事情了。
以上五个人的钓鱼方式就对应着五中 IO 模型。
- 张三:阻塞式 IO
- 李四:非阻塞轮询式 IO
- 王五:信号驱动式 IO
- 赵六:多路复用 / 多路转接
- 田七:异步 IO
谁的钓鱼效率最高呢?
赵六毫无疑问是五个人中钓鱼效率最高的,因为赵六有一百根鱼竿,可以同时等多根鱼竿有鱼上钩。也就是说,在单位时间内,鱼咬赵六鱼钩的概率更大,那么他的调用效率也就更高了。也就说明了,在五种 IO 模型中,多路复用的 IO 效率是最高的。
- 阻塞 IO
- 非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码。
非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。
- 信号驱动 IO:内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作。
- IO 多路转接:虽然从流程图上看起来和阻塞 IO 类似。实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。
select 函数可以同时等多个文件描述符,当这些文件描述符没有就绪,select 函数也会阻塞。当文件描述符就绪时,就会通知上层,一个个地将就绪文件描述符中的数据拷贝上来,那么调用 recvfrom 拷贝数据时就不会被阻塞了。
- 异步 IO:当内核拷贝数据到用户层的缓冲区时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
任何 IO 过程中,都包含两个步骤:第一是等待,第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是让等待的时间尽量少。
👉高级IO重要概念👈
同步通信 VS 异步通信
同步和异步关注的是消息通信机制。
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。
- 异步则是相反, 调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后, 被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
IO 的本质是 “等” 加 “数据拷贝”,那么同步 IO 就是要么参与 “等” 的过程,要么参与 “数据拷贝” 的过程或者要么两个过程都参与。
显而易见的是,张三、李四和王五的钓鱼方式都是同步 IO,那王五的钓鱼方式是同步 IO 还是异步 IO 呢?其实王五的调鱼方式是同步 IO,尽管王五可以通过铃铛是否响了来判断是否有鱼上钩,不需要观察鱼漂的状态,但是王五在有鱼上钩的时候也需要将鱼从河里钓上来并放到同理。也就是说,尽管信号的产生是异步的,但是进程或线程也需要将内核缓冲区中的数据拷贝到应用层缓冲区中,也需要参与 IO 的过程,因此王五的钓鱼方式也是同步 IO。
同步通信 VS 同步与互斥
在学习多进程多线程的时候,也提到同步和互斥,但是这里的同步通信和进程之间的同步是完全不想干的概念。
- 进程 / 线程同步是指,在保证临界资源安全的情况下,让线程 / 进程以某种顺序来访问临界资源,其谈论的是进程 / 线程之间直接的制约关系。
- 而同步 IO 更多谈论的是主动参与 IO 的过程。
阻塞IO VS 非阻塞IO
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
- 阻塞 IO 和非阻塞 IO 都需要自己进行数据拷贝,它们的区别是等待的方式。
- 阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程,并通过轮询的方式来判断结果是否就绪。
👉其他高级IO👈
非阻塞 IO,纪录锁,系统 V 流机制, IO 多路转接(也叫 IO多路复用),readv 和 writev 函数以及存储映射 IO(mmap),这些统称为高级 IO。
- 纪录锁(Record Lock)是一种用于在共享文件上同步进程之间访问的机制。当一个进程使用纪录锁时,它可以锁定文件中的一部分,以确保其他进程无法在此时修改该部分。当另一个进程尝试修改已被锁定的部分时,它将被阻塞,直到该部分被解锁。纪录锁还可以用于防止多个进程同时对文件进行写操作,从而保护数据的完整性和一致性。
- 系统V流机制(System V Streams)是一种在 Unix 系统上实现的高级 IO 机制。它提供了一种可插拔的架构,可以方便地扩展各种不同类型的IO设备和驱动程序。Streams 还提供了一些高级功能,例如面向字符的 IO、流过滤器和流处理器等,以便更方便地处理数据。Streams 还提供了一种非常灵活的机制,可以用于在进程之间共享文件描述符。
- readv 和 writev 函数是一组用于读取和写入数据的系统调用,它们可以在单个系统调用中读取或写入多个非连续缓冲区。这些函数可以减少数据复制和系统调用的数量,从而提高了 IO 性能。
- 存储映射 IO(mmap)是一种将文件映射到进程的虚拟地址空间中的技术。这种技术可以使应用程序通过直接访问内存来读取和写入文件数据,从而避免了复制数据到缓冲区的过程。存储映射 IO 通常用于处理大型文件和数据库,因为它可以极大地提高 IO 性能。
存储映射 IO
fstat 函数是一个 POSIX 标准函数,用于获取指定文件的元数据(metadata),如文件大小、创建时间、最后修改时间等等。其函数原型如下:
int fstat(int fd, struct stat *buf);
其中,fd 是要查询的文件描述符,buf 是指向一个 struct stat 类型的结构体的指针,用于存储查询结果。
struct stat 结构体包含了文件的元数据信息,具体定义如下:
struct stat
{
dev_t st_dev; // 文件所在设备的ID
ino_t st_ino; // 文件的Inode号
mode_t st_mode; // 文件的类型和权限信息
nlink_t st_nlink; // 文件的硬链接数
uid_t st_uid; // 文件所有者的用户ID
gid_t st_gid; // 文件所有者的组ID
dev_t st_rdev; // 如果文件是一个特殊文件,则这是设备的ID
off_t st_size; // 文件的大小(以字节为单位)
blksize_t st_blksize; // 文件系统用于I/O的最佳块大小
blkcnt_t st_blocks; // 文件所使用的磁盘块数量
time_t st_atime; // 文件的最后访问时间
time_t st_mtime; // 文件的最后修改时间
time_t st_ctime; // 文件的最后状态改变时间
};
调用 fstat 函数后,struct stat 结构体中的各个字段将被填充上文件的相关信息。如果函数调用成功,返回值为 0,否则返回 -1,并设置 errno 变量表示错误原因。
mmap 是 Unix 和类 Unix 系统提供的函数,其功能是将文件或其他对象的一段区域映射到调用进程的虚拟地址空间中。使用 mmap 函数可以在程序中直接访问文件内容,而无需进行 read 和 write 等系统调用。其函数原型如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
参数说明:
- addr:映射区的开始地址,如果设为0,操作系统会自动分配地址。
- length:映射区的长度。
- prot:映射区的保护方式。可选值有:可读 PROT_READ,可写 PROT_WRITE 和可执行 PROT_EXEC。
- flags:标志参数,用于控制映射区的各种属性。可选值有:共享映射区 MAP_SHARED,私有映射区 MAP_PRIVATE、指定映射区地址 MAP_FIXED 和匿名映射区 MAP_ANONYMOUS 等。
- fd:要映射到内存的文件描述符。
- offset:文件偏移量,表示从文件的哪个位置开始映射。
mmap 函数的返回值是映射区的起始地址。mmap 函数的主要用途是对大型文件进行高效的随机访问,它通常比传统的文件操作更快,因为它避免了在文件和内存之间频繁地进行数据传输。此外,它还可以用于创建共享内存区域、在内存中操作设备等。
使用 munmap 函数来释放这个映射区,其原型如下:
int munmap(void *addr, size_t length);
参数说明:
- addr: 映射区的起始地址。
- length: 映射区的长度。
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main()
{
struct stat sb;
int fd = open("Test.txt", O_RDONLY);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE); // 退出进程
}
// fstat函数可以获取文件的相关信息
if (fstat(fd, &sb) == -1)
{
perror("fstat");
exit(EXIT_FAILURE);
}
char* mapped = (char*)mmap(0, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (mapped == MAP_FAILED)
{
perror("mmap");
exit(EXIT_FAILURE);
}
for (size_t i = 0; i < sb.st_size; i++)
{
putchar(mapped[i]);
usleep(750);
}
if (munmap(mapped, sb.st_size) == -1)
{
perror("munmap");
exit(EXIT_FAILURE);
}
if (close(fd) == -1)
{
perror("close");
exit(EXIT_FAILURE);
}
return 0;
}
# Test.txt
自由的旗帜在风中飘扬
梦想的火炬在心中燃烧
不管身处何方
心中的信仰
永远都是自由和理想
长路漫漫,艰难险阻
追逐梦想,不畏困苦
不屈不挠,向前奔跑
为了那片自由的天空
没有束缚的翅膀
自由飞翔在天上
不畏风雨,不惧迷途、
只要有梦想,就有希望
心中的理想,如同星光
永远照耀着前方
走过荆棘,跨越障碍
迎接着自由的曙光
让我们肩并肩,手牵手
一起去追逐自由和理想
不论前路多么险阻
只要我们相信,必能飞翔
👉非阻塞IO👈
打开文件时,默认是以阻塞的方式来打开的。如果要以非阻塞的方式来打开一个文件,那么调用 open 函数需要传入 O_NONBLOCK 或 O_NDELAY,此时就是以非阻塞的方式打开一个文件了。
这时以非阻塞的方式打开一个文件,如果一个文件已经以阻塞的方式打开了,那么想要将该文件设置成非阻塞的方式,就需要用到 fcntl 函数了。
fcntl 是一个 Unix / Linux系统下的函数,用于控制已打开的文件描述符的各种属性。它可以用于打开 / 关闭文件描述符、设置 / 获取文件描述符标记、设置 / 获取文件锁、以及调整文件描述符的各种属性等。
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);
其中,参数 fd 是文件描述符,cmd 是要执行的操作,后面的可选参数 arg 用于传递操作所需的参数。
fcntl 函数的常见操作包括:
- 复制文件描述符:cmd 参数设置为 F_DUPFD 或F_DUPFD_CLOEXEC,将 fd 复制到一个新的文件描述符,返回新的文件描述符。
- 获取 / 设置文件描述符标记:cmd 参数设置为 F_GETFD 或 F_SETFD,用于获取或设置文件描述符的 close-on-exec 标记,如果设置为 1,则在进程调用 exec 函数时自动关闭该文件描述符。
- 获取 / 设置文件状态标记:cmd 参数设置为 F_GETFL 或 F_SETFL,用于获取或设置文件状态标记,包括非阻塞标记 O_NONBLOCK 和追加标记 O_APPEND 等。
- 获取 / 设置文件锁:cmd 参数设置为 F_GETLK、F_SETLK 或 F_SETLKW,用于获取、设置或阻塞式设置文件锁,以控制多个进程对同一文件的访问。
- 设置异步 IO:cmd 参数设置为 F_SETOWN 或 F_SETSIG,用于设置异步 IO 的通知方式,例如当文件描述符上有数据可读时,向进程发送信号或者向进程发送 SIGIO 信号。
fcntl 函数的返回值取决于具体的操作和参数。下面是一些可能的返回值及其含义:
- 对于 F_DUPFD,返回的是新的文件描述符,表示复制已有的文件描述符。
- 对于 F_GETFD ,返回的是当前文件描述符的文件标志 FD_CLOEXEC 或 0。
- 对于 F_SETFD ,返回的是 0 表示成功,返回 -1 表示出错。
- 对于 F_GETFL,返回的是当前文件描述符的文件状态标志。
- 对于 F_SETFL 命令,返回的是 0 表示成功,返回 -1 表示出错。
- 对于 F_GETOWN ,返回的是当前文件描述符的拥有者进程 ID 或进程组 ID。
- 对于 F_SETOWN,返回的是 0 表示成功,返回 -1 表示出错。
fcntl 函数的返回值和具体操作和参数相关,需要仔细阅读相应的文档和错误码来确定其含义和处理方法。
复制文件描述符
#include <fcntl.h>
#include <unistd.h>
#include <iostream>
int main()
{
int fd1 = open("test.txt", O_RDONLY | O_CREAT, 0664); // 打开一个文件
int fd2 = fcntl(fd1, F_DUPFD, 0); // 复制文件描述符
std::cout << "fd1: " << fd1 << std::endl;
std::cout << "fd2: " << fd2 << std::endl;
return 0;
}
F_DUPFD 参数的作用是复制一个文件描述符,并返回一个新的文件描述符。在上述代码中,我们打开了一个名为 “test.txt” 的文件,并将其文件描述符赋值给 fd1。我们通过调用 fcntl 函数并指定 F_DUPFD 参数以复制文件描述符,这将创建一个新的文件描述符 fd2,并使其与 fd1 指向相同的打开的文件。我们可以看到,fd1 和 fd2 的值是不同的,但它们都指向同一个打开的文件。
在 F_DUPFD 命令中,第三个参数是新的文件描述符的最小值。系统会选择一个大于等于第三个参数的最小未使用的文件描述符,并返回它的值。第三个参数为 0,这意味着系统将返回当前可用的最小文件描述符。
将文件描述符设置为非阻塞
基于 fcntl 函数,我们实现一个 SetNonBlock 函数,将文件描述符设置为非阻塞。
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL); // 获取当前fd的读写标记位
if(fl < 0)
{
perror("fcntl\n");
return false;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
return true;
}
- 使用 F_GETFL 将当前的文件描述符的属性取出来(这是一个位图)。
- 然后再使用 F_SETFL 将文件描述符设置回去,设置回去的同时加上一个 O_NONBLOCK 参数。
以非阻塞轮询的方式读取标准输入
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#include <cstring>
using namespace std;
const int SIZE = 1024;
// 将文件描述符设置为非阻塞
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL); // 获取当前fd的读写标记位
if(fl < 0)
{
perror("fcntl\n");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
char buffer[SIZE];
SetNonBlock(STDIN_FILENO); // 将标准输入设置为非阻塞
while(true)
{
sleep(1);
errno = 0;
ssize_t s = read(STDIN_FILENO, buffer, sizeof(buffer) - 1);
if(s > 0)
{
buffer[s - 1] = '\0'; // 去除换行符
cout << "echo# " << buffer << endl;
}
else
{
if(errno == EWOULDBLOCK || errno == EAGAIN)
{
cout << "标准输入仍未就绪!" << errno << " " << strerror(errno) << endl;
continue;
}
else if(errno == EINTR)
{
cout << "标准输入被信号中断!" << errno << " " << strerror(errno) << endl;
continue;
}
else
{
cout << "标准输入出现错误!" << errno << " " << strerror(errno) << endl;
break;
}
}
}
return 0;
}
代码说明:
- 当 read 函数以非阻塞的方式读取标准输入时,如果底层的数据没有就绪,read 函数就会以出错的方式立即返回,并将错误码设置为 EWOULDBLOCK 或 EAGAIN,以表示底层数据没有准备好。
- 当程序调用一些慢系统调用(例如 read、write 和 accept 等)时,如果这些系统调用的执行时间很长,在系统调用执行的过程中,如果接收到了一个信号,那么系统会中断当前的系统调用,并且将 errno 设置为 EINTR,然后系统会将控制权返回给程序,让程序自己处理这个信号。
- 当 read 函数的返回值为 -1 时,并不代表 read 函数在读取底层数据时出现了什么问题。当返回值为 -1 时,我们需要检查错误码,如果错误码为 EWOULDBLOCK 或 EAGAIN 时,说明底层数据没有就绪,可以再进行轮询检测底层数据是否就绪;如果错误码为 EINTR 时,说明 read 函数被信号中断了,此时需要进行信号处理,然后再进行轮询。
代码运行起来后,我们并没有输入数据,就会出现轮询检测底层数据是否就绪的情况了。
只要我们进行了数据输入,此时 read 函数就能够检测到数据的输入,并将数据从内核缓冲区拷贝到用户层缓冲区中去,然后再进行数据打印。
👉总结👈
本篇博客主要讲解了 IO 的基本概念、五种 IO 模型、高级 IO 的重要概念、其他高级 IO 以及非阻塞 IO 等等。