[Linux] 典型IO模型与多路转接IO模型
Posted 哦哦呵呵
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[Linux] 典型IO模型与多路转接IO模型相关的知识,希望对你有一定的参考价值。
1. 什么是IO?
IO的字面含义的含义就是输入与输出,最重要的过程是IO的过程。
从缓冲区当中拷贝数据,如果缓冲区当中没有数据,调用接收或发送接口就会阻塞等待,拷贝数据到buf中。
读IO = 读事件就绪 + 内核数据拷贝到用户空间
写IO = 写事件就绪 + 从用户空间数据拷贝至内核
典型IO模型: 不同的处理方式,处理读写事件中的等待过程。
多路转接模型(高级IO): 在内核将数据准备好之前,系统会继续进行别的操作,当数据准备好之后,直接拷贝至用户空间,告知用户数据已经拷贝完毕。尽可能的较少等的比重,因为读写事件就绪的过程是对用户没有贡献的
2. 典型的五种IO模型
这里所说的IO都是等事件就绪+拷贝数据。我们用钓鱼举例,钓鱼也是等鱼上钩+把鱼钓起来
2.1 阻塞IO
阻塞IO: 当发起一个IO调用的时候,如果IO资源没有准备好,则阻塞等待资源的到来。
eg: 当人们在钓鱼时,将鱼钩抛入水中,眼睛一直盯着鱼漂,直到有鱼咬钩。
阻塞IO的特点
- 一旦调用阻塞IO,阻塞等待的市场取决于内核
- 在等待过程中,对于等待的执行流而言效率是很低的
- 在等待数据就绪到拷贝数据之间,是非实时的
- 代码较简单
2.2 非阻塞IO
不断检测事件是否就绪,不管就绪与否都会直接返回,如果内核还未将数据准备好,系统调用接口会返回EWOULDBLOCK
错误码。
IO调用返回了,不代表IO操作成功了,需要程序员使用循环的方式反复读写文件描述符,这一过程叫做轮询,对资源消耗很高。
eg:钓鱼时,抛下鱼钩,不一直盯着鱼漂,而是时不时的看一眼,有鱼就吊起来,没有鱼就继续一会看一眼。
特点
- 非阻塞IO相较于阻塞IO而言,CPU利用率稍高
- 非阻塞IO由于增加了循环,非阻塞IO稍显复杂
- 非阻塞IO在数据就绪到拷贝之间,并没有阻塞IO实时
- 非阻塞IO需要搭配循环实现
2.3 信号驱动IO
等待信号到来,收到信号,就去拷贝数据。内核将数据准备好的时候,使用SIGIO
信号通知应用程序进行IO操作。
eg: 同样是钓鱼,放下鱼钩,同时在鱼漂上绑个铃铛,然后去干自己的事情,铃铛响了,过来把鱼取走,没响就继续干自己的事情。
特点
- 代码更加复杂,需要自定义信号处理函数,IO就绪后进行回调
- 相较于非阻塞IO而言,不需要循环调用,但是需要将IO调用放到回调函数中
- 相较于非阻塞IO而言,信号驱动IO在数据就绪到拷贝之间更加实时
2.4 IO多路转接
同时等待多个文件描述符,只要有一个就绪了,就去进行数据的拷贝。可以一次性监控多个文件描述符,当有某个文件描述符发送了期望的IO事件的时候,则就会通知程序员去拷贝数据。
接口select poll epoll
,这三个只负责IO中的等待过程,一次可以等待多个文件描述符。大大减少了等的比重,提高了效率。
eg: 钓鱼时,用很多鱼竿同时钓,不断去检查每个鱼竿是不是有鱼上钩,有鱼上钩就把鱼吊起来,那么他钓鱼成功的概率高了很多。
特点
- 效率高,一直在检测文件描述符是否发生就绪,等的比重减少了
- 一次等待多个文件描述符
适用场景:适用于长链接的场景,因为长链接会有大量的请求通信,并且大部分事件在等,多路转接会一直扫描这些请求
2.5 异步IO
由内核在数据拷贝完成时,通知应用程序,数据已经拷贝完成可以使用了。
当用户发起一个异步调用时,会注册一个回调函数,当程序等待到数据,并且完成拷贝之后,回调注册的函数,用户就相当于拿到了IO的请求。
还是钓鱼,但是钓鱼时,去找个人帮他钓鱼,自己不钓鱼,这个人怎么去钓鱼,我不管。到最后我只需要取钓到的鱼。
使用过程
1.发起一个IO调用,告诉操作系统内核,想要完成什么IO操作,IO调用直接返回,IO调用函数就调用完毕了,并没有陷入到阻塞当中。
2.内核针对用户提出的IO请求进行等待数据和拷贝数据。当内核等待到数据同时完成拷贝
3.向程序递交指定信号,用户就可以直接拿着数据进行操作了。
2.6 概念的区分
2.6.1 阻塞和非阻塞
只需要判断在资源不可用的情况下,发起的IO请求是否直接返回。
上述IO模型中,除了阻塞IO其它都是非阻塞IO。
- 阻塞: 进行死等,IO调用不返回。
- 非阻塞:直接报错返回
2.6.2 同步和异步
同步和异步关心的消息通信的机制。
- 同步: 就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
- 异步: 调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用.
通俗的讲: 同步和异步只需要判断,当发起一个IO调用之后,数据是由谁进行拷贝的。
数据用用户拷贝:同步
数据由内核拷贝:异步
注意区分这里的同步,不是多线程的同步,多线程的同步是为了保证执行流对资源的合理访问
3. 非阻塞IO接口
文件描述符默认都是阻塞的,所以我们要调整为非阻塞的方式去使用
对文件描述符进行设值
int fcntl(int fd, int 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).
void SetNoBlock(int fd) {
// 1.获取文件描述符状态
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
// 2.设置文件描述符为非阻塞
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
while()
{
轮询检测IO是否就绪,
}
}
4. IO多路转接—select
4.1 原理
程序员将多个文件描述以及期望的IO事件告知给select(交给内核处理),让内核轮询遍历文件描述符是否产生了程序员期望的IO事件。一旦发现由某个文件描述符就绪了(期望的IO事件发生了),则返回该文件描述符,让用户去执行相应的事件处理。
select是一个就绪事件的通知方案,高效体现在一次可以等待多个文件描述符。
4.2 函数接口
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
返回值:
> 0 文件描述符就绪的个数
= 0 等待超时,没有文件描述符iuxu
= -1 等待失败,发生错误
从文件描述符集合中,移除某个文件描述符
void FD_CLR(int fd, fd_set *set);
判断fd是否在事件集合中
int FD_ISSET(int fd, fd_set *set);
将fd放入事件集合
void FD_SET(int fd, fd_set *set);
将文件描述符集合清空
void FD_ZERO(fd_set *set);
4.2.1 参数详解
nfds
: 轮询遍历监控文件描述符的返回,传递参数应该为:所等待文件描述中最大的文件描述符值+1
readfds
:读事件集合 readfds
:写事件集合 exceptfds
:异常事件集合
以上三个参数既是入参又是出参,参数类型是fd_set
文件描述符集合。用户告诉操作系统需要关心指定范围的文件描述符,操作系统告诉用户哪些文件描述符就绪或者发生了异常。
timeout
: 秒和微妙构成的时间结构体。
timeout = 0 表示非阻塞等待,轮询一次,不管有没有文件描述符就绪,都会调用返回
timeout > 0 表示等待了多少秒,结束后直接返回,由返回值区分是否有文件描述符就绪,在时间内有文件描述就绪,直接返回。超时也直接返回
NULL,阻塞等待。
4.2.2 fd_set
本质是一个结构体
使用方法
即使输入型参数,又是输出型参数。输入时使用户想告诉操作系统,输出时是操作系统告诉用户。哪些文件描述符就绪了。
注意: 因为fd_set 是输入输出型参数,那么每次操作系统告诉用户时,只会返回就绪的文件描述符,将未就绪的文件描述符从集合中去掉,就导致了下一次还需要重新添加文件描述符集合
4.2.3 select监控事件集合的过程
4.3 总结
4.3.1 读写就绪情况
读就绪
- socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
- socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
- 监听的socket上有新的连接请求;
- socket上有未处理的错误;
写就绪
- socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
- socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号;
- socket使用非阻塞connect连接成功或失败之后;
- socket上有未读取的错误;
4.3.2 特点
可监控的文件描述符个数取决与sizeof(fd_set)的值. 比如说某台机器上sizeof(fd_set)=512,每bit表示一个文件描述符,则这台机器上上支持的最大文件描述符是512*8=4096.
将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,
- 一是用于再select 返回后,array作为源数据和fd_set进行FD_ISSET判断。
- 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
4.3.3 优缺点
优点
- select只会等,是一种就绪事件通知方式
- select遵循的时POSIX标准,可以跨平台
- select超时时间可以精确到微秒
缺点
- select所能等待的链接数是有上限的,即所能承受的最大连接是由上线的,上限由内核参数
FD_SETSIZE决定
,进程本身的文件描述符也是由上线的,一般为32个,但是每一个进程还含有一个扩展文件描述符表- select在监控成功之后,在返回给用户的事件集合当中,会将为就绪的文件描述符去掉,导致二次监控时需要重新添加去除掉的文件描述符很麻烦
- select在监控成功之后,给用户返回的是一个事件集合,并没有直接告诉用户哪一个文件描述符就绪了,需要用户自己检测哪个描述符就绪
- selct采用轮询遍历的方式进行监控,随着文件描述符越来越多,轮询的效率会降低。
- 每次调用select,都需要把fd从用户态拷到内核态,开销大
5. IO多路转接—poll
同样也是一个就绪通知方案,但是相较于select来说,poll能等待的文件描述符是没有上限的,不用每次都重新设置fd集合。
但是地位不如select,因为不能跨平台。而且性能不如epoll。
5.1 接口函数
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
返回值:
> 0 监控成功 返回就绪的个数
= 0 监控超时
< 0 监控失败
poll函数是通过用户传入事件结构数组进行监控的,如果需要监控一个文件描述符,则需要组织一个文件描述符传递给poll。如果需要监控多个文件描述符,则需要组织多个事件结构,放到数组中,传递给poll。
5.2 参数详解
struct pollfd *fds
其中events
,用户告诉内核,用户想要内核等对应文件描述符的事件。可以一次设置多个事件,按位与多个事件,实际就是使用位图的方式进行组织。
其中revents
内核告诉用户对应的文件描述符已经就绪了。
nfds
事件结构体数组中的有效元素个数,指定poll函数轮询遍历的范围
timeout
毫秒级
5.3 总结
优点
1.poll函数引入了事件结构数组的方式,对需要内核监控的文件描述符个数是没有限制的。用户想要监控多少个文件描述符,只需要组织多大的数组即可。
2.与select对比,当一次监控成功之后,下一次再进行监控的时候,不需要重新添加文件描述符
3.用户如果关心一个文件描述符的多种事件,可以直接在同一个事件结构体当中表示。
缺点
1.poll在监控事件结构数组的时候,也是采用轮询的方式进行监控,随着监控的事件结构增多,监控轮询的效率下降
2.用户与内核互相拷贝数据时,如果数组过大,拷贝的数据量就会很大,效率会变低。
3.poll不支持跨平台
6. IO多路转接—epoll
epoll是当今性能最高的多路转接IO模型。不支持跨平台。具备上述两种多路转接的所有优点。
6.1 接口函数
6.1.1 epoll_create
调用时会在内核层面创建一个epoll模型
int epoll_create(int size);
参数
size
: 忽略处理,一般不用设置,没有实际含义,其底层采用扩容的方式
返回值
返回epoll的操作句柄,本质上就是在内核当中创建struct eventpoll{}结构体,程序员通过操作句柄就可以找到eventpoll结构体,从而实现操作
6.1.2 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数
epfd
:epoll操作句柄,epoll创建出来的epoll模型
op
:告诉epoll_ctl函数做什么操作,表示对第三个参数要做的事情
- EPOLL_CTL_ADD: 向epoll当中添加想要监控的文件描述符的事件结构
- EPOLL_CTL_MOD: 修改已经注册过的fd监听事件
- EPOLL_CTL_DEL: 从epfd中删除一个fd
fd
: 要操作的文件描述符
event
: 所监听的事件结构体
6.1.2 epoll_wait
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
参数
epfd
: epoll操作句柄,创建出来的epoll模型
events
: 拷贝就绪事件结构数组,出参,从epoll双向链表中拷贝就绪事件到用户空间events事件数组中
maxevents
: 告之用户内核的这个events有多大,最大可以拷贝多少个事件结构
timeout
超时事件
6.2 工作原理
中断: 外设通过CPU的针脚发送某些中断,所以外设能够识别外设发送的信息。操作系统如何直到网络中有数据发来,网卡是一种外设,当有数据传输过来,会发出中断进行相应处理。
原理
采用了两种结构进行存储, 双向链表用于存放已经就绪的文件描述符,红黑树用于存放需要监控的文件描述符。
epoll通过监控红黑树来判断事件结构中对应的事件有没有就绪,在监控过程中发现某个事件结构就绪,就会将该事件结构拷贝至双向链表。红黑树效率为O(log n)
函数具体功能
当有很多事件需要被检测时,系统必然会轮询检测,轮询会降低工作效率,但是通过给各个事件注册一个回调方法,那么系统就不用轮询检测了,当事件就绪时就会调用这个方法。
epoll_create
1.调用epoll_create会在内核中建立一颗红黑树,节点中存放的时文件描述符与对应的需要关心的事件,fd作为红黑树键值
2.建立回调机制,在操作系统底层,使用驱动层功能,完成某些回调功能
3.创建就绪队列,当底层有事件就绪时,回调机制执行回调方法生成一个新的结点,将这个结点放到就绪队列中
epoll_ctl
添加、删除、修改红黑树的结点,创建或删除对应结点事件的回调函数
epoll_wait
检测就绪队列中是否有结点,有则从就绪队列的头取出结点
6.3 工作方式
6.3.1 LT水平触发方式
epoll的默认工作方式就是LT
当事件就绪后,就会一直通知一直通知,直到程序员将该救出事件处理了
接收数据,如果一次没有把数据接受完,那么还会进行通知,直到数据全部处理完。
特点
- 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
- 如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait仍然会立刻返回并通知socket读事件就绪.
- 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
- 支持阻塞读写和非阻塞读写
6.3.2 ET边缘触发方式
当事件就绪后,只会通知一次,不关心程序员是否处理了该事件,直到有新的就绪事件到来之后,再去通知一次。
注意:
因为ET模式下,只会通知一次,所以要一次将数据全部读完,否则就会造成数据的丢失。
ET模式和非阻塞文件描述符
一次将数据读取完,就必须用到循环读取的方式,直到最后一次读取的数据量小于期望读到的数据量,则代表数据读取完成。
如果最后一次恰好把数据读取完了,缓冲区没有数据,则read写一次就会进入阻塞状态,所以再ET模式下必须将fd设置为非阻塞,可以使用read的返回值判断是否读取完成,返回值为-1时表示读取完成了,但是同样也必须注意errno,errno为EAGIN和EWOULDBLOCK时属于正常读到结尾退出。
如何将epoll设置为ET模式
1.ev.events = 关心的事件类型 | EPOLLET
2.将所有文件描述符设置为非阻塞状态
3.将recv事件进行重新封装,循环读取或写入,一次将数据全部读完或写进去
6.3.3 效率比较
ET比LT更加高校
1.ET没有多余通知,只会通知一次
2.可以将读事件作为ET模式,读数据时要一次性将数据全部读取,并且将该文件描述符设置为非阻塞模式
6.3.4 问题 ET模式和TCP粘包如何考虑?
TCP粘包:可以通过自定制协议解决
epoll ET模式:再通知一次读事件之后,将数据一次性从接收缓冲去中读出来。应用层缓冲区,将读回来的数据全部都放在缓冲区当中,通过报头与分隔符区分一条消息是否接收完毕,接收完毕则直接进行处理,如果没有接收完毕就等待应用层缓冲区数据接收完毕后进行处理
6.4 epoll代码
#include "Sock.hpp"
#include <sys/epoll.h>
#include <cstring>
#define SIZE 64
// 每一个节点对应一个Bucket
class Bucket
{
public:
char buffer[16]; // request
int pos; // 记录这次读取内容的位置,方便下次继续读取
int fd;
Bucket(int sock)
: fd(sock)
, pos(0)
{
memset(buffer, 0, sizeof(buffer));
}
};
class EpollServer
{
private:
int lsock;
int port;
int epfd; // epoll模型的文件描述符
public:
EpollServer(int _p = 8080)
: port(_p)
{}
void InitServer()
{
lsock = Sock::Socket();
Sock::SetSockopt(lsock);
Sock::Bind(lsock, port);
Sock::Listen(lsock);
// 创建epoll模型
epfd = epoll_create(256);
if (epfd < 0)
{
std::cerr << "epoll_create err..." << std::endl;
exit(5);
}
}
// 将套接字添加至epoll模型,用户告诉操作系统需要关心哪些操作符的哪些事件
void AddEvents2Epoll(int sock, uint32_t event)
{
struct epoll_event ev;
ev.events = event;
if (sock == lsock)
{
// 监听套接字
ev.data.ptr = nullptr;
}
else
{
ev.data.ptr = new Bucket(sock);
}
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);
}
// 当事件处理完成之后(数据读完并且发送出去之后),删除掉监听的该事件
void DelEventFromEpoll(int sock)
{
close(sock);
// 将节点从红黑树中取出,不再监听
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
}
// 事件处理函数
void HandlerEvents(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++)
{
uint32_t ev = revs[i].events; // 就绪事件的内容
if (ev & EPOLLIN)
{
// read
if (revs[i].data.ptr != nullptr)
{
// 普通事件 已经就绪
Bucket *bp = (Bucket*)revs[i].data.ptr; // 取出普通文件描述符中的事件
// 读事件
ssize_t s = recv(bp->fd, bp->buffer + bp->pos, sizeof(bp->buffer) - bp->pos, 0);
if (s > 0)
{
// 协议的处理 判断读到的内容是否读取完成
bp->pos += s; // 从上次的位置继续读
std::cout << "client# " << bp->buffer << std::endl;
// 检测数据是否接收够了
if (bp->pos >= sizeof(bp->buffer))
{
// 数据接收够了 就要将数据发送出去
// 发送时该文件描述符就不要监听读就绪了,而是监听写就绪
struct epoll_event tmp;
tmp.events = EPOLLOUT;
tmp.data.ptr = bp;
epoll_ctl(epfd, EPOLL_CTL_MOD, bp->fd, &tmp);
}
}
else if (s == 0)
{
// 客户端关闭链接 服务器断开链接 取消监听时间
std::cout << "client colse" << std::endl;
DelEventFromEpoll(bp->fd);
delete bp;
}
else
{
}
}
else
{
// 说明是监听套接字就绪
int sock = Sock::Accpet(lsock);
if (sock > 0)
{
// 将监听事件添加到监听列表中,继续检测监听套接字
AddEvents2Epoll(sock, EPOLLIN);
}
}
}
else if (ev & EPOLLOUT)
{
// write
// 数据读够之后,读事件变成了写事件
// 将就绪数据发送出去
Bucket* bp = (Bucket*)revs[i].data.ptr;
// 如果上方数据没有读够就发出去,不行
// 定义pos位置....未完
send(bp->fd, bp->buffer, sizeof(bp->buffer), 0);
// 数据发送完成 关闭链接 取消监听该文件描述符
DelEventFromEpoll(bp->fd);
delete bp;
}
else
{
// other events
}
}
}
void EpollServerStart()
{
// 将lsock添加只epoll模型,监听套接字只关心读事件
AddEvents2Epoll(lsock, EPOLLIN);
int timeout = 1000;
// 操作系统告诉用户哪些事件已经就绪的机构体
struct epoll_event revs[SIZE];
while (true)
{
// 调用epoll模型进行等待, 将就绪的事件通过revs告诉用户
// 返回值代表有多少事件就绪,如果就绪事件超过缓冲区大小,则下一次继续将没有放入缓冲区的事件放进去
// 如何判断哪些文件描述符已经就绪, 会将就绪的事件从0下标一次放入revs中,放入num个
int num = epoll_wait(epfd, revs, SIZE, timeout);
switch(num)
{
case 0:
std::cout << "time out ..." << std::endl;
break;
case -1:
std::cerr << "epoll_wait err..." << std::endl;
break;
default:
HandlerEvents(revs, num);
break;
}
}
}
~EpollServer()
{
close(lsock);
close(epfd);
}
};
以上是关于[Linux] 典型IO模型与多路转接IO模型的主要内容,如果未能解决你的问题,请参考以下文章