Netty的高性能设计
在本小节中,我们来探索Netty的高性能设计,来了解Netty的性能高在哪里?
要想了解Netty的性能为什么高,就需要从Java的IO模型聊起,然后对网络编程中的Reactor线程模型理 解,Netty就是使用Java的NIO实现了Reactor线程模型,理解这么多内容需要理解很多的概念,下面我 们将一点点的进行学习了解。
# 2.1、Java中的IO模型
# 2.1.0、I/0 中的两组概念
同步/异步,阻塞/非阻塞
在I/O操作中有这么两组概念,其中同步/异步 要和线程中的同步线程/异步线程要区分开,这里指的是同步 IO/异步IO
- 阻塞/非阻塞:没有数据传过来时,读会阻塞直到有数据;缓冲区满时,写操作也会阻塞。 非阻塞遇到这些情况,都是直接返回。
- 同步/异步:数据就绪后需要自己去读是同步,数据就绪后系统直接读好再回调给程序是异步。
在JDK1.4之前,基于Java所有的socket通信都采用了同步阻塞模型(BIO),这种模型性能低下,当时 大型的服务均采用C或C++开发,因为它们可以直接使用操作系统提供的异步IO或者AIO,使得性能得到 大幅提升。
2002年,JDK1.4发布,新增了java.nio包,提供了许多异步IO开发的API和类库。新增的NIO,极大的促 进了基于Java的异步非阻塞的发展和应用。
2011年,JDK7发布,将原有的NIO进行了升级,称为NIO2.0,其中也对AIO进行了支持。
# 2.1.1、BIO模型
java中的BIO是blocking I/O的简称,它是同步阻塞型IO,其相关的类和接口在java.io下。 BIO模型简单来讲,就是服务端为每一个请求都分配一个线程进行处理,I/O操作都是基于流Stream的操作
如下:
示例代码:
public class BIOServer
{
public static void main(String[] args) throws Exception
{
ServerSocket serverSocket = new ServerSocket(6666);
ExecutorService executorService = Executors.newCachedThreadPool();
while(true)
{
System.out.println("等待客户端连接。。。。");
Socket socket = serverSocket.accept(); //阻塞
executorService.execute(() - >
{
try
{
InputStream inputStream = socket.getInputStream(); //阻塞
byte[] bytes = new byte[1024];
while(true)
{
int length = inputStream.read(bytes);
if(length == -1)
{
break;
}
System.out.println(new String(bytes, 0, length, "UTF-8 "));
}
}
catch (Exception e)
{
e.printStackTrace();
}
});
}
}
}
这种模式存在的问题:
线程开销:客户端的并发数与后端的线程数成1:1的⽐例,线程的创建、销毁是⾮常消耗系统资源的,随着并发量增⼤,服务端性能将显著下降,甚⾄会发⽣线程堆栈溢出等错误。
线程阻塞:当连接创建后,如果该线程没有操作时,会进⾏阻塞操作,这样极⼤的浪费了服务器资源。
# 2.1.2、NIO模型
NIO,称之为New IO 或是 non-block IO (⾮阻塞IO),这两种说法都可以,其实称之为⾮阻塞IO更恰
当⼀些。
NIO相关的代码都放在了java.nio包下,其三⼤核⼼组件:Buffer(缓冲区)、Channel(通道)、
Selector(选择器/多路复⽤器)
# Buffer(缓冲区):
Buffer是一个对象,包含一些要写入或者读出的数据,体现了与原I/O的一个重要区别,在面向流的I/O中,数据读写是直接进入到Steam中,而在NIO中,所有数据都是用缓冲区处理的,读数据直接从缓冲区读,写数据直接写入到缓冲区。
缓冲区的本质是一个数组,通常是一个字节数组(ByteBuffer),也可以使用其他类型,但缓冲区又不仅仅是一个数组,它还提供了对数据结构化访问以及维护读写位置等操作。
在NIO中,所有的读写操作都是基于缓冲区完成的,底层是通过数组实现的,常⽤的缓冲区是ByteBuffer,每⼀种java基本类型都有对应的缓冲区对象(除了Boolean类型),如:CharBuffer、IntBuffer、LongBuffer等。
# Channel(通道)
Channel 是一个通道,管道,网络数据通过Channel读取和写入,Channel和流 Stream的不同之处在于Channel是双向的,流只在一个方向上移动(InputStream/OutputStream), 而Channel可以用于读写同时进行,即Channel是全双工的。
在BIO中是基于Stream实现,⽽在NIO中是基于通道实现,与流不同的是,通道是双向的,
既可以读也可以写。
JAVA NIO-Channel
网络读写 文件读写
JAVA NIO-ServerSocketChannel和SocketChannel
# Selector
Selector会不断轮询注册在其上的Channel,如果某个Channel上 面发生读或者写事件,即该Channel处于就绪状态,它就会被Selector轮询出来,然后通过 selectedKeys可以获取就绪Channel的集合,进行后续的I/O操作。
Selector是多路复⽤器,它会不断的轮询注册在其上的Channel,如果某个Channel上发⽣读或写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey获取就绪Channel的集合,进⾏IO的读写操作。
基本示意图如下:
可以看出,NIO模型要优于BIO模型,主要是:
通过多路复⽤器就可以实现⼀个线程处理多个通道,避免了多线程之间的上下⽂切换导致系统开销过⼤。
NIO⽆需为每⼀个连接开⼀个线程处理,并且只有通道真正有有事件时,才进⾏读写操作,这样⼤⼤的减少了系统开销。
示例代码:
public class SelectorDemo
{
/**
* 注册事件
*
* @return
*/
private Selector getSelector() throws Exception
{
//获取selector对象
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); //⾮阻塞
//获取通道并且绑定端⼝
ServerSocket socket = serverSocketChannel.socket();
socket.bind(new InetSocketAddress(6677));
//注册感兴趣的事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
return selector;
}
public void listen() throws Exception
{
Selector selector = this.getSelector();
while(true)
{
selector.select(); //该⽅法会阻塞,直到⾄少有⼀个事件的发⽣
Set < SelectionKey > selectionKeys = selector.selectedKeys();
Iterator < SelectionKey > iterator = selectionKeys.iterator();
while(iterator.hasNext())
{
SelectionKey selectionKey = iterator.next();
process(selectionKey, selector);
iterator.remove();
}
}
}
private void process(SelectionKey key, Selector selector) throws Exception
{
if(key.isAcceptable())
{ //新连接请求
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel channel = server.accept();
channel.configureBlocking(false); //⾮阻塞
channel.register(selector, SelectionKey.OP_READ);
}
else if(key.isReadable())
{ //读数据
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
channel.read(byteBuffer);
System.out.println("form 客户端 " + new String(byteBuffer.array(), 0, byteBuffer.position()));
}
}
public static void main(String[] args) throws Exception
{
new SelectorDemo().listen();
}
}
# 2.1.3、AIO模型
在NIO中,Selector多路复⽤器在做轮询时,如果没有事件发⽣,也会进⾏阻塞,如何能把这个阻塞也
优化掉呢?那么AIO就在这样的背景下诞⽣了。
AIO是asynchronous I/O的简称,是异步IO,该异步IO是需要依赖于操作系统底层的异步IO实现。
AIO的基本流程是:⽤户线程通过系统调⽤,告知kernel内核启动某个IO操作,⽤户线程返回。kernel
内核在整个IO操作(包括数据准备、数据复制)完成后,通知⽤户程序,⽤户执⾏后续的业务操作。
- kernel的数据准备
- 将数据从⽹络物理设备(⽹卡)读取到内核缓冲区。
- kernel的数据复制
- 将数据从内核缓冲区拷⻉到⽤户程序空间的缓冲区。
⽬前AIO模型存在的不⾜:
需要完成事件的注册与传递,这⾥边需要底层操作系统提供⼤量的⽀持,去做⼤量的⼯作。
Windows 系统下通过 IOCP 实现了真正的异步 I/O。但是,就⽬前的业界形式来说,Windows 系统,很少作为百万级以上或者说⾼并发应⽤的服务器操作系统来使⽤。
⽽在 Linux 系统下,异步IO模型在2.6版本才引⼊,⽬前并不完善。所以,这也是在 Linux 下,实现⾼并发⽹络编程时都是以 NIO 多路复⽤模型模式为主。
# 2.2、Reactor线程模型
Reactor线程模型不是Java专属,也不是Netty专属,它其实是⼀种并发编程模型,是⼀种思想,具有指
导意义。⽐如,Netty就是结合了NIO的特点,应⽤了Reactor线程模型所实现的。
Reactor模型中定义的三种⻆⾊:
Reactor:负责监听和分配事件,将I/O事件分派给对应的Handler。新的事件包含连接建⽴就绪、读就绪、写就绪等。
Acceptor:处理客户端新连接,并分派请求到处理器链中。
Handler:将⾃身与事件绑定,执⾏⾮阻塞读/写任务,完成channel的读⼊,完成处理业务逻辑后,负责将结果写出channel。
常⻅的Reactor线程模型有三种,如下:
Reactor单线程模型
Reactor多线程模型
主从Reactor多线程模型
# 2.2.1、单Reactor单线程模型
说明:
Reactor充当多路复⽤器⻆⾊,监听多路连接的请求,由单线程完成
Reactor收到客户端发来的请求时,如果是新建连接通过Acceptor完成,其他的请求由Handler完成。
Handler完成业务逻辑的处理,基本的流程是:Read --> 业务处理 --> Send 。
这种模型的优缺点:
优点
结构简单,由单线程完成,没有多线程、进程通信等问题。
适合⽤在⼀些业务逻辑⽐较简单、对于性能要求不⾼的应⽤场景。
缺点
由于是单线程操作,不能充分发挥多核CPU的性能。
当Reactor线程负载过重之后,处理速度将变慢,这会导致⼤量客户端连接超时,超时之后往往会进⾏重发,这更加重Reactor线程的负载,最终会导致⼤量消息积压和处理超时,成为系统的性能瓶颈。
可靠性差,如果该线程进⼊死循环或意外终⽌,就会导致整个通信系统不可⽤,容易造成单点故障。
# 2.2.2、单Reactor多线程模型
说明:
在Reactor多线程模型相⽐较单线程模型⽽⾔,不同点在于,Handler不会处理业务逻辑,只是负责响应⽤户请求,真正的业务逻辑,在另外的线程中完成。
这样可以降低Reactor的性能开销,充分利⽤CPU资源,从⽽更专注的做事件分发⼯作了,提升整个应⽤的吞吐。
但是这个模型存在的问题:
多线程数据共享和访问⽐较复杂。如果⼦线程完成业务处理后,把结果传递给主线程Reactor进⾏发送,就会涉及共享数据的互斥和保护机制。
Reactor承担所有事件的监听和响应,只在主线程中运⾏,可能会存在性能问题。例如并发百万客户端连接,或者服务端需要对客户端握⼿进⾏安全认证,但是认证本身⾮常损耗性能。
为了解决性能问题,产⽣了第三种主从Reactor多线程模型。
# 2.2.3、主从Reactor多线程模型
在主从模型中,将Reactor分成2部分:
MainReactor负责监听server socket,⽤来处理⽹络IO连接建⽴操作,将建⽴的socketChannel指定注册给SubReactor。
SubReactor主要完成和建⽴起来的socket的数据交互和事件业务处理操作。
这种模式的基本工作流程为:
1)Reactor 主线程 MainReactor 对象通过 select 监听客户端连接事件,收到事件后,通过 Acceptor 处理客户端连接事件。
2)当 Acceptor 处理完客户端连接事件之后(与客户端建立好 Socket 连接),MainReactor 将连接分配给SubReactor。(即:MainReactor 只负责监听客户端连接请求,和客户端建立连接之后将连接交由SubReactor 监听后面的 IO 事件。)
3)SubReactor 将连接加入到自己的连接队列进行监听,并创建 Handler 对各种事件进行处理。
4)当连接上有新事件发生的时候,SubReactor 就会调用对应的 Handler 处理。
5)Handler 通过 read 从连接上读取请求数据,将请求数据分发给 Worker 线程池进行业务处理。
6)Worker 线程池会分配独立线程来完成真正的业务处理,并将处理结果返回给 Handler。Handler 通过send 向客户端发送响应数据。
7)一个 MainReactor 可以对应多个 SubReactor,即一个 MainReactor 线程可以对应多个SubReactor 线程
这种模式的优势如下:
1)MainReactor 线程与 SubReactor 线程的数据交互简单职责明确,MainReactor 线程只需要接收新连接,SubReactor 线程完成后续的业务处理。
2)MainReactor 线程与 SubReactor 线程的数据交互简单, MainReactor 线程只需要把新连接传给SubReactor 线程,SubReactor 线程无需返回数据。
3)多个 SubReactor 线程能够应对更高的并发请求。这种模式的缺点是编程复杂度较高。但是由于其优点明显,在许多项目中被广泛使用,包括 Nginx、Memcached、Netty 等。
这种模式也被叫做服务器的 1+M+N 线程模式,即使用该模式开发的服务器包含一个(或多个,1 只是表示相对较少)连接建立线程+M 个 IO 线程+N 个业务处理线程。这是业界成熟的服务器程序设计模式。
该模型的优点:
响应快,不必为单个同步事件所阻塞,虽然Reactor本身依然是同步的。
可扩展性强,可以⽅便地通过增加SubReactor实例个数来充分利⽤CPU资源。
可复⽤性⾼,Reactor模型本身与具体事件处理逻辑⽆关,具有很⾼的复⽤性。
# 2.3、Netty模型
# Netty对三种IO的支持
Netty模型是基于Reactor模型实现的,对于以上三种模型都有⾮常好的⽀持,也⾮常的灵活,⼀般情
况,在服务端会采⽤主从架构模型,基本示意图如下:
说明:
在Netty模型中,负责处理新连接事件的是BossGroup,负责处理其他事件的是WorkGroup。Group就是线程池的概念。
NioEventLoop表示⼀个不断循环的执⾏处理任务的线程,⽤于监听绑定在其上的读/写事件。
通过Pipeline(管道)执⾏业务逻辑的处理,Pipeline中会有多个ChannelHandler,真正的业务逻辑是在ChannelHandler中完成的。
工作流程
- 1)Netty 抽象出两组线程池:BossGroup和WorkerGroup,每个线程池中都有EventLoop 线程(可以是OIO,NIO,AIO)。BossGroup中的线程专门负责和客户端建立连接,WorkerGroup中的线程专门负责处理连接上的读写, EventLoopGroup 相当于一个事件循环组,这个组中含有多个事件循环
- 2)EventLoop 表示一个不断循环的执行事件处理的线程,每个EventLoop 都包含一个 Selector,用于监听注册在其上的 Socket 网络连接(Channel)。
- 3)每个 Boss EventLoop 中循环执行以下三个步骤:
- 3.1)select:轮训注册在其上的 ServerSocketChannel 的 accept 事件(OP_ACCEPT 事件)
- 3.2)processSelectedKeys:处理 accept 事件,与客户端建立连接,生成一个SocketChannel,并将其注册到某个 WorkerEventLoop 上的 Selector 上
- 3.3)runAllTasks:再去以此循环处理任务队列中的其他任务
- 4)每个 Worker EventLoop 中循环执行以下三个步骤:
- 4.1)select:轮训注册在其上的SocketChannel 的 read/write 事件(OP_READ/OP_WRITE 事件)
- 4.2)processSelectedKeys:在对应的SocketChannel 上处理 read/write 事件
- 4.3)runAllTasks:再去以此循环处理任务队列中的其他任务
- 5)在以上两个processSelectedKeys步骤中,会使用 Pipeline(管道),Pipeline 中引用了 Channel,即通过 Pipeline 可以获取到对应的 Channel,Pipeline 中维护了很多的处理器(拦截处理器、过滤处理器、自定义处理器等)。
# Netty 线程模型其他事项
- 1)Netty 的线程模型基于主从多Reactor模型。通常由一个线程负责处理OP_ACCEPT事件,拥有 CPU 核数的两倍的IO线程处理读写事件
- 2)一个通道的IO操作会绑定在一个IO线程中,而一个IO线程可以注册多个通道
- 3)在一个网络通信中通常会包含网络数据读写,编码、解码、业务处理。默认情况下网络数据读写,编码、解码等操作会在IO线程中运行,但也可以指定其他线程池。
- 4)通常业务处理会单独开启业务线程池(看业务类型),但也可以进一步细化,例如心跳包可以直接在IO线程中处理,而需要再转发给业务线程池,避免线程切换
- 5)在一个IO线程中所有通道的事件是串行处理的。
- 6)通常业务操作会专门开辟一个线程池,那业务处理完成之后,如何将响应结果通过 IO 线程写入到网卡中呢?业务线程调用 Channel对象的 write 方法并不会立即写入网络,只是将数据放入一个待写入缓存区,然后IO线程每次执行事件选择后,会从待写入缓存区中获取写入任务,将数据真正写入到网络中