Skip to content

Latest commit

 

History

History
120 lines (101 loc) · 9.52 KB

io-multiplexing.md

File metadata and controls

120 lines (101 loc) · 9.52 KB

网络IO

  • 什么是IO多路复用
  • 为什么有IO多路复用机制

什么是IO多路复用

IO多路复用是一种同步IO模型,实现一个线程可以监听多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的独写操作;没有文件句柄就绪会阻塞应用程序,交出cpu。多路是指多个网络连接,复用指的是同一个线程。也可能是进程,但是实际执行的时候是线程执行任务

为什么有IO多路复用机制

没有IO多路复用机制的时候,使用的是BIO, NIO两种方式,但有一些问题。

  • BIO同步阻塞IO。服务端采用单线程,当accept一个请求后,在recv或send调用阻塞时,将无法accept其他请求(必须等上一个请求处理完recv或send),无法处理并发
// 伪代码,早期版本
while(1) {
    // accept阻塞。accept接受连接,如果没有连接,就一直等待
    client_fd = accept(listen_fd)
    fds.append(client_fd)
    for (fd in fds) {
        // recv阻塞(会影响上面的accept)。rece接收数据,如果没有数据到达,也会等待
        if (recv(fd)) {
            // logic
        }
    }
}

服务端采用多线程,当accept一个请求后,开启线程进行recv,可以完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销。

while(1) {
    client_fd = accept(listen_fd);
    fds.append(fd);
    // 一旦接收到请求,就开启线程read数据。早期可以通过多线程来解决,但是随着web2.0的到来,这种思路出现了瓶颈,线程不能无限创建。
    new Thread func() {
        if (recv(fd)) {
            // logic
        }
    }
}

只要没有客户端连接上服务器,accept方法就一直不能返回,这就是阻塞;对应的读写操作道理也一样,想要读取数据,必须等到有数据到达才能返回,这就是阻塞。

  • NIO 同步非阻塞 BIO的瓶颈在于accept客户端的时候会阻塞,以及进行读写操作的时候会阻塞,导致效率低。为了突破这个瓶颈,操作系统发展出了NIO,这里的NIO指的是非阻塞IO。也就是说在accept客户端连接的时候,不需要阻塞,如果当没有客户端连接的时候就返回-1,在读写操作的时候,也不会阻塞,有数据就读,没数据就直接返回。同时NIO用数组集合保存每个连接的客户端,单线程通过while循环来对每个连接进行操作。
// NIO是操作系统对原来的accept/recv等函数做了优化,让其不再阻塞
setNonblocking(listen_fd)
while(1) {
    // 不阻塞了
    client_fd = accept(listen_fd)
    if (client_fd != null) {
        // 有人连接
        fds.append(client_fd)
    } else {
        // 无人连接。直接返回
    }

    for (fd in fds) {
        // recv非阻塞
        setNonblocking(client_fd)
        if (len = recv(fd) && len > 0) {
            // 有读写数据
        } else {
            // 直接返回
        }
    }
}

尽管上面的单线程NIO服务器模型比BIO的优良很多,但是仍然有一个大问题。在客户端与服务器建立连接后,后续会进行一系列的读写操作。虽然这些读写操作是非阻塞的,但是每调一次读写操作在操作系统层面都要进行一次用户态和内核态的切换,这也是一个巨大的开销(读写等系统调用都是在内核态完成的)。在上面的代码中每次循环遍历都进行读写操作,我们以读操作为例:大部分读操作都是在数据没有准备好的情况下进行读的,相当于一次空操作。我们要想办法避免这种无效的读取操作,避免内核态和用户态的频繁切换。

补充:客户端与服务器端两端都是通过socket进行连接的,socket在linux操作系统中有对应的文件描述符,我们的读写操作都是以该文件描述符为单位进行操作。

为了避免上述的无效读写,我们得想办法得知当前的文件描述符是否可读可写。如果逐个文件描述符去询问,那么效率就和直接进行读写操作差不多了,我们希望有一种方法能够一次性得知哪些文件描述符可读,哪些文件描述符可写,这就是操作系统后来发展出来的多路复用器。

也就是说,多路复用器的核心功能就是告诉我们哪些文件描述符可读,哪些文件描述符可写。而多路复用器也分为几种,他们也经历了一个演化的过程。最初的多路复用器是select模型,它的模式是这样的:程序端每次把文件描述符集合交给select的系统调用,select遍历每个文件描述符后返回那些可以操作的文件描述符,然后程序对可以操作的文件描述符进行读写。

它的弊端是,一次传输的文件描述符集合有限,只能给出1024个文件描述符,poll在此基础上进行了改进,没有了文件描述符数量的限制。

但是select和poll在性能上还可以优化,它们共同的弊端在于:

  1. 它们需要在内核中对所有传入的文件描述符进行遍历,这也是一项比较耗时的操作

  2. (这点是否存在优化空间有待考证)每次要把文件描述符从用户态的内存搬运到内核态的内存,遍历完成后再搬回去,这个来回复制也是一项耗时的操纵。

后来操作系统加入了epoll这个多路复用器,彻底解决了这个问题: epoll多路复用器的模型是这样的:

为了在发起系统调用的时候不遍历所有的文件描述符,epoll的优化在于:当数据到达网卡的时候,会触发中断,正常情况下cpu会把相应的数据复制到内存中,和相关的文件描述符进行绑定。epoll在这个基础之上做了延伸,epoll首先是在内核中维护了一个红黑树,以及一些链表结构,当数据到达网卡拷贝到内存时会把相应的文件描述符从红黑树中拷贝到链表中,这样链表存储的就是已经有数据到达的文件描述符,这样当程序调用epoll_wait的时候就能直接把能读的文件描述符返回给应用程序。(零拷贝)

除了epoll_wait之外,epoll还有两个系统调用,分别是epoll_create和epoll_ctl,分别用于初始化epoll和把文件描述符添加到红黑树中。

以上就是多路复用器与常见io模型的关系了,网上常常有文章把多路复用器说成是nio的一部分,我觉得也是合理的,因为在具体编程的时候两个概念往往会融为一体。

后续

其实Java已经为我们把多路复用器用Selector类给封装起来了,我们完全可以基于Selector进行NIO服务器开发。但是我们自己写nio服务器可能不够严谨,Java届有一款优秀nio框架,名叫Netty,这部分内容我们留到下一次再讲啦。

  • IO多路复用 服务器端采用单线程通过select/poll/epoll等系统调用获取fd列表,遍历有事件的fd进行accept/recv/send

IO多路复用的三种实现方式

  • select
    • 单个进程所打开的FD是有限制的,通过FD_SETSIZE设置,默认1024
    • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
    • 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)
  • poll
    • poll与select相比,只是没有fd的限制,其它基本一样
    • 每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
    • 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)
  • epoll
    • epoll只能工作在linux下
    • epoll LT 与 ET模式的区别: epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。
      • LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作
      • ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,或者遇到EAGAIN错误

下面举一个例子,模拟一个tcp服务器处理30个客户socket。假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确。 你有下面几个选择:

  1. 第一种选择:按顺序逐个检查,先检查A,然后是B,之后是C、D。。。这中间如果有一个学生卡主,全班都会被耽误。这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。
  2. 第二种选择:你创建30个分身,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
  3. 第三种选择,你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。 这种就是IO复用模型,Linux下的select、poll和epoll就是干这个的。将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。

参考文章