Linux下五种IO模型及性能分析

一、概念理解

1.同步通信VS异步通信

     同步和异步关注的是消息通信机制。

同步:

所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。

异步:

      异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态。通知来通知调用者,或通过回调函数处理这个调用。

 

另外,这里的同步通信和异步通信和进程线程中的同步和互斥是完全不相干的概念。

      进程/线程同步也是进程/线程之间的直接制约关系。是为完成某种任务而建立的两个过多个进程/线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系,尤其是在访问临界资源的时候。

 

阻塞VS非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。

     阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。

     非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

总结来说:

1.同步:就是我调用一个功能,该功能没有结束前,我死等结果。

2.异步:就是我调用一个功能,不需要知道该功能结果,该功能 有结果后通知我(回调通知)

3.阻塞:就是调用函数,函数没有接收完数据或者没有得到结果之前,我不会返回。

4.非阻塞:就是调用函数,函数立即返回,通过select通知调用者

同步IO和异步IO的区别就在于:数据拷贝时候进程是否阻塞。

阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回。

例如:

1.打印日志:用非阻塞异步IO的方式;优点:性能高,缺点:不稳定

2.数据统计:用同步阻塞IO的方式。

 

二、Linux下五种IO模型:

1.阻塞IO模型:在内核将数据准备好之前,系统调用会一直等待,所有的套接字,默认都是阻塞方式。

 

阻塞I/O模型图:在调用recv()/recvfrom()函数时,发生在内核中等待数据和复制数据的过程。

 Linux下五种IO模型及性能分析

当调用recv()函数时,系统首先查是否有准备好的数据。如果数据没有准备好,那么系统就处于等待状态。当数据准备好后,将数据从系统缓冲区复制到用户空间,然后该函数返回。在套接应用程序中,当调用recv()函数时,未必用户空间就已经存在数据,那么此时recv()函数就会处于等待状态。

 

2.非阻塞IO模型:如果内核还未将数据准备好,系统调用仍然会直接返回(多次系统调用,并马上返回),并且返回EWOULDBLOCK错误码;

  非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费。

阻塞I/O模型图:

 

3.信号驱动IO模型:内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。

 

信号驱动IO模型图:

 Linux下五种IO模型及性能分析

 

4.IO多路转接:看起来跟阻塞IO模型类似,实际上在于IO多路转接能够同时等待多个文件描述符的状态。

 

IO多路转接模型图:

Linux下五种IO模型及性能分析

 

5.异步IO模型:由内核在数据拷贝完成时,通知应用程序。

异步IO模型图:

 Linux下五种IO模型及性能分析

总结:在任何IO过程中,都包含两个步骤,第一是等待,第二是拷贝,而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间,让IO更高效,最核心的办法就是等待的时间尽量少。

 

5IO模型的比较:

Linux下五种IO模型及性能分析

三、fcntl、dup/dup2

1.fcntl//一个文件描述符,默认都是阻塞IO。

函数原型如下:

#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ ); 

传入的cmd的值不同,后面追加的参数也不同。

fcntl函数有5种功能:
复制一个现有的描述符(cmd=F_DUPFD) .
获得/设置文件描述符标记(cmd=FGETFD或FSETFD).
获得/设置文件状态标记(cmd=FGETFL或FSETFL).
获得/设置异步I/O所有权(cmd=FGETOWN或FSETOWN).
获得/设置记录锁(cmd=FGETLK,FSETLK或F_SETLKW)

fcntl应用:(轮询方式读取标准输入)

Linux下五种IO模型及性能分析 

2.dup/dup2系统调用(重定向)

函数原型:

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd); 

使用dup将标准输出重定向到文件中。

 Linux下五种IO模型及性能分析

使用dup2将标准输出重定向到文件中。

Linux下五种IO模型及性能分析 

四、selectpollepoll简介

1.select函数来实现多路复用输入/输出模型

     · select系统调用是用来让我们的程序监视多个文件描述符的状态变化的。

     · 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变

函数原型:

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

参数解释:
·参数nfds是需要监视的最大的文件描述符值+1;
· rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合;
·参数timeout为结构timeval,用来设置select()的等待时间
参数timeout取值:
·NULL:则表示select()没有timeout, select将一直被阻塞,直到某个文件描述符上发生了事件;
·0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
·特定的时间值:如果在指定的时间段里没有事件发生, select将超时返回

关于fd_set结构

其实就是一个整数数组,更严格的说,是一个“位图”。使用位图中对应的位来表示要监视的文件描述符。

提供了一组操作fd_set的接口,来比较方便的操作位图。

void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位

函数返回值:
·执行成功则返回文件描述词状态已改变的个数
·如果返回0代表在描述词状态改变前已超过timeout时间,没有返回

