原文链接:《netty实战》
Netty是一款异步的事件驱动的网络应用程序框架,支持快速地开发可维护的高性能的面向协议的服务器和客户端。

一 异步和事件驱动
1.1 阻塞io(bio)

每个网络请求都需要独立的线程完成数据的读操作、业务处理、写操作。当并发量大时,需要创建大量线程来处理网络连接,系统资源占用大,连接建立后,如果没有数据需要读,则线程阻塞在读操作上,造成资源浪费。
1.2 非阻塞io(nio)

i/o复用模型中,用到了selector,它也会阻塞,但是和阻塞i/o不同的是,selector会阻塞多个i/o的读写操作,直到某个i/o有数据可读或可写时,才会真正调用i/o操作函数。
由于读写操作都是非阻塞的,一个i/o线程可以处理多个客户端连接和读写操作,这就可以提高i/o线程的运行效率,避免频繁i/o阻塞导致的线程挂起。
1.3 netty的io模型

EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap server = new ServerBootstrap();
server.group(bossGroup, workerGroup)
- bossGroup线程池只是在bind某个端口后,获取一个线程做为mainReactor,专门处理端口的accept事件,每个端口对应一个 Boss 线程;
- workerGroup线程池会被SubReactor和Worker线程充分利用;
推荐阅读
二 你的第一款netty应用程序
2.1 channel、channelPipeline、channelHandler关系
在开始编码之前,我们先了解下netty中常用组件的概念:channel、channelPipeline、channelHandler;

