探秘Netty1:从IO模型谈到Reactor、Proactor线程模型

简介: 版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/a724888/article/details/80741828 目录 随着计算机硬件性能不断提高,服务器 CPU 的核数越来越越多,为了充分利用多核 CPU 的处理能力,提升系统的处理效率和并发性能,多线程并发编程越来越显得重要。
版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/a724888/article/details/80741828

目录

随着计算机硬件性能不断提高,服务器 CPU 的核数越来越越多,为了充分利用多核 CPU 的处理能力,提升系统的处理效率和并发性能,多线程并发编程越来越显得重要。无论是 C++ 还是 Java 编写的网络框架,大多数都是基于 Reactor 模式进行设计和开发,Reactor 模式基于事件驱动,特别适合处理海量的 I/O 事件,今天我们就简单聊聊 Reactor 线程模型,主要内容分为以下几个部分:

  • 经典的 I/O 通信模型;

  • Reactor 线程模型详述;

  • Reactor 线程模型几种模式;

  • Netty Reactor 线程模型的实践;

IO 通信模型

我们先要来谈谈 I/O 通信。说到 I/O 通信,往往会提到同步(synchronous)I/O 、异步(asynchronous)I/O、阻塞(blocking)I/O 和非阻塞(non-blocking)I/O 四种。有关同步、异步、阻塞和非阻塞的区别很多时候解释不清楚,不同的人知识背景不同,对概念很难达成共识。本文讨论的背景是 Linux 环境下的 Network I/O。

一次 I/O 过程分析

对于一次 Network I/O (以 read 举例),它会涉及到两个系统对象,一个是调用这个 I/O 的进程或线程,另一个就是系统内核 (kernel)。当一个 read 操作发生时,会经历两个阶段(记住这两个阶段很重要,因为不同 I/O 模型的区别就是在两个阶段上各有不同的处理):

  • 第一个阶段:等待数据准备 (Waiting for the data to be ready);

  • 第二个阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process);

五种 I/O 模型

Richard Stevens 的《UNIX® Network Programming Volume》提到了 5 种 I/O 模型:

  1. Blocking I/O (同步阻塞 I/O)

  2. Nonblocking I/O(同步非阻塞 I/O)

  3. I/O multiplexing(多路复用 I/O)

  4. Signal driven I/O(信号驱动 I/O,实际很少用,Java 不支持)

  5. Asynchronous I/O (异步 I/O)

接下来我们对这 5 种 I/O 模型进行说明和对比。

Blocking I/O

在 Linux 中,默认情况下所有的 Socket 都是 blocking 的,也就是阻塞的。一个典型的读操作时,流程如图:

当用户进程调用了 recvfrom 这个系统调用, 这次 I/O 调用经历如下 2 个阶段:

  1. 准备数据: 对于网络请求来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的 UDP 包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。

  2. 数据返回:kernel 一但等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。

Nonblocking IO

Linux 下,可以通过设置 socket 使其变为 non-blocking,也就是非阻塞。当对一个 non-blocking socket 执行读操作时,流程如图:

当用户进程发出 read 操作具体过程分为如下 3 个过程:

  1. 开始准备数据:如果 Kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。

  2. 数据准备中: 从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作(重复轮训)。

  3. 一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

I/O multiplexing

这种 I/O 方式也可称为 event driven I/O。Linux select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 I/O。它的基本原理就是 select/epoll 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。流程如图:

当用户进程调用了 select:

  1. 整个进程会被 block,与此同时kernel 会 “监视” 所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。

  2. 户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。这时和 blocking I/O 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call (select 和 recvfrom),而 blocking I/O 只调用了一个 system call (recvfrom)。

  3. 在 I/O multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket I/O 给 block。

Asynchronous IO

Linux 下的 asynchronous I/O,即异步 I/O,其实用得很少(需要高版本系统支持)。它的流程如图:

当用户进程发出 read 操作具体过程:

  1. 用户进程发起 read 操作之后,并不需要等待,而是马上就得到了一个结果,立刻就可以开始去做其它的事。

  2. 从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个 signal,告诉它 read 操作完成了。


通过以上 4 种 I/O 通信模型的说明,总结一下它们各自的特点:

  • Blocking I/O 的特点就是在 I/O 执行的两个阶段都被 block 了。

  • Non-blocking I/O 特点是如果 kernel 数据没准备好不需要阻塞。

  • I/O multiplexing 的优势在于它用 select 可以同时处理多个 connection。(如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking I/O 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

  • Asynchronous IO 的特点在于整个调用过程客户端没有任何 block 状态,但是需要高版本的系统支持。

生活中通信模型

以上五种 I/0 模型的介绍,比如枯燥,其实在生活中也存在类似的 “通信模型”,为了帮助理解,我们用生活中约妹纸吃饭这个不是很恰当的例子来说明这几个 I/O Model(假设我现在要用微信叫几个妹纸吃饭):

  • 发个微信问第一个妹纸好了没,妹子没回复就一直等,直到回复在发第二个 (blocking I/O)。

  • 发个微信问第一个妹纸好了没,妹子没回复先不管,发给第二个,但是过会要继续问之前 没有回复的妹纸有没有好(nonblocking I/O)。

  • 将所有妹纸拉一个微信群,过会在群里问一次,谁好了回复一下(I/O multiplexing)。

  • 直接告诉妹纸吃饭的时间地址,好了自己去就行(Asynchronous I/O)。

Reactor 线程模型

Reactor 是什么?

Reactor 是一种处理模式。 Reactor 模式是处理并发 I/O 比较常见的一种模式,用于同步 I/O,中心思想是将所有要处理的IO事件注册到一个中心 I/O 多路复用器上,同时主线程/进程阻塞在多路复用器上;一旦有 I/O 事件到来或是准备就绪(文件描述符或 socket 可读、写),多路复用器返回并将事先注册的相应 I/O 事件分发到对应的处理器中。

Reactor 也是一种实现机制。 Reactor 利用事件驱动机制实现,和普通函数调用的不同之处在于:应用程序不是主动的调用某个 API 完成处理,而是恰恰相反,Reactor 逆置了事件处理流程,应用程序需要提供相应的接口并注册到 Reactor 上,如果相应的事件发生,Reactor 将主动调用应用程序注册的接口,这些接口又称为 “回调函数”。用 “好莱坞原则” 来形容 Reactor 再合适不过了:不要打电话给我们,我们会打电话通知你。

为什么要使用 Reactor?

一般来说通过 I/O 复用,epoll 模式已经可以使服务器并发几十万连接的同时,维持极高 TPS,为什么还需要 Reactor 模式?原因是原生的 I/O 复用编程复杂性比较高。

一个个网络请求可能涉及到多个 I/O 请求,相比传统的单线程完整处理请求生命期的方法,I/O 复用在人的大脑思维中并不自然,因为,程序员编程中,处理请求 A 的时候,假定 A 请求必须经过多个 I/O 操作 A1-An(两次 IO 间可能间隔很长时间),每经过一次 I/O 操作,再调用 I/O 复用时,I/O 复用的调用返回里,非常可能不再有 A,而是返回了请求 B。即请求 A 会经常被请求 B 打断,处理请求 B 时,又被 C 打断。这种思维下,编程容易出错。

Reactor 线程模型

Reactor 有三种线程模型,用户能够更加自己的环境选择适当的模型。

  1. 单线程模型

  2. 多线程模型(单 Reactor)

  3. 多线程模型(多 Reactor)

单线程模式

单线程模式是最简单的 Reactor 模型。Reactor 线程是个多面手,负责多路分离套接字,Accept 新连接,并分派请求到处理器链中。该模型适用于处理器链中业务处理组件能快速完成的场景。不过这种单线程模型不能充分利用多核资源,所以实际使用的不多。

多线程模式(单 Reactor)

该模型在事件处理器(Handler)链部分采用了多线程(线程池),也是后端程序常用的模型。

多线程模式(多 Reactor)

比起多线程单 Rector 模型,它是将 Reactor 分成两部分,mainReactor 负责监听并 Accept新连接,然后将建立的 socket 通过多路复用器(Acceptor)分派给subReactor。subReactor 负责多路分离已连接的 socket,读写网络数据;业务处理功能,其交给 worker 线程池完成。通常,subReactor 个数上可与 CPU 个数等同。

Reactor 使用

软件领域很多开源的产品使用了 Ractor 模型,比如 Netty。

Netty Reactor 实践

服务端线程模型

服务端监听线程和 I/O 线程分离,类似于 Reactor 的多线程模型,它的工作原理图如下:

服务端用户线程创建

  • 创建服务端的时候实例化了 2 个 EventLoopGroup。bossGroup 线程组实际就是 Acceptor 线程池,负责处理客户端的 TCP 连接请求。workerGroup 是真正负责 I/O 读写操作的线程组。通过这里能够知道 Netty 是多 Reactor 模型。

  • ServerBootstrap 类是 Netty 用于启动 NIO 的辅助类,能够方便开发。通过 group 方法将线程组传递到 ServerBootstrap 中,设置 Channel 为 NioServerSocketChannel,接着设置 NioServerSocketChannel 的 TCP 参数,最后绑定 I/O 事件处理类 ChildChannelHandler。

  • 辅助类完成配置之后调用 bind 方法绑定监听端口,Netty 返回 ChannelFuture,f.channel().closeFuture().sync() 对同步阻塞的获取结果。

  • 调用线程组 shutdownGracefully 优雅推出,释放资源。

public class TimeServer {
    public void bind(int port) {
        // 配置服务端的NIO线程组
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workGroup).channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childHandler(new ChildChannelHandler());
            // 绑定端口,同步等待成功
            ChannelFuture f = b.bind(port).sync();
            // 等待服务端监听端口关闭
            f.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放线程池资源
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }
    private class ChildChannelHandler extends ChannelInitializer<SocketChannel> {
        @Override
        protected void initChannel(SocketChannel ch) throws Exception {
            ch.pipeline().addLast(new TimeServerHandler());
        }
    }
}

