Netty:高性能、高可用的NIO通信框架

Netty:高性能、高可用的NIO通信框架

前言

最近在做老钥匙箱的重构,一个要解决的关键问题是:如何让一台服务器同时支撑成千上万个tcp长连接?

老的钥匙箱项目基于jdk的bio通信,一直以来,存在内存占用过多、CPU使用率高的问题。因此,我们花了一段时间考虑更换通信框架的问题。

在讨论到底层io通讯框架的时候,我们最终选择了netty。依靠netty,实现了单台服务器同时支撑几万个tcp长连接。

由于最新的netty5已经被官方废弃了,我们最终所采用的netty版本为:netty-4.1.16.Final

本文内容也基于此。

PART 1.

五分钟入门:编写一个tcp server,在控制台打印其收到的消息

public class SimpleServerHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf in = (ByteBuf) msg;
        byte[] bytes = new byte[in.readableBytes()];
        in.readBytes(bytes);
        System.out.println("收到消息:" + new String(bytes));
    }

    @Override
    public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
        System.out.println("已连接:" + ctx.channel().remoteAddress());
        ctx.fireChannelRegistered();
    }

    @Override
    public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
        System.out.println("已断开:" + ctx.channel().remoteAddress());
        ctx.fireChannelUnregistered();
    }

}
public class SimpleServer {
    static int port = 8001;

    public static void main(String[] args) {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer() {
                        @Override
                        protected void initChannel(Channel ch) throws Exception {
                            ch.pipeline().addLast(new SimpleServerHandler());
                        }
                    })
                    .option(ChannelOption.SO_BACKLOG, 128)
                    .childOption(ChannelOption.SO_KEEPALIVE, true);

            System.out.println("netty server 已启动");

            // 绑定端口,开始接收进来的连接
            ChannelFuture f = b.bind(port).sync(); // (7)

            // 等待服务器  socket 关闭
            f.channel().closeFuture().sync();

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();

            System.out.println("netty server 已关闭");
        }
    }
}

运行SimpleServer中的main,即开启了一个tcp server。不需要netty提供的客户端,任何支持tcp协议的客户端都可以连接。

使用telnet命令连接这个server并发送消息,控制台会把客户端发过来的消息打印出来。

PART 2.

为什么用netty?

从C10K问题说起

所谓C10K问题,指的是服务器同时支持成千上万个客户端的问题,也就是concurrent 10 000 connection。

随着互联网的飞速发展,C10K问题也逐渐具有了现实意义,单台服务器同时提供一万以上的连接成为了互联网公司很常见的应用场景。

对于c10K问题,如果采用传统阻塞式的BIO模型,即对每个tcp连接分配一个线程,必然会产生上万个线程。线程是操作系统中昂贵的资源,主要表现在:

  1. 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
  2. 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
  3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
  4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。

所以,当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理模型。

java NIO

为了弥补传统BIO方式的不足,在java1.4引入了NIO。NIO是同步非阻塞的I/O模型。说到这,可能你要头大了:什么是同步非阻塞啊?和同步阻塞的区别是什么?

以socket.read()为例子:

传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据。

对于NIO,如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不会阻塞。

所以,对于BIO,由于read的时候会阻塞当前线程,如果连接的socket很多,前面的任何一个socket一旦阻塞都会导致后面所有的socket阻塞。因此,BIO才必须对每个连接建立一个单独的线程来操作,来避免这种情况发生。

而在NIO中,由于是非阻塞的I/O,不会产生上述问题。从而使通过单线程管理所有的连接——即多路复用——成为了可能,避免了维持大量线程的系统开销,使系统可以支持大量连接的并发,10K问题自然迎刃而解。

参考资料:深入浅出NIO Socket实现机制
参考资料:JAVA NIO浅析

为什么不用NIO?

看到这估计你要问了,既然NIO实现了多路复用,可以支撑大量并发的连接,为什么不直接用NIO来实现我们的服务端呢?主要有以下几点原因(引自《netty权威指南》):

  1. NIO的类库和API繁杂,使用麻烦,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等;
  2. 需要具备其它的额外技能做铺垫,例如熟悉Java多线程编程,因为NIO编程涉及到Reactor模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的NIO程序;
  3. 可靠性能力补齐,工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等,NIO编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大;
  4. JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%。官方声称在JDK1.6版本的update18修复了该问题,但是直到JDK1.7版本该问题仍旧存在,只不过该bug发生概率降低了一些而已,它并没有被根本解决。该BUG以及与该BUG相关的问题单如下:
    http://bugs.java.com/bugdatabase/view_bug.do?bug_id=6403933
    http://bugs.java.com/bugdatabase/view_bug.do?bug_id=2147719
为什么选择Netty

Netty是业界最流行的NIO框架之一,它的健壮性、功能、性能、可定制性和可扩展性在同类框架中都是首屈一指的,它已经得到成百上千的商用项目验证,例如Hadoop的RPC框架avro使用Netty作为底层通信框架。很多其它业界主流的RPC框架,也使用Netty来构建高性能的异步通信能力。