· 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds, writefds, exceptfds和timeout的值变成不可预测。
·错误值可能为: * EBADF 文件描述词为无效的或该文件已关闭 * EINTR 此调用被信号所中断 * EINVAL
·参数n 为负值。 * ENOMEM 核心内存不足

select的特点:

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

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

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

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

select的缺点:

· 每次调用select, 都需要收动设置fd集合, 从接口使用角度来说也非常不便.
·每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
·同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
·select支持的文件描述符数量太小

2. poll

函数原型:

#include <poll.h>
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数组的长.
·timeout表示poll函数的超时时间, 单位是毫秒(ms).
events和revents的取值:

事件

描述

是否可作为输入

是否可作为输出

POLLIN

数据(包括普通数据和优先数据)可读

POLLRDNORM

普通数据可读

POLLRDBAND

优先级带数据可读(Linux不支持)

POLLPRI

高优先级数据可读,比如TCP带外数据

POLLOUT

数据(包括普通数据和优先数据)可写

POLLWRNORM

普通数据可写

POLLWRBAND

优先级带数据可写

POLLRDHUP

TCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入

POLLERR

错误

POLLHUP

挂起。比如管道的写端被关闭后,该端描述符上将收到POLLHUP事件

POLLNVAL

文件描述符没打开


返回结果
·返回值小于0, 表示出错;
·返回值等于0, 表示poll函数等待超时;
·返回值大于0, 表示poll由于监听的文件描述符就绪而返回

poll的优点: 

·不同与select使用三个位图来表示三个fdset的方式, poll使用一个pollfd的指针实现.
· pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比select更方便.
·poll并没有最大数量限制 (但是数量过大后性能也是会下降)

poll的缺点:

·poll中监听的文件描述符数目增多时
·select函数一样, poll返回后,需要轮询pollfd来获取就绪的描述符.
· 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
· 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长,其效率也会线性下降 .

3.epoll

epoll 有3个相关的系统调用

(1)epoll_create 

int epoll_create(int size);
创建一个epoll的句柄.
·自从linux2.6.8之后, size参数是被忽略的.
·用完之后, 必须调用close()关闭.

(2)epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数.
·它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.
·第一个参数是epoll_create()的返回值(epoll的句柄).
·第二个参数表示动作,用三个宏来表示.
·第三个参数是需要监听的fd.
·第四个参数是告诉内核需要监听什么事.
第二个参数的取值:
·EPOLL_CTL_ADD :注册新的fd到epfd中;
·EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
·EPOLL_CTL_DEL :从epfd中删除⼀个fd;
struct epoll_event结构如下 :

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 */

   };

events可以是以下几个宏的集合:
·EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
·EPOLLOUT : 表示对应的文件描述符可以写;
·EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
·EPOLLERR : 表示对应的文件描述符发⽣错误;
·EPOLLHUP : 表示对应的文件描述符被挂断;
·EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
·EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里。

(3)epoll_wait:

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

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

epoll的使用过程就是三部曲:
·调用epoll_create创建一个epoll句柄;
·调用epoll_ctl, 将要监控的文件描述符进行注册;
·调用epoll_wait, 等待文件描述符就绪

epoll的优点

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

总结:

综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。

1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

 

五、epoll工作方式

epoll有两种工作方式-水平触发(LT)和边缘触发(ET)

假如有这样一个例子:
·我们已经把一个tcp socket添加到epoll描述符这个时候socket的另一端被写入了2KB的数据
·调用epoll_wait,并且它会返回. 说明它已经准备好读取操作
·然后调用read, 只读取了1KB的数据
·继续调用epoll_wait.....

1.水平触发Level Triggered 工作模式
epoll默认状态下就是LT工作模式.
·epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
·如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时,epoll_wait 仍然会立刻返回并通知socket读事件就绪.
·直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
·支持阻塞读写和非阻塞读写

2.边缘触发Edge Triggered工作模式
如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.
·epoll检测到socket上事件就绪时, 必须立 刻处理.
· 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候, epoll_wait 不会再返回了.
·也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
·ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
·只支持非阻塞的读写

3.理解ET模式非阻塞读写的原因 

ET(边缘触发)数据就绪只会通知一次,也就是说,如果要使用ET模式,当数据就绪时,需要一直read,直到出错或完成为止.但倘若当前fd为阻塞(默认),那么在当读完缓冲区的数据时,如果对端并没有关闭写端,那么该read函数会一直阻塞,影响其他fd以及后续逻辑.所以此时将该fd设置为非阻塞,当没有数据的时候, read虽然读取不到任何内容,但是肯定不会被hang住,那么此时,说明缓冲区数据已经读取完毕,需要继续处理后续逻辑(读取其他fd或者进行wait)