Spark源码解析01-Master启动流程

522 阅读9分钟

1、前言

Master是spark中核心角色,涉及到集群通信以及资源调用申请,不仅要接收Driver,Worker的注册调用,还需要根据调度情况知道其他角色的状态,例如:Executor、Driver的状态等。

通过以上的推断,Master需要有个传输层(TransportServer)专门用来发送或者接受服务,如下图

00-架构1

由上图可见,如果使用传输的实例过多,势必会存在问题,如:这个实例消息A传给谁,实例消息B由哪个实例接收等,为此,我们可以在传输层上添加一个分发器(Dispatcher),如下图

00-架构2

引入分发器解决多实例发送接收问题,同时又引出了一个新的问题,如果实例过多,传输消息必然过多,消息必将积压在传输层,为此,我们可以在传输层跟分发器之间,添加一个消息队列,用于缓冲数据传输,如下图

00-架构3

引入消息队列解决了消息解压问题,从上面架构图还可以看出,消息是实例主动去推送数据,为了进一步解决实例压力,引入信箱(Inbox)作为消息的载体,由分发器主动去信箱拉取消息,进一步解放实例的作用,如下图

00-架构4

至此,我们将个人认为的Master架构图绘制出来,下面进一步探索spark源码。

2、Master源码解析

2.1、spark集群启动流程

从spark集群启动指令可以看到,SPARKHOME/sbin/startall.sh实际上到调用了另外两个脚本启动MasterWorker,分别是{SPARK_HOME}/sbin/start-all.sh 实际上到调用了另外两个脚本启动Master和Worker,分别是{SPARK_HOME}/sbin/start-master.sh 和 ${SPARK_HOME}/sbin/start-slaves.sh,如下图

00-spark-shell1

${SPARK_HOME}/sbin/start-master.sh,启动Master是通过脚本创建 org.apache.spark.deploy.master.Master 来启动Master

00-spark-shell2

SPARKHOME/sbin/startslaves.sh底层是调用{SPARK_HOME}/sbin/start-slaves.sh 底层是调用{SPARK_HOME}/sbin/start-slave.sh(少了个s),启动Worker是通过脚本创建 org.apache.spark.deploy.worker.Worker 来创建Worker

00-spark-shell3

2.2、Master创建流程

2.2.1、追溯流程01-Master整体流程

本文使用spark源码版本为 2.3.4 ,打开Master类图,可见Master拥有伴生对象,直接从main()方法入手

01-Master-类结构图

main()方法中主要就是调用了同类下的startRpcEnvAndEndpoint(),并将参数传递给该方法;startRpcEnvAndEndpoint()方法主要完成两件事:

  • 一是准备好Rpc环境
  • 二就是将Master注册到对应的Rpc环境中,实际上这个注册,才是将Master启动起来

01-Master-main

点进RpcEnv.create()方法,RpcEnv是一个抽象类,具体的实现是由NettyRpcEnv类来实现具体细节

02-RpcEnv

NettyRpcEnv 继承自 RpcEnv,且RpcEnv有且只有NettyRpcEnv实现,可见spark底层的Rpc传输层暂时只有Netty实现

03-NettyRpcEnv-类结构图

观看NettyRpcEnv 的属性,可以发现几个关键属性:

  • Dispatcher:分发器

    private val dispatcher: Dispatcher = new Dispatcher(this, numUsableCores)
    
  • TransportContext:传输上下文,且包含分发器

    private val transportContext = new TransportContext(transportConf,
      new NettyRpcHandler(dispatcher, this, streamManager))
    

这两个属性与上文的架构图是否有相似的点

03-NettyRpcEnv

小结:
  • spark集群的启动是通过脚本启动创建Master和Worker进程,具体脚本地址在${SPARK_HOME}/sbin/目录下

  • Master进程启动分为两步:

    • 一是创建RpcEnv环境,且Rpc传输层实现暂时只有Netty这种实现方式
    • 二是将Master注册到Rpc环境中,这里实际才是真正启动Master进程
  • NettyRpcEnv中存在两个与我们推测画出的Master架构图相似的属性,Dispatcher和TransportContext

下面我们记录下已知的类以及调用关系

08-流程1

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() 从方法名可知是启动服务,是我们关注的重点。

