漫谈五种IO模型(主讲IO多路复用)

首先引用levin的回答让我们理清楚五种IO模型
因为熊吉_22cb提出levin的回答有点不知所云~
我对下面的回答进行了小部分的修改,有哪里错误的还望大家指出~

1.阻塞I/O模型
老李去火车站买票,排队三天买到一张退票。
耗费:在车站吃喝拉撒睡 3天,其他事一件没干。

2.非阻塞I/O模型
老李去火车站买票,隔12小时去火车站问有没有退票,三天后买到一张票。耗费:往返车站6次,路上6小时,其他时间做了好多事。

3.I/O复用模型
1.select/poll
老李去火车站买票,委托黄牛,然后每隔6小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票。
耗费:打电话
2.epoll
老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。
耗费:无需打电话

4.信号驱动I/O模型
老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李,然后老李去火车站交钱领票。
耗费:无需打电话

5.异步I/O模型
老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门。
耗费:无需打电话

1. I/O多路复用

1.1 它的形成原因

如果一个I/O流进来,我们就开启一个进程处理这个I/O流。那么假设现在有一百万个I/O流进来,那我们就需要开启一百万个进程一一对应处理这些I/O流(——这就是传统意义下的多进程并发处理)。思考一下,一百万个进程,你的CPU占有率会多高,这个实现方式及其的不合理。所以人们提出了I/O多路复用这个模型,一个线程,通过记录I/O流的状态来同时管理多个I/O,可以提高服务器的吞吐能力

1.2 通过它的英文单词来理解一下I/O多路复用

I/O multiplexing 也就是我们所说的I/O多路复用,但是这个翻译真的很不生动,所以我更喜欢将它拆开,变成 I/O multi plexing
multi意味着多,而plex意味着丛(丛:聚集,许多事物凑在一起。),那么字面上来看I/O multiplexing 就是将多个I/O凑在一起。就像下面这张图的前半部分一样,中间的那条线就是我们的单个线程,它通过记录传入的每一个I/O流的状态来同时管理多个IO。

multiplexing

1.3 I/O多路复用的实现
I/O多路复用模型

我们来分析一下上面这张图

  1. 当进程调用select,进程就会被阻塞
  2. 此时内核会监视所有select负责的的socket,当socket的数据准备好后,就立即返回。
  3. 进程再调用read操作,数据就会从内核拷贝到进程。

其实多路复用的实现有多种方式:select、poll、epoll

1.3.1 select实现方式

先理解一下select这个函数的形参都是什么

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

  • nfds:指定待测试的描述子个数
  • readfds,writefds,exceptfds:指定了我们让内核测试读、写和异常条件的描述字
  • fd_set:为一个存放文件描述符的信息的结构体,可以通过下面的宏进行设置。

void FD_ZERO(fd_set *fdset);
//清空集合
void FD_SET(int fd, fd_set *fdset);
//将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset);
//将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset);
// 检查集合中指定的文件描述符是否可以读写

  • timeout:内核等待指定的描述字中就绪的时间长度
  • 返回值:失败-1 超时0 成功>0
#define FILE "/dev/input/mouse0"
int main(void)
{
 int fd = -1;
 int sele_ret = -1;
 fd_set Fd_set;
 struct timeval time = {0};
 char buf[10] = {0};

 //打开设备文件
 fd = open(FILE, O_RDONLY);
 if (-1 == fd)
{
      perror("open error");
      exit(-1);
}

//构建多路复用IO
FD_ZERO(&Fd_set); //清除全部fd
FD_SET(0, &Fd_set); //添加标准输入
FD_SET(fd, &Fd_set); //添加鼠标
time.tv_sec = 10; //设置阻塞超时时间为10秒钟
time.tv_usec = 0; 

sele_ret = select(fd+1, &Fd_set, NULL, NULL, &time);
if (0 > sele_ret)
{
    perror("select error");
    exit(-1);
}
else if (0 == sele_ret)
{
    printf("无数据输入,等待超时.\n");
}
else
{
    if (FD_ISSET(0, &Fd_set)) //监听得到得到的结果若是键盘,则让去读取键盘的数据
{
    memset(buf, 0, sizeof(buf));
    read(0, buf, sizeof(buf)/2);
    printf("读取键盘的内容是: %s.\n", buf);
}

if (FD_ISSET(fd, &Fd_set)) //监听得到得到的结果若是鼠标,则去读取鼠标的数据
{
    memset(buf, 0, sizeof(buf));
    read(fd, buf, sizeof(buf)/2);
    printf("读取鼠标的内容是: %s.\n", buf);
}
}

//关闭鼠标设备文件
    close(fd);
    return 0;
}
1.3.2 poll实现方式

