远程过程调用(Remote Procedure Call, RPC) 是一种常用的分布式网络通信协议, 它允许运行于一台计算机的程序调用另一台计算机的子程序, 同时将网络的通信细节隐藏起来, 使得用户无须额外地为这个交互作用编程。
RPC 是一种通过网络从远程计算机上请求服务, 但不需要了解底层网络技术的协议。RPC 协议假定某些传输协议(如 TCP 或 UDP 等) 已经存在, 并通过这些传输协议为通信程序之间传递访问请求或者应答信息。 在 OSI 网络通信模型中, RPC 跨越了传输层和应用层。 RPC 使得开发分布式应用程序更加容易 。RPC 通常采用客户机 / 服务器模型。 请求程序是一个客户机, 而服务提供程序则是一个服务器。 一个典型的 RPC 框架如图所示:
主要包括以下几个部分:
- 通信模块。 两个相互协作的通信模块实现”请求 - 应答”协议, 它们在客户和服务器之间传递请求和应答消息, 一般不会对数据包进行任何处理。“请求 – 应答”协议的实现 方式有同步方式和异步方式两种。
如图上图所示, 同步模式下客户端程序一直阻塞到服务器端发送的应答请求到达本地 ;而异步模式不同, 客户端将请求发送到服务器端后, 不必等待应答返回, 可以做其他事情,待服务器端处理完请求后, 主动通知客户端。 在高并发应用场景中, 一般采用异步模式以降低访问延迟和提高带宽利用率。
- Stub 程序。 客户端和服务器端均包含 Stub 程序, 可将之看做代理程序。 它使得远程函数调用表现得跟本地调用一样,对用户程序完全透明。 在客户端, 它表现得就 像一个本地程序, 但不直接执行本地调用, 而是将请求信息通过网络模块发送给服务器端。此外, 当服务器发送应答后, 它会解码对应结果。 在服务器端, Stub 程序 依次进行解码请求消息中的参数、调用相应的服务过程和编码应答结果的返回值等 处理。
- 调度程序。 调度程序接收来自通信模块的请求消息, 并根据其中的标识选择一个 Stub 程序进行处理。 通常客户端并发请求量比较大时,会采用线程池提高处理效率。
- 客户程序 / 服务过程。 请求的发出者和请求的处理者。 如果是单机环境, 客户程序 可直接通过函数调用访问服务过程, 但在分布式环境下,需要考虑网络通信, 这不 得增加通信模块和 Stub 程序(保证函数调用的透明性)。
通常而言, 一个 RPC 请求从发送到获取处理结果, 所经历的步骤如下图下所示:
1) 客户程序以本地方式调用系统产生的 Stub 程序;
2) 该 Stub 程序将函数调用信息按照网络通信模块的要求封装成消息包, 并交给通信模块发送到远程服务器端。
3) 远程服务器端接收此消息后, 将此消息发送给相应的 Stub 程序;
4) Stub 程序拆封消息, 形成被调过程要求的形式, 并调用对应函数;
5) 被调用函数按照所获参数执行, 并将结果返回给 Stub 程序;
6) Stub 程序将此结果封装成消息, 通过网络通信模块逐级地传送给客户程序。
Hadoop RPC 的特点概述
RPC 实际上是分布式计算中 C/S(Client/Server) 模型的一个应用实例, 对于 HadoopRPC 而言, 它具有以下几个特点。
- 透明性。 这是所有 RPC 框架最根本的特点, 即当用户在一台计算机的程序调用另外 一台计算机上的子程序时,用户自身不应感觉到其间涉及跨机器间的通信, 而是感 觉像是在执行一个本地调用。
- 高性能。 Hadoop 各个系统(如 HDFS、 YARN、 MapReduce 等) 均采用了 Master/ Slave 结构,其中, Master 实际上是一个 RPC Server, 它负责处理集群中所有 Slave 发送的服务请求, 为了保证Master的并发处理能力, RPC Server 应是一个高性能服 务器, 能够高效地处理来自多个 Client 的并发 RPC 请求。
- 可控性。 JDK 中已经自带了一个 RPC 框架—RMI(Remote Method Invocation, 远程方法调用),之所以不直接使用该框架, 主要是考虑到 RPC 是 Hadoop 最底层最 核心的模块之一, 保证其轻量级、 高性能和可控性显得尤为重要,而 RMI 重量级过大且用户可控之处太少(如网络连接、 超时和缓冲等均难以定制或者修改) 。
HadoopRPC 总体架构
同其他 RPC 框架一样, Hadoop RPC 主要分为四个部分, 分别是序列化层、 函数调用层、 网络传输层和服务器端处理框架, 具体实现机制如下:
- 序列化层。 序列化主要作用是将结构化对象转为字节流以便于通过网络进行传输或写入持久存储, 在 RPC 框架中,它主要用于将用户请求中的参数或者应答转化成字节流以便跨机器传输。 Protocol Buffers 和 Apache Avro均可用在序列化层, Hadoop 本身也提供了一套序列化框架, 一个类只要实现 Writable 接口即可支持对象序列化与反序列化。
- 函数调用层。 函数调用层主要功能是定位要调用的函数并执行该函数, Hadoop RPC 采用了 Java反射机制与动态代理实现了函数调用。
- 网络传输层。 网络传输层描述了 Client 与 Server 之间消息传输的方式, Hadoop RPC 采用了基于TCP/IP的 Socket 机制。
- 服务器端处理框架。 服务器端处理框架可被抽象为网络 I/O 模型, 它描述了客户端 与服务器端间信息交互方式,它的设计直接决定着服务器端的并发处理能力, 常见 的网络 I/O 模型有阻塞式 I/O、 非阻塞式 I/O、 事件驱动 I/O 等, 而Hadoop RPC 采 用了基于 Reactor 设计模式的事件驱动 I/O 模型。
Haddoop RPC 总体架构如图所示:
自下而上可分为两层, 第一层是一个基于 JavaNIO(New I/O) 实现的客户机 – 服务器(C/S) 通信模型。 其中, 客户端将用户的调用方法及其参数封装成请求包后发送到服务器端。服务器端收到请求包后, 经解包、 调用函数、打包结果等一系列操作后, 将结果返回给客户端。 为了增强 Sever 端的扩展性和并发处理能力, Hadoop RPC 采用了基于事件驱动的 Reactor 设计模式, 在具体实现时, 用到了 JDK提供的各种功能包, 主要包括 java.nio(NIO)、 java.lang.reflect(反射机制和动态代理)、java.net(网络编程库) 等。 第二层是供更上层程序直接调用的 RPC 接口, 这些接口底层即为 C/S 通信模型。
Hadoop RPC 使用方法
Hadoop RPC 对外主要提供了两种接口分别是:
- public static ProtocolProxy getProxy/waitForProxy(…) :构造一个客户端代理对象(该对象实现了某个协议), 用于向服务器发送 RPC 请求。
- public static Server RPC.Builder (Configuration).build() : 为某个协议(实际上是 Java 接口) 实例构造一个服务器对象, 用于处理客户端发送的请求。
通常而言, 使用 Hadoop RPC 可分为以下 4 个步骤。
1. 定义 RPC 协议
RPC 协议是客户端和服务器端之间的通信接口, 它定义了服务器端对外提供的服务接口。 如下所示, 我们定义一个 ClientProtocol 通信接口, 声明了 echo() 和 add() 两个方法。需要注意的是, Hadoop 中所有自定义 RPC 接口都需要继承 VersionedProtocol 接口, 它描述了协议的版本信息。
interface ClientProtocol extends org.apache.hadoop.ipc.VersionedProtocol {
// 版本号,默认情况下,不同版本号的 RPC Client 和 Server 之间不能相互通信
public static final long versionID = 1L;
String echo(String value) throws IOException;
int add(int v1, int v2) throws IOException;
}
2. 实现 RPC 协议
Hadoop RPC 协议通常是一个 Java 接口, 用户需要实现该接口。 对 ClientProtocol 接口进行简单的实现如下所示:
public static class ClientProtocolImpl implements ClientProtocol {
// 重载的方法,用于获取自定义的协议版本号,
public long getProtocolVersion(String protocol, long clientVersion) {
return ClientProtocol.versionID;
}
// 重载的方法,用于获取协议签名
public ProtocolSignature getProtocolSignature(String protocol, long clientVersion,
inthashcode) {
return new ProtocolSignature(ClientProtocol.versionID, null);
}
public String echo(String value) throws IOException {
return value;
}
public int add(int v1, int v2) throws IOException {
return v1 + v2;
}
}
3. 构造并启动 RPC Server
直接使用静态类 Builder 构造一个 RPC Server, 并调用函数 start() 启动该 Server:
Server server = new RPC.Builder(conf).setProtocol(ClientProtocol.class)
.setInstance(new ClientProtocolImpl()).setBindAddress(ADDRESS).setPort(0)
.setNumHandlers(5).build();
server.start();
其中, BindAddress(由函数 setBindAddress 设置) 和 Port(由函数 setPort 设置, 0 表示由系统随机选择一个端口号) 分别表示服务器的 host 和监听端口号, 而 NnumHandlers(由函数 setNumHandlers 设置) 表示服务器端处理请求的线程数目。 到此为止, 服务器处理监听状态, 等待客户端请求到达。
4. 构造 RPC Client 并发送 RPC 请求
使用静态方法 getProxy 构造客户端代理对象, 直接通过代理对象调用远程端的方法,具体如下所示:
proxy = (ClientProtocol)RPC.getProxy(
ClientProtocol.class, ClientProtocol.versionID, addr, conf);
int result = proxy.add(5, 6);
String echoResult = proxy.echo("result");
经过以上四步, 我们便利用 Hadoop RPC 搭建了一个非常高效的客户机 – 服务器网络模型。
Hadoop RPC 类详解
Hadoop RPC 主要由三个大类组成, 即 RPC、 Client 和 Server, 分别对应对外编程接口、客户端实现和服务器实现。
1. ipc.RPC 类分析
RPC 类实际上是对底层客户机 – 服务器网络模型的封装, 以便为程序员提供一套更方便简洁的编程接口。如图 所示,
RPC 类定义了一系列构建和销毁 RPC 客户端的方法, 构建方法分为getProxy 和 waitForProxy 两类, 销毁方只有一个, 即为 stopProxy。 RPC 服务器的构建则由静态内部类 RPC.Builder, 该类提供了一些列 setXxx 方法(Xxx 为某个参数名称) 供用户设置一些基本的参数, 比如 RPC 协议、 RPC 协议实现对象、 服务器绑定地址、 端口号等,一旦设置完成这些参数后, 可通过调用 RPC.Builder.build() 完成一个服务器对象的构建, 之后直接调用 Server.start() 方法便可以启动该服务器。
2. ipc.Client
Client 主要完成的功能是发送远程过程调用信息并接收执行结果。 它涉及到的类关系如图所示:
Client 类对外提供了一类执行远程调用的接口, 这些接口的名称一样, 仅仅是参数列表不同, 比如其中一个的声明如下所示:
public Writable call(RPC.RpcKind rpcKind,
Writable rpcRequest,
ConnectionId remoteId,
AtomicBoolean fallbackToSimpleAuth)
throws IOException {
return call(rpcKind, rpcRequest, remoteId, RPC.RPC_SERVICE_CLASS_DEFAULT,
fallbackToSimpleAuth);
}
Client 内部有两个重要的内部类, 分别是 Call 和 Connection。
Call 类 :
封装了一个 RPC 请求, 它包含 5 个成员变量, 分别是唯一标识 id、 函数调用信息 param、 函数执行返回值value、 出错或者异常信息 error 和执行完成标识符done。 由于 Hadoop RPC Server采用异步方式处理客户端请求, 这使远程过程调用的发生顺序与结果返回顺序无直接关系, 而 Client 端正是通过 id识别不同的函数调用的。 当客户端向服务器端发送请求时, 只需填充 id 和 param 两个变量, 而剩下的3 个变量(value、error 和 done) 则由服务器端根据函数执行情况填充。Connection 类 :
Client 与每个 Server 之间维护一个通信连接, 与该连接相关的基本信息及操作被封装到Connection 类中, 基本信息主要包括通信连接唯一标识(remoteId)、 与 Server 端通信的 Socket(socket)、 网络输入数据流(in)、 网络输出数据流(out)、 保存 RPC 请求的哈希表(calls) 等。 操作则包括:addCall—将一个 Call 对象添加到哈希表中;
sendParam—向服务器端发送 RPC 请求;
receiveResponse —从服务器端接收已经处理完成的 RPC 请求;
run—Connection 是一个线程类, 它的 run 方法调用了 receiveResponse 方法, 会一直等待接收 RPC返回结果。
当调用 call 函数执行某个远程方法时, Client 端需要进行(如图所示) 以下 4 个
步骤。
1) 创建一个 Connection 对象, 并将远程方法调用信息封装成 Call 对象, 放到 Connection对象中的哈希表中;
2) 调用 Connection 类中的 sendRpcRequest() 方法将当前 Call 对象发送给 Server 端;
3) Server 端 处 理 完 RPC 请 求 后, 将 结 果 通 过 网 络 返 回 给 Client 端, Client 端 通 过receiveRpcResponse() 函数获取结果;
4) Client 检查结果处理状态(成功还是失败), 并将对应 Call 对象从哈希表中删除。
3. ipc.Server 类分析
采用了 Master/Slave 结构, 其中 Master 是整个系统的单点, 如 NameNode 或JobTracker , 这是制约系统性能和可扩展性的最关键因素之一 ; 而 Master 通过 ipc.Server接收并处理所有 Slave 发送的请求, 这就要求ipc.Server 将高并发和可扩展性作为设计目标。 为此, ipc.Server 采用了很多提高并发处理能力的技术, 主要包括线程池、 事件驱动和Reactor 设计模式等, 这些技术均采用了 JDK 自带的库实现, 这里重点分析它是如何利用Reactor 设计模式提高整体性能的。
Reactor 是并发编程中的一种基于事件驱动的设计模式, 它具有以下两个特点 : 通过派发 / 分离 I/O 操作事件提高系统的并发性能 ; 提供了粗粒度的并发控制, 使用单线程实现,避免了复杂的同步处理。 典型的 Reactor 实现原理如图 所示:
典型的 Reactor 模式中主要包括以下几个角色。
- Reactor: I/O 事件的派发者。
- Acceptor : 接受来自 Client 的连接, 建立与 Client 对应的 Handler, 并向 Reactor 注 册此
Handler。 - Handler : 与一个 Client 通信的实体, 并按一定的过程实现业务的处理。 Handler 内部往往会有更进一步的层次划分, 用来抽象诸如 read、 decode、 compute、 encode 和 send 等过程。 在Reactor 模式中, 业务逻辑被分散的 I/O 事件所打破, 所以 Handler 需要有适当的机制在所需的信息还不全(读到一半)的时候保存上下文, 并在下一 次 I/O 事件到来的时候(另一半可读) 能继续上次中断的处理。
- Reader/Sender : 为了加速处理速度, Reactor 模式往往构建一个存放数据处理线程的线程池, 这样数据读出后,立即扔到线程池中等待后续处理即可。 为此, Reactor 模式一般分离 Handler 中的读和写两个过程,分别注册成单独的读事件和写事件, 并由对应的 Reader 和 Sender 线程处理。
ipc.Server 实际上实现了一个典型的 Reactor 设计模式, 其整体架构与上述完全一致。
一旦读者了解典型 Reactor 架构便可很容易地学习 ipc.Server 的设计思路及实现。 接下来,我们分析 ipc.Server 的实现细节。前面提到,ipc.Server 的主要功能是接收来自客户端的 RPC 请求, 经过调用相应的函数获取结果后, 返回给对应的客户端。为此, ipc.Server 被划分成 3 个阶段 : 接收请求、 处理请求和返回结果, 如图所示。 各阶段实现细节如下。
(1) 接收请求
该阶段主要任务是接收来自各个客户端的 RPC 请求, 并将它们封装成固定的格式(Call 类) 放到一个共享队列(callQueue) 中, 以便进行后续处理。 该阶段内部又分为建立连接和接收请求两个子阶段, 分别由 Listener 和Reader 两种线程完成。整个 Server 只有一个 Listener 线程, 统一负责监听来自客户端的连接请求, 一旦有新的请求到达, 它会采用轮询的方式从线程池中选择一个 Reader 线程进行处理, 而 Reader 线程可同时存在多个, 它们分别负责接收一部分客户端连接的 RPC 请求, 至于每个 Reader 线程负责哪些客户端连接, 完全由 Listener 决定, 当前 Listener 只是采用了简单的轮询分配机制。Listener 和 Reader 线程内部各自包含一个 Selector 对象, 分别用于监听 SelectionKey.OP_ACCEPT 和 SelectionKey.OP_READ 事件。 对于 Listener 线程, 主循环的实现体是监听是否有新的连接请求到达, 并采用轮询策略选择一个 Reader 线程处理新连接 ; 对于 Reader线程, 主循环的实现体是监听(它负责的那部分) 客户端连接中是否有新的 RPC 请求到达, 并将新的 RPC 请求封装成 Call 对象, 放到共享队列 callQueue 中。
(2) 处理请求
该阶段主要任务是从共享队列 callQueue 中获取 Call 对象, 执行对应的函数调用, 并将结果返回给客户端, 这全部由 Handler 线程完成。Server 端可同时存在多个 Handler 线程, 它们并行从共享队列中读取 Call 对象, 经执行对应的函数调用后, 将尝试着直接将结果返回给对应的客户端。 但考虑到某些函数调用返回结果很大或者网络速度过慢, 可能难以将结果一次性发送到客户端, 此时 Handler 将尝试着将后续发送任务交给 Responder 线程。
(3) 返回结果
前面提到, 每个 Handler 线程执行完函数调用后, 会尝试着将执行结果返回给客户端,但对于特殊情况, 比如函数调用返回结果过大或者网络异常情况(网速过慢), 会将发送任务交给 Responder 线程。Server 端仅存在一个 Responder 线程, 它的内部包含一个 Selector 对象, 用于监听SelectionKey.OP_WRITE 事件。 当 Handler 没能将结果一次性发送到客户端时, 会向该Selector 对象注册 SelectionKey.OP_WRITE 事件, 进而由 Responder 线程采用异步方式继续发送未发送完成的结果。
Hadoop RPC 参数调优
Hadoop RPC 对外提供了一些可配置参数, 以便于用户根据业务需求和硬件环境对其进行调优。 主要的配置参数如下。
Reader 线程数目。 由参数 ipc.server.read.threadpool.size 配置, 默认是 1, 也就是说,默认情况下, 一个 RPC Server 只包含一个 Reader 线程。
每个 Handler 线程对应的最大 Call 数目。 由参数 ipc.server.handler.queue.size 指定,默认是 100, 也就是说, 默认情况下, 每个 Handler 线程对应的 Call 队列长度为 100。 比如, 如果 Handler数目为 10, 则整个 Call 队列(即共享队列 callQueue) 最 大长度为: 100×10=1000。
Handler 线程数目。 在 Hadoop 中, ResourceManager 和 NameNode 分别是 YARN 和 HDFS两个子系统中的 RPC Server, 其对应的 Handler 数目分别由参数 yarn.resourcemanager.resource-tracker.client.thread-count 和dfs.namenode.service.handler.count 指定, 默认值分别 为 50 和 10, 当集群规模较大时,这两个参数值会大大影响系统性能。
客户端最大重试次数。在分布式环境下, 因网络故障或者其他原因迫使客户端重试 连接是很常见的,但尝试次数过多可能不利于对实时性要求较高的应用。客户端最大重试次数由参数ipc.client.connect.max.retries指定, 默认值为 10,也就是会连续 尝试 10 次(每两次之间相隔 1 秒)。