03-NettyRpcEnv-create

点进 nettyEnv.startServer() 方法,可以看到我们需要关注的NettyRPCEnv两个重要属性,Dispatcher和TransportContext

03-NettyRpcEnv-startServer

点进transportContext.createServer() 方法,该方法是创建一个 TransportServer(),从注释可知,“创建一个服务并绑定一个特殊的ip和端口”,这里才是真正启动传输层服务

04-TransportContext-createServer

进入TransportServer类,注释更能证明我们上面的结论 TransportServer 才是真正的传输层服务,这里init()方法就是将服务启动,底层是由Netty实现【由于文章篇幅问题,这里笔者决定不做过多的去解释Netty,后续会找一章节把Netty解释补上】;

这里需要关注另一个重点属性,RpcHandler,从上 NettyRpcEnv 类截图可以看出,RpcHandler包含了Dispatcher

private val transportContext = new TransportContext(transportConf,
  new NettyRpcHandler(dispatcher, this, streamManager))

05-TransportServer

这里看下 RpcHandler 实现类,可以看到我们关注相同包下的,NettyRpcHandler

09-RpcHandler

小结:
  • spark中默认使用的序列化器是java序列化器,因为在多线程情况下安全
  • TransportServer 才是真正的传输层服务,且 TransportServer 下的 RpcHandler 包含了 Dispatcher 分区器

下面我们记录下已知的类以及调用关系

08-流程2

2.2.3、追溯流程03-RpcEnv源码二TransportServer 调用追踪

接下来我们追踪下,具体 Dispatcher 在传输层的作用,打开 TransportServer.init() 方法,可以发现init里面所做的操作基本都是涉及到Netty代码的使用,这里关注下 appRpcHandler 被转化为 rpcHandler 并传入 initializePipeline()方法,下面我们具体跟踪下代码流程,找出Dispatcher 用途,涉及代码细节不作阐述

05-TransportServer-inti

04-TransportContext-initializePipeline

点开TransportContext.createChannelHandler() 方法,抛开Netty代码的,用个人的理解方式所谓的消息传输就是,需要知道 client(客户端)、request(请求)、response(返回),客户端发送请求给服务端,服务端接收处理完后返回消息给客户端

04-TransportContext-createChannelHandler

通过追踪源码,我们来到了TransportRequestHandler,所谓Handler就是Netty里处理消息的细节,这里重点关注handle() 方法,针对请求的方法做处理,这里我们挑一个方法继续追踪

11-TransportRequestHandler

点开Dispatcher.processOneWayMessage() 方法,这里看到我们熟悉的 RPCHandler,查看具体实现,我们又回到了我们熟悉的 NettyRpcHandler

11-TransportRequestHandler-processOneWayMessage

回到NettyRPCHandler.receive()方法,我们可以清楚的看到

dispatcher.postOneWayMessage(messageToDispatch)

又回到了Dispatcher 类

03-NettyRpcEnv-receive

点进 Dispatcher.postOneWayMessage() 方法,至此我们追踪 Dispatcher 也得到了答案:传输层在传输消息的时候最终会调用 Dispatcher.postMessage() 进行传输,饶了一个圈又回到了 Dispatcher

06-Dispatcher-postOneWayMessage

小结:
  • TransportServer 传输服务的实现底层是有Netty实现的,当源码跟踪到TransportServer.init() 证明传输层服务已经启动
  • TransportServer 传输服务在传输消息的底层实际上是通过调用分发器Dispatcher.postMessage() ,饶了一个弯处理消息又是由分发器处理

下面我们记录下已知的类以及调用关系

08-流程3

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]

06-Dispatcher

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
}

06-Dispatcher-threadpool

从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)
  }
}

06-Dispatcher-MessageLoop

有从消息队列里取出数据的处理的,也就把数据放入消息队列里的,接下来我们回到 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
}

06-Dispatcher-postMessage

这边我们再看一下 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)
  }

07-Inbox

07-Inbox-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 针对消息的处理流程,我们已经了解清楚了

07-Inbox-process

