本篇博客介绍:介绍Linux中的高级IO
高级IO
- IO
- 五种IO模型
- 非阻塞IO
- 如何判断异常读取
- IO多路转接之Select
- select的优缺点
网络通信的本质其实就是一种IO
而我们知道的是 IO的效率实际上是十分低下的
所以说我们需要一些机制或者方法来解决这个效率问题
IO
IO实际上就是 input && output
在冯诺依曼体系中就是与外设交互的意思
IO 为什么低效
我们以读取为例介绍IO
- 情况一: 当我们底层调用read函数的时候 如果缓冲区没有数据 此时会怎么样?
- 情况二: 当我们底层调用read函数的时候 如果缓冲区有数据 此时会怎么样?
很明显 在情况一中我们的程序会阻塞住 在情况二中我们的程序会拷贝数据
那么阻塞的本质是什么呢?
从原理上讲 阻塞就是将PCB放到等待队列中 从本质上讲阻塞的本质就是等
所以说IO实际上就是 等 + 数据拷贝
实际上不光是在网络中 在本地主机进行IO的时候也是进行这两个阶段
当我们的程序需要读取磁盘中的内容时 磁盘需要先将内容加载到内存里面
而在加载还未完成时 我们的程序在做什么呢? 阻塞 或者说 等
这也就是为什么我们使用scanf
和cin
等输入函数的时候 命令行会阻塞住
因为此时它们在IO的 等 阶段
所以说现在我们可以这么定义低效的IO
在进行IO的时间里 大部分时间都在等待
如何提高IO效率
在进行IO的时间里 让等待时间所占的比重变小
那么此时 我们的问题就变成了 如何让IO的比重降低
实际上在经历了这么长时间的发展之后 计算机的前辈们已经总结出来了五种IO模型
我们现在开始学习这五种IO模型
五种IO模型
大家肯定都看过或者自己钓过鱼 我们这里把钓鱼的模型简化一下分成两步 等+钓
那么此时大家可以思考一下 在什么情况下 我们会认为一个人的钓鱼效率特别高呢? 肯定是这个人钓的动作特别多 而等的动作特别少的时候 我们认为这个人的钓鱼效率很高
下面我们使用五个关于钓鱼的小故事来介绍这五种IO模型
鱼漂是一种垂钓时鱼儿咬钩的讯息反应的工具
我们可以通过观察鱼漂来得知鱼儿有没有上钩
故事一
张三去钓鱼的时候不喜欢被打扰 甩钩之后就一直盯着鱼漂 等什么时候余漂有反应了就立刻拉钩
故事二
李四去钓鱼的时候专心不了 甩钩之后就喜欢刷刷手机 每刷一会儿手机就看一眼鱼漂 如果有反应了就拉钩 如果没反应就继续刷手机
故事三
王五去钓鱼的时候喜欢在鱼漂上挂个铃铛 之后就去刷手机玩了 如果铃铛响了 那么王五就去拉钩 如果没响 就一直玩手机
故事四
赵六去钓鱼的时候喜欢多备几根鱼竿 所有鱼竿下水之后赵六就在旁边巡视 哪一根鱼竿的鱼漂动了就去拉哪根鱼竿
故事五
田七去钓鱼的时候带着一个小跟班 每次只需要布置任务让小跟班钓多少鱼就好 自己处理自己的事情去了
上面的五个小故事分别代表了五个IO模型 分别是
- 故事一: 阻塞
- 故事二: 非阻塞轮询
- 故事三: 信号驱动
- 故事四: 多路复用多路转接
- 故事五: 异步IO
那么这里就有个问题了 谁钓鱼是最高效的呢?
很显然 故事四中的赵六效率最高 因为其他人都只有一个鱼竿 而赵六有很多个 所以说赵六钓到鱼的概率比其他人高很多了
同样的 因为赵六有很多个鱼竿 所以说它肯定一直在拉钩 所以说在相同时间内 它等待的时间也是最短的
什么是同步IO 异步IO
如果一个人(或者进程)参与到IO的过程 那么我们就把这个IO称为同步IO
反之就是异步IO
阻塞和非阻塞的区别主要在哪里
我们都知道 IO = 等 + 拷贝
阻塞和非阻塞拷贝的过程是相同的 但是它们等的过程是不同的
学到这里的时候同学们可能会发现一点问题
我们这里讲的同步和多线程部分讲的同步好像不太一样?
多线程部分讲的同步是按照一定的顺序访问临界资源
而我们这里讲的同步则是 线程或者进程要参与IO的过程
这是因为在计算机中 关于同步有很多不同的概念 具体是什么意思我们带入到具体的场景中才会明白
非阻塞IO
我们如果想让IO进行非阻塞的话 打开文件的时候就可以进行非阻塞设置
比如说 open
socket
但是如果我们使用每个函数的时候都记住它们的非阻塞标志未免也有点太麻烦了
所以说我们这里使用 fcntl
函数来统一设置
该函数原型如下
int fcntl(int fd, int cmd, ... /* arg */ );
返回值:
- 如果设置失败会返回-1 并且错误码会被设置 成功返回大于等于0
参数:
- 参数一是文件描述符
- 参数二是一个标志位 它有以下几种功能
- 复制一个现有的描述符(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)
我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞
- 参数三是一个可变参数模板 我们可以传入各种参数
其实我们在刚刚学习C语言的时候 就学到了如何进行一个阻塞读取了
那就是从0号文件描述符中读取数据 (0代表标准输入)
代码标识如下
#include <iostream>
using namespace std;
#include <unistd.h>
int main()
{
char buff[1024];
while (true)
{
ssize_t s = read(0 , buff , sizeof(buff)-1);
if (s > 0)
{
buff[s] = 0;
cout << "echo# " << buff << endl;
}
else
{
cout << "read \"error\"" << endl;
}
}
return 0;
}
当我们编译运行该程序的时候 该程序会直接阻塞住 这是因为我们标准输入缓冲区中没有数据
只有当我们输入数据之后 程序才会继续运行
其实我们的cout也是阻塞式的 只不过因为没有到达等的条件 或者说等的时间很少 所以我们认为它的非阻塞的
此时我们将 标准输入
设置为非阻塞式看看效果
bool SetNonBlock(int fd)
{
int f1 = fcntl(fd , F_GETFL);
if (f1 < 0)
{
return false;
}
fcntl(fd , F_SETFL , f1 | O_NONBLOCK);
return true;
}
这段代码的意思是 我们首先得到一个fd的各个参数 然后再设置该fd为非阻塞
此时我们再次编译运行就会出现下面的情况
程序会一直输出read “error”
需要注意的是 虽然我们看上去系统一直在打印read error 但是实际上它是一直在调用read函数 只不过read函数里面没有数据 所以说read的字符为0 所以才会打印 read error
如何判断异常读取
但是这里又会出现一个问题了
- 在正常读取的时候 我们通过打印的字符串可以知道是正常读取了
- 可是在异常读取的时候 我们怎么分辨是读取失败还是缓冲区中没有数据呢
在目前的情况下 我们是无法分辨的
因为不管是读取失败 还是缓冲区中没有数据 显示器上都会打印 read error
所以说 我们这个时候就需要 错误码来告诉我们 究竟是为什么没有读取成功
我们首先要包含下面两个头文件
#include <cstring>
#include <cerrno>
紧接着 在每次读取失败的打印后面加上错误码和错误码描述
cout << "read \"error\" " << "error: " << errno << "strerrno: " << strerror(errno) << endl;
之后编译运行
之后我们就可以发现 异常读取是因为缓冲区为空了
EWOULDBLOCK
在Linux中是一个宏定义 是11号错误码 代表的就是缓冲区为空 再试一次
EINTR
在Linux中是7号错误码 代表的是当前IO过程被信号中断了 如果出现该错误码 我们让进程继续读取信号即可
非阻塞读取的意义
当我们使用了非阻塞IO的时候 每次读取如果遇到了 EWOULDBLOCK
EINTR
我们就可以让我们的进程去做一会儿其他事情
当然如果遇到了其他错误码我们就要考虑进行错误排查了 是不是自己的代码哪里写的有问题
IO多路转接之Select
我们前面说过了 IO的本质就是 等+数据拷贝
而我们select的主要做的工作就是在 等 这一步
它的工作过程如下
- 帮用户一次等待多个sock
- 如果有sock就绪了 select就要通知用户 这些sock就绪了 让用户调用read/recv函数来进行读取
我们下面会通过7步来帮大家理解下select 步骤如下
- 认识下select函数的各个接口
- 理解fd_set
- 理解一个select函数的重要参数
- 推而广之 理解所有参数
- 快速上手写代码
- 理解select代码模式
- 完成代码
第一步 认识select函数各个接口
select函数的头文件如下
#include <sys/select.h>
函数原型如下
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数:
参数一:int nfds
它代表的含义是让我们等待的文件描述符的 最大值+1
这个很好理解 因为我们的文件描述符的本质就是数组的下标 而下标是连续增长的 所以说我们只需要啊给一个要等待的文件描述符最大值就能够得到一个要等待的文件描述符的范围
有同学可能会有疑问 这样子的话我们岂不是要等待从 0 ~ nfds-1所有文件描述符了呢 关于这个问题看了下面的参数介绍就能明白了
参数二 三 四:
参数二 三 四 五全部是输入输出形参数
对于参数二 三 四来说我们要传入的是一个fd_set类型的指针
这些参数在输入的时候分别表示
- 我们是否关心读就绪
- 我们是否关心写就绪
- 我们是否关心有异常
在输出的时候分别表示
- 哪些读就绪了
- 哪些写就绪了
- 出现哪些异常了
参数五:timeval *timeout
timeval实际上是一个结构体 在Linux系统中 它的定义如下
它的第一个成员可以获取当前时间的秒 它的第二个成员可以获取当前时间的微秒
我们让select进行等待的时候 有三种模式可以供我们选择
- 阻塞式
- 非阻塞式
- 阻塞一段时间 之后返回
对于这个参数来说
- 如果我输入nullptr 那么它就是阻塞式的
- 如果我们输入结构体 {0 , 0} 那么它就是非阻塞式的
- 如果我们输入结构体{5, 0}那么它就会等待五秒钟 之后返回 但是如果说五秒内有文件描述符就绪了的话 这个参数就会显示出输出性 比如说我们要求等五秒 而实际上2秒就有文件描述符就绪了 那么它就会返回{3 , 0}
返回值:int
它表示的是就绪的文件描述符的个数
只要让我们等待的文件描述符中 有一个就绪了 它就会返回
第二步 理解fd_set
fd_set 叫做文件描述符集 它本质上是一个位图 如果有关位图不理解的同学可以参考我的这篇博客
位图
系统提供了四个函数来让我们进行文件描述符集操作 它们的作用如下
- 清除某个文件描述符
- 判断某个文件描述符是否被设置
- 设置文件描述符
- 清空文件描述符
第三步 挑选一个参数细致的分析下
我们以读文件描述符集为例
fd_set *readfds
当它作为一个输入参数时
- 它是用户通知内核的一种方式
- 在比特位中 比特位的下标表示文件描述符
- 比特位下标对应的内容是否为1表示我对于该文件的读是否关心
- 比如 0101 就是我对于2号和0号文件描述符的读关心
当它作为一个输出参数时
- 它是内核通知用户的一种方式
- 在比特位中 比特位的下标表示文件描述符
- 比特位下标对应的内容是否为1表示该文件描述符的读是否就绪
- 比如说 0100 就是用户让系统关心的0号和2号文件描述符中 2号文件描述符就绪了
我们可以总结下
用户和内核都会使用同一个位图结构
所以说 我们只要使用过一次这个参数 它就需要被重新设置一次
第四步 推而广之
推而广之
对于 fd_set *writefds
fd_set *exceptfds
来说 它们的作用也和 fd_set *readfds
类似
只不过关心和通知的内容分别变成了 是否关心写和是否关心异常
第五步 快速上手写代码
我们首先先包含之前写的 Sock.hpp
err.hpp
log.hpp
这三个文件内容可以参考这个gitee连接
参考代码
接下来我们上手开始写select服务器
关于select服务器 我们只完成读取 写入和异常不做处理
雏形如下
class SelectServer
{
private:
uint16_t _port;
int _listensock;
public:
SelectServer(const uint16_t& port = 8080)
:_port(port)
{
_listensock = Sock::Socket();
Sock::Bind(_listensock , _port);
Sock::Listen(_listensock);
logMessage(DEBUG , "create base socket success");
}
~SelectServer()
{
if (_listensock > 0)
{
close(_listensock);
}
};
现在要让这个服务器运行起来我们只需要写一个start函数
让它不停的接收新的套接字
void Start()
{
while (true)
{
int sock = Sock::Accept(_listensock , ...);
}
}
而我们接收套接字接收的过程实际上就是一个IO 因为它就是 等 + 数据拷贝(拷贝套接字信息)
而当没有数据到来的时候我们依旧调用 accept
实际上这个程序就被阻塞住了
那我们这里使用select改写下这段代码
fd_set rfds;
FD_ZERO(&rfds);
while (true)
{
// int sock = Sock::Accept(_listensock , ...);
FD_SET(_listensock , &rfds);
int n = select(_listensock + 1 , &rfds , nullptr , nullptr , nullptr);
switch (n)
{
case 0:
logMessage(DEBUG , "time out..");
break;
case -1:
logMessage(WARNING , "select error: %d : %s" , errno , strerror(errno));
default:
break;
}
}
我们使用 select
方法 对于上面的函数进行重写 当然我们也可以加上一个 timeout
struct timeval timeout = {5 , 0};
此时我们的select函数便会五秒进行一次检测了
为什么在经过一次五秒的检测之后 等待的时间就变成0了呢?
因为timeout是一个输入输出参数 在五秒的等待之后变成 {0 , 0}了
如果我们想要一直经历五秒的检测的话 只需要在每次进入循环的时候设置下就好
接下来为了不让多余的信息干扰我们 我们将等待模式变成阻塞式等待
int n = select(_listensock + 1 , &rfds , nullptr , nullptr , nullptr);
如果说我们得到新连接的话 我们就打印一条debug信息
default:
logMessage(DEBUG , "get a new link...");
break;
演示结果如下
为什么select会一直通知呢?
原因 同 timeout rfds是一个输入输出参数
所以说 我们需要一个函数去处理接收的文件描述符 并且重新设置
处理函数如下
void HandlerEvent(const fd_set& rfds)
{
string clientip;
uint16_t clientport = 0;
if (FD_ISSET(_listensock , &rfds))
{
int sock = Sock::Accept(_listensock , &clientip , &clientport);
if (sock < 0)
{
logMessage(WARNING , "Accept error"); return;
}
logMessage(DEBUG , "get a new line success :[%s : %d] , %d" , clientip.c_str() , clientport , sock);
}
}
此时就可以正常的accept了
那么当我们正常接收到一个文件描述符后 我们现在要立刻读取嘛?
答案肯定是不行的 因为现在我们并没有使用任何的多线程 多进程 这是一个单执行流程序
如果我们现在读取那么这个进程就会在这里阻塞住了
当然我们也不能够创建多线程或者是多进程来处理这些数据 否则这些内容就和我们之前学的东西没区别了
我们这里的思路是让select函数来帮助我们进行处理何时读取的问题
可是这里的问题就来了 我们应该怎么让select函数来帮我们进行处理呢? 这两个地方都跨函数了啊
那么要解决这个问题 我们就要进行到第六步 理解select代码模式
第六步 理解select代码模式
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
我们再次回顾下select函数
- 随着我们获取的sock越来越多 我们添加到select的sock也越来越多 这就注定了nfds每次都要变化 我们就需要对它进行一个动态的计算
- rfds / wfds / efds 是输入输出形参数 所以注定了我们每一次都要对于它们进行重新添加
- timeout是输入输出形参数 如果我们需要每次等待的时间一样的话 我们也需要每次进行重新设置
其中1 和 2 注定了 我们必须要将合法的文件描述符保存起来 用来支持
- 更新最大文件描述符
- 更新位图结构
所以说我们的select函数一般来说需要一个第三方数组 用来保存所有的合法fd
所以说我们的伪代码如下
while(true)
{
1. 遍历数组 更新出最大值
2. 遍历数组 添加所有需要关心的fd 到位图中
3. 调用select进行时间检测
}
select能够检测的文件描述符有最大值嘛?
有的 我们只需要计算出fd_set的大小然后*8就好
所以说我们设置一个第三方数组的时候大小要刚好覆盖到
数组用什么
我们这里不选择使用vector容器 而使用一个原生的int类型的数组来做
这主要是因为vector容器做的太容易了 不能很好的暴露出select服务器的一些缺点来
第七步 : 完成select服务器
我们将数组的大小设置为select所能检测的文件描述符最大值 并且全部初始化为-1
int _fdarr[ARRNUM];
for (int i = 0; i < ARRNUM ; i++)
{
_fdarr[i] = FD_NONE;
}
同时 我们约定数组的0号下标为listensock套接字
与此同时 当我们每次进入while循环的时候 我们将合法的文件描述符全部添加到fd_set中 并且 我们在循环的时候就可以比较文件描述符的大小 找到最大的文件描述符 maxfd
FD_ZERO(&rfds);
// int sock = Sock::Accept(_listensock , ...);
int maxfd = _listensock;
for (int i = 0; i < ARRNUM; i++)
{
if(_fdarr[i] == FD_NONE)
{
;
}
else
{
FD_SET(_fdarr[i] , &rfds);
if (maxfd < _fdarr[i])
{
maxfd = _fdarr[i];
}
}
}
但是光是这样还没完 我们还需要设置好我们accept的sock套接字 这样子 在每次进入while循环的时候 我们的sock套接字就会自动被设置到rfds中
int pos = 1;
for(pos = 1; pos < static_cast<int>(ARRNUM) ; pos++)
{
if (_fdarr[pos] == FD_NONE)
{
break;
}
}
if (pos == static_cast<int>(ARRNUM))
{
logmessage...;
}
else
{
_fdarr[pos] = sock;
}
}
测试
我们可以写一个 debugprint 来打印 fdarry 中所有的文件描述符来看看结果是否和我们的预期相符合
void DebugPrint()
{
cout << "fd_array: ";
for (int i = 0; i < static_cast<int>(ARRNUM); i++)
{
if (_fdarr[i] == FD_NONE)
{
;
}
else
{
cout << _fdarr[i] << " ";
}
}
cout << endl;
}
我们发现结果符合预期
但是我们这里又会遇到一个新的问题
当走到我们的select函数的时候 我们rfds里面有两种套接字 一个是listen套接字 一个是普通的套接字 并且我们select中 就绪的fd会越来越多
但是 我们可以回顾下我们上面的handler方法 是不是里面只处理了接收新连接的请求啊? 这样子显然是不可以的
所以说我们要重写下 handler 方法 让该方法可以处理所有就绪的请求
代码框架如下
W> void HandlerEvent(const fd_set& rfds)
{
for (int i = 0; i < static_cast<int>(ARRNUM); i++)
{
if (_fdarr[i] == FD_NONE)
{
;
}
else
{
if (_fdarr[i] == _listensock)
{
// accept new link;
}
else
{
// read
}
}
}
}
我们之前的handler方法由于只判断了listen套接字 只完成了accpet的任务
所以说我们将它封装成acceptr方法
而对于非listensock套接字 我们则直接进行读取即可 我们这里使用logmessage进行测试
我们发现 结果符合预期
我们现在暂时不写读取的代码 因为这里会涉及到tcp粘包问题 需要我们定制一个协议 而目前我们主要学习的是select服务器的设计模式 这些东西可以先放一放 等到epoll服务器再说
select服务器整体代码如下
select服务器代码
select的优缺点
优点
- 效率高 IO等的时间少 尤其是在有大量连接 并且只有少量活跃的情况下
- 单进程 占用资源少
缺点
- 为了维护第三方数组 select服务器充满大量的遍历操作
- 每一次都要对select参数进行重新设定
- 能够同时管理的fd的个数是有上限的
- 由于参数是输入输出的 所以避免不了大量用户和内核之间的拷贝
- 编码比较复杂