1. IO 理解
1.1 IO 的基本概念
I/O即输入/输出(input/output),是计算机系统中极为关键的操作环节。
在经典的冯诺依曼体系结构框架下,其核心在于数据的传输流向界定了输入与输出的概念。具体而言,当把数据从诸如键盘、鼠标等输入设备拷贝传输到计算机的内存当中,这一过程便被称作输入操作;而当数据从内存拷贝传输至像显示器、打印机这类输出设备时,就被定义为输出操作。
从实际应用场景来看,对文件展开的读写操作实质上就是一种I/O体现。在这种情况下,与之对应的外部设备是磁盘,即读取磁盘上存储的文件内容至内存可视为输入操作,而将内存中的数据写入磁盘形成文件则属于输出操作。同样地,针对网络进行的读写操作同样归属于I/O范畴,这里与之对应的外设是网卡,例如从网络接收数据并存储到内存就是网络输入操作,将内存中的数据通过网卡发送到网络则是网络输出操作。
总的来说,I/O涵盖了计算机与外部设备之间数据交互的各类情形,是实现计算机系统与外界进行信息沟通、数据传递的重要途径,它使得计算机能够获取外部信息并将处理结果反馈出去,从而完成各种复杂的任务和功能实现。
1.2 OS 得知数据就像的方式
操作系统(OS)得知外设当中有数据可读取主要是通过中断机制来实现的。
首先,输入操作是指将数据从外设拷贝到内存的过程,但外设并非随时都有可供操作系统读取的数据。例如在网络访问场景中,用户发出请求报文后需等待从网卡读取服务器响应数据,而在此期间可能存在多种情况致使数据未就绪。
然后,操作系统不会主动频繁检测外设数据是否就绪,因为多数情况下外设无数据,主动检测效率低下。
实际情况是,当外设上有数据就绪时,该外设有权直接将控制信号以中断的形式发送给CPU中的中断控制器。中断控制器会依据中断信号的优先级顺序将其传送给CPU。
而在系统中存有中断向量表,它存储着中断信号与中断处理程序的映射关系。当CPU收到中断信号后,会自动暂停正在运行的程序,依据中断向量表找到并执行该中断信号对应的中断处理程序,待处理完毕后再返回原被暂停的程序继续运行,以此实现得知外设中有数据可读取并进行相应处理的流程。
理解上来说,这种基于中断的机制使得操作系统无需持续关注外设状态,避免了无效检测带来的资源浪费,同时又能及时响应外设的数据就绪情况,保证了数据读取的及时性和系统运行的高效性,是一种合理且高效的外设数据读取感知方式。
1.3 OS 处理网络数据
那么操作系统具体是如何处理网络数据的呢?当操作系统从网卡读取到数据包时,会创建 sk_buff
结构。它的 data
指针指向所读取的数据包。这些 sk_buff
结构以双链表形式组织,操作系统通过对双链表的操作来管理众多数据包。为保证高效的网络报文处理,sk_buff
结构本身需设计得高效。由于要被内核各协议共用,sk_buff
结构需兼容所有网络协议,这使其结构较为复杂。
为了方便理解,下面是一个简单的 sk_buff
结构:
struct sk_buff {
char* transport_header;
char* network_header;
char* mac_header;
char* data;
struct sk_buff* next;
struct sk_buff* prev;
};
当操作系统从网卡读取一个数据包时,会依次将数据交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户,而数据包解包与分用向上交付的过程,本质不过是 sk_buff
中指针之间的交换。具体过程如下:
- 链路层处理:让
sk_buff
结构中的mac_header
指针指向数据包,通过读取指针指向位置之后的数据获取链路层报头,剩余部分则是网络层要处理的有效载荷,完成链路层解包。- 网络层处理:链路层将有效载荷“传递”给网络层,实际操作是让
sk_buff
结构中的network_header
指针指向链路层报头之后的数据,然后读取网络层报头,完成网络层解包。- 传输层处理:使
sk_buff
结构中的transport_header
指针指向网络层报头之后的数据,再读取传输层报头,完成传输层解包。- 数据交付给用户传输层解包后,依据所使用的传输层协议(如TCP或UDP),将剩余数据拷贝到相应的接收缓冲区,供用户读取。
发送数据时,依次在数据前拷贝对应的报头(链路层、网络层、传输层报头),最后根据协议将数据发送(UDP)或拷贝到发送缓冲区(TCP)。整个封装和解包过程中,数据的存储位置基本不变,主要是通过不同指针操作来实现处理。
2. 五种 IO 模型
五种 I/O 模型是在计算机网络编程等领域用于描述操作系统处理输入 / 输出(I/O)操作的不同方式,以下我将为你详细介绍:
2.1 阻塞 IO
阻塞IO(Blocking I/O)是一种在进行输入输出操作时,进程(或线程)会被阻塞的IO模型。
当进程发起一个IO操作时,阻塞 IO 的流程如下:
- 如果所请求的数据尚未准备好(例如从网络读取数据时,数据还未到达网卡缓冲区;或者从文件读取数据时,文件内容还未加载到内存合适位置等情况),那么发起IO操作的进程就会进入阻塞状态。
- 在阻塞状态下,进程会暂停执行后续代码,一直等待,直到所请求的IO操作完成,也就是数据准备好并且成功被读取到进程所指定的缓冲区(对于读取操作而言),或者数据成功从进程缓冲区发送出去(对于发送操作而言)。
例如,在一个简单的网络客户端程序中,如果使用阻塞IO从网络套接字读取服务器的响应数据,当客户端发出读取请求后,若服务器响应数据未到达,客户端程序就会阻塞在读取操作处,直到接收到完整数据才会继续执行后续流程。
2.2 非阻塞 IO
非阻塞IO(Non-Blocking I/O)是与阻塞IO相对的一种IO模型,在进行输入输出操作时,进程(或线程)不会因IO操作未完成而一直处于阻塞状态。
当进程发起一个IO操作时,非阻塞 IO 的流程如下:
- 如果所请求的数据尚未准备好(比如从网络读取数据时,数据还未到达网卡缓冲区;或者从文件读取数据时,文件内容还未加载到内存合适位置等情况),发起IO操作的进程不会进入阻塞状态,而是会立即得到一个反馈结果,告知当前IO操作暂时无法完成,数据还未就绪。
- 进程在得到数据未就绪的反馈后,可以继续执行后续的其他代码逻辑,而不是像阻塞IO那样一直等待。之后进程可以选择过一段时间再次发起相同的IO操作来检查数据是否已经准备好,直到成功完成IO操作(对于读取操作而言是将数据读取到进程所指定的缓冲区,对于发送操作而言是将数据从进程缓冲区发送出去)。
例如,在一个网络客户端程序中采用非阻塞IO从网络套接字读取服务器的响应数据,当客户端发出读取请求后,如果服务器响应数据未到达,客户端程序不会阻塞在读取操作处,而是可以继续去做诸如更新界面显示、处理其他用户输入等操作,并且会定期或根据一定策略再次发起读取请求来查看数据是否已经到达并可读取。
2.3 信号驱动 IO
信号驱动IO(Signal-Driven I/O)是另一种IO模型,与阻塞IO和非阻塞IO有所不同,它通过信号机制来处理输入输出操作。
当进程发起一个IO操作时:
- 首先,进程会通过系统调用向内核注册一个信号处理函数,用于在IO操作完成时接收通知信号。这个注册过程告知内核当指定的IO操作就绪(比如数据准备好可读取,或者数据可从缓冲区成功发送出去等情况)时,要发送一个特定的信号给该进程。
- 然后,进程可以继续执行后续的其他代码逻辑,而不会像阻塞IO那样因为等待IO操作完成而阻塞在此处。在注册完信号处理函数后,进程就去忙自己的其他事情了,不会不断地去轮询检查数据是否就绪,就如同非阻塞IO那样的操作在此模型下是不需要的。
- 当所请求的IO操作最终完成时,内核会根据之前的注册,发送相应的信号给该进程。进程在接收到这个信号后,就知道对应的IO操作已经就绪了,于是会暂停当前正在执行的代码,转而执行之前注册好的信号处理函数来处理IO操作的后续事宜(比如将数据从内核缓冲区读取到进程指定的缓冲区,或者将进程缓冲区中的数据发送出去等)。
例如,在一个网络服务器程序中采用信号驱动IO来处理客户端的连接请求和数据传输。程序先向内核注册好针对连接请求和数据读取、发送等IO操作的信号处理函数。当有客户端发起连接请求或者有数据要传输时,内核会在相应操作完成后发送信号给服务器程序。服务器程序接收到信号后,就会执行对应的信号处理函数来处理这些IO相关的事宜,比如接受客户端连接、读取客户端发送的数据或者向客户端发送响应数据等,而在等待这些IO操作完成的过程中,服务器程序可以继续处理其他客户端的请求或者进行一些内部的数据维护等工作。
2.4 IO 多路转接
IO多路转接是一种高效的IO处理方式,与其他IO模型有所区别。
当进程使用IO多路转接时,流程如下:
- 进程先通过系统调用(如
select
、poll
或epoll
)将多个IO事件对应的文件描述符交给内核进行监视。这些IO事件可以是多个套接字的读操作、写操作等。- 内核会同时监控这些文件描述符对应的IO事件。此时,进程不会因为等待某一个特定IO事件完成而被阻塞,它可以去执行其他的任务。
- 当这些被监视的文件描述符中有一个或多个IO事件就绪(例如某个套接字有数据可读,或者可以写入数据),进程就可以会对就绪的IO事件进行处理,例如读取数据或者写入数据。
例如,在一个网络服务器程序中使用IO多路转接处理客户端请求。服务器将所有客户端连接对应的套接字文件描述符交给内核监视。当有新的客户端连接请求到来或者已有客户端发送数据时。服务器进程就可以根据具体情况,处理新连接或者读取/发送客户端数据,并且在等待数据就绪的过程中,服务器进程还可以处理其他事务,如记录日志、更新服务器状态等。
2.5 异步 IO
异步IO是一种高效的IO模型,与其他模型有明显差异。
当进程使用异步IO时,流程如下:
- 进程发起一个IO操作后,不会等待这个IO操作完成,也不会等待数据是否就绪,而是可以直接继续执行后续的其他代码逻辑。
- 当IO操作完成时(如数据已读取到指定位置或者数据已成功发送),系统会通过某种方式(比如回调函数、信号等)通知进程。进程收到通知后,再对已经完成的IO操作结果进行处理。
例如,在一个网络应用程序中采用异步IO来获取服务器的数据。当程序发起数据读取请求后,它会立刻去执行如检查更新、预加载其他资源等操作。一旦服务器的数据读取完成,系统会通过事先设定的回调函数通知程序,程序就可以对读取到的数据进行后续处理,如展示数据、分析数据等。
3. 同步通信与异步通信
3.1 同步通信
- ****在同步通信的情境下,当发起一个调用操作时,调用者会一直处于等待状态,直至获取到该调用的最终结果才会继续后续的流程。也就是说,这个调用不会在未得到结果前就返回,只有当结果明确返回给调用者时,整个调用过程才算完成。
比如你在网上购买一件商品,点击“提交订单”按钮后(发起调用),页面会一直显示加载状态,直到系统处理完订单相关的所有流程,如库存检查、价格核算、支付确认等,并返回一个明确的提示,如“订单提交成功”或者告知你失败的原因(这就是得到返回值),你才能进行后续操作,比如继续浏览其他商品或者查看订单详情等。在这个过程中,你作为调用者(点击提交订单的操作主体)是主动在等待订单提交这个调用的结果。
3.2 异步通信
与同步通信截然不同,异步通信在调用发出之后,调用本身会立即返回,并不会马上给出结果。调用者在发出调用后可以继续去执行其他的任务或操作,而不需要一直等待结果。被调用者会在合适的时候,通过诸如更新状态让调用者可查看、发送通知消息或者执行回调函数等方式,来告知调用者关于该调用的最终结果。
还是以网上购物为例,当你点击“提交订单”按钮(发起调用)后,页面可能只是短暂显示一个提示,如“订单正在处理中”,然后你就可以继续浏览其他商品、查看购物车等其他操作了(因为调用已经返回,你可以继续做其他事)。之后,系统会通过发送短信通知你订单是否提交成功(这就是通过通知告知调用者结果),或者在订单页面更新订单状态供你查看(通过状态告知结果),又或者在后台执行一个回调函数来完成一些后续相关的处理(比如根据订单结果为你推荐相关商品等),而这些告知结果的操作都是在你已经继续进行其他活动之后发生的。
其中需要注意的是多进程/线程里的同步与互斥,和同步通信概念不同。进程/线程同步是保证数据安全下按序访问临界资源,避免饥饿。而同步IO则关乎进程/线程与操作系统关系,涉及是否主动参与IO过程。
4. 其他高级IO
非阻塞IO,记录锁,系统V流机制,I/O多路转接(也叫I/O多路复用),readv和writev函数以及存储映射IO(mmap),这些统称为高级IO。
5. fcntl 函数
我们在打开文件 open
时,默认为阻塞打开,但是用open
函数携带O_NONBLOCK
或O_NDELAY
选项就可以可用非阻塞的方式打开文件。
但是如果我们要将已打开文件或套接字设为非阻塞状态,需用用到fcntl
函数,其用法如下:
- 函数原型:int fcntl(int fd, int cmd,… /* arg */);
- 参数说明:
fd
:已打开的文件描述符。cmd
:要进行的操作,不同cmd
取值对应不同功能,常见取值及功能如下:
F_DUPFD
:复制一个现有的描述符。F_GETFD
、F_SETFD
:获得/设置文件描述符标记。F_GETFL
、F_SETFL
:获得/设置文件状态标记。F_GETOWN
、F_SETOWN
:获得/设置异步I/O所有权。F_GETLK
、F_SETLK
、F_SETLKW
:获得/设置记录锁。
- 返回值:函数调用成功,返回值取决于具体操作。函数调用失败,返回 -1,同时错误码会被设置。
然后通过以下代码可将指定文件描述符设为非阻塞状态:
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;
}