网络编程框架中,为什么 Netty 这么受欢迎?

6,490 阅读13分钟

参考自:Netty 核心原理剖析与 RPC 实践

仅仅是做了一些总结和整理,再加上了一些自己的思考,目的是方便自己后续复习巩固

1. Netty 是什么?

Netty 官网

Netty Github

Netty 的官网首页有这样一句话:

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients.

翻译:Netty 是一个异步事件驱动的网络应用程序框架用于快速开发可维护的高性能协议服务器和客户端

总结:

  1. 本质:网络应用程序框架。
  2. 实现:异步、事件驱动。
  3. 特性:高性能、可维护、快速开发。
  4. 用途:开发服务器和客户端。

2. 为什么选择 Netty?

Netty 是一款用于高效开发网络应用的 NIO 网络框架,它大大简化了网络应用的开发过程。

网络应用框架需要关注的几个核心点:

  • I/O 模型、线程模型和事件处理机制;
  • 易用性 API 接口;
  • 对数据协议、序列化的支持。

Netty 围绕这些核心要点可以做到尽善尽美,其健壮性、性能、可扩展性在同领域的框架中都首屈一指。

选择 Netty 的三个原因:

  1. 高性能,低延迟;
  2. 完美弥补 NIO 的缺陷;
  3. 更低的资源消耗。

2.1 高性能,低延迟

IO 模型的基础知识

I/O 请求可以分为两个阶段,分别为调用阶段和执行阶段。

  • 第一个阶段为 I/O 调用阶段,即用户进程向内核发起系统调用。
  • 第二个阶段为 I/O 执行阶段。此时,内核等待 I/O 请求处理完成返回。该阶段分为两个过程:首先等待数据就绪,并写入内核缓冲区;随后将内核缓冲区数据拷贝至用户态缓冲区。

下图:Process 代表应用程序(可以理解为应用进程),Kernel 代表操作系统内核I/O Device 代表处理 IO 的设备。

Drawing 0.png

Linux 的 5 种主要 I/O 模式:

(1)同步阻塞 I/O(BIO)

1.png

如上图所表现的那样,应用进程向内核发起 I/O 请求,发起调用的线程一直等待内核返回结果。一次完整的 I/O 请求称为 BIOBlocking IO,阻塞 I/O),所以 BIO 在实现异步操作时,只能使用多线程模型,一个请求对应一个线程。但是,线程的资源是有限且宝贵的,创建过多的线程会增加线程切换的开销。

其实可以使用线程池解决线程过多的情况,但这就意味着,线程池的最大数量就是服务端可以支撑的最大并发数量。

可以联想到 JavaSocket

  • 服务端会有两个阻塞操作:一个是 accept(),也就是等待客户端连接,如果没有客户端连接,就会阻塞;第二个是 getInputStream(),如果客户端连接上了,但是没有发送数据,也是会阻塞的。
  • 客户端获取数据也就是 getInputStream(),也是会阻塞的。

(2)同步非阻塞 I/O(NIO)

2.png

如上图所示,应用进程向内核发起 I/O 请求后不再会同步等待结果,而是会立即返回,通过轮询的方式获取请求结果。

NIO 相比 BIO 虽然大幅提升了性能,但是轮询过程中大量的系统调用导致上下文切换开销很大。所以,单独使用非阻塞 I/O 时效率并不高,而且随着并发量的提升,非阻塞 I/O 会存在严重的性能浪费。

为什么轮询会导致上下文开销很大呢?

因为轮询操作是需要用户态切换到核心态的,这样是很耗费性能的。

(3)I/O 多路复用

3.png

多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socketselect、poll、epoll 都是 I/O 多路复用的具体实现,线程一次 select 调用可以获取内核态中多个数据通道的数据状态。

多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。

其实说简单点,就是把轮询的操作放到内核中进行了,这样就可以避免大量的用户态和内核态进行切换,select、poll、epoll 都是 Linux 内核提供的函数,目的就是在内核中遍历所有的文件描述符(在 Linux 中,一切皆文件,所以 Socket 被抽象成了文件,简称 fd)。

Redis 采用的就是 IO 多路复用模型。

(4)信号驱动 I/O

4.png

信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。

(5)异步 I/O

5.png

异步 I/O 最重要的一点是从内核缓冲区拷贝数据到用户态缓冲区的过程也是由系统异步完成,应用进程只需要在指定的数组中引用数据即可。

异步 I/O 与信号驱动 I/O 这种半异步模式的主要区别:信号驱动 I/O 由内核通知何时可以开始一个 I/O 操作,而异步 I/O 由内核通知 I/O 操作何时已经完成。

Netty 如何实现自己的 I/O 模型

