欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

五种IO模型

程序员文章站 2022-06-14 11:00:12
...

高级IO模型共有五种:

  • 阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式。
  • 非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码    -----  非阻塞IO一般需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一般只有特定场景下才使用。
  • 信号驱动IO:内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作。
  • IO多路转接: 表面看起来与阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。
  • 异步IO: 由内核在数据拷贝完成时, 通知应用程序(信号驱动是告诉应用程序何时可以开始拷贝数据).

五种IO模型的比较

五种IO模型

任何IO过程都包含了两个步骤:等待和拷贝。而一般情况下,等待消耗的时间往往远大于数据拷贝的时间。那么如果我们减少了等待的时间,那么IO过程小号的时间就会更少。

同步通信和异步通信:  这里一定也要注意与之前在进程和线程处的同步和互斥理解错误

同步:在系统发出一个调用时,在没有得到响应的结果之前,这个调用不会返回,一旦这个调用返回,那么一定得到了返回值。也就是由调用者来等待这调用的返回结果。

异步:在系统发出调用以后,这个调用就直接返回,并不会带回来这个调用的返回结果。被调用者通过状态、通知来通知调用者,或者通过回调函数来处理这个调用。

这里我们重点讨论IO多路转接:

讨论之前,我们先联想这样一种情况----当我们的程序要从多个文件描述符读取数据时,我们应该怎么办呢?

        如果采用之前学习过的知识采用多进程或者多线程,那么我们就不得不考虑进程和线程的终止、同步互斥。

        另一个方法是继续采用一个进程,但是采用非阻塞的IO来进行数据的读取。基本方法是将多个输入的文件描述符设置为非阻塞,对第一个描述符发出一个read,如果该输入上有数据,那么就读取数据并处理它。然后对后面的文件描述符做同样的处理。然后等待若干秒后,对这些描述符进行轮询。这种方法的缺点是浪费CPU的时间。因为大多数时间这些文件描述是无数据可读的,但是仍然要花费大量的时间反复的执行read系统调用。

        有一种方法是异步IO,其基本思想是告诉内核,当前描述符已经准备好了,可以进行IO操作,用一个信号通知它。这种技术往往存在两个问题。第一,并非所有的系统都支持这种机制。第二,这种信号对于每个进程来说只有一个,当这个信号到达时,进程无法判断是哪个描述符准备好IO了。

        一种比较好的技术就是IO多路转接。先构造一张有关文件描述符的列表,然后调用一个函数,直到这些描述符中一个已经准备好进行IO时,这个函数才返回。在返回时,它告诉进程哪些文件描述符已经准备好可以进行IO。

在以下几种情况下,网络程序需要使用I/O多路转接。

  • 客户端程序要同时处理多个socket。
  • 客户端程序要同时处理用户输入金额网络连接。
  • TCP服务器需要同时监听socket和链接socket。----这是IO多路转接使用最多的场合
  • 服务器要同时处理TCP请求和UDP请求。
  • 服务器摇头挺熟监听多个端口,或者处理多种服务。

需要注意的是,IO多路转接技术虽然能监听多个文件描述符,但是它本身是阻塞的,并且当多个文件描述符同时就绪时,如果不采用额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作。如果要实现并发,还需要采用多进程和多线程等手段。

select:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件。

 int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

返回值:成功准备就绪的文件描述符,若超时则返回0,若出错则返回-1

nfds:指定被监听的文件描述符的总数。它通常被设置为select监听的所有文件描述符中最大值加1因为文件描述符是从0开始计数的。

readfds、writefds、exceptfds 分别表示可读、可写和异常等事件对应的文件描述符的集合。进程调用select函数时,通过这三个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知进程哪些文件描述符已经就绪。

这三个参数都是fd_set结构体指针类型,fd_set结构体仅包含一个整形数组,每个元素的每一位(bit)标记一个文件描述符

由于位操作过于繁琐,我们应该使用下面一系列宏来访问fd_set结构体中具体的位。

