网络通信模块是分布式系统中最底层的模块。它直接支撑了上层分布式环境下复杂的进程间通信(Inter-Process Communication, IPC)逻辑,是所有分布式系统的基础。远程过程调用(Remote Procedure Call, RPC)是一种常用的分布式网络通信协议。它允许运行于一台计算机的程序调用另一台计算机的子程序,同时将网络的通信细节隐藏起来,使得用户无须额外地为这个交互作用编程。由于RPC大大简化了分布式程序开发,因此备受欢迎。
作为一个分布式系统,Hadoop实现了自己的RPC通信协议,它是上层多个分布式子系统(如MapReduce, HDFS, HBase等)公用的网络通信模块
Hadoop RPC框架概述
RPC实际上是分布式计算中客户机/服务器(Client/Server)模型的一个应用实例。对于Hadoop RPC而言,它具有以下几个特点。
透明性:这是所有RPC框架的最根本特征,即当用户在一台计算机的程序调用另外一台计算机上的子程序时,用户自身不应感觉到其间涉及跨机器间的通信,而是感觉像是在执行一个本地调用。
高性能:Hadoop各个系统(如HDFS, MapReduce)均采用了Master/Slave结构。其中,Master实际上是一个RPC server,它负责处理集群中所有Slave发送的服务请求。为了保证Master的并发处理能力,RPC server应是一个高性能服务器,能够高效地处理来自多个Client的并发RPC请求。
可控性:JDK中已经自带了一个RPC框架——RMI(Remote Method Invocation,远程方法调用)。之所以不直接使用该框架,主要是因为考虑到RPC是Hadoop最底层、最核心的模块之一,保证其轻量级、高性能和可控性显得尤为重要,而RMI过于重量级且用户可控之处太少(如网络连接、超时和缓冲等均难以定制或者修改)
与其他RPC框架一样,Hadoop RPC主要分为四个部分,分别是序列化层、函数调用层、网络传输层和服务器端处理框架,具体实现机制如下:
- 序列化层:序列化层的主要作用是将结构化对象转为字节流以便于通过网络进行传输或写入持久存储。在RPC框架中,它主要用于将用户请求中的参数或者应答转化成字节流以便跨机器传输。Hadoop自己实现了序列化框架,一个类只要实现Writable接口,即可支持对象序列化与反序列化。
- 函数调用层:函数调用层的主要功能是定位要调用的函数并执行该函数。HadoopRPC采用Java反射机制与动态代理实现了函数调用
- 网络传输层:网络传输层描述了Client与Server之间消息传输的方式。Hadoop RPC采用了基于TCP/IP的Socket机制,
- 服务器端处理框架:服务器端处理框架可被抽象为网络I/O模型。它描述了客户端与服务器端间信息交互的方式。它的设计直接决定着服务器端的并发处理能力。常见的网络I/O模型有阻塞式I/O、非阻塞式I/O、事件驱动I/O等,而Hadoop RPC采用了基于Reactor设计模式的事件驱动I/O模型。
Hadoop RPC总体架构如图4-1所示,自下而上可分为两层。第一层是一个基于Java NIO(New IO)实现的客户机/服务器(Client/Server)通信模型。其中,客户端将用户的调用方法及其参数封装成请求包后发送到服务器端。服务器端收到请求包后,经解包、调用函数、打包结果等一系列操作后,将结果返回给客户端。为了增强Server端的扩展性和并发处理能力,Hadoop RPC采用了基于事件驱动的Reactor设计模式,在具体实现时,用到了JDK提供的各种功能包,主要包括java.nio(NIO)、java.lang.reflect(反射机制和动态代理)、java.net(网络编程库)等。第二层是供更上层程序直接调用的RPC接口,这些接口底层即为客户机/服务器通信模型。
Java反射机制与动态代理
interface CalculatorProtocol{//定义一个接口协议
public int add(int a, int b);//两个数相加
public int subtract(int a, int b);//两个数相减
}
class Server implements CalculatorProtocol{//实现接口协议
public int add(int a, int b){
return a+b;
}
public int subtract(int a, int b){
return a-b;
}
}
//实现调用处理器接口
class CalculatorHandler implements InvocationHandler{
private Object objOriginal;
public CalculatorHandler(Object obj){
this.objOriginal=obj;
}
public Object invoke(Object proxy, Method method, Object[]args)
throws Throwable{
//可添加一些预处理
Object result=method.invoke(this.objOriginal, args);
//可添加一些后续处理
return result;
}
}
//测试用例
public class DynamicProxyExample{
public static void main(String[]args){
CalculatorProtocol server=new Server();//创建Server
InvocationHandler handler=new CalculatorHandler(server);
CalculatorProtocol client=(CalculatorProtocol)Proxy.newProxyInstance(server.
getClass().getClassLoader(),server.getClass().getInterfaces(),handler);
//创建一个Client
int r=client.add(5,3);
System.out.println("5+3="+r);
r=client.subtract(10,2);
System.out.println("10-2="+r);
}
}
Java网络编程
通常,Java网络程序建立在TCP/IP协议基础上,致力于实现应用层。传输层向应用层提供了套接字Socket接口,它封装了下层的数据传输细节;应用层的程序可通过Socket与远程主机建立连接和进行数据传输。
JDK提供了3种套接字类:java.net.Socket、java.net.ServerSocket和java.net.DatagramSocket。其中,java.net.Socket和java.net.ServerSocket类建立在TCP协议基础上,而java.net.DatagramSocket类则建立在UDP协议基础上。Java网络程序均采用客户机/服务器通信模式。下面介绍如何使用java.net.Socket和java.net.ServerSocket编写客户端和服务器端程序。
如图4-2所示,编写一个客户端程序需要以下3个步骤。
在Client/Server模型中,Server往往需要同时处理大量来自Client的访问请求,因此Server端需采用支持高并发访问的架构。一种简单而又直接的解决方案是“one-thread-per-connection”。这是一种基于阻塞式I/O的多线程模型,如图4-3所示。在该模型中,Server为每个Client连接创建一个处理线程,每个处理线程阻塞式等待可能到达的数据,一旦数据到达,则立即处理请求、返回处理结果并再次进入等待状态。由于每个Client连接有一个单独的处理线程为其服务,因此可保证良好的响应时间。但当系统负载增大(并发请求增多)时,Server端需要的线程数会增加,这将成为系统扩展的瓶颈所在。
Java NIO
自从J2SE 1.4版本以来,JDK发布了全新的I/O类库,简称NIO(New IO)。它不但引入了全新的高效的I/O机制,同时引入了基于Reactor设计模式的多路复用异步模式。NIO的包中主要包含了以下几种抽象数据类型。
Channel(通道):NIO把它支持的I/O对象抽象为Channel。它模拟了通信连接,类似于原I/O中的流(Stream),用户可以通过它读取和写入数据。目前已知的实例类有SocketChannel、ServerSocketChannel、DatagramChannel、FileChannel等。
Buffer(缓冲区):Buffer是一块连续的内存区域,一般作为Channel收发数据的载体出现。所有数据都通过Buffer对象来处理。用户永远不会将字节直接写入通道中,相反,需将数据写入包含一个或者多个字节的缓冲区;同样,也不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
-
Selector(选择器):Selector类提供了监控一个或多个通道当前状态的机制。只要Channel向Selector注册了某种特定事件,Selector就会监听这些事件是否会发生,一旦发生某个事件,便会通知对应的Channel。使用选择器,借助单一线程,就可对数量庞大的活动I/O通道实施监控和维护,具体如图4-4所示。
2.常用类
(1)Buffer相关类
java. nio包公开了Buffer API,使得Java程序员可以直接控制和运用缓存区。所有缓冲区包含以下3个属性。
- capacity:缓冲区的末位值。它表明了缓冲区最多可以保存多少数据。
- limit:表示缓冲区的当前存放数据的终点。不能对超过limit的区域进行读写数据。
- position:下一个读写单元的位置。每次读写缓冲区时,均会修改该值,为下一次读写数据做准备。
这三个属性的大小关系是capacity≥limit≥position≥0。
如图4-5所示,Buffer有两种不同的工作模式——写模式和读模式。在写模式下,limit与capacity相同,position随着写入数据增加,逐渐增加到limit,因此,0到position之间的数据即为已经写入的数据;在读模式下,limit初始指向position所在位置,position随着数据的读取,逐渐增加到limit,则0到position之间的数据即为已经读取的数据。函数flip()可将写模式转化为读模式,其他常用函数如下。
- clear():重置Buffer,即将limit设为capacity,而position为0。
- hasRemaining()/remaining():分别用于判断Buffer是否有剩余空间和获取Buffer剩余空间,其中剩余空间大小即为limit-position。
- capacity()/limit()/position():分别用于获取Buffer的capacity、limit和position属性的值。
- limit(int newLimit)/position(newPosition):分别用于设置Buffer的limit和position属性。
java. nio.Buffer是一个抽象类,不能被实例化。除boolean类型外,每种基本类型都有对应的具体的Buffer类,其中最基本、最常用的是ByteBuffer。它存放的数据单元是字节。它并没有提供直接的构造函数,而是提供了以下两个静态工厂方法:
//方法1 创建一个Heap Buffer,其空间分配在JVM的堆上,和其他对象一样,由GC回收
static ByteBuffer allocate(int capacity)
/*方法2 创建一个Direct Buffer,并通过底层的JNI调用C Runtime Time的malloc函数分配空
间,可看作“内核空间”。其创建代价比Heap Buffer大,但更高效。*/
static ByteBuffer allocateDirect(int capacity)
2)Channel相关类
java. nio提供了多种Channel实现,其中,最常用的是以SelectableChannel为基类的通道。SelectableChannel是一种支持阻塞I/O和非阻塞I/O的通道,它的主要方法如下
SelectableChannel configureBlocking(boolean block)throws IOException。
作用:设置当前SelectableChannel的阻塞模式。
参数含义:block表示是否将SelectableChannel设置为阻塞模式。SelectionKey register(Selector sel, int ops)throws ClosedChannelException。
作用:将当前Channel注册到一个Selector中。
参数含义:sel表示要注册的Selector;ops表示注册事件。
返回值:与注册Channel关联的SelectionKey对象,用于跟踪被注册事件。
SelectableChannel的两个子类是ServerSocketChannel和SocketChannel,它们分别是ServerSocket和Socket的替代类。
ServerSocketChannel主要用于监听TCP连接,它提供了以下3个最常用的方法。
ServerSocketChannel open()throws IOException:用于创建ServerSocketChannel的静态工厂方法。其返回的ServerSocketChannel对象没有与任何本地端口号绑定,处于阻塞状态。
SocketChannel accept()throws IOException:接收来自客户端的连接。当ServerSocket-Channel设置为阻塞模式时,该函数一直会处于阻塞状态,直到有客户端请求到达或者抛出异常。一旦有客户端请求出现,则会返回一个处于阻塞模式的SocketChannel。
ServerSocket socket():返回一个与ServerSocketChannel关联的ServerSocket对象。注意,每个ServerSocketChannel对象都有一个ServerSocket对象与之关联。
SocketChannel可看作Socket的替代类,但功能比Socket更加强大。同ServerSocket-Channel类似,它提供了静态工厂方法open()(创建对象)和socket()方法(返回与SocketChannel关联的Socket对象)。它的其他常用方法如下。
- boolean connect(SocketAddress remote)throws IOException:连接Channel对应的Socket。如果SocketChannel处于阻塞模式,则直接返回结果;否则,进入阻塞状态,直到连接成功或者抛出异常。
- int read(ByteBuffer dst)throws IOException:将当前Channel中的数据读取到ByteBuffer中。在阻塞和非阻塞模式下,该函数实现方式不同:在非阻塞模式下,遵从能读取多少数据就读取多少数据的原则,总是立即返回结果;而在阻塞模式下,将尝试一直读取数据,直到ByteBuffer被填满,到达输入流末尾或者抛出异常。该函数的返回值为实际读取的数据字节数。
- int write(ByteBuffer src)throws IOException:将ByteBuffer中的数据写入Channel中。与read函数类似,在阻塞模式和非阻塞模式下,该函数实现方式不同:在非阻塞模式下,遵从能输出多少数据就输出多少数据的原则,总是立即返回结果;在阻塞模式下,会尝试着将所有数据写入Channel,如果底层的网络缓冲区容纳不了这么多字节,则会阻塞至可写入所有数据或者抛出异常。
当调用write方法将一个Heap Buffer中的数据写入某个Channel(或者调用read方法将Channel中的数据读入一个Heap Buffer对象)时,Sun Java底层实现中使用了Direct Buffer暂时对数据进行缓冲,大体步骤为:JVM初始创建一个固定大小的Direct Buffer,并将数据写入该Buffer,如果Buffer大小不够,则再创建一个更大的Direct Buffer,并将之前的Direct Buffer中的内容复制到新的Direct Buffer中,依此类推,直到将数据全部写入。很明显,该过程涉及大量内存复制操作,会明显降低性能。此外,由于Direct Buffer所占内存不会被马上释放,因此会造成内存使用骤升。为解决该问题,可将写入的数据分成固定大小(比如8KB)的chunk,并以chunk为单位写入Direct Buffer,代码如代码清单4-2所示。
代码清单4-2 将ByteBuffer中的数据以chunk为单位写入Direct Buffer
int NIO_BUFFER_LIMIT=8*1024;//chunk大小:8KB
//将Buffer中的数据写入Channel中,其中Channel处于非阻塞模式
int channelWrite(WritableByteChannel channel,
ByteBuffer buffer)throws IOException{
//如果缓冲区中的数据小于8KB,则直接写到Channel中,否则以chunk为单位写入
return(buffer.remaining()<=NIO_BUFFER_LIMIT)?
channel.write(buffer):channelIO(null, channel, buffer);
}
private static int channelIO(ReadableByteChannel readCh,
WritableByteChannel writeCh,
ByteBuffer buf)throws IOException{
int originalLimit=buf.limit();
int initialRemaining=buf.remaining();
int ret=0;
while(buf.remaining()>0){
try{
int ioSize=Math.min(buf.remaining(),NIO_BUFFER_LIMIT);
buf.limit(buf.position()+ioSize);
ret=(readCh==null)?writeCh.write(buf):readCh.read(buf);
//非阻塞模式下,write或者read函数对应的网络缓冲区满后,会直接返回
//返回值为实际写入或者读取的数据
if(ret<ioSize){
break;
}
}finally{
buf.limit(originalLimit);
}
}
int nBytes=initialRemaining-buf.remaining();
return(nBytes>0)?nBytes:ret;
}
图4-6阐释了按照代码清单4-2中的逻辑,ByteBuffer中的数据写入Channel过程中,其内部各个属性的变化情况。
(3)Selector类
Selector可监听ServerSocketChannel和SocketChannel注册的特定事件,一旦某个事件发生,则会通知对应的Channel。SelectableChannel的register()方法负责注册事件,该方法返回一个SelectionKey对象,该对象即为用于跟踪这些注册事件的句柄。
Selector中常用的方法如下。
- static Selector open():一个静态工厂方法,可用于创建Selector对象。
- int select(long timeout):该方法等待并返回发生的事件。一旦某个注册的事件发生,就会返回对应的SelectionKey的数目,否则,一直处于阻塞状态,直到以下四种情况之一发生:至少一个事件发生;其他线程调用了Selector的wakeup()方法;当前执行select()方法的线程被中断;超出等待时间timeout,如果不设置等待时间,则表示永远不会超时。
- set selectedKeys():Selector捕获的已经发生事件对应的SelectionKey集合。
- Selector wakeup():立刻唤醒当前处于阻塞状态的Selector。常见应用场景是,线程A调用Selector对象的select()方法,阻塞等待某个注册事件发生,线程B通过调用wakeup()函数可立刻唤醒线程A,使其从select()方法中返回。
(4)SelectionKey类
ServerSocketChannel或SocketChannel通过register()方法向Selector注册事件时,register()方法会创建一个SelectionKey对象,用于跟踪注册事件。在SelectionKey中定义了4种事件,分别用以下4个整型常量表示。
- SelectionKey. OP_ACCEPT:接收(accept)连接就绪事件,表示服务器端接收到了客户端连接。
- SelectionKey. OP_CONNECT:连接就绪事件,表示客户端与服务器端的连接已经建立成功。
- SelectionKey. OP_READ:读就绪事件,表示通道中已经有了可读数据,可执行读操作了。
- SelectionKey. OP_WRITE:写就绪事件,表示可向通道中写入数据了。
通常而言,ServerSocketChannel对象向Selector中注册SelectionKey.OP_ACCEPT事件,而SocketChannel对象向Selector中注册SelectionKey.OP_CONNECT、SelectionKey.OP_READ和SelectionKey.OP_WRITE三种事件。
SelectionKey类中比较重要的方法如下。
- Object attach(Object ob):为当前SelectionKey关联一个Object类型的对象。每个SelectionKey只能关联一个对象。
- Object attachment():获取当前SelectionKey关联的Object对象。
- SelectableChannel channel():返回与当前SelectionKey关联的SelectableChannel对象。
Hadoop RPC基本框架分析
RPC是一种通过网络从远程计算机上请求服务,但不需要了解底层网络技术的协议。RPC协议假定某些传输协议已经存在,如TCP或UDP等,并通过这些传输协议为通信程序之间传递访问请求或者应答信息。在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发分布式应用程序更加容易。
RPC通常采用客户机/服务器模型。请求程序是一个客户机,而服务提供程序则是一个服务器。一个典型的RPC框架主要包括以下几个部分。
通信模块:两个相互协作的通信模块实现请求-应答协议。它们在客户机和服务器之间传递请求和应答消息,一般不会对数据包进行任何处理。
请求-应答协议的实现方式有两种,分别是同步方式和异步方式。如图4-7所示,同步模式下客户端程序一直阻塞到服务器端发送的应答请求到达本地;而异步模式则不同,客户端将请求发送到服务器端后,不必等待应答返回,可以做其他事情,待服务器端处理完请求后,主动通知客户端。在高并发应用场景中,一般采用异步模式以降低访问延迟和提高带宽利用率。Stub程序:客户端和服务器端均包含Stub程序,可将之看作代理程序。它使得远程函数调用表现的跟本地调用一样,对用户程序完全透明。在客户端,它表现的就像一个本地程序,但不直接执行本地调用,而是将请求信息通过网络模块发送给服务器端。此外,当服务器端发送应答后,它会解码对应结果。在服务器端,Stub程序依次进行以下处理:解码请求消息中的参数、调用相应的服务过程和编码应答结果的返回值。
-
调度程序:调度程序接收来自通信模块的请求消息,并根据其中的标识选择一个Stub程序处理。通常客户端并发请求量比较大时,会采用线程池提高处理效率
客户程序/服务过程:请求的发出者和请求的处理者。如果是单机环境,客户程序可直接通过函数调用访问服务过程,但在分布式环境下,需要考虑网络通信,这不得不增加通信模块和Stub程序(保证函数调用的透明性)。
通常而言,一个RPC请求从发送到获取处理结果,所经历的步骤如下(见图4-8):步骤1 客户程序以本地方式调用系统产生的Stub程序;
步骤2 该Stub程序将函数调用信息按照网络通信模块的要求封装成消息包,并交给通信模块发送到远程服务器端;
步骤3 远程服务器端接收此消息后,将此消息发送给相应的Stub程序;
步骤4 Stub程序拆封消息,形成被调过程要求的形式,并调用对应的函数;
步骤5 被调用函数按照所获参数执行,并将结果返回给Stub程序;
步骤6 Stub程序将此结果封装成消息,通过网络通信模块逐级地传送给客户程序。
Hadoop RPC基本框架
1.Hadoop RPC使用
在正式介绍Hadoop RPC基本框架之前,先介绍怎么样使用它。Hadoop RPC主要对外提供了两种接口。
- public static VersionedProtocol getProxy/waitForProxy():构造一个客户端代理对象(该对象实现了某个协议),用于向服务器端发送RPC请求。
- public static Server getServer():为某个协议(实际上是Java接口)实例构造一个服务器对象,用于处理客户端发送的请求。
通常而言,Hadoop RPC使用方法可分为以下几个步骤。
步骤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 String echo(String value)throws IOException{
return value;
}
public int add(int v1,int v2)throws IOException{
return v1+v2;
}
}
步骤3 构造并启动RPC Server。直接使用静态方法getServer()构造一个RPC Server,并调用函数start()启动该Server:
server=RPC.getServer(new ClientProtocolImpl(),serverHost, serverPort,
numHandlers, false, conf);
server.start();
其中,serverHost和serverPort分别表示服务器的host和监听端口号,而numHandlers表示服务器端处理请求的线程数目。到此为止,服务器处理监听状态,等待客户端请求到达。
- 步骤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,分别对应对外编程接口、客户端实现和服务器端实现。
Hadoop RPC使用了Java动态代理完成对远程方法的调用,对于Hadoop RPC,函数调用由客户端发出,并在服务器端执行并返回, 因此不能像4.2.1节的本地动态代理实例代码一样直接在invoke方法中本地调用相关函数,它的做法是,在invoke方法中,将函数调用信息(函数名、函数参数列表等)打包成可序列化的Invocation对象,并通过网络发送给服务器端,服务器端收到该调用信息后,解析出函数名和函数参数列表等信息,利用Java反射机制完成函数调用.期间涉及的类关系如图4-10所示。
Client类分析
Client主要完成的功能是发送远程过程调用信息并接收执行结果。它涉及的类关系如图4-11所示。Client类对外提供了两种接口,一种用于执行单个远程调用。另外一种用于执行批量远程调用。它们的声明如下所示:
public Writable call(Writable param, ConnectionId remoteId)
throws InterruptedException, IOException;
public Writable[]call(Writable[]params, InetSocketAddress[]addresses,
Class<?>protocol, UserGroupInformation ticket, Configuration conf)
throws IOException, InterruptedException;
Client内部有两个重要的内部类,分别是Call和Connection。
- Call类:该类封装了一个RPC请求,它包含五个成员变量,分别是唯一标识id、函数调用信息param、函数执行返回值value、出错或者异常信息error和执行完成标识符done。由于Hadoop RPC Server采用了异步方式处理客户端请求,这使得远程过程调用的发生顺序与结果返回顺序无直接关系,而Client端正是通过id识别不同的函数调用。当客户端向服务器端发送请求时,只需填充id和param两个变量,而剩下的三个变量:value, error和done,则由服务器端根据函数执行情况填充。
- Connection类:Client与每个Server之间维护一个通信连接。该连接相关的基本信息及操作被封装到Connection类中。其中,基本信息主要包括:通信连接唯一标识(remoteId),与Server端通信的Socket(socket),网络输入数据流(in),网络输出数据流(out),保存RPC请求的哈希表(calls)等;操作则包括:
- addCall——将一个Call对象添加到哈希表中;
- sendParam——向服务器端发送RPC请求;
- receiveResponse——从服务器端接收已经处理完成的RPC请求;
- run——Connetion是一个线程类,它的run方法调用了receiveResponse方法,会一直等待接收RPC返回结果。
当调用call函数执行某个远程方法时,Client端需要进行如图4-12所示的几个步骤:
- 步骤1 创建一个Connection对象,并将远程方法调用信息封装成Call对象,放到Connection对象中的哈希表calls中;
- 步骤2 调用Connetion类中的sendParam()方法将当前Call对象发送给Server端;
- 步骤3 Server端处理完RPC请求后,将结果通过网络返回给Client端,Client端通过receiveResponse()函数获取结果;
- 步骤4 Client端检查结果处理状态(成功还是失败),并将对应的Call对象从哈希表中删除。
Hadoop采用了Master/Slave结构。其中,Master是整个系统的单点,如NameNode或JobTracker,这是制约系统性能和可扩展性的最关键因素之一,而Master通过ipc.Server接收并处理所有Slave发送的请求,这就要求ipc.Server将高并发和可扩展性作为设计目标。为此,ipc.Server采用了很多具有提高并发处理能力的技术,主要包括线程池、事件驱动和Reactor设计模式等。这些技术均采用了JDK自带的库实现。这里重点分析它是如何利用Reactor设计模式提高整体性能的。
一个典型的Reactor模式中主要包括以下几个角色。
- Reactor:IO事件的派发者。
- Acceptor:接受来自Client的连接,建立与Client对应的Handler,并向Reactor注册此Handler。
- Handler:与一个Client通信的实体,并按一定的过程实现业务的处理。Handler内部往往会有更进一步的层次划分,用来抽象诸如read, decode, compute, encode和send等的过程。在Reactor模式中,业务逻辑被分散的IO事件所打破,所以Handler需要有适当的机制在所需的信息还不全(读到一半)的时候保存上下文,并在下一次IO事件到来的时候(另一半可读了)能继续上次中断的处理。
- Reader/Sender:为了加速处理速度,Reactor模式往往构建一个存放数据处理线程的线程池,这样,数据读出后,立即扔到线程池中等待后续处理即可。为此,Reactor模式一般分离Handler中的读和写两个过程,分别注册成单独的读事件和写事件,并由对应的Reader和Sender线程处理。
ipc.Server实际上实现了一个典型的Reactor设计模式,其整体架构与上述完全一致。ipc.Server的主要功能是接收来自客户端的RPC请求,经过调用相应的函数获取结果后,返回给对应的客户端。为此,ipc.Server被划分成三个阶段:接收请求,处理请求和返回结果。如图4-14所示,各阶段实现细节如下:
(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=1 000。
Handler线程数目:在Hadoop中,JobTracker和NameNode分别是MapReduce和HDFS两个子系统中的RPC Server,其对应的Handler数目分别由参数mapred.job.tracker.handler.count和dfs.namenode.service.handler.count指定,默认值均为10。当集群规模较大时,这两个参数值会大大影响系统性能。
客户端最大重试次数:在分布式环境下,因网络故障或者其他原因迫使客户端重试连接是很常见的,但尝试次数过多可能不利于对实时性要求较高的应用。客户端最大重试次数由参数ipc.client.connect.max.retries指定,默认值为10,也就是会连续尝试10次(每两次之间相隔1秒钟)。