通过对Netty的分析,我们将它的优点总结如下:

  1. API使用简单,开发门槛低;
  2. 功能强大,预置了多种编解码功能,支持多种主流协议;
  3. 定制能力强,可以通过ChannelHandler对通信框架进行灵活的扩展;
  4. 性能高,通过与其它业界主流的NIO框架对比,Netty的综合性能最优;
  5. 成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼;
  6. 社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功能会被加入;
  7. 经历了大规模的商业应用考验,质量已经得到验证。在互联网、大数据、网络游戏、企业应用、电信软件等众多行业得到成功商用,证明了它可以完全满足不同行业的商业应用。
  8. 不仅仅是对NIO的封装。网上很多资料把Netty简单的描述为对NIO的封装,这其实很不准确。 Netty复用了一部分JAVA NIO的组件和代码,但是在底层众多涉及对操作系统函数调用的Native方法层面,却是自己实现了一套,比如,在对linux著名的epoll函数的使用上,使用了边缘触发(edge-triggered notification)的模式,和JAVA NIO的条件触发有着本质区别。这些底层的优化也最终使得Netty的效率和健壮性都远远强过JAVA NIO。

参考资料:
高性能网络服务器编程:为什么linux下epoll是最好,Netty要比NIO.2好?

正是因为这些优点,Netty逐渐成为Java NIO编程的首选框架。

PART3 .

Netty原理

总览:一图看Netty工作原理

Alt text这里写图片描述

Netty线程模型:基于Reactor模式的多路复用

什么是Reactor模式?

关于Java NIO 构造Reator模式,Doug lea(Doug lea是谁?翻一翻jdk源码,很多类的作者都是他)在《Scalable IO in Java》中给了很好的阐述,这里截取PPT对Reator模式的实现进行说明

  1. 第一种实现模型如下:
    这里写图片描述
    这是最简单的Reactor单线程模型,由于Reactor模式使用的是异步非阻塞IO,所有的IO操作都不会被阻塞,理论上一个线程可以独立处理所有的IO操作。这时Reactor线程是个多面手,负责多路分离套接字,Accept新连接,并分发请求到处理链中。
    对于一些小容量应用场景,可以使用到单线程模型。但对于高负载,大并发的应用却不合适,主要原因如下:
    当一个NIO线程同时处理成百上千的链路,性能上无法支撑,即使NIO线程的CPU负荷达到100%,也无法完全处理消息
    当NIO线程负载过重后,处理速度会变慢,会导致大量客户端连接超时,超时之后往往会重发,更加重了NIO线程的负载。
    可靠性低,一个线程意外死循环,会导致整个通信系统不可用
    为了解决这些问题,出现了Reactor多线程模型。

  2. Reactor多线程模型:
    这里写图片描述
    相比上一种模式,该模型在处理链部分采用了多线程(线程池)。
    在绝大多数场景下,该模型都能满足性能需求。但是,在一些特殊的应用场景下,如服务器会对客户端的握手消息进行安全认证。这类场景下,单独的一个Acceptor线程可能会存在性能不足的问题。为了解决这些问题,产生了第三种Reactor线程模型

  3. Reactor主从模型
    这里写图片描述
    该模型相比第二种模型,是将Reactor分成两部分,mainReactor负责监听server socket,accept新连接;并将建立的socket分派给subReactor。subReactor负责多路分离已连接的socket,读写网络数据,对业务处理功能,其扔给worker线程池完成。通常,subReactor个数上可与CPU个数等同。

  4. Netty模型
    说完了Reactor的三种模型,那么Netty是哪一种呢?其实Netty的线程模型是Reactor模型的变种,那就是去掉线程池的第三种形式的变种,这也是Netty NIO的默认模式。Netty中Reactor模式的参与者主要有下面一些组件:
    4.1 Selector
    4.2 EventLoopGroup/EventLoop
    4.3 ChannelPipeline
    Selector即为NIO中提供的SelectableChannel多路复用器,充当着demultiplexer的角色,这里不再赘述;下面对另外两种功能和其在Netty之Reactor模式中扮演的角色进行介绍。

EventLoop:I/O任务串行化的执行引擎

什么是EventLoop?
当系统在运行过程中,如果频繁的进行线程上下文切换,不仅会带来线程安全的问题,还会带来额外的性能损耗。

为了解决上述问题,Netty采用了串行化设计理念,从消息的读取、编码以及后续Handler的执行,始终都由IO线程EventLoop负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险。

EventLoopGroup是一组EventLoop的抽象,EventLoopGroup提供next接口,可以从一组EventLoop里面按照一定规则获取其中一个EventLoop来处理任务。