小结:
包含关系如下:
TransportServer 传输层 {
    Dispatcher 分发器{
        Inbox 信箱 {messages 信息队列}
        receivers 消息队列
        endpoints 端点信息集合
        endpointRefs 端点引用信息集合
    }
}
  • Dispatcher 中还存放着端点信息集合以及端点引用信息集合(后面解释端点跟端点引用)

  • Dispatcher 处理消息是通过线程池启动多个 MessageLoop 线程主动去消息队列里拉取数据处理,其中处理消息是调用 **Inbox.process()**方法

  • Inbox.process() 方法的处理逻辑最终是调用端点的各个方法进行处理

下面我们记录下已知的类以及调用关系

08-流程4

2.2.5、追溯流程05-RpcEnv源码四之RpcEndpoint 端点以及RpcEndpointRef 端点引用

从上面 Dispatcher 源码截图我们知道,端点信息类 EndpointData 里面除了有 inbox 这个属性外,更主要的还有端点类 RpcEndpoint 和 端点引用类 NettyRpcEndpointRef

06-Dispatcher

这边我们看下 RpcEndpoint 这是一个接口类,只有一个属性 rpcEnv(Rpc调用环境),从注释我们可以了解到针对特定的消息,触发特定的函数,就是这个接口类的定义

13-RpcEndpoint

接下来我们看下,这个类下面定义的方法,其中我们可以看到,onStart() 、onStop() 、receive() 、receiveAndReply() 这些方法与 Inbox.process() 里面调用的端点方法一一对应

13-RpcEndpoint-类结构

再看下receive() 和 receiveAndReply() 方法的注释可以得到,接收来自端点引用类发送的消息,并根据类型进行处理

13-RpcEndpoint-receive

接下来我们跳到端点引用类 RpcEndpointRef,这也是个接口类,其中最主要的属性就是这个 address: RpcAddress,以及定义了与端点类通信的方法

14-RpcEndpointRef

14-RpcEndpointRef-类结构

RpcAddress 类的作用就是将端点的地址进行了包装,如:ip、端口号等信息

15-RpcAddress

小结:
  • 至此,我们可以了解到 RpcEndpoint 与 RpcEndpointRef 是成对存在的
    • RpcEndpointRef 引用类内部存放有RpcEndpoint 的地址信息,并且定义了与 RpcEndpoint 通信的方法,既拿到了地址信息就可以跟 RpcEndpoint 进行通信,发送消息
    • RpcEndpoint 则是接收消息以及定义了消息的处理方式,根据接收到的消息,根据消息类型执行特定的方法
  • RpcEndpointRpcEndpointRef 分别定义了通信的类型以及通信的方法

下面我们记录下已知的类以及调用关系

08-流程5

2.2.6、追溯流程06-Master启动

通过前面的了解,我们已经RpcEnv环境的前期准备以及各角色之间的调用,下面我们通过一张图把前面的源码总结下

  • 传输层服务数据最终是通过 Dispatcher.postMessage()将数据放入消息队列
  • Dispatcher 中通过多线程的方式轮询去取消息队列的数据进行处理
  • 消息的最终处理方式,是通过调用端点的方法进行处理
  • RpcEnv是通信的基础,所有需要通信的角色都必须在RpcEnv之上进行通信

16-RpcEnv1

前面我们已经跟踪完RpcEnv环境准备的流程了,继续看下Master的代码

 val masterEndpoint = rpcEnv.setupEndpoint(ENDPOINT_NAME,
      new Master(rpcEnv, rpcEnv.address, webUiPort, securityMgr, conf))

01-Master-main

进入 rpcEnv.setupEndpoint() 方法,进入到具体实现类 NettyRpcEnv,可以清楚的看到底层调用了Dispatcher.registerRpcEndpoint() 方法,且第二个参数,必须是 RpcEndpoint,可以确定我们要启动的Master也是RpcEndpoint 的实现类之一,同样的,我们可以猜测,所有涉及到通信的也必须是RpcEndpoint的实现类之一,例如:Worker、Driver

06-Dispatcher-setupEndpoint

进入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环境

06-Dispatcher-registerRpcEndpoint

回到Master 类以及查看类继承关系图可以看到,与我们前面的猜测一致,Master、Worker等角色都是RpcEndpoint 的实现类

01-Master

13-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 也更新进我们源码总结图中

16-RpcEnv2

小结:
  • 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通信