IO多路复用


多路复用要解决的问题

在多路复用之前,并发多客户端连接,最简单和典型的方案是:同步阻塞网络IO模型。

这种模式的特点就是用一个进程来处理一个网络连接(一个网络请求)。

优点是这种方式容易理解,但缺点是性能差,每个用户请求都需要占用一个进程来处理。

而进程在Linux上是个不小的开销,不说创建,光是上下文切换也需要耗费资源。所以为了高效地对海量用户提供服务,必须要让一个进程能同时处理多个TCP连接。

那么进程如何发现是哪个连接可读了或者可写了?循环遍历这种方式显然太低级。Linux操作系统其实已经帮我们做好了,那就是多路复用机制。

这里的复用是指对进程的复用。

这里的进程也可以理解为线程层次,个人理解为1个worker对应多个socket。


Redis的IO多路复用

Redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器 将事件分发给 事件处理器。

IO多路复用-1

Redis是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以IO操作在一般情况下往往不能直接返回,这会导致某一文件的IO阻塞导致整个进程无法对其他客户提供服务,而IO多路复用就是为了解决这个问题而出现的。

所谓IO多路复用机制,就是说通过一种机制可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),就能够通知程序进行相应的读写操作。这种机制的使用需要select、poll、epoll来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。


Redis服务采用Reactor的方式来实现文件事件处理器(每一个网络连接都对应一个文件描述符)。

Reactor模式,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reator模式又叫Dispatcher模式。即IO多路复用统一监听事件,收到事件后分发,是编写高性能网络服务器的必备技术。

Reactor模式

Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它由四部分组成:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。

因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。


补充:从Redis6开始,将网络数据读写、请求协议解析通过多个IO线程来处理,解决网络IO问题。

IO多路复用-2


IO中的阻塞、非阻塞、同步、异步

举一个生活中的案例:

你去餐厅点餐,服务员拿到你的订单后,把订单交给厨师,然后等待厨师把餐做完,而不去关注其他食客,这种情况就叫做阻塞。

如果服务员把订单交给厨师后,继续去服务其他食客,这种情况就叫做非阻塞。

服务员把订单交给厨师后,不断主动向厨师询问菜好了没有,这就是同步(主动获取)。

如果厨师把菜做完后,告知服务员菜已经好了,这就是异步(被动通知)。

总结:阻塞/非阻塞关注的是线程在等待消息时候的状态,同步/异步关注的是消息通知的方式

由此引申出来四种组合,我们还是继续通过上面的例子来说明:

  • 同步阻塞

    服务员拿到订单,交给厨师后,等待厨师出餐,并不断询问出餐状态,直到出餐完成

  • 异步阻塞

    服务员拿到订单,交给厨师后,等待厨师出餐,厨师出餐完成后通知服务员

    通过这个例子可以看出,消息已经被动通知了,线程完全没有必要阻塞等待,所以异步阻塞IO实际上是不存在的

  • 同步非阻塞

    服务员拿到订单,交给厨师后,继续服务其他食客,期间不断向厨师询问出餐状态

  • 异步非阻塞

    服务员拿到订单,交给厨师后,继续服务其他食客,厨师出餐完成后通知服务员


Unix网络编程中的五种IO模型

  • 阻塞IO
  • 非阻塞IO
  • IO多路复用
  • 信号驱动IO
  • 异步IO

https://www.cnblogs.com/flashsun/p/14591563.html


IO多路复用的实现方式

IO多路复用就是我们说的select,poll,epoll,有些书籍也将这种IO方式称为事件驱动IO。就是通过一种机制,一个进程可以监控多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。可以基于一个阻塞对象并同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样就可以大大节省系统资源。

所以,IO多路复用的特点是通过一种机制一个进程能够同时等待多个文件描述符,而文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select、poll、epoll等函数就可以返回。


  • select

    select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
    // nfds:监控的文件描述符集里最大文件描述符加1
    // readfds:监控有读数据到达文件描述符集合,传入传出参数
    // writefds:监控写数据到达文件描述符集合,传入传出参数
    // exceptfds:监控异常发生达文件描述符集合, 传入传出参数
    // timeout:定时阻塞监控时间,3种情况
    // 1.NULL,永远等下去
    // 2.设置timeval,等待固定时间
    // 3.设置timeval里时间均为0,检查描述字后立即返回,轮询

    select函数监视的文件描述符分3类,分别是readfds、writefds和exceptfds,将用户传入的数组拷贝到内核空间。

    调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写或有except)或超时,函数返回。

    当select函数返回时,可以通过遍历fdset,来找到就绪的描述符。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    while(1) {
    nready = select(list);
    // 用户层依然要遍历,只不过少了很多无效的系统调用
    for(fd <-- fdlist) {
    if(fd != -1) {
    // 只读已就绪的文件描述符
    read(fd, buf);
    // 总共只有 nready 个已就绪描述符,不用过多遍历
    if(--nready == 0) break;
    }
    }
    }

    细节:

    • select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)

    • select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)

    • select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

    可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。

  • poll

    poll 也是操作系统提供的系统调用函数。

    1
    2
    3
    4
    5
    6
    7
    int poll(struct pollfd *fds, nfds_tnfds, int timeout);

    struct pollfd {
    intfd; /*文件描述符*/
    shortevents; /*监控的事件*/
    shortrevents; /*监控事件中满足条件返回的事件*/
    };

    它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

    这是因为select函数底层使用了bitmap,bitmap默认大小为1024

  • epoll

    epoll 解决了 select 和 poll 的一些问题。

    它主要针对上面 select 细节当中的三点进行了改进:

    • 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。

    • 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。

    • 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

    具体,操作系统提供了这三个函数:

    1
    2
    3
    4
    5
    6
    7
    8
    第一步,创建一个 epoll 句柄
    int epoll_create(int size);
    第二步,向内核添加、修改或删除要监控的文件描述符。
    int epoll_ctl(
    int epfd, int op, int fd, struct epoll_event *event);
    第三步,类似发起了 select() 调用
    int epoll_wait(
    int epfd, struct epoll_event *events, int max events, int timeout);