先理解一下poll这个函数的形参是什么

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

  • pollfd:又是一个结构体
struct pollfd {
int fd; //文件描述符
short events; //请求的事件(请求哪种操作)
short revents; //返回的事件
};

后两个参数都与select的第一和最后一个参数概念一样,就不细讲了

  • 返回值:失败-1 超时0 成功>0
#define FILE "/dev/input/mouse0"

int main(void)
{
    int fd = -1;
    int poll_ret = 0;
    struct pollfd poll_fd[2] = {0};
    char buf[100] = {0};

    //打开设备文件
    fd = open(FILE, O_RDONLY);
    if (-1 == fd)
    {
        perror("open error");
        exit(-1);
    }

    //构建多路复用IO
    poll_fd[0].fd = 0; //键盘
    poll_fd[0].events = POLLIN; //定义请求的事件为读数据
    poll_fd[1].fd = fd; //鼠标
    poll_fd[1].events = POLLIN; //定义请求的事件为读数据
    int time = 10000; //定义超时时间为10秒钟

    poll_ret = poll(poll_fd, fd+1, time);
    if (0 > poll_ret)
    {
        perror("poll error");
        exit(-1);
    }
     else if (0 == poll_ret)
    {
        printf("阻塞超时.\n");
    }
    else
    {
        if (poll_fd[0].revents == poll_fd[0].events)
 //监听得到得到的结果若是键盘,则让去读取键盘的数据
        {
            memset(buf, 0, sizeof(buf));
            read(0, buf, sizeof(buf)/2);
            printf("读取键盘的内容是: %s.\n", buf);
        }

        if (poll_fd[1].revents == poll_fd[1].events) 
//监听得到得到的结果若是鼠标,则去读取鼠标的数据
        {
              memset(buf, 0, sizeof(buf));
              read(fd, buf, sizeof(buf)/2);
              printf("读取鼠标的内容是: %s.\n", buf);
        }
  }
//关闭文件
close(fd);
return 0;
}
1.3.3 epoll实现方式(太过复杂,为了不增加篇幅不放进来了)

epoll操作过程中会用到的重要函数

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  • int epoll_create(int size):创建一个epoll的句柄,size表示监听数目的大小。创建完句柄它会自动占用一个fd值,使用完epoll一定要记得close,不然fd会被消耗完。
  • int epoll_ctl:这是epoll的事件注册函数,和select不同的是select在监听的时候会告诉内核监听什么样的事件,而epoll必须在epoll_ctl先注册要监听的事件类型。
    它的第一个参数返回epoll_creat的执行结果
    第二个参数表示动作,用下面几个宏表示

EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;

第三参数为监听的fd,第四个参数是告诉内核要监听什么事

  • int epoll_wait:等待事件的发生,类似于select的调用

2. select

2.1 select函数的调用过程

a. 从用户空间将fd_set拷贝到内核空间
b. 注册回调函数
c. 调用其对应的poll方法
d. poll方法会返回一个描述读写是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
e. 如果遍历完所有的fd都没有返回一个可读写的mask掩码,就会让select的进程进入休眠模式,直到发现可读写的资源后,重新唤醒等待队列上休眠的进程。如果在规定时间内都没有唤醒休眠进程,那么进程会被唤醒重新获得CPU,再去遍历一次fd。
f. 将fd_set从内核空间拷贝到用户空间

2.2 select函数优缺点

