Java开发大型互联网RPC底层原理之Netty线程模型浅析

引言

Netty是由JBOSS提供的一个java开源框架。Netty提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。

也就是说,Netty 是一个基于NIO的客户、服务器端编程框架,使用Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户,服务端应用。Netty相当简化和流线化了网络应用的编程开发过程,例如,TCP和UDP的socket服务开发。

RPC(Remote Procedure Call Protocol)——远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。RPC协议假定某些传输协议的存在,如TCP或UDP,为通信程序之间携带信息数据。在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。

RPC采用客户机/服务器模式。请求程序就是一个客户机,而服务提供程序就是一个服务器。首先,客户机调用进程发送一个有进程参数的调用信息到服务进程,然后等待应答信息。在服务器端,进程保持睡眠状态直到调用信息到达为止。当一个调用信息到达,服务器获得进程参数,计算结果,发送答复信息,然后等待下一个调用信息,最后,客户端调用进程接收答复信息,获得进程结果,然后调用执行继续进行。

有多种 RPC模式和执行。最初由 Sun 公司提出。IETF ONC 宪章重新修订了 Sun 版本,使得 ONC RPC 协议成为 IETF 标准协议。现在使用最普遍的模式和执行是开放式软件基础的分布式计算环境(DCE)。

1 定义

RPC 的全称是 Remote Procedure Call 是一种进程间通信方式。 它允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不用程序员显式编码这个远程调用的细节。即程序员无论是调用本地的还是远程的函数,本质上编写的调用代码基本相同。

2 起源

RPC 这个概念术语在上世纪 80 年代由 Bruce Jay Nelson(参考[1])提出。 这里我们追溯下当初开发 RPC 的原动机是什么?在 Nelson 的论文Implementing Remote Procedure Calls(参考[2]) 中他提到了几点:

简单:RPC 概念的语义十分清晰和简单,这样建立分布式计算就更容易。

高效:过程调用看起来十分简单而且高效。

通用:在单机计算中「过程」往往是不同算法部分间最重要的通信机制。

通俗一点说,就是一般程序员对于本地的过程调用很熟悉,那么我们把 RPC 做成和本地调用完全类似,那么就更容易被接受,使用起来毫无障碍。 Nelson 的论文发表于 30 年前,其观点今天看来确实高瞻远瞩,今天我们使用的 RPC 框架基本就是按这个目标来实现的。

3 目标

RPC 的主要目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用。

4 分类

RPC 调用分以下两种:

同步调用:客户端等待调用执行完成并获取到执行结果。

异步调用:客户端调用后不用等待执行结果返回,但依然可以通过回调通知等方式获取返回结果。若客户端不关心调用返回结果,则变成单向异步调用,单向调用不用返回结果。

异步和同步的区分在于是否等待服务端执行完成并返回结果。

5 结构

下面我们对 RPC 的结构从理论模型到真实组件一步步抽丝剥茧。

5.1 模型

最早在 Nelson 的论文中指出实现 RPC 的程序包括 5 个理论模型部分:

User

User-stub

RPCRuntime

Server-stub

Server

这 5 个部分的关系如下图所示:

《Java开发大型互联网RPC底层原理之Netty线程模型浅析》
《Java开发大型互联网RPC底层原理之Netty线程模型浅析》

这里 User 就是 Client 端。当 User 想发起一个远程调用时,它实际是通过本地调用 User-stub。 User-stub 负责将调用的接口、方法和参数通过约定的协议规范进行编码并通过本地的 RPCRuntime 实例传输到远端的实例。 远端 RPCRuntime 实例收到请求后交给 Server-stub 进行解码后发起向本地端 Server 的调用,调用结果再返回给 User 端。

5.2 拆解

上面给出了一个比较粗粒度的 RPC 实现理论模型概念结构,这里我们进一步细化它应该由哪些组件构成,如下图所示。

RPC 服务端通过RpcServer去导出(export)远程接口方法,而客户端通过RpcClient去导入(import)远程接口方法。客户端像调用本地方法一样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理RpcProxy。代理封装调用信息并将调用转交给RpcInvoker去实际执行。在客户端的RpcInvoker通过连接器RpcConnector去维持与服务端的通道RpcChannel,并使用RpcProtocol执行协议编码(encode)并将编码后的请求消息通过通道发送给服务端。

RPC 服务端接收器RpcAcceptor接收客户端的调用请求,同样使用RpcProtocol执行协议解码(decode)。

解码后的调用信息传递给RpcProcessor去控制处理调用过程,最后再委托调用给RpcInvoker去实际执行并返回调用结果。