Netty 的 I/O 模型是基于非阻塞 I/O 实现的,底层依赖的是 JDK NIO 框架的多路复用器 Selector。一个多路复用器 Selector 可以同时轮询多个 Channel,采用 epoll 模式后,只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。

在 I/O 多路复用的场景下,当有数据处于就绪状态后,需要一个事件分发器(Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。事件分发器有两种设计模式:Reactor 和 Proactor,Reactor 采用同步 I/O, Proactor 采用异步 I/O

Reactor 实现相对简单,适合处理耗时短的场景,对于耗时长的 I/O 操作容易造成阻塞。Proactor 性能更高,但是实现逻辑非常复杂,目前主流的事件驱动模型还是依赖 select 或 epoll 来实现。

6.png

摘自 Lea D. Scalable IO in Java

上图所描述的便是 Netty 所采用的主从 Reactor 多线程模型,所有的 I/O 事件都注册到一个 I/O 多路复用器上,当有 I/O 事件准备就绪后,I/O 多路复用器会将该 I/O 事件通过事件分发器分发到对应的事件处理器中。

该线程模型避免了同步问题以及多线程切换带来的资源开销,真正做到高性能、低延迟。

2.2 完美弥补 Java NIO 的缺陷

在 JDK 1.4 投入使用之前,只有 BIO 一种模式。开发过程相对简单。新来一个连接就会创建一个新的线程处理。随着请求并发度的提升,BIO 很快遇到了性能瓶颈。

JDK 1.4 以后开始引入了 NIO 技术,支持 select 和 poll;JDK 1.5 支持了 epoll;JDK 1.7 发布了 NIO2,支持 AIO 模型。Java 在网络领域取得了长足的进步。

Netty 是基于 JDK NIO 的,相比于 NIO,Netty 做的更多,并且做得更好。

Netty 突出的优势

  • 易用性。 我们使用 JDK NIO 编程需要了解很多复杂的概念,比如 Channels、Selectors、Sockets、Buffers 等,编码复杂程度令人发指。相反,Netty 在 NIO 基础上进行了更高层次的封装,屏蔽了 NIO 的复杂性;Netty 封装了更加人性化的 API,统一的 API(阻塞/非阻塞) 大大降低了开发者的上手难度;与此同时,Netty 提供了很多开箱即用的工具,例如常用的行解码器、长度域解码器等,而这些在 JDK NIO 中都需要你自己实现。
  • 稳定性。 Netty 更加可靠稳定,修复和完善了 JDK NIO 较多已知问题,例如臭名昭著的 select 空转导致 CPU 消耗 100%,TCP 断线重连,keep-alive 检测等问题。
  • 可扩展性。 Netty 的可扩展性在很多地方都有体现,这里我主要列举其中的两点:一个是可定制化的线程模型,用户可以通过启动的配置参数选择 Reactor 线程模型;另一个是可扩展的事件驱动模型,将框架层和业务层的关注点分离。大部分情况下,开发者只需要关注 ChannelHandler 的业务逻辑实现。

2.3 更低的资源消耗

作为网络通信框架,需要处理海量的网络数据,那么必然面临有大量的网络对象需要创建和销毁的问题,对于 JVM GC 并不友好。为了降低 JVM 垃圾回收的压力,Netty 主要采用了两种优化手段:

  • 对象池复用技术。 Netty 通过复用对象,避免频繁创建和销毁带来的开销。
  • 零拷贝技术。 除了操作系统级别的零拷贝技术外,Netty 提供了更多面向用户态的零拷贝技术,例如 Netty 在 I/O 读写时直接使用 DirectBuffer,从而避免了数据在堆内存和堆外内存之间的拷贝。

3. Netty 的使用

摘自 Netty 源码!

引入 Maven 依赖:

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.39.Final</version>
</dependency>

3.1 Netty 实现客户端和服务端

3.1.1 服务端代码

/**
 * Echoes back any received data from a client.
 */
public final class EchoServer {

    static final boolean SSL = System.getProperty("ssl") != null;
    static final int PORT = Integer.parseInt(System.getProperty("port", "8007"));

    public static void main(String[] args) throws Exception {
        // Configure SSL.
        final SslContext sslCtx;
        if (SSL) {
            SelfSignedCertificate ssc = new SelfSignedCertificate();
            sslCtx = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build();
        } else {
            sslCtx = null;
        }

        // Configure the server.
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        final EchoServerHandler serverHandler = new EchoServerHandler();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .option(ChannelOption.SO_BACKLOG, 100)
             .handler(new LoggingHandler(LogLevel.INFO))
              // 两种设置 keepalive 风格
             .childOption(ChannelOption.SO_KEEPALIVE, true)
             .childOption(NioChannelOption.SO_KEEPALIVE, true)

              // 切换到 unpooled 的方式之一
             .childOption(ChannelOption.ALLOCATOR, UnpooledByteBufAllocator.DEFAULT)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     ChannelPipeline p = ch.pipeline();
                     if (sslCtx != null) {
                         p.addLast(sslCtx.newHandler(ch.alloc()));
                     }
                     p.addLast(new LoggingHandler(LogLevel.INFO));
                     p.addLast(serverHandler);
                 }
             });

            // Start the server.
            ChannelFuture f = b.bind(PORT).sync();

            // Wait until the server socket is closed.
            f.channel().closeFuture().sync();
        } finally {
            // Shut down all event loops to terminate all threads.
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

3.1.2 服务端处理逻辑代码

/**
 * Handler implementation for the echo server.
 */
@Sharable
public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ctx.write(msg);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // Close the connection when an exception is raised.
        cause.printStackTrace();
        ctx.close();
    }
}

