聊聊 IO 多路复用

像 Nginx 这种以高并发高性能闻名的项目,之所以性能如此优秀,其原因是使用了 IO 多路复用技术,可以用最少的进程来支持大量的请求。本文和大家一起聊聊什么是 IO 多路复用,它能带来什么

常见的 IO 模型

四种 IO 模型

所有的 IO 都分为两个阶段:"等待数据就绪" 和 "拷贝到用户空间"。

我最开始不熟悉 IO 的时候,很难理解为什么有这两个过程,下面我来仔细说说

等待数据就绪:以 socket.read 为例,不管我们使用什么编程语言,这个操作都是去交给操作系统完成的(调用系统函数)。在执行系统函数时 CPU 向网卡发出 IO 请求。网卡在接收到数据之后,由网卡的 DMA 把的数据写到操作系统的内核缓冲区,完成后通知 CPU,之后 CPU 会将内核缓冲区的数据拷贝到用户空间

拷贝到用户空间:操作系统有自己的内存区域,叫做内核空间。我们平常跑的程序都在用户空间,用户空间无法直接访问内核空间的数据,所以需要拷贝。拷贝到用户空间这个过程可以理解为纯 CPU 操作,非常地快,可以认为基本不耗时

聊聊开发中 BIO 场景

我们平时 Java 开发常用的框架 Spring 或者 Servlet 这都是经典的 BIO (Blocking IO,阻塞 IO)模型。

使用 Servlet 这种 BIO 的模型都需要大量的线程,其根本原因有两点

虽然 Servlet 已经支持了 NIO,但是本文还是把它作为一个经典的 BIO 模型来讨论

1. 压榨 CPU

当 IO 阻塞时,CPU 处于空闲状态。想象一下,你一条 SQL 发给数据库,这个时候必须等到数据库给你响应才能继续往下执行。而 IO 本身并不是时刻都需要 CPU 的参与(例如我们上面说的等待就绪过程不需要 CPU 参与)。虽然这个过程对于人类来说可能很快,但是 CPU 确实在摸鱼。所以 BIO 模型中为了充分的利用 CPU 必须使用大量的线程(当发生 IO 阻塞时,CPU切换到其他线程继续工作)

2. 为了处理更多的连接

以前的我很傻的认为一个线程只能处理一个连接。但是其实 Thread 和 Connection 这两个东西之间根本没有什么羁绊,你完全可以在一个线程中处理 N 多个请求,只不过对于靠后的请求来说,他要等待的时间太长了。所以在 BIO 模型中,大多数都是 每连接每线程 的方式

BIO 带来的瓶颈

BIO 为了更充分的利用 CPU 和处理更多的连接,必须要使用大量的线程。而线程过多带来的副作用就是占用大量内存,线程上下文切换占用大量 CPU 时间片等等

那么有没有更好的方案呢?

NIO

相对于 BIO 来说,NIO 调用可以立刻得到反馈,不需要再傻等了。以 read 为例,如果数据已经就绪则返回给用户;反之返回 0,永远不会阻塞。

NIO 特性貌似可以解决 BIO 的痛点,我们通过一个线程来监听所有的 socket,当 socket 就绪时,再进行读写操作,这样做可以吗?

可以,但是需要不断的遍历所有的 socket,这样做的话效率还是有点低,有更好的办法吗?

IO 多路复用

IO 多路复用可以在一个线程中监听多个文件描述符(Linux 中万物皆是文件描述符),一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

select poll epoll

这三个函数都可以用于实现 IO 多路复用,简单来聊聊这三个函数

select:当被监听的 fd(文件描述符)就绪后会返回,但是我们无法知道具体是哪些 fd 就绪了,只能遍历所有的 fd。通常来说某一时刻,就绪的 fd 并不会很多,但是使用 select 必须要遍历所有的 fd,这就造成了一定程度上的性能损失。select 最多可监听的 fd 是有限制的,32位操作系统默认1024个,64位默认2048

poll:和 select 一样,使用 poll 时也无法知道具体哪些 fd 就绪了,还是需要遍历。poll 最大的改进就是没有了监听数量的限制,但是监听了过多的 fd 会导致性能不佳

epoll:通常在 Linux 系统中使用 IO 多路复用,都是在使用 epoll 函数。epoll 是 select 和 poll 的增强,可以通知我们哪些 fd 已经就绪了,并且没有监听数量的限制。所以使用 epoll 的性能要远远优于 select 和 poll

关于这三个函数的细节,感兴趣的同学自行谷歌一下,这里我就不多说了。

基于 IO 多路复用的开发模型

BIO 线程模型

BIO 线程模型

