博客主页:花果山~程序猿-CSDN博客
关注我一起学习,一起进步,一起探索编程的无限可能吧!让我们一起努力,一起成长!
目录
[3. 5种IO模型图](#3. 5种IO模型图)
[2. I/O多路转接(select)](#2. I/O多路转接(select))
嗨!收到一张超美的图,愿你每天都能顺心!
一,前文
我们曾经学习过类似IO的知识,例如接口:C语言的fwrite,fread,操作系统的read,write等对磁盘的IO,我们现在可以称他为------单机IO,本质上是内存到磁盘之间的读写操作,相比与网络IO,本地IO有着一些先天的优势(物理优势------近):
- 低延迟:由于数据交换不涉及网络传输,因此相对网络IO,单机IO的延迟较低。
- 简单直接:实现较为简单,通常使用操作系统提供的API(如C语言中的fopen, fread, fwrite等)直接进行操作。
- 可靠性高:相比网络环境,本地IO受外界干扰小,数据传输更可靠。
应用的场景多是对本地文件内容的读写。
网络IO本质也是对外设进行读写(接口如:send,recv) ,但由于需要经过长延迟的网络环境与经历的繁琐步骤,网络IO的效率极其的低下,优化网络效率的问题被大家所关注。
网络IO的劣势:
- 高延迟:数据需要经过网络传输,可能受到网络延迟、丢包、拥塞等因素影响,因此延迟通常高于单机IO。
- 复杂性:需要处理网络连接建立、数据包封装/解封装、错误处理、流量控制等复杂问题。
如何理解IO时间 = 等 + 数据拷贝?
答:
本地IO: 当我们通过read打开某个文件,实质上是让操作系统帮我们向磁盘中获取数据,我们应用层只需要等待 操作系统的缓冲区开始有数据,等到一定的时机,我们再拷贝到应用层准备好的缓冲区中。
网络IO: 网络版本其实也是类似外设从磁盘变成了网卡 ,依旧是操作系统为我们提取数据,应用程序等待 操作系统从网络接收数据到内核的套接字缓冲区,然后再拷贝到应用层缓冲区中。
2,5种IO模型(钓鱼例子)
5种模型为:IO = 等 + 数据拷贝,这里用一个人在河边钓鱼为例子,"等"=等鱼上钩,"数据拷贝" = 将鱼钓上放在桶(应用层缓冲区)里。
- 阻塞式 :这个人 ,就坐在哪儿,盯着鱼漂,啥事不干,就单纯在哪儿等鱼上钩,再调上。
- 非阻塞轮询式:这个人,过一会来看看是否有上钩,上钩了再把鱼钓起来。
- 信号驱动式:这个人将鱼钩上安装一个上钩提醒器,而他就做其他的事情,等到提醒了才过来把鱼钓起。
- 多路复用,多路转接式 :这个人安装100个鱼竿,坐在旁边,那个杆上钩了,把那个鱼钓起,因此这种方式效率最高。
- 异步IO式 :有两个人,老板自己嫌累,雇了个员工,员工在那里钓鱼,钓上来的鱼放在桶里,等到桶被装满,员工会提醒老板过来取鱼。
其中,除了异步IO,其余都是同步IO,那如何区分同步,异步IO ?只要(进程,线程)参与了等或者数据拷贝,都算同步IO,都没参加就是异步IO。
通过了上面的IO模型下面我们来看看官方怎么解释的:(光看图理解是浅显的,这里就简单看看,后面会有代码实践)
3. 5种IO模型图
阻塞 IO 是最常见的 IO 模型。
非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回 EWOULDBLOCK错误码。
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为 轮询 . 这对 CPU来说是较大的浪费, 一般只有特定场景下才使用。
信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作。
IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够 同时等待多个文件
描述符的就绪状态.
异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).
小结
任何IO过程中, 都包含两个步骤. 第一是 等待 , 第二是 拷贝。 而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少。
4,进程同步&IO同步区分
另外, 我们回忆 多进程多线程 的时候, 也提到 同步 。这里的 同步通信 和 进程之间的同步是完全不想干的概念。
多进程多线程的同步 : 在操作系统或程序内部, 多进程或多线程之间的同步 是指控制多个进程或线程 访问共享资源的方式,以避免竞态条件和其他并发问题。常见的同步机制有:互斥锁,条件便利,信号量,原子操作等
同步通信 : 同步通信是指两个或多个网络实体之间进行有序的信息交换,其中一方在发送消息后 必须等待另一方的响应才能继续执行。
那如何区分同步通信和异步通信?
以钓鱼为例,"在河边等待鱼上钩"=等待,"将鱼吊起"=拷贝,只要进程参与任一一步都理解为同步IO;都不参加,直接来提桶拿鱼为异步IO
以后在看到 "同步" 这个词, 一定要先搞清楚大背景是什么。这个同步, 是同步通信异步通信的同步, 还是进程,线程同步与互斥的同步。
5,阻塞与非阻塞区别
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
- 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
二,非阻塞IO
首先我们先看看我们常见的阻塞式IO:
cpp
int main()
{
while (1)
{
char buff[1024];
int ret = read(0, buff, sizeof buff - 1);
if (ret)
{
buff[ret] = 0;
std::cout << "echo#:" << buff ;
} else
{
std::cout << "....." << std::endl;
}
}
return 0;
}
结论:I/O的文件描述符默认是阻塞式的。如果非阻塞式,需要我们通过fcntl设置。最常见的IO阻塞就是标准输入,会阻塞的等待我们输入内容。
1.设置I/O为非阻塞(fcntl)
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
(注:这个接口适用于 **所有的本地IO,**网络IO所打开的 **文件描述符设置是否阻塞形式。**有了这个我们就可以设置非阻塞IO,不用单独学习特定接口参数来设置非阻塞)
fcntl函数有5种功能(方法):
- 复制一个现有的描述符(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).
废话少说,先上手看看,写一下用于 设置文件描述符方式为非阻塞式的函数,如下:
cpp
int SetNoBlock(int fd)
{
int ret = fcntl(fd, F_GETFL); // 获取原来fd的读写标志位,就比如:open时设置的O_RDONLY,O_RDWR等等
if (ret == -1)
return -1;
fcntl(fd, F_SETFL, ret | O_NONBLOCK);
// fcntl 更换为设置读写标准位,并在原来标准位的基础上添加 非阻塞模式
return 0;
}
我们此处只是用第三种功能, 获取/设置文件状态标记 , 就可以将一个文件描述符设置为非阻塞。设置好非阻塞后,在循环内文件描述符会被轮询,如果没有资源就绪,会 设置错误码,如:EAGAIN,EWOULDBLOCK------资源未就绪。
因此,修改后的非阻塞IO案例,如下:
cpp
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#include <string.h>
int SetNoBlock(int fd)
{
int ret = fcntl(fd, F_GETFL); // 获取原来fd的读写标志位,就比如:open时设置的O_RDONLY,O_RDWR等等
if (ret == -1)
return -1;
fcntl(fd, F_SETFL, ret | O_NONBLOCK);
// fcntl 更换为设置读写标准位,并在原来标准位的基础上添加 非阻塞模式
return 0;
}
int main()
{
SetNoBlock(0); //文件描述符也只需要设置一次,如果还要设置标准位可以后面自己添加
char buff[1024];
while (1)
{
sleep(1);
errno = 0;
int ret = read(0, buff, sizeof buff - 1);
if ( ret > 0)
{
buff[ret] = 0;
std::cout << "echo# " << buff;
continue;
}else if( ret == 0)
{
std::cout << "echo# try again" << std::endl;
}else if (ret == -1)
{
std::cout << "echo# "<< errno << ": "<< strerror(errno);
// 由于文件描述符采用非阻塞式后,我们无法区分是错误导致,还是单纯是IO资源未就绪
}
// 针对无法区分非阻塞式IO错误,我们需要对错误码进行区分
if (errno == EAGAIN || errno == EWOULDBLOCK) // RWOULDBLOCK 本质是 EAGAIN
{
std::cout << " IO资源未就绪, try again" << std::endl;
continue;
}else if (errno == EINTR) // read时被signal打断,返回时无法再调用read
{
continue;
}else
{
break;
}
}
return 0;
}
设置非阻塞后,socket的读事件未就绪,需要对错误值进行,判断。
其他错误值可能为:
EBADF 文件描述词为无效的或该文件已关闭
EINTR 此调用被信号所中断
EINVAL 参数n 为负值。
ENOMEM 核心内存不足
2. I/O多路转接(select)
select接口
是一个用于监控多个文件描述符(file descriptors,简称fd)的系统调用,它可以检测多个文件描述符上是否有事件发生(如数据可读、可写或异常)。这对于需要同时处理多个客户端连接的服务器程序来说非常有用,比如Web服务器、聊天服务器等。(AI)
参数理解
1). timeval结构体 类型,里面存放着秒与微秒数,如下:
输入意义:告诉此次select 阻塞tv_sec + tv_usec秒后select超时返回。
输出意义:如果在阻塞时间内,有fd事件就绪,则会设置剩余时间量。
这里扩展一下,获取时间戳的方法,如:C语言的time_t time(time_t *),系统接口gettimeofday
2). fd_set类型
select接口中有三个fd_set类型,意义:让系统分别注意,读事件,写事件,其他事件fd就绪。
fd_set 本质是一个位图数据结构 ,其中位图下标值代表fd ,一个位图最大能容纳1024个fd;
每个位的值为1,
- 输入意义:告诉系统注意该fd的读事件(写事件,其他事件);
- 输出意义:select会设置fd_set,输出时标记fd就绪的下标位为1;
设置fd_set类型,需要特定操作宏:
用select优化listen_socket
首先,我们需要理解listen_socket在网络通信中的角色。listen_socket就像是餐馆门口的迎宾员,负责接收潜在顾客(客户端)的到来,并准备好迎接他们(监听连接请求)。accept
操作则像是餐馆内的服务员,负责正式接待顾客并安排座位(接受连接请求并创建新的套接字来处理这个连接)。
listen_socket不仅要监听传入的连接请求,还要与客户进行TCP的三次握手,以确认连接的建立。只有当三次握手成功完成后,accept
操作才能接收这个新连接,并为其提供服务(创建新的套接字来处理连接)。
当客户完成数据交换并准备断开连接时,需要进行TCP的四次挥手,以确保双方都知道连接将要关闭,并且释放占用的资源。
使用如下例子:
cpp
MySocket tool;
tool.listen_run(_server_socket);
// 实现多路转接select,这里只有一个listen,不过演示一下即可
fd_set fdset;
while (1)
{
timeval time = {0, 0} //非阻塞
FD_ZERO(&fdset);
FD_SET(_server_socket, &fdset);
int n = select(_server_socket + 1, &fdset, nullptr, nullptr, &time);
switch (n)
{
case 0:
continue;
break;
case -1:
Logmessage(ERROR, "select fail errno %d: %s", errno, std::to_string(errno).c_str());
exit(-1);
break;
default:
Logmessage(INFO, "select get a connect");
// 获取成功
}
}
上面是select只监视listen_socket一个标识符的读事件就绪,那如何将已连接客户端(或者本地IO)描述符一起管理起来呢?
思路:
- 需要一个外部的int数组,自称它为socket数组集,用于存放select关注的所有描述符。
- 每次客户端连接成功后,将soket放入数组集中;当select监视到其中某socket读事件就绪并处理后,需要从数组集中消除。
- 3.有个该数组集,我们可以每次遍历数组集循环设置fd_set,同时方便FD_ISSET判断。
下面是用web服务器,对socket的select处理代码示例链接(仅看webserver.hpp):
select的优缺点
优:
缺点:
1. 用户,OS层存在大量遍历:用户层,需要不断修改第三方数组;OS层,也需要不断遍历fd_set与修改。
2. fd_set本身的1024上限。
- 输入输出型参数,从用户到内核,从内核到用户存在频繁的数据拷贝。
4.编码复杂
3.I/O多路转接(poll)
针对select的缺点,经过过优化后的poll能解决大部分问题。
fds 是一个 poll 函数监听的结构列表 . 每一个元素中 , 包含了三部分内容 : 文件描述符 , 监听的事件集合 , 返回的事件集合。
nfds 表示: fds 数组的长度。
timeout 表示: poll 函数的超时时间 , 单位是毫秒 (ms)。
events 如何设置事件集合
使用示例如下:
cpp
#include <stdio.h>
#include <sys/time.h>
#include <sys/poll.h>
int main() {
struct pollfd fds[2];
int timeout;
// 初始化第一个文件描述符为标准输入
fds[0].fd = 0; // 标准输入
fds[0].events = POLLIN; // 只关心可读事件
// 假设这里有一个套接字,其文件描述符为 sockfd
int sockfd = 3;
fds[1].fd = sockfd;
fds[1].events = POLLRDNORM; // 监听套接字是否有数据可读
timeout = 5000; // 超时时间为5秒
// 进行 poll 调用
int ret = poll(fds, 2, timeout);
if (ret > 0) {
for (int i = 0; i < 2; i++) {
if (fds[i].revents & POLLIN) {
printf("文件描述符 %d 可读\n", fds[i].fd);
fds[i].revents = 0; //poll不在关注该描述符
}
}
} else if (ret == 0) {
printf("超时\n");
} else {
perror("poll error");
}
return 0;
}
poll优点:
缺点:
-
用户层,OS层任存在不少的遍历。用户层需要不断检测数组就绪;OS层不断检测fd就绪,本质上与select一样需要维护第三方数组。
-
当有大量链接接入,但就绪事件较少,这样遍历fd数组量加大,效率线性下降。
-
编码较select容易些。
没关系,epoll会解决,按照 man 手册的说法 : 是为处理大批量句柄而作了改进的 poll . epoll 几乎具备了之前所说的一切优点,被公认为Linux2.6 下性能最好的多路 I/O 就绪通知方法。
结语
本小节就到这里了,感谢小伙伴的浏览,如果有什么建议,欢迎在评论区评论,如果给小伙伴带来一些收获,请动动你发财的小手点个免费的赞,你的点赞和关注永远是博主创作的动力源泉。