3.1.3 客户端代码

/**
 * Sends one message when a connection is open and echoes back any received
 * data to the server.  Simply put, the echo client initiates the ping-pong
 * traffic between the echo client and server by sending the first message to
 * the server.
 */
public final class EchoClient {

    static final boolean SSL = System.getProperty("ssl") != null;
    static final String HOST = System.getProperty("host", "127.0.0.1");
    static final int PORT = Integer.parseInt(System.getProperty("port", "8007"));
    static final int SIZE = Integer.parseInt(System.getProperty("size", "256"));

    public static void main(String[] args) throws Exception {
        // Configure SSL.git
        final SslContext sslCtx;
        if (SSL) {
            sslCtx = SslContextBuilder.forClient()
                .trustManager(InsecureTrustManagerFactory.INSTANCE).build();
        } else {
            sslCtx = null;
        }

        // Configure the client.
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .handler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     ChannelPipeline p = ch.pipeline();
                     if (sslCtx != null) {
                         p.addLast(sslCtx.newHandler(ch.alloc(), HOST, PORT));
                     }
                     p.addLast(new LoggingHandler(LogLevel.INFO));
                     p.addLast(new EchoClientHandler());
                 }
             });

            // Start the client.
            ChannelFuture f = b.connect(HOST, PORT).sync();

            // Wait until the connection is closed.
            f.channel().closeFuture().sync();
        } finally {
            // Shut down the event loop to terminate all threads.
            group.shutdownGracefully();
        }
    }
}

3.1.4 客户端处理逻辑代码

/**
 * Handler implementation for the echo client.  It initiates the ping-pong
 * traffic between the echo client and server by sending the first message to
 * the server.
 */
public class EchoClientHandler extends ChannelInboundHandlerAdapter {

    private final ByteBuf firstMessage;

    /**
     * Creates a client-side handler.
     */
    public EchoClientHandler() {
        firstMessage = Unpooled.buffer(EchoClient.SIZE);
        for (int i = 0; i < firstMessage.capacity(); i ++) {
            firstMessage.writeByte((byte) i);
        }
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        ctx.writeAndFlush(firstMessage);
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ctx.write(msg);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
       ctx.flush();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // Close the connection when an exception is raised.
        cause.printStackTrace();
        ctx.close();
    }
}
复制代码

3.2 Netty 实现 http 服务端

3.2.1 服务端代码

/**
 * An HTTP server that sends back the content of the received HTTP request
 * in a pretty plaintext form.
 */
public final class HttpHelloWorldServer {