void FD_CLR(int fd, fd_set *set);
int  FD_ISSET(int fd, fd_set *set);       
void FD_SET(int fd, fd_set *set);       
void FD_ZERO(fd_set *set);

timeout:指定slect函数的超时时间,它是一个timeval类型的结构体指针。

timeval == NULL表示永远等待。

timeout->tv_sec == 0 && timeout->tv_usec == 0   表示完全不等待

timeout->tv_sec != 0 || timeout->tv_usec != 0 指定等待的秒数和微秒数

 struct timeval {
        long    tv_sec;         /* seconds */
        long    tv_usec;        /* microseconds */
};


struct timespec {
        long    tv_sec;         /* seconds */
        long    tv_nsec;        /* nanoseconds */
};

常见的程序片段为

fs_set readset;
FD_SET(fd,&readset);
select(fd+1,&readset,NULL,NULL,NULL);
if(FD_ISSET(fd,readset))
{
    //do_something
}

读就绪:

  • socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0。
  • socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0。
  • 监听的socket上有新的连接请求。
  • socket上有未处理的错误。

写就绪:

  • socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0。
  • socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号。
  • socket使用非阻塞connect连接成功或失败之后。
  • socket上有未读的错误。

异常就绪:

  • socket上接收到外带数据

select的特点:

可监控文件描述符取决于sizeof(fdset)的值。

将fd加入select监控集的同时,还需要一个使用一个数据结构array来保存放到select监控集中的fd。

         一是用于在select 返回后,array作为源数据和fdset进行FDISSET判断。

         二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描arr扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

select的缺点:

每次调用select都要手动设置fd集合,从接口使用角度来说也不方便。

每次调用select都要把fd集合从用户态拷贝到内核态,在fd数量较多时系统开销会变得相当大。

每次调用select都要在内核遍历传过来的所有fd,在fd数量较多时系统开销会比较大。

select所支持的文件描述符的数量太少。

select 实现回显服务器:  select版本的回显服务器

poll:用法类似于select。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

// pollfd结构
struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events 
    short revents; /* returned events */
};

fds:poll函数监听的结构体列表,每个元素中包含了三个部分的内容,文件描述符,监听的事件集合,返回的事件集合。

nfds:表示fds数组的长度。

返回值:成功返回准备就绪的描述符数,超时返回0,出错返回-1。

socket就绪的条件:    同select。

poll的优点:

        1.poll不会为每个状态(读、写、异常)都设置一个描述符集,而是构造一个pollfd数组,每个数组元素指定一个描述符编号及其所关心的状态。接口使用起来比select更方便。

        2.poll没有最大socket数量限制。(但是数量过多性能也会下降)

poll的缺点:

        1.和select函数一样,poll返回之后,需要轮询pollfd来获得就绪的文件描述符。

        2.每次调用poll都要把大量的pollfd从用户态拷贝到内核中。

        3.同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长,其效率也会线性下降

poll实现简单的回显服务器:epoll版本的回显服务器

epoll:为了处理大量句柄而做了改进的epoll。它时公认的Linux2.6下最好的多路I/O就绪通知方法。

epoll三个系统调用:

1. int epoll_create(int size);

        创建一个epoll的句柄。自从linux2.6.8之后,size参数是被忽略的。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

        epoll的事件注册函数,它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

  • 第一个参数是epoll_create()的返回值(epoll的句柄)
  • 第二个参数指定操作类型,用三个宏来表示
EPOLL_CTL_ADD:注册新的fd到epfd中;

EPOLL_CTL_MOD:修改已经注册的fd的监听事件;

EPOLL_CTL_DEL:从epfd中删除一个fd;
  • 第三个参数是需要监听的fd
  • 第四个参数是告诉内核需要监听什么事