如上图所示:对于每个客户端的socket连接,都会有一一对应的channel,每个channel又有一个与之关联的channelPipeline,一个channelPipeline会有一个channelHandler实例链。
2.2 编写echo服务器
- 编写EchoServerHandler处理接收到的消息
public class EchoServerHandler extends ChannelInboundHandlerAdapter {
private static AtomicInteger clientCount = new AtomicInteger(0);
private Integer clientId;
private void print(String msg) {
System.out.println("client id=[" + clientId + "] msg=[" + msg + "]");
}
public EchoServerHandler() {
super();
clientId = clientCount.incrementAndGet();
print("EchoServerHandler");
}
@Override
public boolean isSharable() {
print("isSharable");
return super.isSharable();
}
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
print("handlerAdded");
super.handlerAdded(ctx);
}
@Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
super.channelRegistered(ctx);
print("channelRegistered");
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
super.channelActive(ctx);
print("channelActive");
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
super.channelRead(ctx, msg);
String content = msg.toString();
print("channelRead -> " + content);
ctx.write(content);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
super.channelReadComplete(ctx);
ctx.flush();
print("channelReadComplete");
}
}
我们重写ChannelInboundHandlerAdapter的部分方法,只是在调用方法时简单的打印了方法名;
在接收到消息后只是简单的打印输出控制台,并将消息回执给客户端;
- 编写引导服务器
public class Main {
public static void main(String[] args) {
start(9090);
}
private static void start(int port) {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new StringDecoder(), new StringEncoder(), new EchoServerHandler());
}
});
ChannelFuture future = bootstrap.bind(port).sync();
future.channel().closeFuture().sync();
} catch (Exception e) {
System.out.println("服务器启动出错了, e.msg=" + e.getMessage());
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
- 通过终端telnet连接服务器
telnet 127.0.0.1 9090

- 在终端输入消息发送给服务器;

- 重新打开第二个客户端,连接到服务器,如下图:我们可以看到服务器针对第二个客户端的连接,又重新实例化了一个新的
EchoServerHandler;

三 netty的组件和设计
3.1 EventLoopGroup、EventLoop、channel关系

- EventLoopGroup:可以理解为线程池;
- EventLoop:线程池内的线程,实现上也是一个EventLoop对应一个Thread;
- channel:与网络连接的scoket对应,同一个channel只会被同一个EventLoop管理,但是同一个EventLoop可以管理多个channel;
3.2 编解码器
网络中传输的数据都是字节流,当我们接收到客户端消息时就需要把字节流解码为能够识别的数据格式,这时候就需要解码器。当我们响应客户端请求时,又需要将响应的数据编码为网络传输的字节流,这时候就需要编码器。
在netty中对入站/出站消息已经默认支持了StringDecoder/StringEncoder、HttpRequestDecoder/HttpResponseEncoder、ProtobufDecoder/ProtobufEncoder等。
如果默认的编解码器不满足业务场景的话,netty还支持自定义消息的编解码器,可以实现更灵活的消息格式,自定义的消息编解码协议;
3.3 引导
- 客户端和服务器引导的区别比较

- 为什么服务器需要两个 EventLoopGroup
因为服务器需要两组不同的Channel。第一组将只包含一个ServerChannel,代表服务器自身的已绑定到某个本地端口的正在监听的套接字。而第二组将包含所有已创建的用来处理传入客户端连接(对于每个服务器已经接受的连接都有一个)的Channel。图3-4说明了这个模型,并且展示了为何需要两个不同的EventLoopGroup。

与ServerChannel相关联的EventLoopGroup将分配一个负责为传入连接请求创建Channel的EventLoop。一旦连接被接受,第二个EventLoopGroup就会给它的Channel分配一个EventLoop。
四 传输
4.1 channel的方法


Netty的Channel实现是线程安全的,因此你可以存储一个到Channel的引用,并且每当你需要向远程节点写数据时,都可以使用它,即使当时许多线程都在使用它。需要注意的是,消息将会被保证按顺序发送。
4.2 内置的传输


4.3 零拷贝

五 ByteBuf
5.1 优点
- 它可以被用户自定义的缓冲区类型扩展;
- 通过内置的复合缓冲区类型实现了透明的零拷贝;
- 容量可以按需增长(类似于JDK的StringBuilder);
- 在读和写这两种模式之间切换不需要调用ByteBuffer的flip()方法;
- 读和写使用了不同的索引;
- 支持方法的链式调用;
- 支持引用计数;
- 支持池化;
5.2 ByteBuf是如何工作的
ByteBuf维护了两个不同的索引:一个用于读取,一个用于写入。当你从ByteBuf读取时,它的readerIndex将会被递增已经被读取的字节数。同样地,当你写入ByteBuf时,它的writerIndex也会被递增。图5-1展示了一个空ByteBuf的布局结构和状态。

名称以read或者write开头的ByteBuf方法,将会推进其对应的索引,而名称以set或者get开头的操作则不会。后面的这些方法将在作为一个参数传入的一个相对索引上执行操作。
5.3 ByteBuf的使用模式
1. 堆缓冲区
最常用的ByteBuf模式是将数据存储在JVM的堆空间中。这种模式被称为支撑数组(backing array),它能在没有使用池化的情况下提供快速的分配和释放。
2. 直接缓冲区
NIO在JDK 1.4中引入的ByteBuffer类允许JVM实现通过本地调用来分配内存。这主要是为了避免在每次调用本地I/O操作之前(或者之后)将缓冲区的内容复制到一个中间缓冲区(或者从中间缓冲区把内容复制到缓冲区)。
ByteBuffer的Javadoc明确指出:“直接缓冲区的内容将驻留在常规的会被垃圾回收的堆之外。”这也就解释了为何直接缓冲区对于网络数据传输是理想的选择。如果你的数据包含在一个在堆上分配的缓冲区中,那么事实上,在通过套接字发送它之前,JVM将会在内部把你的缓冲区复制到一个直接缓冲区中。
直接缓冲区的主要缺点是,相对于基于堆的缓冲区,它们的分配和释放都较为昂贵。如果你正在处理遗留代码,你也可能会遇到另外一个缺点:因为数据不是在堆上,所以你不得不进行一次复制。
3. 复合缓冲区
第三种也是最后一种模式使用的是复合缓冲区,它为多个ByteBuf提供一个聚合视图。在这里你可以根据需要添加或者删除ByteBuf实例,这是一个JDK的ByteBuffer实现完全缺失的特性。
为了举例说明,让我们考虑一下一个由两部分——头部和主体——组成的将通过HTTP协议传输的消息。这两部分由应用程序的不同模块产生,将会在消息被发送的时候组装。该应用程序可以选择为多个消息重用相同的消息主体。当这种情况发生时,对于每个消息都将会创建一个新的头部。

六 ChannelHandler和ChannelPipeline
6.1 Channel的生命周期
Channel的正常生命周期如图6-1所示。当这些状态发生改变时,将会生成对应的事件。这些事件将会被转发给ChannelPipeline中的ChannelHandler,其可以随后对它们做出响应。

| 状态 | 描述 |
|---|---|
| ChannelUnregistered | Channel已经被创建,但还未注册到EventLoop |
| ChannelRegistered | Channel已经被注册到了EventLoop |
| ChannelActive | Channel处于活动状态(已经连接到它的远程节点)。它现在可以接收和发送数据了 |
| ChannelInactive | Channel没有连接到远程节点 |
6.2 ChannelHandler
在ChannelHandler被添加到ChannelPipeline中或者被从ChannelPipeline中移除时会调用这些操作。这些方法中的每一个都接受一个ChannelHandlerContext参数。
| 类型 | 描述 |
|---|---|
| handlerAdded | 当把ChannelHandler添加到ChannelPipeline中时被调用 |
| handlerRemoved | 当从ChannelPipeline中移除ChannelHandler时被调用 |
| exceptionCaught | 当处理过程中在ChannelPipeline中有错误产生时被调用 |
Netty定义了下面两个重要的ChannelHandler子接口:
- ChannelInboundHandler——处理入站数据以及各种状态变化;

当某个ChannelInboundHandler的实现重写channelRead()方法时,它将负责显式地释放与池化的ByteBuf实例相关的内存。
但是以这种方式管理资源可能很繁琐。一个更加简单的方式是使用SimpleChannelInboundHandler,由于SimpleChannelInboundHandler会自动释放资源,所以你不应该存储指向任何消息的引用供将来使用,因为这些引用都将会失效。
- ChannelOutboundHandler——处理出站数据并且允许拦截所有的操作

6.3 ChannelHandler适配器
你可以使用ChannelInboundHandlerAdapter和ChannelOutboundHandlerAdapter类作为自己的ChannelHandler的起始点。这两个适配器分别提供了ChannelInboundHandler和ChannelOutboundHandler的基本实现。通过扩展抽象类ChannelHandlerAdapter,它们获得了它们共同的超接口ChannelHandler的方法。生成的类的层次结构如图6-2所示。

ChannelHandlerAdapter还提供了实用方法isSharable()。如果其对应的实现被标注为Sharable,那么这个方法将返回true,表示它可以被添加到多个ChannelPipeline中。
七 EventLoop和线程模型
7.1 线程模型概述
基本的线程池化模式可以描述为:
- 从池的空闲线程列表中选择一个Thread,并且指派它去运行一个已提交的任务(一个Runnable的实现);
- 当任务完成时,将该Thread返回给该列表,使其可被重用;

7.2 EventLoop接口
“运行任务来处理在连接的生命周期内发生的事件”与之相应的编程上的构造通常被称为事件循环(EventLoop)。
以下代码说明了事件循环的基本思想,其中每个任务都是一个Runnable的实例。
while (!terminated) {
 List<Runnable> readyEvents = blockUntilEventsReady(); // ← -- 阻塞,直到有事件已经就绪可被运行
  for (Runnable ev: readyEvents) {
   ev.run(); // ← -- 循环遍历,并处理所有的事件
  }
}

在这个模型中,一个EventLoop将由一个永远都不会改变的Thread驱动,同时任务(Runnable或者Callable)可以直接提交给EventLoop实现,以立即执行或者调度执行。根据配置和可用核心的不同,可能会创建多个EventLoop实例用以优化资源的使用,并且单个EventLoop可能会被指派用于服务多个Channel。
事件/任务的执行顺序 事件和任务是以先进先出(FIFO)的顺序执行的。这样可以通过保证字节内容总是按正确的顺序被处理,消除潜在的数据损坏的可能性。
7.3 任务调度
偶尔,你将需要调度一个任务以便稍后(延迟)执行或者周期性地执行。
jdk的ScheduledExecutorService的实现具有局限性,例如,事实上作为线程池管理的一部分,将会有额外的线程创建。如果有大量任务被紧凑地调度,那么这将成为一个瓶颈。Netty通过Channel的EventLoop实现任务调度解决了这一问题。
Netty的EventLoop扩展了ScheduledExecutorService(见图7-2),所以它提供了使用JDK实现可用的所有方法。
要想取消或者检查(被调度任务的)执行状态,可以使用每个异步操作所返回的Scheduled- Future。
ScheduledFuture<?> future = ch.eventLoop().scheduleAtFixedRate(...); // ← -- 调度任务,并获得所返回的ScheduledFuture
// Some other code that runs...
boolean mayInterruptIfRunning = false;
future.cancel(mayInterruptIfRunning); // ← -- 取消该任务,防止它再次运行
这些例子说明,可以利用Netty的任务调度功能来获得性能上的提升。反过来,这些也依赖于底层的线程模型。
7.4 实现细节
7.4.1 线程管理
Netty线程模型的卓越性能取决于对于当前执行的Thread的身份的确定,也就是说,确定它是否是分配给当前Channel以及它的EventLoop的那一个线程。(回想一下EventLoop将负责处理一个Channel的整个生命周期内的所有事件)
如果(当前)调用线程正是支撑EventLoop的线程,那么所提交的代码块将会被(直接)执行。否则,EventLoop将调度该任务以便稍后执行,并将它放入到内部队列中。当EventLoop下次处理它的事件时,它会执行队列中的那些任务/事件。这也就解释了任何的Thread是如何与Channel直接交互而无需在ChannelHandler中进行额外同步的。(如下图)

我们之前已经阐明了不要阻塞当前I/O线程的重要性。我们再以另一种方式重申一次:“永远不要将一个长时间运行的任务放入到执行队列中,因为它将阻塞需要在同一线程上执行的任何其他任务。”如果必须要进行阻塞调用或者执行长时间运行的任务,我们建议使用一个专门的EventExecutor。
除了这种受限的场景,如同传输所采用的不同的事件处理实现一样,所使用的线程模型也可以强烈地影响到排队的任务对整体系统性能的影响。
7.4.2 EventLoop/线程的分配
1. 异步传输

EventLoopGroup负责为每个新创建的Channel分配一个EventLoop。在当前实现中,使用顺序循环(round-robin)的方式进行分配以获取一个均衡的分布,并且相同的EventLoop可能会被分配给多个Channel。
另外,需要注意的是,EventLoop的分配方式对ThreadLocal的使用的影响。因为一个EventLoop通常会被用于支撑多个Channel,所以对于所有相关联的Channel来说,ThreadLocal都将是一样的。这使得它对于实现状态追踪等功能来说是个糟糕的选择。然而,在一些无状态的上下文中,它仍然可以被用于在多个Channel之间共享一些重度的或者代价昂贵的对象,甚至是事件。
2. 阻塞传输

正如同之前一样,得到的保证是每个Channel的I/O事件都将只会被一个Thread(用于支撑该Channel的EventLoop的那个Thread)处理。
八 引导
8.1 Bootstrap类

服务器致力于使用一个父Channel来接受来自客户端的连接,并创建子Channel以用于它们之间的通信;而客户端将最可能只需要一个单独的、没有父Channel的Channel来用于所有的网络交互。
8.2 引导客户端

在引导的过程中,在调用bind()或者connect()方法之前,必须调用以下方法来设置所需的组件:
- group();
- channel()或者channelFactory();
- handler();
如果不这样做,则将会导致IllegalStateException。对handler()方法的调用尤其重要,因为它需要配置好ChannelPipeline。
8.3 引导服务器

具体来说,ServerChannel的实现负责创建子Channel,这些子Channel代表了已被接受的连接。
8.4 从Channel引导客户端
假设你的服务器正在处理一个客户端的请求,这个请求需要它充当第三方系统的客户端。在这种情况下,将需要从已经被接受的子Channel中引导一个客户端Channel。
一个好的解决方案是:通过将已被接受的子Channel的EventLoop传递给Bootstrap的group()方法来共享该EventLoop。因为分配给EventLoop的所有Channel都使用同一个线程,所以这避免了额外的线程创建,以及前面所提到的相关的上下文切换。这个共享的解决方案如图8-4所示。

我们在这一节中所讨论的主题以及所提出的解决方案都反映了编写Netty应用程序的一个一般准则:尽可能地重用EventLoop,以减少线程创建所带来的开销。
8.5 关闭
最重要的是,你需要关闭EventLoopGroup,它将处理任何挂起的事件和任务,并且随后释放所有活动的线程。这就是调用EventLoopGroup.shutdownGracefully()方法的作用。这个方法调用将会返回一个Future,这个Future将在关闭完成时接收到通知。需要注意的是,shutdownGracefully()方法也是一个异步的操作,所以你需要阻塞等待直到它完成,或者向所返回的Future注册一个监听器以在关闭完成时获得通知。