Linux网络编程——五种I/O模式
目录
一、前言
二、I/O
1、什么是I/O?
2、五种I/O模式
I、生活中的小例子
II、 阻塞式 I/O
III、非阻塞式I/O
IV、信号驱动I/O
V、I/O多路转接
VI、异步I/O
VII、一些重要概念
三、现象
1、阻塞式I/O
2、非阻塞式I/O
四、重要概念补充
1、操作系统如何得知外设中有数据的?
2、操作系统如何处理从网卡中读取到的数据包?
一、前言
在前面的学习中,我们不断地刷新着对 I/O 的认识,从最开始的我们简单认为 I/O 就是操作系统与外设的直接交互,从外设将数据拷贝到内存,和从内存将数据拷贝到外设,但是我们也知道在调用 read() 和 write()时,万一缓冲区中没有数据或者数据满了,这就会导致阻塞,只有等待资源就绪之后才能继续 I/O,这是我们深入理解 I/O 的一个关键——I/O=等待+数据拷贝。
二、I/O
这里我们先对之前学过的简单的 I/O知识做一个回顾,接着对这些知识点做一些新的补充。
1、什么是I/O?
首先,什么是 I/O ?在我们之前学习操作系统的我们首先了解了冯诺依曼体系结构,如下图所示
在该体系结构中,我们将数据从输入设备拷贝到内存的过程就称为输入(I,Input);将数据从内存拷贝到输出设备的过程就叫输出(O,Output).
I/O也有着类型之分:
- 文件I/O:即对文件进行读写操作,所涉及到的外设为磁盘。
- 网络I/O:是在网络传输过程中发生的读写操作,涉及到的外设主要为网卡。
其中软硬件的灌流工作是由操作系统负责的,因此I/O也主要是由操作系统来完成
2、五种I/O模式
读取的本质就是将缓冲区中的数据从内核拷贝到用户区,写的本质是将数据拷贝到发送缓冲区中,或者将用户的数据拷贝到内核缓冲区。
- 读I/O=等待读事件就绪+拷贝内核数据至用户空间
- 写I/O=等待写事件就绪+拷贝用户数据至内核空间
当我们想要读取数据或者写入数据时,阻塞了,那么这些等待的时间成本就要算在用户头上,所以怎么将这些等待的时间利用起来才是我们这些接下来的I/O模型诞生的场景。所以我们所谓的高级I/O的本质总结起来就是一句话——尽可能地减少等的比重。
I、生活中的小例子
接下来在介绍五种I/O模式之前,我们先用一个生活中的小例子
I/O的过程其实是和钓鱼是非常像的
- 钓鱼的过程同样分为“等”和“拷贝”两个步骤,只不过这里的“等”指的是等鱼上钩,“拷贝”指的是当鱼上钩后将鱼从河里“拷贝”到我们的鱼桶当中。
- IO时“等”消耗的时间往往比“拷贝”消耗的时间多,钓鱼也恰好符合这个特点,钓鱼时我们大部分时间都在等鱼上钩,而当鱼上钩后只需要一瞬间就能将鱼“拷贝”上来。
在谈论高效的IO之前,我们先来看看什么样的钓鱼方式才是高效的。
下面给出五个人的钓鱼方式:
- 张三:拿了1个鱼竿,将鱼钩抛入水中后就死死的盯着浮漂,什么也不做,当有鱼上钩后就挥动鱼竿将鱼钓上来。
- 李四:拿了1个鱼竿,将鱼钩抛入水中后就去做其他事情,然后定期观察浮漂,如果有鱼上钩则挥动鱼竿将鱼钓上来,否则继续去做其他事情。
- 王五:拿了1个鱼竿,将鱼钩抛入水中后在鱼竿顶部绑一个铃铛,然后就去做其他事情,如果铃铛响了就挥动鱼竿将鱼钓上来,否则就根本不管鱼竿。
- 赵六:拿了100个鱼竿,将100个鱼竿抛入水中后就定期观察这100个鱼竿的浮漂,如果某个鱼竿有鱼上钩则挥动对应的鱼竿将鱼钓上来。
- 田七:田七是一个有钱的老板,他给了自己的司机一个桶、一个电话、一个鱼竿,让司机去钓鱼,当鱼桶装满的时候再打电话告诉田七来拿鱼,而田七自己则开车去做其他事情去了。
注意他们每个人的钓鱼方式(红色字体标识出来),这是问题的关键。
其中,张三、李四、王五的钓鱼效率本质上是一样的。首先,他们的钓鱼方式是一样的,都是先等鱼上钩,然后再将鱼钓上来;其次,他们每个人都是拿的一根鱼竿,当河里有鱼来咬鱼钩时,这条鱼咬哪一个鱼钩的概率都是相等的。
张三、李四、王五、赵六都是自己亲自钓鱼,但很明显,赵六是这四人中钓鱼效率最高的。赵六持有多个鱼竿,可以同时等待多个鱼竿有鱼上钩,因此在单位时间内,赵六的鱼竿有鱼上钩的概率在四人中是最大的。假设赵六拿了 97 个鱼竿,加上张三、李四、王五一人一个的鱼竿一共就有 100 个鱼竿。每当有一条鱼来咬钩时,咬张三、李四、王五鱼钩的概率都是百分之一,但咬赵六的鱼钩的概率是百分之九十七。
田七本人并没有参与整个钓鱼的过程,但他仍能获得鱼。他发起了钓鱼的任务,让自己的司机帮自己钓鱼,在此期间自己可以做其他任何事情。
而从钓鱼的过程话说回 I/O 的过程,鱼所在的河对应就是内核,每一个参与钓鱼的人对应就是进程或线程,鱼竿对应就是文件描述符或套接字,鱼桶对应就是用户缓冲区。
这五人不同的钓鱼方式,分别就对应了五种不同的 I/O 模型:
- 张三这种死等的钓鱼方式,就类似于阻塞 I/O。
- 李四这种定时检测是否有鱼上钩的方式,就类似于非阻塞 I/O。
- 王五这种通过设置铃铛得知事件是否就绪的方式,就类似于信号驱动 I/O。
- 王五这种一次等待多个鱼竿上有鱼的钓鱼方式,就类似于 I/O 多路转接。
- 田七这种让别人帮自己钓鱼的钓鱼方式,就类似于异步 I/O。
II、 阻塞式 I/O
阻塞式I/O是我们最常见的I/O模型了,所有的套接字,默认都是阻塞方式。它是指在内核将数据准备好之前,相关系统调用会一直阻塞等待。第二段阻塞考虑的是用户在拿取数据时,怎么确定将数据拿干净了,如果在拿的时候,缓冲区中没有数据了是不是就阻塞住了。
-
对于普通文件:读取操作通常不会因为“无数据”而阻塞,因为普通文件的数据是持久存储的,并且可以随时访问。在这种情况下,读取操作会返回当前文件指针位置的数据,当到达文件末尾时,会返回文件结束标识(EOF)。
-
对于网络套接字、管道或终端设备等:如果缓冲区中没有数据可读,读操作将阻塞,直到有数据到达或对端关闭连接。如果有数据到达,内核会将这些数据放入缓冲区,然后读操作可以从缓冲区中读取数据;如果对端关闭了连接,读操作可能会读到文件结束标识(EOF),表示不会再有更多的数据到来。
-
阻塞式I/O是当检测到数据没有准备好时,进程就被阻塞了,即进程在读某个文件描述符时,在读某个文件时,文件缓冲区中没有数据,所以其本质实际上是操作系统将该进程或线程的状态设置为某种非 R 状态,然后把当前进程挂到了struct file结构体所维护的等待队列中,直到数据就绪后,操作系统才将其从等待队列中唤醒,然后该进程或线程才会将数据从内核拷贝到用户空间。
以阻塞方式进行I/O操作的进程或线程,在“等”和“拷贝”期间都不会返回,在用户看来就像是阻塞住了,因此我们称之为阻塞I/O。
III、非阻塞式I/O
非阻塞调用简单一句话就是遇到数据没准备好时直接返回了,并且返回 EWOULDBLOCK 错误码。
非阻塞IO往往需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用。
- 比如当调用recvfrom函数以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么recvfrom函数会立马错误返回,而不会让该进程或线程进行阻塞等待。
- 因为没有读取的数据,因此该进程或线程后续还需要继续调用recvfrom函数,检测底层数据是否就绪,如果没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。
- 每次调用recvfrom函数读取数据时,就算底层数据没有就绪,recvfrom函数也会立马返回,在用户看来该进程或线程就没有被阻塞住,因此我们称之为非阻塞IO。
IV、信号驱动I/O
信号驱动I/O也好理解,就是当内核将数据准备好之后,操作系统会使用 SIGIO 信号来通知相关的应用程序进行I/O操作。
当内核还没有将数据准备好的时候,进程就提前对 SIGIO 信号定义捕捉,当数据就绪时,操作系统会向该进程递达信号,即系统向PCB中写入信号通知数据准备好。
当底层数据就绪时,操作系统会向当前进程或线程递交 SIGIO 信号,由此,可以通过系统调用 signal() 或 sigaction() 将对SIGIO 的信号进行捕捉,然后将处理动作自定义为需要进行的 I/O操作,使得底层数据就绪时,相应的 I/O操作能够自动执行。
尽管信号在任何时刻都可能产生,也因此信号的产生是异步的,但相关进程或线程仍会参与 I/O 的过程,因此信号驱动 I/O 是属于同步 I/O,与“信号的产生是异步的”无关。
该模型用的并不多,因为SIGIO 是一个普通信号,当多个信号递达的时候可能会造成丢失的情况。
V、I/O多路转接
I/O 多路转接也叫做 I/O 多路复用,虽然与阻塞 I/O 类似,但其核心特点在于,能够同时等待多个文件描述符的就绪状态
因 I/O多路转接,系统特别提供了一些接口,来帮多个进程或线程进行排队,使排队时间重叠,提高效率。
I/O 的过程分为“等”和“拷贝”两步,因此相关系统调用在底层都做了两件事,一件就是在数据不就绪时让进程或线程进行等待,另一件就是在数据就绪后进行数据的拷贝。
虽然 recvfrom() 等接口也具有有“等”的能力,但这些接口一次只能“等”一个文件描述符上的数据或空间就绪,如此,I/O效率就太低了。
于是,系统又提供了三组多路转接接口,分别为 select()、poll()、epoll(),支持一次性“等”多个文件描述符,以此将进程或线程“等”的时间进行重叠,便于数据就绪后调用对应的 recvfrom() 等接口进行数据的拷贝。
VI、异步I/O
异步I/O 的特点是,不用用户自己动手,是由操作系统来完成数据的拷贝,并在拷贝完成时,通知相关应用程序。
用户调用异步I/O的相关接口(aio_read),接口中包含着用户缓冲区和一些通知的方法,虽然它名字中带了read,但是它并不是读,它只是让系统去读某一个文件描述符的数据,读完之后放在它指定的缓冲区中,并使用自己设定的通知方法通知自己,所以接下来的等和拷贝都是操作系统自己完成的。
进行异步 I/O的进程或线程,并不参与I/O 的过程,因此异步 I/O 也不涉及“等”和“拷贝”,而只负责只是发起 I/O,并让操作系统去负责“等”和“拷贝”。
系统也特别为异步 I/O提供了一些接口,这些接口在调用后会发起 I/O,然后立即返回,始终不参与“等”和“拷贝”。
VII、一些重要概念
同步通信 vs 异步通信
同步和异步关注的是消息通信机制。
- 同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所有没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
通信同步与线程同步
他们两个是完全不相干的概念
- 进程/线程同步指的是,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
- 而同步IO指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与IO过程。
三、现象
1、阻塞式I/O
我们都知道系统中大部分的接口都是阻塞式接口,0号文件描述符通常对应的是标准输入stdin,默认情况下,标准输入时处于阻塞模式的,这意味着当尝试从标准输入读取数据时,如果当前没有可用的数据,读操作将会被挂起,直到有数据可读或遇到输入结束标志为止。
这种行为是为了确保程序能够有序地获取输入数据,避免因非阻塞读取导致数据不完整或需要复杂的同步逻辑来处理输入流。下面我们就用这个例子来看看阻塞式的现象。
#include<iostream>
#include<unistd.h>
#include<fcntl.h>
int main()
{char buffer[1024];while(1){ssize_t s=read(0,buffer,sizeof(buffer)-1);if(s>=0){buffer[s]='\0';std::cout<<"echo#::"<<buffer<<std::endl;}else if(s<0){std::cout<<"read error"<<std::endl;break;}}return 0;
}
程序运行后,如果我们不进行输入操作,此时该进程就会阻塞住,根本原因就是因为此时底层数据不就绪,因此read函数需要进行阻塞等待,直到数据准备就绪之后才开始读取
输入之后
C++当中的cin和C语言当中的scanf也可以读取从键盘输入的字符,但是cin和scanf会提供用户缓冲区,为了避免这些因素的干扰,因此这里选择使用read函数进行读取。
cin:这是C++中的标准输入流对象,通常用于从标准输入(通常是键盘)读取数据。它提供了缓冲区机制,这意味着输入的数据首先存储在缓冲区中,然后根据程序的需求进行处理。这种机制虽然方便,但在某些情况下(例如需要精确控制输入时),可能会导致一些问题。
scanf:这是C语言中的一个函数,同样用于从标准输入读取格式化的输入。它也依赖于缓冲区,并且允许用户指定输入数据的格式。不过,由于它的格式化特性以及对缓冲区的依赖,在某些场景下可能不如预期那样工作,特别是在处理行尾字符或非阻塞输入的时候。
相比之下,read函数提供了一种更直接的方式来进行输入操作,尤其是在处理低级别的输入输出时。它不会自动解析输入的数据类型,也不会做额外的格式化处理,而是简单地将输入从文件描述符读到提供的缓冲区中。因此,当需要避免因缓冲区机制引起的潜在问题时,使用 read 函数可以提供更加精确的控制。
2、非阻塞式I/O
我们知道打开文件时都是以默认阻塞的方式打开的,如果想要以非阻塞的形式打开文件,则需要在使用open()接口时携带 O_NONBLOCK 或者O_NDELAY 选项,此时就能够以非阻塞方式打开文件。这种方法是在打开文件的时候就设置好的。
如果相对于打开的文件或者套接字设置为非阻塞,此时就需要用到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)。
返回值说明:
- 如果函数调用成功,则返回值取决于具体进行的操作。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
代码:
我们可以定义一个函数,该函数就用于将指定的文件描述符设置为非阻塞状态。
先调用 fcntl 函数获取该文件描述符对应的文件状态标记(这是一个位图),此时调用 fcntl 函数时传入的 cmd 值为 F_GETFL
在获取到的文件状态标记上添加非阻塞标记 O_NONBLOCK,再次调用 fcntl 函数对文件状态标记进行设置,此时调用 fcntl 函数时传入的cmd值为 F_SETFL
代码:
#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(1){ssize_t s=read(0,buffer,sizeof(buffer)-1);if(s<0){//当read函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么read函数就会立即返回,//但当底层数据不就绪时,read函数是以出错的形式返回的,此时的错误码会被设置为EAGAIN或EWOULDBLOCK。//因此在以非阻塞方式读取数据时,如果调用read函数时得到的返回值是-1,此时还需要通过错误码进一步进行判断,//如果错误码的值是EAGAIN或EWOULDBLOCK,说明本次调用read函数出错是因为底层数据还没有就绪,//因此后续还应该继续调用read函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。if(errno==EAGAIN || errno==EWOULDBLOCK){std::cout<<strerror(errno)<<std::endl;sleep(1);continue;}//调用read函数在读取到数据之前可能会被其他信号中断,此时read函数也会以出错的形式返回,//此时的错误码会被设置为EINTR,此时应该重新执行read函数进行数据的读取。else if(errno==EINTR){std::cout<<strerror(errno)<<std::endl;sleep(1);continue;}else{std::cerr<<"read error"<<std::endl;break;}}buffer[s]='\0';std::cout<<"echo#:"<<buffer<<std::endl;}return 0;
}
四、重要概念补充
我们知道I/O中的输入操作就是将外设中的数据拷贝到内存中,但是思考一个问题,操作系统是如何知道外设中是有数据可以读取的?然后它又是怎么样读到外设中的数据的?
1、操作系统如何得知外设中有数据的?
- 并不是操作系统想要从外设读取数据时外设上就一定有数据。比如用户正在访问某台服务器,当用户的请求报文发出后就需要等待从网卡当中读取服务器发来的响应数据,但此时对方服务器可能还没有收到我们发出的请求报文,或是正在对我们的请求报文进行数据分析,也有可能服务器发来的响应数据还在网络中路由。
- 但操作系统不会主动去检测外设上是否有数据就绪,这种做法一定会降低操作系统的工作效率,因为大部分情况下外设当中都是没有数据的,因此操作系统所做的大部分检测工作其实都是徒劳的。
- 操作系统实际采用的是中断的方式来得知外设上是否有数据就绪的,当某个外设上面有数据就绪时,该外设就会向CPU当中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给CPU。
- 每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表叫做中断向量表,当CPU收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表执行该中断信号对应的中断处理程序,处理完毕后再返回原被暂停的程序继续运行。
2、操作系统如何处理从网卡中读取到的数据包?
操作系统任何时刻都可能会收到大量的数据包,因此操作系统必须将这些数据包管理起来。所谓的管理就是“先描述,再组织”,在内核当中有一个结构叫做sk_buff,该结构就是用来管理和控制接收或发送数据包的信息的。
//简化版的sk_buff
struct sk_buff {// 协议头指针(各层数据快速访问)char *transport_header; // [传输层] TCP/UDP头char *network_header; // [网络层] IP头char *mac_header; // [链路层] MAC头// 有效载荷指针char *data; // 实际数据起始位置
};
当操作系统从网卡当中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户,那对应到这个sk_buff结构来说具体是如何进行数据包的解包和分用的呢?
- 当操作系统从网卡中读取到一个数据包后,就会定义出一个sk_buff结构,然后用sk_buff结构当中的data指针指向这个读取到的数据包,并将定义出来的这个sk_buff结构与其他sk_buff结构以双链表的形式组织起来,此时操作系统对各个数据包的管理就变成了对双链表的增删查改等操作。
- 接下来我们需要将读取上来的数据包交给最底层的链路层处理,进行链路层的解包和分用,此时就是让sk_buff结构当中的mac_header指针指向最初的数据包,然后向后读取链路层的报头,剩下的就是需要交给网络层处理的有效载荷了,此时便完成了链路层的解包。
- 这时链路层就需要将有效载荷向上交付给网络层进行解包和分用了,这里所说的向上交付只是形象的说法,实际向上交付并不是要将数据从链路层的缓冲区拷贝到网络层的缓冲区,我们只需要让sk_buff结构当中的network_header指针,指向数据包中链路层报头之后的数据即可,然后继续向后读取网络层的报头,便完成了网络层的解包。
- 紧接着就是传输层对数据进行处理了,同样的道理,让sk_buff结构当中的transport_header指针,指向数据包中网络层报头之后的数据,然后继续向后读取传输层的报头,便完成了传输层的解包。
- 传输层解包后就可以根据具体使用的传输层协议,对应将剩下的数据拷贝到TCP或UDP的接收缓冲区供用户读取即可
发送数据时对数据进行封装也是同样的道理,就是依次在数据前面拷贝上对应的报头,最后再将数据发送出去(UDP)或拷贝到发送缓冲区(TCP)即可。也就是说,数据包在进行封装和解包的过程中,本质数据的存储位置是没有发生变化的,我们实际只是在用不同的指针对数据进行操作而已。
但内核中的sk_buff并不像上面那样简单:
- 一方面,为了保证高效的网络报文处理效率,这就要求sk_buff的结构也必须是高效的。
- 另一方面,sk_buff结构需要被内核协议中的各个协议共同使用,因此sk_buff必须能够兼容所有的网络协议。
五、总结
学习了上面的五种高级的I/O模型,我们需要知道的是,当我们再看到read,recv、send、write这种I/O接口,这类接口一般都是身兼两职,我们不能简简单单看到他们的拷贝功能,还要看到它们的“等待”。
I/O中 “等待”的时间是远远高于“拷贝”的时间的。
感谢阅读!