//保存触发事件的某个文件描述符相关的数据(与具体使用方式有关)
typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;
//感兴趣的事件和被触发的事件
struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
};

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

       收集在epoll监控的事件中已经发送的事件底层使用红黑树实现的,是一颗空的红黑树,之后的添加,修改,删除有epoll_ctl维护,红黑树节点中中存放的是文件描述符及其所关心的事件还会创建一个队列——就绪队列:都就绪事件的有序结点。时间复杂度O(1)

       创建好epoll模型之后,底层事件一旦就绪,底层操作系统以回调方式**红黑树中特定文件描述符的结点,(通知上层对应的文件描述符的事件已经发生,) **:底层有数据就绪,将该节点及其所关心的事件生成一个新结点放入就绪队列中。

  • 参数events是分配好的epoll_event结构体数组.
  • epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
  • maxevents告之内核这个events有多大,这个maxevents的值不能用于创建epoll_create()时的size。
  • 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
  • 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数调用失败.

epoll的工作原理:

        epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。

         另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速**这个文件描述符,当进程调用epoll_wait()时便得到通知。

epoll的工作方式:  默认的是水平触发(LT)模式。

一个典型的场景是:

1.把一个tcp socket添加到epoll描述符
2.这个时候socket的另一端被写入了2KB的数据
3.调用epoll_wait,并且它会返回. 说明它已经准备好读取操作
4.调用read,只读1KB的数据
5.继续调用epoll

水平触发(LT):高效的poll。epoll_wait检测到文件描述符上有时间发生并将此事件通知给应用程序后,应用程序可以不立即处理该事件,这样,当程序下一次调用epoll_wait的时候,epoll_wait还会向应用程序通知这个事件,直到该事件被处理。

当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
    如上述的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait时,poll_wait仍然会立刻返回,并通知socket读事件就绪。
    直到缓冲区上的数据被全部读取,epoll_wait才不会立刻返回。
    支持阻塞读写和非阻塞读写。

边缘触发(ET):当epoll_wait检测到文件描述符上有事件发生并将此事件告知应用程序后,应用程序必须立即处理该事件。因为后续的epoll_wait调用将不再向应用程序通知这个事件。ET模式很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。

当epoll检测到socket上事件就绪时, 必须立刻处理
    如上述的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用epoll_wait的时候,epoll_wait不会再返回了.
    在ET模式下文件描述符上的时间就绪后就只有一次处理机会。
    ET模式的性能比LT模式性能更高(epoll_wait返回次数减少了许多),Nginx默认采用ET模式的epoll
    只支持非阻塞读写。

 

epoll的优点:

  • 文件描述符数目无上限: 通过epoll_ctl()来注册一个文件描述符, 内核中使用红黑树的数据结构来管
    理所有需要监控的文件描述符.(对比select的有限个)。
  • 基于事件的就绪通知方式: 一旦被监听的某个文件描述符就绪, 内核会采用类似于callback的回调机制,迅速**这个文件描述符. 这样随着文件描述符数量的增加, 也不会影响判定就绪的性能;(对比poll的大量文件描述符时的低效率)
  • 维护就绪队列: 当文件描述符就绪, 就会被放到内核中的一个就绪队列中. 这样调用epoll_wait获取就绪文件描述符的时候, 只要取队列中的元素即可, 操作的时间复杂度是O(1)。
  • 内存映射机制: 内核直接将就绪队列通过mmap的方式映射到用户态. 避免了拷贝内存这样的额外性
    能开销。

epoll的使用场景:epoll的高性能,具有一定的使用场景,如果场景选择错误,epoll的性能可能适得其反。

        对于多连接,且多连接中只有一部分链接比较活跃的时候,epoll的性能提升最为明显。

        典型的例子:一个需要处理上万个客户端的服务器;各种互联网APP的入口服务器。。

epoll使用的一般过程:

1.调用epoll_create() 创建一个epoll句柄,使用结束要记得关闭这个句柄。

2.调用epoll_ctl()将要监控的文件描述符进行注册

3.调用epoll_wait()等待文件描述符就绪。

epoll实现简单的回显服务器(LT模式):epoll版本的回显服务器