传统的 BIO 模型,每当服务端 accept 到一个 socket 时,就将其分配给一个线程单独处理。

单线程 Reactor

单线程 Reactor 模型

上图展示了一个单线程的 Reactor 模型

步骤1:accept,等待事件到来(Reactor负责)
步骤2:read + dispatch,将读就绪事件分发给用户定义的处理器(Reactor负责)
步骤3:decode,读数据(用户处理器负责)
步骤4:compute,处理数据(用户处理器负责)
步骤5:encode(用户处理器负责)
步骤6:send,写就绪后发送数据(Reactor负责)

为了方便大家理解,这里贴出单线程 Reactor 伪代码

// 参考自美团技术团队
interface ChannelHandler{
    void channelReadComplate(Channel channel,byte[] data);
    void channelWritable(Channel channel);
}
class Channel{
    Socket socket;
    Event event;//读,写或者连接
}

//IO线程主循环:
class IOThread extends Thread{
    Map handlerMap;//所有channel的对应事件处理器

    public void run(){
        Channel channel;
        while(channel=Selector.select()){//选择就绪的事件和对应的连接
            if(channel.event==accept){
                registerNewChannelHandler(channel);//如果是新连接,则注册一个新的读写处理器
                Selector.interested(read);
            }
            if(channel.event==write){
                getChannelHandler(channel).channelWritable(channel);//如果可以写,则执行写事件
            }
            if(channel.event==read){
                byte[] data = channel.read();
                if(channel.read()==0)//没有读到数据,表示本次数据读完了
                {
                    getChannelHandler(channel).channelReadComplate(channel, data);//处理读完成事件
                }

            }
        }
    }
}

通过使用 IO 多路复用,Reactor 模型可以非阻塞的在单个线程中处理多个 Socket,这样做性能确实很不错,但是能否在此基础上充分利用 CPU 多核来实现多路 IO 复用呢?

我们理解了单线程 Reactor 模型的工作原理之后,再来看看多线程 Reactor 模型如何工作

多线程 Reactor 1

多线程 Reactor 1

上图是 Reactor 的多线程的一种。例如 Netty 以及基于 Netty 的一些框架(Vert.x 和 WebFlux),使用的就是类似的线程模型,可以充分利用多核 CPU

mainReactor 负责 accpet。subReactor 是一组线程,在监听到 Socket 读写就绪时,进行相应的处理

需要注意的是,我们刚刚讲过的这两种 Reactor 模型,不能阻塞主 IO 线程。在 compute 过程中如果涉及 IO 调用,或者大量的计算的话,会导致整体系统吞吐量和响应时间降低。

但是不管什么系统,不涉及 IO 调用是不现实的。通常在 Vert.x 和 WebFlux 这种 Reactor 模型的框架中,执行阻塞 IO 和数据库调用都会在单独的线程池中

多线程 Reactor 2

多线程 Reactor 2

这也是多线程 Reactor 的一种,同样可以利用 IO 多路复用非阻塞的进行 read 和 send。

和单线程 Reactor 的区别是,把 decode, compute, encode 这三个步骤交给线程池来处理

该模型的缺点也和单线程模型类似,只使用了一个线程进行 Socket 的读写,所以该模型可以再优化一下。如下图所示

多线程 Reactor 2 优化

和上面的多线程 Reactor 1 思路是一样的,mainReactor 负责 accept,subReactor 是一组线程负责 Socket 的读写

Tomcat NIO 模式使用的就是类似的线程模型。虽然 Tomcat 中支持了 NIO,但是为什么基于 Tomcat 的应用还是需要大量的线程?首先 Tomcat 的NIO 仅仅是作用在 Socket 的读写。其次我们业务开发中使用了太多的 BIO API(例如 JDBC),没法完全的非阻塞化

使用 IO 多路复用框架能带来什么

IO 多路复用这么强,如果把业务开发全部改造成这种模型是不是性能会大幅度提升?实则不然,IO 多路复用的优势是使用更少的线程处理更多的连接,例如 Nginx,网关,这种可能需要处理海量连接转发的服务,它们就非常适合使用 IO 多路复用。IO 多路复用并不能让你的业务系统提速,但是它可以让你的系统支撑更多的连接

参考

https://tech.meituan.com/2016/11/04/nio.html
http://c.biancheng.net/view/2349.html
https://segmentfault.com/a/1190000003063859
https://www.zhihu.com/question/28594409
https://www.zhihu.com/question/37271342
https://juejin.cn/post/6844903492121788429
https://www.pdai.tech/md/java/io/java-io-nio-select-epoll.html

你可能感兴趣的:(聊聊 IO 多路复用)