服务端 I/O 线程处理(TimeServerHandler)

  • exceptionCaught 方法: 当 I/O 处理发生异常时被调用,关闭 ChannelHandlerContext,释放资源。

  • channelRead 方法: 是真正处理读写数据的方法,通过 buf.readBytes 读取请求数据。通过 ctx.write(resp) 将相应报文发送给客户端。

  • channelReadComplete 方法: 为了提高性能,Netty write 是将数据先写到缓冲数组,通过 flush 方法可以将缓冲数组的所有消息发送到 SocketChannel 中。

public class TimeServerHandler extends ChannelHandlerAdapter {

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
            throws Exception {
        ctx.close();
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg)
            throws Exception {
        // msg转Buf
        ByteBuf buf = (ByteBuf) msg;
        // 创建缓冲中字节数的字节数组
        byte[] req = new byte[buf.readableBytes()];
        // 写入数组
        buf.readBytes(req);
        String body = new String(req, "UTF-8");
        String currenTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(
                System.currentTimeMillis()).toString() : "BAD ORDER";
        // 将要返回的信息写入Buffer
        ByteBuf resp = Unpooled.copiedBuffer(currenTime.getBytes());
        // buffer写入通道
        ctx.write(resp);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        // write读入缓冲数组后通过invoke flush写入通道
        ctx.flush();
    }
}

总结