5.3 组件

上面我们进一步拆解了 RPC 实现结构的各个组件组成部分,下面我们详细说明下每个组件的职责划分。

1 RpcServer

负责导出(export)远程接口

2 RpcClient

负责导入(import)远程接口的代理实现

3 RpcProxy

远程接口的代理实现

4 RpcInvoker

客户端:负责编码调用信息和发送调用请求到服务端并等待调用结果返回

服务端:负责调用服务端接口的具体实现并返回调用结果

5 RpcProtocol

负责协议编/解码

6 RpcConnector

负责维持客户端和服务端的连接通道和发送数据到服务端

7 RpcAcceptor

负责接收客户端请求并返回请求结果

8 RpcProcessor

负责在服务端控制调用过程,包括管理调用线程池、超时时间等

9 RpcChannel

数据传输通道

6 实现

Nelson 论文中给出的这个概念模型也成为后来大家参考的标准范本。十多年前,我最早接触分布式计算时使用的 CORBAR(参考[3])实现结构基本与此基本类似。CORBAR 为了解决异构平台的 RPC,使用了 IDL(Interface Definition Language)来定义远程接口,并将其映射到特定的平台语言中。

后来大部分的跨语言平台 RPC 基本都采用了此类方式,比如我们熟悉的 Web Service(SOAP),近年开源的 Thrift 等。 他们大部分都通过 IDL 定义,并提供工具来映射生成不同语言平台的 User-stub 和 Server-stub,并通过框架库来提供 RPCRuntime 的支持。 不过貌似每个不同的 RPC 框架都定义了各自不同的 IDL 格式,导致程序员的学习成本进一步上升。而 Web Service 尝试建立业界标准,无赖标准规范复杂而效率偏低,否则 Thrift 等更高效的 RPC 框架就没必要出现了。

IDL 是为了跨平台语言实现 RPC 不得已的选择,要解决更广泛的问题自然导致了更复杂的方案。 而对于同一平台内的 RPC 而言显然没必要搞个中间语言出来,例如 Java 原生的 RMI,这样对于 Java 程序员而言显得更直接简单,降低使用的学习成本。

在上文进一步拆解了组件并划分了职责之后,下面就以在 Java 平台实现该 RPC 框架概念模型为例,详细分析下实现中需要考虑的因素。

6.1 导出

导出是指暴露远程接口的意思,只有导出的接口可以供远程调用,而未导出的接口则不能。 在 Java 中导出接口的代码片段可能如下:

DemoService demo =new…;

RpcServer server =new…;

server.export(DemoService.class, demo, options);

我们可以导出整个接口,也可以更细粒度一点只导出接口中的某些方法,如下:

// 只导出 DemoService 中签名为 hi(String s) 的方法

server.export(DemoService.class, demo,”hi”,newClass[] { String.class}, options);

Reactor单线程模型

所有的I/O操作都由同一个NIO线程完成。只适合小容量应用场景。不适合高负载大并发的应用场景。原因如下:

a、 一个NIO线程同时处理成百上千条线路,性能上无法支撑,即使CPU使用率达到百分之百,也无法满足海量消息编解码、读取、发送。

b、 当NIO线程负载过重后,处理会变慢,导致大量客户端连接超时,而超时可能重发,导致越来越积压严重。

c、 可靠性问题,一旦NIO线程跑飞了或者进入死循环,整个系统通信都不可用了,造成单点故障。

Reactor多线程模型

由一组NIO线程来处理I/O操作。特点如下:

a、 有专门一个NIO线程–Acceptor线程用来监听服务端,接收客户端的TCP连接请求。

b、 网络IO操作–读写等由一个NIO线程池负责,线程池可以采用标准的JDK线程池实现,它包含一个任务队列和N个可用的线程,由这些NIO线程来完成消息的读取、发送和编解码操作。

c、 一个NIO线程可以同时处理N条链路。但是同一个链路只对应一个NIO线程,防止发生并发操作问题。

在绝大多数场景下,Reactor多线程模型可以满足性能要求。但是,个别特殊场景中,一个NIO线程负责监听和处理所有的客户端连接可能会存在性能问题。例如并发百万客户端连接,或者服务端需要对客户端握手进行安全认证,但是认证本身非常损耗性能。

主从Reactor多线程模型

特点是:服务端用来接收客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO线程池。Acceptor接收到客户端TCP连接请求并处理完成后,将新创建的SocketChannel注册到IO线程池的某个IO线程上,由它负责SocketChanel的读写和编解码操作。Acceptor线程池仅仅用于客户端的登录、握手和安全认证,一旦链路创建成功,就将链路注册到后端的线程池中的Io线程上,有IO线程负责后续的操作。

