一、前言
经常听到周围的同事谈起 Netty,但是我对 Netty 的理解一直懵懵懂懂,只知道它是一个后端服务器,或者说是一个网络通信框架。最近大概学习了一下 Netty,借此总结一下,也站在一个 Netty 小白的角度,向大家分享一下 Netty 的基础知识。
Netty 之所以可以高效地管理海量的连接,得益于底层的 I/O 多路复用模型。因此,在正式开始 Netty 的介绍之前,我们首先对齐一下常见的几种 I/O 模型,弄明白 Netty 为什么会选择 I/O 多路复用模型,它的好处是什么。接着,我们会介绍一下 Java NIO 的基础知识,因为 Netty 本质上是对 Java NIO 的封装,提供比 Java NIO 更易用并且性能更好的使用方式。最后,我们再正式开始对 Netty 的学习。
话不多说,让我们发车吧!
二、常见的几种 I/O 模型
我们知道,客户端和服务器之间的网络通信涉及到网络 I/O,而网络 I/O 是会被阻塞的。对于我们常用的 TCP 协议来说,阻塞主要体现在三个方面:
- 建立连接:客户端和服务器需要通过三次握手建立连接,建立连接的过程中线程会被阻塞
- 读数据:读数据时,如果没有数据可读,线程会被阻塞
- 写数据:写数据时,由于 TCP 协议需要保证数据可靠地、有序地在客户端和服务器之间进行传输,并且对数据传输做流量控制,因此,写数据时需要先把数据拷贝到一个发送缓冲区,然后再把数据发送出去。由于缓冲区的大小是有限的,如果写数据时缓冲区已经满了,此时写数据的线程就会被阻塞
为了提高网络 I/O 的效率,一些大佬们经过不断地优化和演进,提出了多种 I/O 模型。在 UNIX 系统下一共有五种 I/O 模型,它们分别是阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
在介绍具体的 I/O 模型之前,我们需要知道程序获取网络数据时需要经过的两个阶段:(1)数据从网卡拷贝到内核;(2)内核把数据拷贝到用户空间。经过上面两个阶段后,程序才能访问到这个数据。
下面分别介绍一下这五种 I/O 模型。
2.1 阻塞 I/O
用户线程调用 recvfrom 获取网络数据,期间线程一直被阻塞,直到数据从内核复制到线程中才返回。
它的优点很明显,简单。线程调用 recvfrom 之后就不管了,直到数据来了且准备好了进行处理即可;它的缺点也很明显,一个连接对应一个线程,并且这个线程一直被霸占着,即使没有数据到来,也阻塞等待着。
我们都知道线程是一种比较贵的系统资源,为了不浪费资源,我们不想它一直傻傻地等着,于是就有了非阻塞 I/O。
2.2 非阻塞 I/O
用户线程调用 recvfrom 之后,如果没有就绪的数据,内核会返回一个错误码,此时,线程可以继续执行,但是需要不断地执行系统调用来获知 I/O 是否完成。
这里需要注意,虽然线程执行系统调用的过程是非阻塞的,不过在数据从内核拷贝到线程的过程中,线程还是阻塞的。
相比阻塞 I/O 而言,非阻塞 I/O 更加灵活,比如线程发起系统调用后,如果暂无数据,线程可以先去干别的事情,干完别的事情后,回来再看看有没有数据。
但是,如果你的线程就是获取数据然后处理数据,不干别的事情,那么这个模型还是有点问题,因为你需要不断地进行系统调用。如果我们要处理海量的请求,那么就会有海量的线程不断地进行系统调用,频繁地进行内核态和用户态的转换,耗费大量的系统资源。
那么怎么办?于是就有了 I/O 多路复用。
2.3 I/O 多路复用
用户线程调用 select 等待数据,查看多个连接中是否有数据已准备就绪。当某个连接的数据准备就绪时返回,之后线程再调用 recvfrom 把数据从内核复制到线程中。
也就是说,我们可以只用一个线程查看多个连接是否有数据已准备就绪。具体到代码上,我们用一个线程专门执行 select 调用,往 select 线程上注册需要被监听的连接,由 select 线程来监控它所管理的连接是否有数据已准备就绪,如果有,则可以通知别的线程来读取数据。这样一来,我们就可以用少量的线程去监控多条连接,减少了线程的数量、节省了系统资源。
看到这,你再想想,还有什么地方可以优化的?
2.4 信号驱动 I/O
上面的 select 线程虽然不阻塞了,但是他得时刻去查询是否有数据已准备就绪。我们可以思考,是不是可以让内核告诉我们数据准备就绪,而不是我们自己去查呢?信号驱动 I/O 就实现了这个功能,由内核告知数据已准备就绪,然后用户线程再去获取数据。
用户线程执行 sigaction 系统调用后,内核立即返回,此时线程可以继续执行。内核在数据到达时向线程发送 SIGIO 信号,线程收到信号之后调用 recvfrom 将数据从内核复制到线程中。
听起来貌似信号驱动 I/O 比 I/O 多路复用更好呀,那为什么市面上用的都是 I/O 多路复用而不是信号驱动 I/O 呢?
因为我们的应用通常用的都是 TCP 协议,而 TCP 协议的 Socket 可以产生的信号事件有七种。也就是说,不仅仅只有数据准备就绪才会发信号,其他事件也会发信号,而这个信号又是同一个信号,所以我们的应用程序无法区分到底是什么事件产生的这个信号。
那还有比信号驱动 I/O 更高效、更适用的 I/O 模型吗?有,那就是异步 I/O。
2.5 异步 I/O
用户线程执行 aio_read 系统调用会立即返回,此时线程可以继续执行,内核会在所有操作完成之后向线程发送信号,此时用户线程就拿着已经拷贝到线程中的数据继续执行后面的操作。
那么问题又来了,为什么我们的应用程序通常用的还是 I/O 多路复用,而不是异步 I/O 呢?
因为 Linux 对异步 I/O 的支持不足,你可以认为还未完全实现,所以用不了异步 I/O。
介绍完上述五种 I/O 模型后,我们可以发现,I/O 多路复用是更适合我们应用的模型,事实上,I/O 多路复用也确实是我们平时用得比较多的 I/O 模型,比如 Java NIO 就是基于 I/O 多路复用实现的,而 Netty 则基于 Java NIO 做了更高级的封装。
下面,我们简单介绍下 Java NIO 的基础知识,方便为后面学习 Netty 做铺垫。
三、Java NIO 基础知识
关于 Java NIO 的核心,总的来看包含以下三点,它们分别是 Channel、Buffer 和 Selector。
3.1 Channel
Channel 翻译过来就是「通道」。我们可以往通道里写数据,也可以从通道里读数据,它是双向的。与之配套的是 Buffer,也就是你想要往一个通道里写数据,必须要将数据写到一个 Buffer 中,然后再写到通道里。相反,从通道里读数据,必须先将通道的数据读到一个 Buffer 中,然后再进行读。
在 NIO 中 Channel 有多种类型:
- SocketChannel
- ServerSocketChannel
- DatagramChannel
- FileChannel
其中,DatagramChannel 和 FileChannel 分别是 UDP 数据报通道和文件通道。由于我们日常开发主要是基于 TCP 协议,所以我们主要介绍 SocketChannel 和 ServerSocketChannel。
3.2 SocketChannel
对标 Socket,我们可以直接将它当做建立的连接。通过 SocketChannel ,我们可以利用 TCP 协议对网络数据进行读写。
3.3 ServerSocketChannel
对标 ServerSocket,也就是服务端创建的 Socket。它的作用就是监听新的 TCP 连接,为新连接创建对应的 SocketChannel。之后,通过新建的 SocketChannel 就可以进行网络数据的读写。
SocketChannel 主要在两个地方出现:
- 客户端:客户端创建一个 SocketChannel 用于连接至远程的服务器
- 服务器:服务器利用 ServerSocketChannel 接收新连接后,为其创建一个 SocketChannel
随后,客户端和服务器就可以通过这两个 SocketChannel 相互发送和接收数据。
ServerSocketChannel 主要出现在一个地方:服务器。服务器需要绑定一个端口,然后监听新连接的到来,这个活儿就由 ServerSocketChannel 来干。服务器通常会利用一个线程,一个死循环,不断地接收新连接的到来,代码如下:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
...
while(true) {
// 接收的新连接
SocketChannel socketChannel = serverSocketChannel.accept();
...
}
3.4 Buffer
Buffer 说白了就是内存中可以读写的一块地方,叫缓冲区,用于缓存数据。
3.5 Selector
Selector 是 I/O 多路复用的核心。一个 Selector 上可以注册多个 Channel ,我们从上面得知一个 Channel 就对应了一个连接,因此一个 Selector 可以管理多个 Channel 。
当任意 Channel 发生读写事件的时候,通过 Selector.select() 就可以捕捉到事件的发生,因此我们利用一个线程,死循环地调用 Selector.select(),这样就可以利用一个线程管理多个连接,减少了线程数、节省了系统资源。
那么如何使用 Selector 呢?一般分为三步:
- 创建 Selector
- 注册 Channel 到 Selector
- 获取事件
3.5.1 创建 Selector
通过如下代码创建一个 Selector:
Selector selector = Selector.open();
3.5.2 注册 Channel 到 Selector
通过如下代码将被管理的 Channel 注册到 Selector 上,并声明感兴趣的事件:
SelectionKey key = channel.register(selector, Selectionkey.OP_READ);
上面注册 Channel 的时候声明了对读事件感兴趣,也可以同时声明对多种类型的事件感兴趣。
3.5.3 获取事件
当某个 Channel 发生读或写事件时,我们调用 Selector.select() 就可以得知有事件发生。返回值就是就绪的 Channel 数,一般判断大于 0 即可进行后续的操作。后续的操作就是调用 selector.selectedKeys() 获得了一个类型为 Set 的 selectedKeys 集合,然后通过迭代器遍历所有发生事件的连接并进行相应的操作,代码如下:
while(true) {
int readyNum = selector.select();
if (readyNum == 0) {
continue;
}
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
// a connection was established with a remote server.
} else if (key.isReadable()) {
// a channel is ready for reading
} else if (key.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove();
}
}
至此,我们就清楚 Java NIO 是如何通过 Selector 管理多个连接的了,下面我们正式进入 Netty 的介绍。
四、Netty 是什么
先来看下 Netty 官方对 Netty 的介绍:
Netty is an asynchronous event-driven network application framework
for rapid development of maintainable high performance protocol servers & clients.
从上面的介绍中我们可以看出,Netty 是一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。
我们平时接触到的很多中间件的底层通信框架用的都是 Netty,例如 RocketMQ、Dubbo、Elasticsearch 等。
五、为什么要用 Netty
我们知道, Netty 是基于 Java NIO 做的封装,那为什么我们不直接使用 Java NIO,而要使用 Netty 呢?
5.1 易用
Netty 对 Java NIO 进行了封装,屏蔽了 Java NIO 使用的复杂性,简化了网络通信的开发。
Netty 支持众多的协议,如 HTTP、HTTP2、DNS、Redis 协议等。此外,网络编程需要考虑的粘包、拆包,连接的管理、编解码的处理等,Netty 也都为你定制好了,开箱即用。
Netty 还提供的内存泄漏检测,IP 过滤、流量整型等高级功能。
5.2 性能
Netty 基于 Java NIO 进行封装,实现了 I/O 多路复用,可由一个线程轮询多个底层 channel,减少了线程资源,也减少了多线程间切换带来的开销,能更高效地处理海量连接,提升系统的性能。
Netty 应用了零拷贝技术,不仅利用操作系统提供的零拷贝,也基于堆外内存节省了一次 JVM 堆内外之间的拷贝。
Netty 应用了对象池技术,通过对象的复用,避免了频繁创建和销毁对象带来的开销。
5.3 扩展性
Netty 基于事件驱动模型,将业务实现剥离成一个个的 ChannelHandler,利用责任链模式,可以很好的根据不同的业务进行扩展,使用者只需要实现相关的 ChannelHandler 即可,实现了框架与业务隔离。
Netty 可以根据实际使用的情况配置线程模型,例如单 Reactor、多 Reactor 、主从 Reactor 等。
Netty 还具有完善的断连、Idle 等异常处理逻辑,同时,Netty 还规避了一些 Java NIO 的 bug(如 JDK Selector 的空轮询 bug)。
六、Netty 架构
下图是 Netty 官网上给出的一张 Netty 的架构图:
这张图把 Netty 分成了三部分:Core、Transport services 和 Protocol support:
- Core:核心,实际上就是提供了一些底层通用实现来供上层使用,从图中可以看出包含的核心有可扩展事件模型、通用通信 API、可零拷贝的 buffer
- Transport services:传输服务,表明 Netty 支持多种传输方式,例如 TCP、UDP 、HTTP 隧道、虚拟机管道。我们可以很方便的切换各种传输方式,因为 Netty 都支持了
- Protocol support:协议的支持,从图中可以看出 Netty 支持了非常多常见的协议,例如 HTTP、WebSocket、SSL、Google Protobuf 等。可以说开箱即用,不必自行实现
总而言之,这幅图想要表现的只是 Netty 大致的分层,或者可以认为是代码分层。下面我们可以看一下各部分对应的包结构。
6.1 Core
Core 层大致包含以下这几个包:
- buffer:这个包主要就是实现了 Netty 自定义的 ByteBuf,因为 Netty 认为 Java ByteBuffer 的 API 太难用了, 并且还有很多优化的余地,所以 Nettty 就实现了个 ByteBuf 替换之
- common:就是一些通用的工具类
- resolver:从名字就可以看出来,解析用的, 解析主机名、IP地址、DNS 等
6.2 Transport services
这层大致包含以下这几个包:
主要的功能就是处理和传输数据了,一些 Netty 的核心类都在这里,如 Bootstrap、Channel、ChannelHandler、ChannelPipeline 、EvenLoop 等都在这里面。
6.3 Protocol support
这其实没啥好说的,就是 Netty 针对各种协议实现了对应的支持,尽量做到开箱即用。
七、Netty 主线
在深入 Netty 的各个细节之前,我们有必要先梳理一下 Netty 的主线,做到从高处俯瞰 Netty。下面是 Netty 的主线流程图:
上面的图第一眼看不懂没事,我们可以先试着分析一下,一个服务器的主线应该是什么?
一般一个服务器的主线无非就是:先启动服务,监听某个端口,接收到客户端的建连,然后持续监听连接上的请求,但凡有请求过来,则解码、解析,并根据请求数据的不同进行不同的业务逻辑处理,然后将响应返回给客户端,最后关闭连接。
沿着这条主线,我们对照着 Netty 的官方示例,来看一下 Netty 是如何启动的。下面是 Netty 官方示例的代码:
// Configure the server.
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 手动把 bossGroup 的线程数设置为 1
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.option(ChannelOption.SO_BACKLOG, 1024); // 配置父 channel 的属性,还有个 .childOption() 用来配置子 channel 的属性
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO)) // 配置父 channel 的 handler
.childHandler(new HttpHelloWorldServerInitializer(sslCtx)); // 配置子 channel 的 handler
Channel ch = b.bind(PORT).sync().channel(); // 绑定端口&启动服务器
System.err.println("Open your web browser and navigate to " +
(SSL? "https" : "http") + "://127.0.0.1:" + PORT + '/');
ch.closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
服务端这边是一个叫 ServerBootstrap 的引导类来启动 Netty,初始化一些组件。启动时,一般有四个核心地方需要配置,分别为:线程模型、I/O 模型、读写逻辑和绑定端口。
7.1 线程模型
线程模型可以配置两个线程组,它们分别为 bossGroup 和 workerGroup。bossGroup 用来接入新的连接,然后将接待完的新连接分配给 workerGroup,之后这个连接上的交互都由 workerGroup 负责完成。
从 boss 和 worker 这两个单词来看,形象的理解就是老板在外面接活,接到活之后分配给下属去干,后面就不管了。当然这里也可以只配置一个线程组,让老板即接活又干活,这都是可配置的。还能配置线程组的线程数,就是规定几个人来干活啦。
7.2 I/O 模型
配置完线程模型后,还需配置 channel 的 I/O 模型,也就是 BIO,NIO 这些,这也是 Netty 灵活的地方。如果你想要替换 I/O 模型,只需要修改下 ServerBootstrap 的配置即可。比如上面我们配置的 channel 就是 NioServerSocketChannel.class,就是 NIO,如果你想配置 BIO,则改为 OioServerSocketChannel.class 即可。
注意,这个 channel 是 Netty 自己实现的,屏蔽了底层 Socket 接口的复杂性,提供更高维度的抽象,像网络 I/O 的操作直接操控 channel 即可。
7.3 读写逻辑
配置完线程模型和 I/O 模型后,我们发现,老板有了,员工也有了,和客户连接的通道(Channel)也有了,现在就是要干活了呀!跟客户的对接会有事件发生,比如客户读数据、客户写数据等,这时候就得员工根据事件去招待客户,那具体如何招待?哪些事件对应哪些操作?这就需要配置具体的读写逻辑,也就是配置 ChannelHandler。
这里配置 ChannelHandler 分为配置 ServerSocketChannel 的 ChannelHandler 和配置 SocketChannel 的 ChannelHandler。ServerSocketChannel 和 SocketChannel 我们前面已经介绍过了,ServerSocketChannel 作为父 channel,用来监听新连接的到来,SocketChannel 作为子 channel,用来进行具体的读写操作。
上述代码中,.channel() 方法用来给父 channel 配置 ChannelHandler,这里我们给父 channel 配置了一个 LoggingHandler,用来输出日志。.childHandler() 方法用来给子 channel 配置 ChannelHandler,这里我们创建了一个 HttpHelloWorldServerInitializer,HttpHelloWorldServerInitializer 继承自 ChannelInitializer,ChannelInitializer 是一个特殊的 ChannelHandler,用来封装一些初始化逻辑,我们可以在 ChannelInitializer 中加入各种 ChannelHandler,实现不同处理逻辑的组合。
7.4 绑定端口
绑定端口就是给服务器指定一个监控的端口,根据我们之前的各种配置,触发服务器真正的启动。
7.5 Netty 是如何处理事件编排的
通过上面的介绍我们知道,Netty 会为每一个连接创建一个 channel,通过往 channel 中配置 ChannelHandler 来处理相应的事件。那么,这些事件是如何编排的呢?它和 bossGroup 和 workerGroup 又有什么联系呢?这一节我们看下 Netty 是如何处理事件编排的。
在开始之前,我们先来看下 Channel、EventLoop、EventLoopGroup 这三者之间的关系。
首先,EventLoopGroup 其实就是一个线程池。从上面我们看到,我们会创建一个 bossGroup ,一般而言,我们只会给 bossGroup 配置一个线程用来处理新连接的建立,此时线程池里面只有一个 EventLoop,对应的就是一个线程,而 workerGroup 往往是多个线程,用来处理已经建立完毕的连接。
EventLoop 从名字来看,就是 loop event,中文就是「循环事件」,字面来看,就是不断的看看 Channel 上有没有什么事件发生,有的话就处理。
也就是说 bossGroup 会处理建连的请求,为新连接生成一个子 Channel,将其注册到 workerGroup 中的其中一个 EventLoop,于是这个 Channel 的整个生命周期都由这个 EventLoop 来处理,这个 EventLoop 会不断地循环此 Channel 是否有事件发生,有就处理。
一个 EventLoop 只会与一个线程绑定,所以是线程安全的。不过,一个 EventLoop 可以与多个 Channel 绑定,这个也比较好理解,咱们一个员工通常都是要对接多个客户的嘛。
小结下关系:
- 一个 Channel 对应一个连接
- 一个 Channel 只会分配给一个 EvenLoop
- 一个 EvenLoop 只会绑定一个线程
- 一个 EvenLoopGroup 包含一个或多个 EvenLoop
现在把客户和干活的员工搞清楚了,接下来再看员工是怎么干活的。也就是来看看 ChannelHandler 、ChannelPipeline、ChannelHandlerContext 的关系。
由于不同公司处理的客户业务不同,所以我们需要把业务剥离出来,让公司可以自行组装来让员工干活,与之对应的是将业务抽象成一个个 ChannelHandler ,由使用者自行编排,由一个叫 ChannelPipeline 的玩意将编排的 ChannelHandler 串联起来。届时,只要事件一来,直接遍历 ChannelPipeline ,逐个调用对应的 ChannelHandler 来进行事件的处理,这样不动的就是框架的行为:“逐个执行对应的 ChannelHandler ”,变的就是用户自行实现和编排的 ChannelHandler,这就是框架与业务隔离,也就是责任链模式。
下面是 HttpHelloWorldServerInitializer 的代码:
public void initChannel(SocketChannel ch) {
// 创建一个 ChannelPipeline,用来串联各个 ChannelHandler
ChannelPipeline p = ch.pipeline();
if (sslCtx != null) {
p.addLast(sslCtx.newHandler(ch.alloc()));
}
// 向 ChannelPipeline 中添加各个 ChannelHandler
p.addLast(new HttpServerCodec());
p.addLast(new HttpServerExpectContinueHandler());
p.addLast(new HttpHelloWorldServerHandler());
}
实际上,ChannelPipeline 是一个双向链表,这是因为 ChannelHandler 分为入站和出站两种类型,称为 ChannelInboundHandler 和 ChannelOutboundHandler。入站是指接收远程消息,出站指发送消息给远程,两种类型分别实现不同的处理逻辑,比如入站需要解码,出站则需要编码等,如下图所示:
还有一点需要注意,每新建一个 Channel,对应的都会 new 一个 ChannelPipeline 和它绑定。此外,ChannelPipeline 内的 ChannelHandler 有可能被多个 channel 共享使用的,也有可能是每个 channel 独享的,这取决于 ChannelHandler 的作用域。
7.6 小结
至此,我们可以梳理出 Netty 服务端的主线就是:
- 配置 bossGroup 和 workerGroup 两个线程组,分别处理建连和 channel 的 I/O 事件,建连生成的 channel 会分配给 workerGroup 中的某个 eventLoop,由这个 eventLoop 来处理此 channel 的发生的所有事件
- 具体的处理逻辑被封装成一个个 ChannelHandler ,ChannelHandler 又划分为入站 ChannelHandler 和出站 ChannelHandler 两个类型,它们会串成链表形成一个 ChannelPipeline
- 每个 Channel 独享一个 ChannelPipeline ,当有事件发生时,事件会顺着 ChannelPipeline 里的 ChannelHandler 一步步传递并执行相应的业务逻辑,执行者就是与之绑定的 eventLoop,而一个 eventLoop 对应一个线程。
再来看下刚开始的图,是不是对 Netty 的主线清晰多了:
八、总结
本文先从常用的几种 I/O 模型入手,引出了 Netty 使用的 I/O 模型,即 Java NIO,接着从 Netty 是什么、为什么要用 Netty、Netty 架构和 Netty 主线对 Netty 作了一个简单的介绍。
由于本人也是刚刚开始自学 Netty 的小白,文章中如果有表达有误的地方还望大家多多指出。个人觉得 Netty 是学习 Java 服务端开发的一个非常好的案例,后面我会继续学习 Netty,如果发现有趣的知识也会继续和大家分享,谢谢大家的支持~