[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模型的主要内容,如果未能解决你的问题,请参考以下文章

[Linux] 典型IO模型与多路转接IO模型

典型I/O模型——阻塞IO,非阻塞IO,信号驱动IO,异步IO,IO多路转接(select&poll&epoll)

五种IO模型与多路转接

五种IO模型与多路转接

五种IO模型与多路转接

五种高阶IO模型以及多路转接技术(selectpoll和epoll)及其代码验证