1、前言
Master是spark中核心角色,涉及到集群通信以及资源调用申请,不仅要接收Driver,Worker的注册调用,还需要根据调度情况知道其他角色的状态,例如:Executor、Driver的状态等。
通过以上的推断,Master需要有个传输层(TransportServer)专门用来发送或者接受服务,如下图
由上图可见,如果使用传输的实例过多,势必会存在问题,如:这个实例消息A传给谁,实例消息B由哪个实例接收等,为此,我们可以在传输层上添加一个分发器(Dispatcher),如下图
引入分发器解决多实例发送接收问题,同时又引出了一个新的问题,如果实例过多,传输消息必然过多,消息必将积压在传输层,为此,我们可以在传输层跟分发器之间,添加一个消息队列,用于缓冲数据传输,如下图
引入消息队列解决了消息解压问题,从上面架构图还可以看出,消息是实例主动去推送数据,为了进一步解决实例压力,引入信箱(Inbox)作为消息的载体,由分发器主动去信箱拉取消息,进一步解放实例的作用,如下图
至此,我们将个人认为的Master架构图绘制出来,下面进一步探索spark源码。
2、Master源码解析
2.1、spark集群启动流程
从spark集群启动指令可以看到,{SPARK_HOME}/sbin/start-master.sh 和 ${SPARK_HOME}/sbin/start-slaves.sh,如下图
${SPARK_HOME}/sbin/start-master.sh,启动Master是通过脚本创建 org.apache.spark.deploy.master.Master 来启动Master
{SPARK_HOME}/sbin/start-slave.sh(少了个s),启动Worker是通过脚本创建 org.apache.spark.deploy.worker.Worker 来创建Worker
2.2、Master创建流程
2.2.1、追溯流程01-Master整体流程
本文使用spark源码版本为 2.3.4 ,打开Master类图,可见Master拥有伴生对象,直接从main()方法入手
main()方法中主要就是调用了同类下的startRpcEnvAndEndpoint(),并将参数传递给该方法;startRpcEnvAndEndpoint()方法主要完成两件事:
- 一是准备好Rpc环境
- 二就是将Master注册到对应的Rpc环境中,实际上这个注册,才是将Master启动起来
点进RpcEnv.create()方法,RpcEnv是一个抽象类,具体的实现是由NettyRpcEnv类来实现具体细节
NettyRpcEnv 继承自 RpcEnv,且RpcEnv有且只有NettyRpcEnv实现,可见spark底层的Rpc传输层暂时只有Netty实现
观看NettyRpcEnv 的属性,可以发现几个关键属性:
-
Dispatcher:分发器
private val dispatcher: Dispatcher = new Dispatcher(this, numUsableCores)
-
TransportContext:传输上下文,且包含分发器
private val transportContext = new TransportContext(transportConf, new NettyRpcHandler(dispatcher, this, streamManager))
这两个属性与上文的架构图是否有相似的点
小结:
-
spark集群的启动是通过脚本启动创建Master和Worker进程,具体脚本地址在${SPARK_HOME}/sbin/目录下
-
Master进程启动分为两步:
- 一是创建RpcEnv环境,且Rpc传输层实现暂时只有Netty这种实现方式
- 二是将Master注册到Rpc环境中,这里实际才是真正启动Master进程
-
NettyRpcEnv中存在两个与我们推测画出的Master架构图相似的属性,Dispatcher和TransportContext
下面我们记录下已知的类以及调用关系
2.2.2、追溯流程02-RpcEnv源码一之TransportServer 传输层服务
点进 new NettyRpcEnvFactory().create(config) 方法,从源码注释可得知,spark中默认使用的是java序列化器,原因是java序列化器在多线程是安全的;
这里有一段有趣的代码,解释下
// 这里利用了scala的特性,定义好特定的函数但是并没有执行
val startNettyRpcEnv: Int => (NettyRpcEnv, Int) = { actualPort =>
nettyEnv.startServer(config.bindAddress, actualPort)
(nettyEnv, nettyEnv.address.port)
}
........
// 执行上面的函数
Utils.startServiceOnPort(config.port, startNettyRpcEnv, sparkConf, config.name)._1
这里的 Utils.startServiceOnPort() 从方法名可知是启动服务,是我们关注的重点。
点进 nettyEnv.startServer() 方法,可以看到我们需要关注的NettyRPCEnv两个重要属性,Dispatcher和TransportContext
点进transportContext.createServer() 方法,该方法是创建一个 TransportServer(),从注释可知,“创建一个服务并绑定一个特殊的ip和端口”,这里才是真正启动传输层服务
进入TransportServer类,注释更能证明我们上面的结论 TransportServer 才是真正的传输层服务,这里init()方法就是将服务启动,底层是由Netty实现【由于文章篇幅问题,这里笔者决定不做过多的去解释Netty,后续会找一章节把Netty解释补上】;
这里需要关注另一个重点属性,RpcHandler,从上 NettyRpcEnv 类截图可以看出,RpcHandler包含了Dispatcher
private val transportContext = new TransportContext(transportConf,
new NettyRpcHandler(dispatcher, this, streamManager))
这里看下 RpcHandler 实现类,可以看到我们关注相同包下的,NettyRpcHandler
小结:
- spark中默认使用的序列化器是java序列化器,因为在多线程情况下安全
- TransportServer 才是真正的传输层服务,且 TransportServer 下的 RpcHandler 包含了 Dispatcher 分区器
下面我们记录下已知的类以及调用关系
2.2.3、追溯流程03-RpcEnv源码二TransportServer 调用追踪
接下来我们追踪下,具体 Dispatcher 在传输层的作用,打开 TransportServer.init() 方法,可以发现init里面所做的操作基本都是涉及到Netty代码的使用,这里关注下 appRpcHandler 被转化为 rpcHandler 并传入 initializePipeline()方法,下面我们具体跟踪下代码流程,找出Dispatcher 用途,涉及代码细节不作阐述
点开TransportContext.createChannelHandler() 方法,抛开Netty代码的,用个人的理解方式所谓的消息传输就是,需要知道 client(客户端)、request(请求)、response(返回),客户端发送请求给服务端,服务端接收处理完后返回消息给客户端
通过追踪源码,我们来到了TransportRequestHandler,所谓Handler就是Netty里处理消息的细节,这里重点关注handle() 方法,针对请求的方法做处理,这里我们挑一个方法继续追踪
点开Dispatcher.processOneWayMessage() 方法,这里看到我们熟悉的 RPCHandler,查看具体实现,我们又回到了我们熟悉的 NettyRpcHandler
回到NettyRPCHandler.receive()方法,我们可以清楚的看到
dispatcher.postOneWayMessage(messageToDispatch)
又回到了Dispatcher 类
点进 Dispatcher.postOneWayMessage() 方法,至此我们追踪 Dispatcher 也得到了答案:传输层在传输消息的时候最终会调用 Dispatcher.postMessage() 进行传输,饶了一个圈又回到了 Dispatcher
小结:
- TransportServer 传输服务的实现底层是有Netty实现的,当源码跟踪到TransportServer.init() 证明传输层服务已经启动
- TransportServer 传输服务在传输消息的底层实际上是通过调用分发器Dispatcher.postMessage() ,饶了一个弯处理消息又是由分发器处理
下面我们记录下已知的类以及调用关系
2.2.4、追溯流程04-RpcEnv源码三之Dispatcher分发器
前面我们已经知道 传输层服务 TransportServer 属性中包含Dispatcher,现在我们来看下 Dispatcher ,点开Dispatcher 可以看到另外两个重要的数据 ,inbox(信箱) 和 receivers 从注释可知用于存放inbox消息,我们可以理解为消息队列,与我们一开始猜测的架构图完全符合。
private class EndpointData(
val name: String,
val endpoint: RpcEndpoint,
val ref: NettyRpcEndpointRef) {
val inbox = new Inbox(ref, endpoint)// 信箱:实际消息存放的位置
}
// 消息队列
// Track the receivers whose inboxes may contain messages.
private val receivers = new LinkedBlockingQueue[EndpointData]
// 存放所有端点信息
private val endpoints: ConcurrentMap[String, EndpointData] =
new ConcurrentHashMap[String, EndpointData]
// 存放所有端点的引用信息
private val endpointRefs: ConcurrentMap[RpcEndpoint, RpcEndpointRef] =
new ConcurrentHashMap[RpcEndpoint, RpcEndpointRef]
Dispatcher 另一个重要属性,从注释可知,用户处理消息队列的数据,通过创建 MessageLoop 线程,可见MessageLoop 线程是消息处理的核心线程
/** Thread pool used for dispatching messages. */
private val threadpool: ThreadPoolExecutor = {
val availableCores =
if (numUsableCores > 0) numUsableCores else Runtime.getRuntime.availableProcessors()
val numThreads = nettyEnv.conf.getInt("spark.rpc.netty.dispatcher.numThreads",
math.max(2, availableCores))
val pool = ThreadUtils.newDaemonFixedThreadPool(numThreads, "dispatcher-event-loop")
for (i <- 0 until numThreads) {
pool.execute(new MessageLoop)
}
pool
}
从MessageLoop 代码可知,当Dispatcher 被创建时,同时创建 numThreads 个MessageLoop 线程去处理 receivers(消息队列里面的数据),具体逻辑如下
// 轮询获取数据,不停止
while (true) {
try {
// 从队列拿出端点数据
val data = receivers.take()
// 如果数据是错误数据,直接跳过
if (data == PoisonPill) {
// Put PoisonPill back so that other MessageLoops can see it.
receivers.offer(PoisonPill)
return
}
// 非错误数据,则拿出端点的 inbox 调用其 process 方法
data.inbox.process(Dispatcher.this)
} catch {
case NonFatal(e) => logError(e.getMessage, e)
}
}
有从消息队列里取出数据的处理的,也就把数据放入消息队列里的,接下来我们回到 Dispatcher.postMessage()方法,具体逻辑如下
// 根据名称取出端点数据
val data = endpoints.get(endpointName)
if (stopped) {
Some(new RpcEnvStoppedException())
} else if (data == null) {
Some(new SparkException(s"Could not find $endpointName."))
} else {
// 将消息放入端点的信箱中
data.inbox.post(message)
// 将端点放入消息队列中,以便后续 MessageLoop 线程取出端点进行处理
receivers.offer(data)
None
}
这边我们再看一下 Inbox 类,可见所有的消息都是存放在 messages 属性中,以及在new Inbox()时,伴随着运行一段代码 inbox.synchronized {} 初始化代码
protected val messages = new java.util.LinkedList[InboxMessage]()// 实际消息
// 这是一段有趣的代码,关系到 Master,我们后面再一一解释
// OnStart should be the first message to process
inbox.synchronized {
// inbox 在创建时放入第一条消息是 样例类 OnStart
messages.add(OnStart)
}
下面再来看下消息是如何处理的,Inbox.process() 方法,具体逻辑如下
var message: InboxMessage = null
inbox.synchronized {
// 从消息集里取出数据
message = messages.poll()
}
while(true){
// 根据message 进行消息匹配
message match{
case RpcMessage(_sender, content, context) => endpoint.receiveAndReply()
case OneWayMessage(_sender, content) => endpoint.receive()
case OnStart => endpoint.onStart() // 调用端点的 onStart()方法
case OnStop => endpoint.OnStop() // 调用端点的 OnStop()方法
.... => endpoint.xxx()
}
}
从代码逻辑可以看出,根据消息的类型,最后是调用端点的实现方法,其中onStart() 和 onStop() 是端点比较核心的方法,从字面含义就可以理解为启动服务和关闭服务
至此,Dispatcher 和 Inbox 针对消息的处理流程,我们已经了解清楚了
小结:
包含关系如下:
TransportServer 传输层 {
Dispatcher 分发器{
Inbox 信箱 {messages 信息队列}
receivers 消息队列
endpoints 端点信息集合
endpointRefs 端点引用信息集合
}
}
-
Dispatcher 中还存放着端点信息集合以及端点引用信息集合(后面解释端点跟端点引用)
-
Dispatcher 处理消息是通过线程池启动多个 MessageLoop 线程主动去消息队列里拉取数据处理,其中处理消息是调用 **Inbox.process()**方法
-
Inbox.process() 方法的处理逻辑最终是调用端点的各个方法进行处理
下面我们记录下已知的类以及调用关系
2.2.5、追溯流程05-RpcEnv源码四之RpcEndpoint 端点以及RpcEndpointRef 端点引用
从上面 Dispatcher 源码截图我们知道,端点信息类 EndpointData 里面除了有 inbox 这个属性外,更主要的还有端点类 RpcEndpoint 和 端点引用类 NettyRpcEndpointRef
这边我们看下 RpcEndpoint 这是一个接口类,只有一个属性 rpcEnv(Rpc调用环境),从注释我们可以了解到针对特定的消息,触发特定的函数,就是这个接口类的定义
接下来我们看下,这个类下面定义的方法,其中我们可以看到,onStart() 、onStop() 、receive() 、receiveAndReply() 这些方法与 Inbox.process() 里面调用的端点方法一一对应
再看下receive() 和 receiveAndReply() 方法的注释可以得到,接收来自端点引用类发送的消息,并根据类型进行处理
接下来我们跳到端点引用类 RpcEndpointRef,这也是个接口类,其中最主要的属性就是这个 address: RpcAddress,以及定义了与端点类通信的方法
RpcAddress 类的作用就是将端点的地址进行了包装,如:ip、端口号等信息
小结:
- 至此,我们可以了解到 RpcEndpoint 与 RpcEndpointRef 是成对存在的
- RpcEndpointRef 引用类内部存放有RpcEndpoint 的地址信息,并且定义了与 RpcEndpoint 通信的方法,既拿到了地址信息就可以跟 RpcEndpoint 进行通信,发送消息
- RpcEndpoint 则是接收消息以及定义了消息的处理方式,根据接收到的消息,根据消息类型执行特定的方法
- RpcEndpoint 和 RpcEndpointRef 分别定义了通信的类型以及通信的方法
下面我们记录下已知的类以及调用关系
2.2.6、追溯流程06-Master启动
通过前面的了解,我们已经RpcEnv环境的前期准备以及各角色之间的调用,下面我们通过一张图把前面的源码总结下
- 传输层服务数据最终是通过 Dispatcher.postMessage()将数据放入消息队列
- Dispatcher 中通过多线程的方式轮询去取消息队列的数据进行处理
- 消息的最终处理方式,是通过调用端点的方法进行处理
- RpcEnv是通信的基础,所有需要通信的角色都必须在RpcEnv之上进行通信
前面我们已经跟踪完RpcEnv环境准备的流程了,继续看下Master的代码
val masterEndpoint = rpcEnv.setupEndpoint(ENDPOINT_NAME,
new Master(rpcEnv, rpcEnv.address, webUiPort, securityMgr, conf))
进入 rpcEnv.setupEndpoint() 方法,进入到具体实现类 NettyRpcEnv,可以清楚的看到底层调用了Dispatcher.registerRpcEndpoint() 方法,且第二个参数,必须是 RpcEndpoint,可以确定我们要启动的Master也是RpcEndpoint 的实现类之一,同样的,我们可以猜测,所有涉及到通信的也必须是RpcEndpoint的实现类之一,例如:Worker、Driver
进入Dispatcher.registerRpcEndpoint() 方法,该方法主要是将端点注册到RpcEnv环境以及启动端点实例,具体代码逻辑如下
def registerRpcEndpoint(name: String, endpoint: RpcEndpoint): NettyRpcEndpointRef = {
val addr = RpcEndpointAddress(nettyEnv.address, name)
val endpointRef = new NettyRpcEndpointRef(nettyEnv.conf, addr, nettyEnv)
synchronized {
if (stopped) {
throw new IllegalStateException("RpcEnv has been stopped")
}
// 判断是否端点已经注册过,如果未注册则,将端点包装成端点信息类存入
if (endpoints.putIfAbsent(name, new EndpointData(name, endpoint, endpointRef)) != null) {
throw new IllegalArgumentException(s"There is already an RpcEndpoint called $name")
}
// 根据名字获取端点
val data = endpoints.get(name)
// 将端点引用放入集合
endpointRefs.put(data.endpoint, data.ref)
/**
* 把端点放入消息队列中,这里很重要,Master的启动也是通过该方法
*/
receivers.offer(data) // for the OnStart message
}
endpointRef
}
查看注释 “for the OnStart message”,OnStart 这个关键字是不是很熟悉,在 new Inbox() 的时候会把往 messages里面放入第一条消息就是样例类 OnStart,既:启动端点服务;
这里我们可以清楚的了解到,只要端点类被注册到RpcEnv环境中,必然会被线程异步调起OnStart方法,这也是为什么前期要花那么多工作准备RpcEnv环境
回到Master 类以及查看类继承关系图可以看到,与我们前面的猜测一致,Master、Worker等角色都是RpcEndpoint 的实现类
下面我们用伪代码说明下,Master 启动
// 准备Rpc环境,既将TransportServer、Dispatcher准备好,用于后面端点Rpc通信使用
val rpcEnv = RpcEnv.create()
// 这里只是创建了Master端点类,并未完成 Master 的启动
val master = new Master()
// 向Rpc环境注册Master端点,异步启动
// 这一步才是真正启动Master服务,前面都是在准备需要的环境
rpcEnv.setupEndpoint(NAME,master)
下面我们来聊聊,为什么要异步启动服务,下面通过伪代码解释下:
// 方式一:
// 代码是线性执行的,既从上往下执行
// 假设thread2的执行需要大量时间,原先thread3启动只需要2s,但是现在需要等待thread2执行完成之后再thread3,这白白等待了太多时间
val thread1 = new Thread()
thread1.start()
val thread2 = new Thread()
thread2.start()
val thread3 = new Thread()
thread3.start()
// 方式二:
// 借助消息队列以及多线程,异步执行
// thread的执行交由到线程池中的各个线程,无需等待前面的线程执行,可以抛开特殊情况,可以理解为三个thread是并行执行
val list = new ArrayList<Thread>()
val thread1 = new Thread()
val thread2 = new Thread()
val thread3 = new Thread()
list.add(thread1)
list.add(thread2)
list.add(thread3)
executors.exec(list)
至此,Master整个启动流程我们已经全部跟踪完毕了,下面我们将 Master 也更新进我们源码总结图中
小结:
- RpcEnv是通信的基础,所有需要通信的角色都必须在RpcEnv之上进行通信
- 只要端点类被注册到RpcEnv环境中,必然会被线程异步调起OnStart方法
- Master、Worker等角色只要涉及到通信,必然存在RpcEnv环境
3、总结
- RpcEnv是Spark通信的基础,要通信的角色必须具备RpcEnv环境,目前Spark的Rpc实现目前只有Netty
- Master进程启动分为两个步骤,第一步是准备好RpcEnv环境,第二部是创建Master并注入到RpcEnv环境中,才能算完成Master JVM进程真正的启动
- 发送消息最终是调用了是Dispatcher.postMessage()方法将消息放入receivers消息队列里,由线程MessageLoop轮询去消息队列获取并处理
- Spark中巧用RpcEndpoint和RpcEndpointRef作为实现Rpc通信规则,如:Worker想要跟Master进行通信,这样Worker只需要在RpcEnv环境中,持有MasterEndpointRef 引用类就可以跟Master通信