通过以上大概了解 Reactor 相关知识。最后做个总结一下使用 Reactor 模型的优缺点。

  • 优点

    • 响应快,虽然 Reactor 本身依然是同步的,不必为单个同步时间所阻塞。

    • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销。

    • 可扩展性,通过并发编程的方式增加 Reactor 个数来充分利用 CPU 资源。

    • 可复用性,Reactor 框架本身与具体事件处理逻辑无关,具有很高的复用性。

  • 缺点

    • 相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,调试相对复杂。

    • Reactor 模式需要底层的 Synchronous Event Demultiplexer 支持,例如 Java 中的 Selector,操作系统的 select 系统调用支持。

    • 单线程 Reactor 模式在 I/O 读写数据时还是在同一个线程中实现的,即使使用多 Reactor 机制的情况下,共享一个 Reactor 的 Channel 如果出现一个长时间的数据读写,会影响这个 Reactor 中其他 Channel 的相应时间,比如在大文件传输时,I/O 操作就会影响其他 Client 的相应时间,因而对这种操作,使用传统的 Thread-Per-Connection 或许是一个更好的选择,或则此时使用 Proactor 模式。




反应器Reactor

Reactor模式结构

241052434069024.jpg

Reactor包含如下角色:

  • Handle 句柄;用来标识socket连接或是打开文件;
  • Synchronous Event Demultiplexer:同步事件多路分解器:由操作系统内核实现的一个函数;用于阻塞等待发生在句柄集合上的一个或多个事件;(如select/epoll;)
  • Event Handler:事件处理接口
  • Concrete Event HandlerA:实现应用程序所提供的特定事件处理逻辑;
  • Reactor:反应器,定义一个接口,实现以下功能:
    1)供应用程序注册和删除关注的事件句柄;
    2)运行事件循环;
    3)有就绪事件到来时,分发事件到之前注册的回调函数上处理;

“反应”器名字中”反应“的由来:

“反应”即“倒置”,“控制逆转”

具体事件处理程序不调用反应器,而是由反应器分配一个具体事件处理程序,具体事件处理程序对某个指定的事件发生做出反应;这种控制逆转又称为“好莱坞法则”(不要调用我,让我来调用你)

业务流程及时序图

241052444538838.jpg

  1. 应用启动,将关注的事件handle注册到Reactor中;
  2. 调用Reactor,进入无限事件循环,等待注册的事件到来;
  3. 事件到来,select返回,Reactor将事件分发到之前注册的回调函数中处理;

Proactor模式

Proactor模式结构

241052458282851.jpg

Proactor主动器模式包含如下角色

  • Handle 句柄;用来标识socket连接或是打开文件;
  • Asynchronous Operation Processor:异步操作处理器;负责执行异步操作,一般由操作系统内核实现;
  • Asynchronous Operation:异步操作
  • Completion Event Queue:完成事件队列;异步操作完成的结果放到队列中等待后续使用
  • Proactor:主动器;为应用程序进程提供事件循环;从完成事件队列中取出异步操作的结果,分发调用相应的后续处理逻辑;
  • Completion Handler:完成事件接口;一般是由回调函数组成的接口;
  • Concrete Completion Handler:完成事件处理逻辑;实现接口定义特定的应用处理逻辑;

业务流程及时序图

241052468598435.jpg

  1. 应用程序启动,调用异步操作处理器提供的异步操作接口函数,调用之后应用程序和异步操作处理就独立运行;应用程序可以调用新的异步操作,而其它操作可以并发进行;
  2. 应用程序启动Proactor主动器,进行无限的事件循环,等待完成事件到来;
  3. 异步操作处理器执行异步操作,完成后将结果放入到完成事件队列;
  4. 主动器从完成事件队列中取出结果,分发到相应的完成事件回调函数处理逻辑中;

对比两者的区别

主动和被动

以主动写为例:

  • Reactor将handle放到select(),等待可写就绪,然后调用write()写入数据;写完处理后续逻辑;
  • Proactor调用aoi_write后立刻返回,由内核负责写操作,写完后调用相应的回调函数处理后续逻辑;

可以看出,Reactor被动的等待指示事件的到来并做出反应;它有一个等待的过程,做什么都要先放入到监听事件集合中等待handler可用时再进行操作;
Proactor直接调用异步读写操作,调用完后立刻返回;