    public static void main(String[] args) throws Exception {
        // Configure the server.
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .handler(new LoggingHandler(LogLevel.INFO))
             .childHandler(new ChannelInitializer() {
                 @Override
                 protected void initChannel(Channel ch) throws Exception {
                     ChannelPipeline p = ch.pipeline();
                     p.addLast(new HttpServerCodec());
                     p.addLast(new HttpServerExpectContinueHandler());
                     p.addLast(new HttpHelloWorldServerHandler());
                 }
             });

            Channel ch = b.bind(8080).sync().channel();

            System.err.println("Open your web browser and navigate to " +
                    "http://127.0.0.1:8080");

            ch.closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

3.2.2 服务端处理逻辑代码

public class HttpHelloWorldServerHandler extends SimpleChannelInboundHandler<HttpObject> {

    private static final byte[] CONTENT = "helloworld".getBytes();

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

    @Override
    public void channelRead0(ChannelHandlerContext ctx, HttpObject msg) {
        if (msg instanceof HttpRequest) {
            HttpRequest req = (HttpRequest) msg;

            FullHttpResponse response = new DefaultFullHttpResponse(req.protocolVersion(), OK,
                                                                    Unpooled.wrappedBuffer(CONTENT));
            response.headers()
                    .set(CONTENT_TYPE, TEXT_PLAIN)
                    .setInt(CONTENT_LENGTH, response.content().readableBytes());

            ChannelFuture f = ctx.write(response);
        }
    }

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

4. 网络框架的选型

Tomcat 作为一款非常优秀的 Web 服务器看上去已经帮我们解决了类似问题,那么它与 Netty 到底有什么不同?

  • Netty 和 Tomcat 最大的区别在于对通信协议的支持,可以说 Tomcat 是一个 HTTP Server,它主要解决 HTTP 协议层的传输,而 Netty 不仅支持 HTTP 协议,还支持 SSH、TLS/SSL 等多种应用层的协议,而且能够自定义应用层协议。
  • Tomcat 需要遵循 Servlet 规范,在 Servlet 3.0 之前采用的是同步阻塞模型,Tomcat 6.x 版本之后已经支持 NIO,性能得到较大提升。然而 Netty 与 Tomcat 侧重点不同,所以不需要受到 Servlet 规范的约束,可以最大化发挥 NIO 特性。
  • 如果你仅仅需要一个 HTTP 服务器,那么我推荐你使用 Tomcat。术业有专攻,Tomcat 在这方面的成熟度和稳定性更好。但如果你需要做面向 TCP 的网络应用开发,那么 Netty 才是你最佳的选择。

为什么 Tomcat 不选择 Netty 来做通信层呢?

  • 历史原因,Tomcat 是 1998 年出现的,Netty 是 2004 年出现的,也就是说 Tomcat 出现的时候还没有 Netty。
  • 其实很多情况都是这样,历史原因导致有些技术选型或者框架已经无法改变了,并不说其他的技术不够好。

此外,比较出名的网络框架还有 Mina 和 Grizzly。

  • Mina 是 Apache Directory 服务器底层的 NIO 框架,由于 Mina 和 Netty 都是 Trustin Lee 主导的作品,所以两者在设计理念上基本一致。Netty 出现的时间更晚,可以认为是 Mina 的升级版,解决了 Mina 一些设计上的问题。比如 Netty 提供了可扩展的编解码接口、优化了 ByteBuffer 的分配方式,让用户使用起来更为便捷、安全。
  • Grizzly 出身 Sun 公司,从设计理念上看没有 Netty 优雅,几乎是对 Java NIO 比较初级的封装,目前业界使用的范围也很小。

综上所述,Netty 是我们一个较好的选择。

5. Netty 的发展现状

Netty 如此成功离不开社区的精心运营,迭代周期短且文档比较齐全,如果你遇到任何问题通过 issue 或者邮件都可以得到非常及时的答复。

image-20220728103329797

Netty 官方提供 3.x、4.x 的稳定版本,之前一直处于测试阶段的 5.x 版本已被作者放弃维护。此前,官方从未对外发布过任何 5.x 的稳定版本。

5.x 版本就是引进了 AIO,但是 Netty 的维护者发现性能并没有显著的提升,而且维护不过来,所以放弃维护。

Netty 3.x 到 4.x 版本发生了较大变化,属于不兼容升级,Netty 4.x 版本的新特性:

  • 项目结构:模块化程度更高,包名从 org.jboss.netty 更新为 io.netty,不再属于 Jboss。

  • 常用 API:大多 API 都已经支持流式风格,更多新的 API 参考以下网址:netty.io/news/2013/0…

  • Buffer 相关优化

    :Buffer 相关功能调整了现在 5 点。

    1. ChannelBuffer 变更为 ByteBuf,Buffer 相关的工具类可以独立使用。由于人性化的 Buffer API 设计,它已经成为 Java ByteBuffer 的完美替代品。
    2. Buffer 统一为动态变化,可以更安全地更改 Buffer 的容量。
    3. 增加新的数据类型 CompositeByteBuf,可以用于减少数据拷贝。
    4. GC 更加友好,增加池化缓存,4.1 版本开始 jemalloc 成为默认内存分配方式。
    5. 内存泄漏检测功能。
  • 通用工具类:io.netty.util.concurrent 包中提供了较多异步编程的数据结构。

  • 更加严谨的线程模型控制,降低用户编写 ChannelHandler 的难度,不必过于担心线程安全问题。

6. 谁在使用 Netty

Drawing 7.png

Netty 经过很多出名产品在线上的大规模验证,其健壮性和稳定性都被业界认可,其中典型的产品有以下几个。

  • 服务治理:Apache Dubbo、gRPC
  • 大数据:Hbase、Spark、Flink、Storm
  • 搜索引擎:Elasticsearch
  • 消息队列:RocketMQ、ActiveMQ