在Netty服务器编程中,我们需要BossEventLoopGroup和WorkerEventLoopGroup两个EventLoopGroup来进行工作。通常一个服务端口即一个ServerSocketChannel对应一个Selector和一个EventLoop线程,也就是说BossEventLoopGroup的线程数参数为1。BossEventLoop负责接收客户端的连接并将SocketChannel交给WorkerEventLoopGroup来进行IO处理。WorkerEventLoopGroup的线程数默认为cpu核数 * 2。

ChannelPipeline:定义了i/o事件到达后的handler处理链

如果说EventLoop是负责执行I/O事件对应的一系列任务的串行化执行引擎,ChannelPipeline则定义了用户对I/O事件的处理逻辑。

在五分钟入门的代码里,我们编写了一个SimpleServerHandler,顺着继承关系看上去,可以发现,它最顶级的接口是一个ChanelHandler。我们重写了channelRead等方法,使channel在发生收到数据等事件之后执行我们自己的代码。

ChannelHandler有两个子接口:

这里写图片描述

这两个接口对应了两个数据流向,如果数据是从外部流入我们的应用程序,我们就看做是inbound,相反便是outbound。一个ChannelHandler处理完接收到的数据会传给下一个Handler,或者什么不处理,直接传递给下一个。听到这是不是有点耳熟?没错,就是责任链模式。

ChannelInBoundHandler对从客户端发往服务器的报文进行处理,一般用来执行半包/粘包,解码,读取数据,业务处理等;ChannelOutBoundHandler对从服务器发往客户端的报文进行处理,一般用来进行编码,发送报文到客户端。

每当触发I/O事件,会有一组ChannelHandler链被调用。这组ChannelHandler链通过一个ChannelPipeline来管理。

在每个Channel初始化的时候,会把ChannelPipeline绑定到Channel上面。

这里写图片描述

上图我们可以看到,一个ChannelPipeline可以把两种Handler(ChannelInboundHandler和ChannelOutboundHandler)混合在一起,当一个数据流进入ChannelPipeline时,它会从ChannelPipeline头部开始传给第一个ChannelInboundHandler,当第一个处理完后再传给下一个,一直传递到管道的尾部。与之相对应的是,当数据被写出时,它会从管道的尾部开始,先经过管道尾部的“最后”一个ChannelOutboundHandler,当它处理完成后会传递给前一个ChannelOutboundHandler。

ByteBuf:比ByteBuffer更好的缓冲区管理

缓冲区是不同的通道之间传递数据的中介。
JAVA的NIO提供了ByteBuffer,用来完成这项任务,Netty也提供了一个名字很相似的载体叫做ByteBuf,相比于ByteBuf而言,它有着更加更多友善的API,也更加易于维护,并且它可以扩容。

在内存中,缓冲区其实是一段内存空间,存放着网络传输的字节数据。通常会是一个byte数组。

所以ByteBuf主要作用就是维护这个byte数组。

与ByteBuffer相比,它维护了两个指针,一个是读指针,一个是写指针,而原生态的ByteBuffer只维护了一个指针,你需要调用flip方法来切换读写的状态,不易用户管理维护。

从内存分配的角度来讲,ByteBuf分为两类:
1. 堆内存(HeapByteBuf)字节缓冲区:存放在jvm的堆内存中,特点是内存的分配和回收速度快,可以被GC;缺点是如果进行Socket的I/O读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核Channel中,影响性能。

  1. 直接内存(DirectByteBuf)字节缓冲区:使用native方法直接分配堆外内存,它的分配和回收速度比堆内存慢一点,但是将它在Socket Channel写入或者读取时,会少一次内存复制,即所谓的“零拷贝”,所以速度会比使用堆内存快。
    直接内存只有在进行full gc时才会进行回收,而它的容量如果没有明确限制,随着数据的不断读写势必造成内存中可利用的空间不断变小。所以netty做了引用计数机制来处理direct memory上的数据。在用完DirectByteBuf后,需要调用release方法减少引用计数,当引用计数为0时会释放这块占用的内存。

由于各有利弊,Netty提供了多种ByteBuf供开发者使用。

根据各自的特点,DirectByteBuf更适用于I/O通信时的读写,HeapByteBuf更适用于后端业务消息的编码/解码。

从内存回收的角度,ByteBuf也分为两类:基于对象池的ByteBuf和普通的ByteBuf。区别是基于对象池的ByteBuf可以重用ByteBuf对象,它自己维护了一个内存池,可以循环利用创建的ByteBuf,提升内存的使用效率,降低由于高负载导致的频繁GC。

链接:Netty内存池原理分析

总结

Netty是一个非常优秀和重要的框架,对它理解越深入越感到其设计之精妙。它的每一部分组件都可以拿来写出一篇专门的文章来研究。限于篇幅,本文就不对其所有组件都一一介绍了。

本次使用Netty之后,顺便对其架构、组件也进行了一些研究,于我自己对编程的理解也大有裨益。

限于时间原因,不能对所有内容都从源码一一仔细验证,如有谬误之处,欢迎指正。

©️2020 CSDN 皮肤主题: 深蓝海洋 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值