实现

Reactor实现了一个被动的事件分离和分发模型,服务等待请求事件的到来,再通过不受间断的同步处理事件,从而做出反应;

Proactor实现了一个主动的事件分离和分发模型;这种设计允许多个任务并发的执行,从而提高吞吐量;并可执行耗时长的任务(各个任务间互不影响)

优点

Reactor实现相对简单,对于耗时短的处理场景处理高效;
操作系统可以在多个事件源上等待,并且避免了多线程编程相关的性能开销和编程复杂性;
事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁;
事务分离:将与应用无关的多路分解和分配机制和与应用相关的回调函数分离开来,

Proactor性能更高,能够处理耗时长的并发场景;

缺点

Reactor处理耗时长的操作会造成事件分发的阻塞,影响到后续事件的处理;

Proactor实现逻辑复杂;依赖操作系统对异步的支持,目前实现了纯异步操作的操作系统少,实现优秀的如windows IOCP,但由于其windows系统用于服务器的局限性,目前应用范围较小;而Unix/Linux系统对纯异步的支持有限,应用事件驱动的主流还是通过select/epoll来实现;

适用场景

Reactor:同时接收多个服务请求,并且依次同步的处理它们的事件驱动程序;
Proactor:异步接收和同时处理多个服务请求的事件驱动程序;

参考

微信公众号【Java技术江湖】一位阿里 Java 工程师的技术小站。(关注公众号后回复”Java“即可领取 Java基础、进阶、项目和架构师等免费学习资料,更有数据库、分布式、微服务等热门技术学习视频,内容丰富,兼顾原理和实践,另外也将赠送作者原创的Java学习指南、Java程序员面试指南等干货资源)


wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==


相关文章
|
17天前
|
算法 数据处理 Python
Python并发编程:解密异步IO与多线程
本文将深入探讨Python中的并发编程技术,重点介绍异步IO和多线程两种常见的并发模型。通过对比它们的特点、适用场景和实现方式,帮助读者更好地理解并发编程的核心概念,并掌握在不同场景下选择合适的并发模型的方法。
|
3月前
|
负载均衡 NoSQL Java
|
4月前
|
缓存 网络协议 Unix
Linux(UNIX)五种网络I/O模型与IO多路复用
Linux(UNIX)五种网络I/O模型与IO多路复用
107 0
|
3月前
|
Linux
Linux 下的五种 IO 模型详细介绍
根据上述定义,我们的前4种模型——阻塞式I/O模型、非阻塞式I/O模型、I/O复用模型和信号驱动式I/O模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom )将阻塞进程。异步请求:A调用B,B的处理是异步的,B在接到请求后先告诉A我已经接到请求了,然后异步去处理,处理完之后通过回调等方式再通知A。和上面的阻塞IO模型相比,非阻塞IO模型在内核数据没准备好,需要进程阻塞的时候,就返回一个错误,以使得进程不被阻塞。阻塞请求:A调用B,A一直等着B的返回,别的事情什么也不干。
34 0
Linux 下的五种 IO 模型详细介绍
|
4月前
|
存储 Linux
图解IO多路复用模型之select、poll、epoll
图解IO多路复用模型之select、poll、epoll
52 0
|
2月前
|
并行计算 开发者 Python
Python中的并发编程:异步IO与多线程比较
本文将探讨Python中的并发编程方法,着重比较异步IO和多线程两种不同的实现方式。通过对它们的特点、优缺点以及适用场景进行分析,帮助读者更好地理解并发编程在Python中的应用。
23 1
|
3月前
|
缓存 Java Unix
五种网络IO模型
前言   本文重点在于介绍五种网络IO模型
434 0
|
3月前
|
Java 数据处理
fastdfs源码阅读:文件传输原理与网络IO模型(accept线程、work线程(网络io处理)、dio线程(文件io处理))
fastdfs源码阅读:文件传输原理与网络IO模型(accept线程、work线程(网络io处理)、dio线程(文件io处理))
43 0