Java 中还有一种比较特殊的调用就是多态,也就是一个接口可能有多个实现,那么远程调用时到底调用哪个?这个本地调用的语义是通过 JVM 提供的引用多态性隐式实现的,那么对于 RPC 来说跨进程的调用就没法隐式实现了。如果前面 DemoService 接口有 2 个实现,那么在导出接口时就需要特殊标记不同的实现,如下:

DemoService demo =new…;

DemoService demo2 =new…;

RpcServer server =new…;

server.export(DemoService.class, demo, options);

server.export(“demo2”, DemoService.class, demo2, options);

上面 demo2 是另一个实现,我们标记为 demo2 来导出,

那么远程调用时也需要传递该标记才能调用到正确的实现类,这样就解决了多态调用的语义。

6.2 导入

导入相对于导出而言,客户端代码为了能够发起调用必须要获得远程接口的方法或过程定义。目前,大部分跨语言平台 RPC 框架采用根据 IDL 定义通过 code generator 去生成 User-stub 代码,这种方式下实际导入的过程就是通过代码生成器在编译期完成的。我所使用过的一些跨语言平台 RPC 框架如 CORBAR、WebService、ICE、Thrift 均是此类方式。

代码生成的方式对跨语言平台 RPC 框架而言是必然的选择,而对于同一语言平台的 RPC 则可以通过共享接口定义来实现。

在 Java 中导入接口的代码片段可能如下:

RpcClient client =new…;

DemoService demo = client.refer(DemoService.class);

demo.hi(“how are you?”);

在 Java 中import是关键字,所以代码片段中我们用 refer 来表达导入接口的意思。 这里的导入方式本质也是一种代码生成技术,只不过是在运行时生成,比静态编译期的代码生成看起来更简洁些。Java 里至少提供了两种技术来提供动态代码生成,一种是 JDK 动态代理,另外一种是字节码生成。 动态代理相比字节码生成使用起来更方便,但动态代理方式在性能上是要逊色于直接的字节码生成的,而字节码生成在代码可读性上要差很多。两者权衡起来,作为一种底层通用框架,个人更倾向于选择性能优先。

6.3 协议

协议指 RPC 调用在网络传输中约定的数据封装方式,包括三个部分:编解码消息头消息体

6.3.1 编解码

客户端代理在发起调用前需要对调用信息进行编码,这就要考虑需要编码些什么信息并以什么格式传输到服务端才能让服务端完成调用。 出于效率考虑,编码的信息越少越好(传输数据少),编码的规则越简单越好(执行效率高)。

原生的NIO服务端

public class NIOServer {

//通道管理器

private Selector selector;

public void initServer(int port) throws IOException {

// 获得一个ServerSocket通道

ServerSocketChannel serverChannel = ServerSocketChannel.open();

// 设置通道为非阻塞

serverChannel.configureBlocking(false);

// 将该通道对应的ServerSocket绑定到port端口

serverChannel.socket().bind(new InetSocketAddress(port));

// 获得一个通道管理器

this.selector = Selector.open();

//将通道管理器和该通道绑定,并为该通道注册SelectionKey.OP_ACCEPT事件,注册该事件后,

//当该事件到达时,selector.select()会返回,如果该事件没到达selector.select()会一直阻塞。

serverChannel.register(selector, SelectionKey.OP_ACCEPT);

}

public static void main(String[] args) throws IOException {

NIOServer server = new NIOServer();

server.initServer(8000);

}

}

从原生的Nio启动逻辑来看

如果要实现Nio的服务端必须经历以下阶段:

Channel.open->configureBlocking(false)->bind->Selctor.open()->register

而Netty也不列外,但是他是怎么做的呢?

Netty的NIO服务端

// Configure the server.

EventLoopGroup bossGroup = new NioEventLoopGroup(1);

EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap b = new ServerBootstrap();

b.group(bossGroup, workerGroup)

.channel(NioServerSocketChannel.class)

.option(ChannelOption.SO_BACKLOG, 100)

.handler(new LoggingHandler(LogLevel.INFO))

.childHandler(new ChannelInitializer() {

@Override

public void initChannel(SocketChannel ch) throws Exception {

ChannelPipeline p = ch.pipeline();

p.addLast(new EchoServerHandler());

}

});

// 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();

}

    原文作者:LeiLv
    原文地址: https://www.jianshu.com/p/c50d4b60d826
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