缺点:两次拷贝耗时、轮询所有fd耗时,支持的文件描述符太小
优点:跨平台支持


3. poll

3.1 poll函数的调用过程(与select完全一致)
3.2 poll函数优缺点

优点:连接数(也就是文件描述符)没有限制(链表存储)
缺点:大量拷贝,水平触发(当报告了fd没有被处理,会重复报告,很耗性能)


4. epoll

4.1 epoll的ET与LT模式

LT延迟处理,当检测到描述符事件通知应用程序,应用程序不立即处理该事件。那么下次会再次通知应用程序此事件。
ET立即处理,当检测到描述符事件通知应用程序,应用程序会立即处理。

ET模式减少了epoll被重复触发的次数,效率比LT高。我们在使用ET的时候,必须采用非阻塞套接口,避免某文件句柄在阻塞读或阻塞写的时候将其他文件描述符的任务饿死

4.2 epoll的函数调用流程

a. 当调用epoll_wait函数的时候,系统会创建一个epoll对象,每个对象有一个evenpoll类型的结构体与之对应,结构体成员结构如下。

rbn,代表将要通过epoll_ctl向epll对象中添加的事件。这些事情都是挂载在红黑树中。
rdlist,里面存放的是将要发生的事件

b. 文件的fd状态发生改变,就会触发fd上的回调函数
c. 回调函数将相应的fd加入到rdlist,导致rdlist不空,进程被唤醒,epoll_wait继续执行。
d. 有一个事件转移函数——ep_events_transfer,它会将rdlist的数据拷贝到txlist上,并将rdlist的数据清空。
e. ep_send_events函数,它扫描txlist的每个数据,调用关联fd对应的poll方法去取fd中较新的事件,将取得的事件和对应的fd发送到用户空间。如果fd是LT模式的话,会被txlist的该数据重新放回rdlist,等待下一次继续触发调用。

4.3 epoll的优点
  1. 没有最大并发连接的限制
  2. 只有活跃可用的fd才会调用callback函数
  3. 内存拷贝是利用mmap()文件映射内存的方式加速与内核空间的消息传递,减少复制开销。(内核与用户空间共享一块内存)

只有存在大量的空闲连接和不活跃的连接的时候,使用epoll的效率才会比select/poll高


下面引用知乎一书焚城的回答再次巩固一下IO模型

  1. 阻塞IO, 给女神发一条短信, 说我来找你了, 然后就默默的一直等着女神下楼, 这个期间除了等待你不会做其他事情, 属于备胎做法.
  1. 非阻塞IO, 给女神发短信, 如果不回, 接着再发, 一直发到女神下楼, 这个期间你除了发短信等待不会做其他事情, 属于专一做法.
  1. IO多路复用, 是找一个宿管大妈来帮你监视下楼的女生, 这个期间你可以些其他的事情. 例如可以顺便看看其他妹子,玩玩王者荣耀, 上个厕所等等. IO复用又包括 select, poll, epoll 模式. 那么它们的区别是什么?
    3.1 select大妈 每一个女生下楼, select大妈都不知道这个是不是你的女神, 她需要一个一个询问, 并且select大妈能力还有限, 最多一次帮你监视1024个妹子
    3.2 poll大妈不限制盯着女生的数量, 只要是经过宿舍楼门口的女生, 都会帮你去问是不是你女神
    3.3 epoll大妈不限制盯着女生的数量, 并且也不需要一个一个去问. 那么如何做呢? epoll大妈会为每个进宿舍楼的女生脸上贴上一个大字条,上面写上女生自己的名字, 只要女生下楼了, epoll大妈就知道这个是不是你女神了, 然后大妈再通知你.

上面这些同步IO有一个共同点就是, 当女神走出宿舍门口的时候, 你已经站在宿舍门口等着女神的, 此时你属于阻塞状态

接下来是异步IO的情况
你告诉女神我来了, 然后你就去王者荣耀了, 一直到女神下楼了, 发现找不见你了, 女神再给你打电话通知你, 说我下楼了, 你在哪呢? 这时候你才来到宿舍门口. 此时属于逆袭做法