在上一节分析了TaskTracker和JobTracker之间通过周期的心跳消息获取任务分配结果的过程。中间留了一个问题,就是任务到底是怎么分配的。任务的分配自然是由JobTracker做出来的,具体来说,存在一个抽象类:TaskScheduler,主要负责分配任务,继承该类的有几个类:

CapacityTaskScheduler、FairScheduler、JobQueueTaskScheduler(LimitTasksPerJobTaskScheduler又继承于该类)。

从名字大致可以看出,CapacityTaskScheduler应该是根据容量进行分配,FairScheduler实现相对公平的一些分配,JobQueueTaskScheduler似乎是按照队列分配,难道是根据队列的任务按照顺序来分配?那岂不是没有优先级、抢占等等的概念了,微观来看,多线程并发执行的时候,线程还有个抢占机制呢,Map或Reduce任务最终实际上就是个JAVA进程,只是一个分布式的,其分配策略应该与操作系统中的线程分配有类似的思想,不过没研究过操作系统里线程是咋分配的,所以这里也无法对比,这里只是提一句,任务分配作为一个大到社会资源,小到CPU资源,都有类似的分配过程,理解其核心策略才是最重要的,Hadoop里也不可能突破人类认知范围,出现十全十美的分配策略,只是一种适应某种场景的分配策略。

MapReduce里默认是使用JobQueueTaskScheduler分配任务的,下文进行分析。

 

任务分配过程在JobQueueTaskScheduler的assignTasks方法中。

这个方法的输入参数是TaskTracker taskTracker,也就是对某个TaskTracker进行分配。

首先来看它第一步做什么。

TaskTrackerStatus taskTrackerStatus = taskTracker.getStatus(); 
    ClusterStatus clusterStatus = taskTrackerManager.getClusterStatus();
    final int numTaskTrackers = clusterStatus.getTaskTrackers();
    final int clusterMapCapacity = clusterStatus.getMaxMapTasks();
    final int clusterReduceCapacity = clusterStatus.getMaxReduceTasks();

    Collection<JobInProgress> jobQueue =
      jobQueueJobInProgressListener.getJobQueue();

首先,获得该TaskTracker的状态,这个状态是通过心跳响应从TaskTracker传给JobTracker的,这个也好理解,只有TaskTracker知道自己处于什么状态,通过心跳把自身的状态上报。

之后,获得集群状态。ClusterStatus是一个描述集群状态的类,用哪些参数来描述集群的状态呢?下面是他的主要变量:

public class ClusterStatus implements Writable {

  private int numActiveTrackers;
  private Collection<String> activeTrackers = new ArrayList<String>();
  private Collection<String> blacklistedTrackers = new ArrayList<String>();
  private Collection<String> graylistedTrackers = new ArrayList<String>();
  private int numBlacklistedTrackers;
  private int numGraylistedTrackers;
  private int numExcludedNodes;
  private long ttExpiryInterval;
  private int map_tasks;
  private int reduce_tasks;
  private int max_map_tasks;
  private int max_reduce_tasks;
  private JobTracker.State state;

可以看出,主要有当前活动的TaskTracker(numActiveTrackers),处于黑名单和灰名单的TaskTracker,这个概念在上一节分析时提过,主要是指一些TaskTracker在应该心跳的时候可能没出现心跳,可能原因是机器出现一些故障了,性能降低了等等,总之,在JobTracker这个总管看来,凡是没有在该出现的时间出现的小弟,就被认为是不信任的小弟,于是加入黑名单。

另外,还有当前Map,Reduce的任务数量,以及集群最大的Map,Reduce任务数量。看起来也比较简单。

之后,获取当前的作业队列。

//
    // Get map + reduce counts for the current tracker.
    //
    final int trackerMapCapacity = taskTrackerStatus.getMaxMapSlots();
    final int trackerReduceCapacity = taskTrackerStatus.getMaxReduceSlots();
    final int trackerRunningMaps = taskTrackerStatus.countMapTasks();
    final int trackerRunningReduces = taskTrackerStatus.countReduceTasks();

    // Assigned tasks
    List<Task> assignedTasks = new ArrayList<Task>();

之后,获取TaskTracker的最大容量,即Map最大数量、Reduce最大数量、以及正在运行着的Map、Reduce数量。

接着,计算当前队列里所有需要分配的任务数量(与TaskTracker无关,理论上,所有任务都有可能分配到这个TaskTracker)。

int remainingReduceLoad = 0;
    int remainingMapLoad = 0;
    synchronized (jobQueue) {
      for (JobInProgress job : jobQueue) {
        if (job.getStatus().getRunState() == JobStatus.RUNNING) {
          remainingMapLoad += (job.desiredMaps() - job.finishedMaps());
          if (job.scheduleReduces()) {
            remainingReduceLoad += 
              (job.desiredReduces() - job.finishedReduces());
          }
        }
      }
    }

根据这个总数,除以集群的全部容量(就是全部TaskTracker的容量之和),可以得到目前Map和Reduce的负载因子,这实际上是评估目前集群的负载压力:

// Compute the 'load factor' for maps and reduces
    double mapLoadFactor = 0.0;
    if (clusterMapCapacity > 0) {
      mapLoadFactor = (double)remainingMapLoad / clusterMapCapacity;
    }
    double reduceLoadFactor = 0.0;
    if (clusterReduceCapacity > 0) {
      reduceLoadFactor = (double)remainingReduceLoad / clusterReduceCapacity;
    }

整个集群的负载压力跟对某个TaskTracker分配任务有什么关系呢?试想一下,如果我们要给某个TaskTracker分配任务,总不能把很多任务都分给他把,应该要使他的负载压力跟整个集群的负载情况大致差不多,这样才算做到了负载均衡把?所以先得到集群的负载情况,可以为后面TaskTracker的负载情况进行一个对比。接着往下看:

final int trackerCurrentMapCapacity = 
      Math.min((int)Math.ceil(mapLoadFactor * trackerMapCapacity), 
                              trackerMapCapacity);
    int availableMapSlots = trackerCurrentMapCapacity - trackerRunningMaps;

这句话是计算要分配任务这个TaskTracker的当前容量,获得目前可以分配的Map数量。可以想见,一个TaskTracker的容量实际上事先是指定好的,是静态的,比如可以执行多少个Map,多少个Reduce,但当前的容量,实际上是根据集群负载情况计算得到的,就是前面说的那个原则,虽然某个机器的最大容量很大,但因为当前负载可能比较低,所以也不能拼命给他分配任务,否则后面如果有大量任务来了(集群整体负载上去了)这个机器就分配不了多少任务,但有可能上面有不少数据,这些数据要计算,岂不是要搬到其他机器上去算?IO消耗必然带来计算耗时加大。

好了,在得到TaskTracker中可以分配的Map数量后,是不是就可以从Job队列里取出Map随意进行分配了呢?还不是,还需要进行一个检测,Hadoop认为需要为一些任务预留一些资源,哪些任务需要预留资源呢?从源代码的解释来看,主要是:failures and speculative executions,直观上看,其中文含义是失败的任务和预测执行,或者投机任务。失败的任务好理解,就是一个任务在某个机器上执行失败了,应该重新调度执行,而不应该不管了;预测执行到底是什么意思呢?要理解这句话,需要理解Hadoop里面的一个优化方式。我们知道,一个作业会分为很多Map任务并发执行,当执行完了Map后才开始Reduce任务的执行,因此,一个作业的执行时间取决于Map的时间和Reduce的时间之和。对于Map或Reduce阶段,因为有很多任务同时执行,这个阶段的执行时间就取决于执行最慢的那个Map或Reduce任务,为了加速执行,对于很慢的那些Map或Reduce任务,Hadoop会启动多份,执行相同的代码,那么Hadoop怎么知道哪个任务执行最慢呢?自然是靠心跳的时候TaskTracker把各个任务的进度汇报上来,当大部分都执行完了,就剩几个进度很慢的任务,就可能会启动推测执行,实际上个人觉得叫冗余执行更好理解。在Hadoop里一个在执行的任务称为Task Attemt,Task就像一个类,而Task Attemt就如同一个实例化的对象,是任务的一次实现。这种实现可以有多个,因为启动了多个,那么,最先完的那个就代表这个任务执行完了,其它还没执行完的就停了就行了。是一种用空间换时间的策略,如果不保留一点资源,当出现任务很慢的时候,想换都没得换了。失败的任务也类似,一个失败的任务,需要调度到其它机器上重新执行一遍,可能就成功了。所以集群内的机器需要保留一小部分资源,防止出现这些情况时需要利用资源。

这种预留资源称为Padding,不能把一台集群内的资源全部都分完了,如果出现这种状况,我称之为到达了预警状态,此时就得慢慢分才行,一次心跳只给一个机器分配一个任务就返回,否则可以分配多个任务返回。那么, 到底要预留多少资源呢?从代码来看,最多预留当前正在执行任务的1%的资源,也就是由下面的变量控制:

padFraction = conf.getFloat("mapred.jobtracker.taskalloc.capacitypad", 0.01f);

比如,整个集群正在执行1000个Map任务,此时最大容量是多少算到达了预警状态呢?Hadoop里认为跟要分配任务的机器有关,比如目前的机器如果最大容量是4个Map任务,而1000x1%=10个,两者取其小值,即4,如果当前容量是1004,即到达了预警状态,此次心跳就只能为这个机器分配1个Map任务。而如果这台机器最大容量是20,大于10,则如果当前容量是1010,则到达了预警状态。为什么这种预警状态的判断跟具体分配任务的机器有关呢?1%当然是一个上限,说白了预留的资源也不能太多了。最多预留10个,最少预留多少呢?跟机器容量有关,极端一点,假如每台机器最多就能执行1个Map任务,那么最少预留1个。这个似乎也可以理解,如果机器性能都很好,那就多预留点,如果机器性能都不咋地,那就少预留点,但是也不能不预留。判断是否到达预警状态的方法如下,其中maxTaskTrackerSlots就是指当前需要分配任务的机器的最大容量:

private boolean exceededPadding(boolean isMapTask, 
                                  ClusterStatus clusterStatus, 
                                  int maxTaskTrackerSlots) { 
    int numTaskTrackers = clusterStatus.getTaskTrackers();
    int totalTasks = 
      (isMapTask) ? clusterStatus.getMapTasks() : 
        clusterStatus.getReduceTasks();
    int totalTaskCapacity = 
      isMapTask ? clusterStatus.getMaxMapTasks() : 
                  clusterStatus.getMaxReduceTasks();

    Collection<JobInProgress> jobQueue =
      jobQueueJobInProgressListener.getJobQueue();

    boolean exceededPadding = false;
    synchronized (jobQueue) {
      int totalNeededTasks = 0;
      for (JobInProgress job : jobQueue) {
        if (job.getStatus().getRunState() != JobStatus.RUNNING ||
            job.numReduceTasks == 0) {
          continue;
        }

        //
        // Beyond the highest-priority task, reserve a little 
        // room for failures and speculative executions; don't 
        // schedule tasks to the hilt.
        //
        totalNeededTasks += 
          isMapTask ? job.desiredMaps() : job.desiredReduces();
        int padding = 0;
        if (numTaskTrackers > MIN_CLUSTER_SIZE_FOR_PADDING) {
          padding = 
            Math.min(maxTaskTrackerSlots,
                     (int) (totalNeededTasks * padFraction));
        }
        if (totalTasks + padding >= totalTaskCapacity) {
          exceededPadding = true;
          break;
        }
      }
    }

    return exceededPadding;
  }

接下来,JobQueueTaskScheduler会对该机器上所有剩余容量(也就是还能执行多少个Map或Reduce)进行逐一分配,调用的方法是:

// Try to schedule a Map task with locality between node-local 
          // and rack-local
          t = job.obtainNewNodeOrRackLocalMapTask(taskTrackerStatus, 
                numTaskTrackers, taskTrackerManager.getNumberOfUniqueHosts());

即从队列里循环取出一个JobInProgress对象,执行该方法。当前心跳的TaskTracker是某个服务器上的一个JAVA进程,这台服务器称为S1,一个Job可能涉及到很多待处理的数据,这些数据可能位于S1的磁盘上,也可能在其他服务器上,而其他服务器,可能与S1位于同一机架内,也可能位于其他机架。这个方法的目的就是获取一个Map任务,这个Map任务要处理的数据要么在S1的磁盘上,要么在与S1位于同一机架内的服务器的磁盘上。因为把这样的任务调度到S1上,数据就不需要跨机架传输了,甚至就在本地。这种原则是Hadoop里面最为核心的一个原则,称为数据本地性。

obtainNewNodeOrRackLocalMapTask调用了该JobInProgress对象的另一个方法obtainNewMapTaskCommon。
在前面分析Job初始化的时候,我们知道Job初始化过程中根据Split的个数创建了一个map对象数组:

maps = new TaskInProgress[numMapTasks];
    for(int i=0; i < numMapTasks; ++i) {
      inputLength += splits[i].getInputDataLength();
      maps[i] = new TaskInProgress(jobId, jobFile, 
                                   splits[i], 
                                   jobtracker, conf, this, i, numSlotsPerMap);
    }

可以想见,分配过程应该就是从作业的这个Map数组中取出Map任务返回。不过取哪些Map呢?难道又是按照队列来取?因为不同的Map对应于不同的Split,而不同的Split位于不同的机器,考虑到上面说的本地化策略,则不会那么简单,需要考虑数据本地性。

obtainNewMapTaskCommon方法里调用了另一个方法findNewMapTask,这个方法是Map任务分配中最核心的方法,理解了这个方法,也就理解了Map任务分配过程。其声明为:

private synchronized int findNewMapTask(final TaskTrackerStatus tts, 
                                          final int clusterSize,
                                          final int numUniqueHosts,
                                          final int maxCacheLevel,
                                          final double avgProgress)

其中,tts也就是跟JobTracker保持心跳的那个TaskTracker的状态,ClusterSize就是当前集群中所有TaskTracker的数量,numUniqueHosts是指集群中机器的数量,从这里可以看出,机器数量和TaskTracker数量还不一样,一个机器也可以有多个TaskTracker,因为TaskTracker本质上就是一个JAVA进程。maxCacheLevel表示本地化策略,这个变量十分关键,控制了findNewMapTask方法中应该执行哪些代码。该变量是前面由方法obtainNewNodeOrRackLocalMapTask传过来的,具体代码为:

public synchronized Task obtainNewNodeOrRackLocalMapTask(
      TaskTrackerStatus tts, int clusterSize, int numUniqueHosts)
  throws IOException {
    return obtainNewMapTaskCommon(tts, clusterSize, numUniqueHosts, maxLevel);
  }

也就是maxLevel,该变量是JobInProgress类中的一个固定变量。默认值是:

this.maxLevel = NetworkTopology.DEFAULT_HOST_LEVEL;

DEFAULT_HOST_LEVEL的值等于2,即两个层次,Hadoop将网络拓扑看做一颗树,主机作为一层,其上层是机架,两层的意思就是在主机本身和机架内部寻找任务。比如如果这个值是anyCacheLevel:

this.anyCacheLevel = this.maxLevel+1;

那就意味着,对于某个要分配任务的节点来说,某个任务的处理数据只要是在节点本地的,或者跟这个节点在一个机架内的(还是在一个交换机下面),甚至是隔着一个交换机的(第三层),都可以分配给这个节点,这就失去了本地化的意义了,所以obtainNewNodeOrRackLocalMapTask这个方法顾名思义就是限定在主机和机架内,而通过maxLevel参数设置为2,即可限定查找范围。

另外,avgProgress是一个0-1之间的数,表示Map任务的进度,这个参数后面会用来评估哪些Map任务执行进度较慢,以启动推测执行,后面分析中会看到。avgProgress属于JobStatus这个类,指的是一个作业中所有Map任务的平均进度。

下面我们仔细分析findNewMapTask这个方法的具体流程。

1,执行shouldRunOnTaskTracker方法,该方法判断是否应该在这个TaskTracker上分配任务,其代码为:

private boolean shouldRunOnTaskTracker(String taskTracker) {
    //
    // Check if too many tasks of this job have failed on this
    // tasktracker prior to assigning it a new one.
    //
    int taskTrackerFailedTasks = getTrackerTaskFailures(taskTracker);
    if ((flakyTaskTrackers < (clusterSize * CLUSTER_BLACKLIST_PERCENT)) && 
        taskTrackerFailedTasks >= maxTaskFailuresPerTracker) {
      if (LOG.isDebugEnabled()) {
        String flakyTracker = convertTrackerNameToHostName(taskTracker); 
        LOG.debug("Ignoring the black-listed tasktracker: '" + flakyTracker 
                  + "' for assigning a new task");
      }
      return false;
    }
    return true;
  }

简单点说,如果该TaskTracker上执行失败的任务太多的话,就不适合继续分配任务了。那么, 到底什么叫失败任务过多呢?即:

taskTrackerFailedTasks >= maxTaskFailuresPerTracker,表明失败任务过多了。

maxTaskFailuresPerTracker是这么来的:

public int getMaxTaskFailuresPerTracker() {
    return getInt("mapred.max.tracker.failures", 4); 
  }

也就是由默认4个以上任务失败了,就不适合分配任务。当然,这里还有一个条件:

flakyTaskTrackers < (clusterSize * CLUSTER_BLACKLIST_PERCENT))

flakyTaskTrackers是超过4个失败任务的TaskTracker的数量,如果这个数量少于整个集群大小的25%:

private static final double CLUSTER_BLACKLIST_PERCENT = 0.25;

那么就不合适分配,去其它TaskTracker上分配。当然,如果每台机器都失败了很多任务,那么可能就不是机器的问题,可能是在某些时候出现了一些有问题的任务,不应该把问题归结为机器,机器仍然可以分配任务。

 

2,检查TaskTracker是否有足够资源来执行任务:

// Check to ensure this TaskTracker has enough resources to 
    // run tasks from this job
    long outSize = resourceEstimator.getEstimatedMapOutputSize();
    long availSpace = tts.getResourceStatus().getAvailableSpace();
    if(availSpace < outSize) {
      LOG.warn("No room for map task. Node " + tts.getHost() + 
               " has " + availSpace + 
               " bytes free; but we expect map to take " + outSize);

      return -1; //see if a different TIP might work better. 
    }

getEstimatedMapOutputSize方法对Map任务的输出需要占的空间进行估计,这里的空间是指磁盘大小,如果一个机器磁盘空间都不够,而Map任务执行后的中间结果不存入HDFS,而是存入本地文件系统(MapReduce的设计原则之一),就会导致Map任务失败。不过这个任务还没执行,怎么知道要输出多少中间数据呢?Hadoop是根据历史经验来判断的,以前的Map任务输出了多少内容,在TaskStatus这个类里面会记录,而ResourceEstimator这个类就是进行空间估计的,里面有一个方法:

protected synchronized void updateWithCompletedTask(TaskStatus ts, 
      TaskInProgress tip) {

    //-1 indicates error, which we don't average in.
    if(tip.isMapTask() &&  ts.getOutputSize() != -1)  {
      completedMapsUpdates++;

      completedMapsInputSize+=(tip.getMapInputSize()+1);
      completedMapsOutputSize+=ts.getOutputSize();

      if(LOG.isDebugEnabled()) {
        LOG.debug("completedMapsUpdates:"+completedMapsUpdates+"  "+
                  "completedMapsInputSize:"+completedMapsInputSize+"  " +
                  "completedMapsOutputSize:"+completedMapsOutputSize);
      }
    }
  }

可以看出,这个类对以前任务执行的结果进行统计,就得到了平均一个Map任务到底会占用多少本地空间。而估计新Map任务所占空间的方法也就依赖于此,就不细讲了。

另外,TaskTracker会把自己的剩余空间通过心跳传递过来,两个比一下,如果不够,自然就不合适分配任务。

 进行了一些预先检测后,要开始调度任务了,任务的调度顺序是怎样的呢?是不是从JobInProgress队列里面逐一取出Job,然后再从该Job里面的TaskInProgress队列里逐一取出Map任务,分给这台机器呢?并不是,Hadoop把队列里的任务分为四类进行调度,首先对执行失败的任务进行调度,如果一个任务执行失败了,按理来说应该马上再次执行,因为这类任务时间比较紧迫,而且Hadoop的JobTracker在调度这类任务的时候并不区分数据本地性,也就是不管这类任务要处理的数据是不是在跟自己心跳的这台机器(或者是不是一个机架内等等),总之都调度给他。换句话说,Map阶段实际上也是有可能存在数据从一台机器拷贝到另一台机器的可能,并不是严格的数据本地性。

不过有一个问题,如果一个任务在机器A上已经执行失败了,再调度给它很可能还会失败,所以调度的都是其它机器执行失败的任务。

失败的任务优先级最高。之后是调度那些还没运行的任务,也就是一般的正在等待中的任务,之后是推测任务,也就是那些执行得很慢的任务,需要冗余执行。在调度还没运行的任务、以及正在运行的但是很慢的任务(正在运行的速度也不错的任务当然就不需要调度了)的过程中,会遵从先本地、再机架、再机架外、最后无位置信息的任务查找顺序,这就是数据本地性的实现方式,按照优先级逐步查找任务。简单点说,心跳节点知道IP地址了,一个任务与Split紧密对应,而Split的位置信息可以由HDFS提供出来,两者一比较就知道这个任务运行在心跳节点上是否划算了。

 

3,先找失败的任务,分配给这个节点。

 其代码为:

// 0) Schedule the task with the most failures, unless failure was on this
    //    machine
    tip = findTaskFromList(failedMaps, tts, numUniqueHosts, false);
    if (tip != null) {
      // Add to the running list
      scheduleMap(tip);
      LOG.info("Choosing a failed task " + tip.getTIPId());
      return tip.getIdWithinJob();
    }

可见,利用了findTaskFromList这个方法,从failedMaps也就是失败的Map任务队列里取出任务。failedMaps是一个SortedSet<TaskInProgress> 对象,存在一个自定义的排序准则,就是任务失败的次数:this.failedMaps = new TreeSet<TaskInProgress>(failComparator),failComparator的定义如下:

// keep failedMaps, nonRunningReduces ordered by failure count to bias
  // scheduling toward failing tasks
  private static final Comparator<TaskInProgress> failComparator =
    new Comparator<TaskInProgress>() {
      @Override
      public int compare(TaskInProgress t1, TaskInProgress t2) {
        if (t1 == null) return -1;
        if (t2 == null) return 1;
        
        int failures = t2.numTaskFailures() - t1.numTaskFailures();
        return (failures == 0) ? (t1.getTIPId().getId() - t2.getTIPId().getId())
            : failures;
      }
    };

也就是说,失败次数最多的那些Map任务被先调度。

后面我们可以看到,后面的几类任务,都是使用了findTaskFromList这个方法,所以对这个方法进行分析。这个方法输入参数的说明为:

/**
   * Find a non-running task in the passed list of TIPs
   * @param tips a collection of TIPs
   * @param ttStatus the status of tracker that has requested a task to run
   * @param numUniqueHosts number of unique hosts that run trask trackers
   * @param removeFailedTip whether to remove the failed tips
   */

即从一个任务集合里面找出一个任务。整个过程就是循环地对集合里面的任务进行判断。其代码为:

Iterator<TaskInProgress> iter = tips.iterator();
    while (iter.hasNext()) {
      TaskInProgress tip = iter.next();

      // Select a tip if
      //   1. runnable   : still needs to be run and is not completed
      //   2. ~running   : no other node is running it
      //   3. earlier attempt failed : has not failed on this host
      //                               and has failed on all the other hosts
      // A TIP is removed from the list if 
      // (1) this tip is scheduled
      // (2) if the passed list is a level 0 (host) cache
      // (3) when the TIP is non-schedulable (running, killed, complete)
      if (tip.isRunnable() && !tip.isRunning()) {
        // check if the tip has failed on this host
        if (!tip.hasFailedOnMachine(ttStatus.getHost()) || 
             tip.getNumberOfFailedMachines() >= numUniqueHosts) {
          // check if the tip has failed on all the nodes
          iter.remove();
          return tip;
        } else if (removeFailedTip) { 
          // the case where we want to remove a failed tip from the host cache
          // point#3 in the TIP removal logic above
          iter.remove();
        }
      } else {
        // see point#3 in the comment above for TIP removal logic
        iter.remove();
      }
    }
    return null;

可见,如果一个任务可以运行,并且没有在运行,那么再判断两个条件,如果没有在这个机器上失败过,那么表明可以调度;或者已经在所有机器上都失败了,那也可以调度。因为所有机器上都失败了,应该不是机器的问题了,可能是任务本身存在什么问题,不管怎样,既然失败了,还是继续调度,如果多执行几次都失败了,那当然就没法了。可能就停止了,向用户报错。

 

4,因为findNewMapTask这个方法返回的是某个Job的Map任务的编号,所以如果没有找到失败的任务,就接着调度那些未运行的普通任务。从前面传进来的参数来看,只查找那些数据位于本地或者同一机架内的任务,调度到心跳TaskTracker执行。

在这类任务的调度里,考虑了数据本地性,我们对数据本地性再进行一些解释,这个概念在Hadoop是如此重要,值得大书特书。Hadoop里的数据本地性反应的是要执行任务的机器与这个任务处理的数据所在机器之间的距离,这两台机器如果是一台机器当然最好,就省得跨机器搬移数据了,这称为local;如果两台机器不是一台,不过位于一个机架,那么就称为Rack-Local,熟悉数据中心机房的同学都知道,目前机房的一般方案是有很多机架,每个机架放着很多1U、2U(不知道1U是多高的建议百度,这是基本概念)的服务器,一个机架可以放多达几十台服务器,一般有一个交换机,称为Top-Of-Rack,交换机都放在一个机架上面,这个机架的所有服务器都用以太网线或者光缆往上走线连接到这个交换机(比如24口等等),也就是说,在一个机架内部两台服务器要通信,需要经过这个交换机一次,如果两个机架的两台服务器要通信,则至少需要经过两个交换机,交换机之间的走线在机架上面。所以,除了Rack-Local,稍微远点的称为Off-Switch,如果继续细分,还可以分为两台服务器是否在一个机房,还是跨越了机房等等。因此,Hadoop把这种呈现树状的结构称为网络拓扑(NetworkTopology),网络拓扑中的服务器或者机架称为节点(Node),需要注意的是Hadoop所说的Node并不一定是一台服务器,可以是一个机架,甚至是一个数据中心,都抽象为一个节点。节点存在一个名称,比如"/default-rack"是一个节点的名字,可以表示为一个机架,机架下面的服务器Server1可以表示为"/default-rack/Server1",而这个服务器的父节点即为这个机架。这样的好处是如果知道了服务器拥有哪些本地任务,那么通过计算就得到机架拥有哪些任务,其实就是机架下面这些服务器所有任务的并集。

说到这里,可能会有一个疑问,Hadoop怎么知道哪些服务器在同一个机架下面呢?实际上这是需要用户按照一个格式写个配置脚本,Hadoop通过解析这个脚本知道的,于是知道了服务器之间的距离,比如XML里,一个机架标签里面把服务器的IP地址全部填进去即可。

有了上面的背景知识,需要着重理解Hadoop里面的关键对象。在JobTracker中有几个比较重要的对象,首先一个是: 

// NetworkTopology Node to the set of TIPs
  Map<Node, List<TaskInProgress>> nonRunningMapCache;

这个变量记录了该节点对应的任务。比如对于一个服务器而言,其对应的Map任务就是那些处理数据位于该服务器的任务,也就是本地任务;举例而言,假如一个作业需要对一个大文件进行处理,该大文件分为5份:F1,F2,F3,F4,F5,分别存储于3台服务器:S1、S2、S3,对应存储关系(依赖于HDFS告知MapReduce)为:

S1:F1、F2、F3、F4

S2:F2、F3、F4、F5

S3:F3、F4、F5、F1

那么,S1这个节点对应的任务也就是处理F1、F2、F3、F4的这四个任务;S2这个节点对应的任务也就是处理F2、F3、F4、F5的这四个任务。

Job在初始化的时候,会根据Splits(即=5)分别创建5个任务,即5个TaskInProgress对象,然后会执行一个createCache方法,将这些任务分别与节点对应起来:

private Map<Node, List<TaskInProgress>> createCache(
                                 TaskSplitMetaInfo[] splits, int maxLevel)
                                 throws UnknownHostException {
    Map<Node, List<TaskInProgress>> cache = 
      new IdentityHashMap<Node, List<TaskInProgress>>(maxLevel);

其中maxLevel也就是记录到哪个级别,默认是    this.maxLevel = NetworkTopology.DEFAULT_HOST_LEVEL,即为2,就是我们前面提到的,本机和本机架两个层次。也就是记录了Local和Rack-Local这两个级别。对应于上面的例子,第一级就是记录S1等与各个任务的关系,第二级会记录S1、S2、S3这些服务器的所有父节点(也就是机架)对应的任务,显然,因为一个机架会有很多服务器,所以一个机架会对应很多任务。比如S1、S2,S3属于两个机架C1、C2,那么,会有以下对应关系:

C1:对应于S1和S2任务的并集;

C2:对应于S3的任务。

最终,nonRunningMapCache这个Map就记录了以下内容:

S1、S2、S3对应的任务,以及C1、C2对应的任务。

为什么要这么记录下来呢?理论来说,在任务分配的时候,逐一取出Job,然后逐一取出Job里面事先创建好的TaskInProgress,通过查询其Split可以获知该任务位于哪个机器,然后再根据网络拓扑计算心跳机器与该机器之间的距离,不过这样做岂不是耗时较长,本来,任务分配就是当心跳发生时JobTracker执行的,速度当然越快越好,所以Hadoop的策略是当Job初始化时,就创建好任务,并且将这些任务与机器的对应关系缓存起来,所以命名为nonRunningMapCache,即还未运行的Map任务的缓存信息。当某个机器与JobTracker心跳时,因为这个映射表的索引就是Node,如果要查找本地任务,则可以以TaskTracker对应的Node为索引,就快速获得了本地任务;而如果要查询机架内任务,则获得其父节点直接查询即可,进而快速完成满足数据本地性的任务分配。
所以,在我们分析任务分配的时候,一定要有以下基本认识:

1)一个JobTracker有一个JobInProgress队列,所有Job在初始化的时候,都会创建TaskInProgress对象数组(作业是队列,存在到达顺序;任务是数组,无先后顺序),每个JobInProgress会有以下对象数组:

  TaskInProgress maps[] = new TaskInProgress[0];
  TaskInProgress reduces[] = new TaskInProgress[0];
  TaskInProgress cleanup[] = new TaskInProgress[0];
  TaskInProgress setup[] = new TaskInProgress[0];

其中,cleanup和setup用于任务执行的开始和结束时候的初始化和清理等工作。

2)虽然上面的任务数组已经记录了该作业的所有任务,但是还存在一些数据结构对这些任务进行了另外形式的记录,首先是目前还未运行的Map任务的映射表,以节点Node为索引,将所有Map任务打乱了重新记录,以提升后面任务分配的速度,如果已经分配了,这些任务就会从这个表里面移除掉;以及正在运行的Map映射表;以及非本地的Map,这些Map任务的运行比较倒霉,并不是以数据本地化进行运行的,其原因有可能是那台服务器已经没有计算资源了,或者运行失败了被调度到其它机器了等等;另外还会记录失败的任务。这些所有的数据结构几乎都是为了更好地对任务进行合理、高效分配而创建的。仔细想想,假如我们要来写这一段任务分配代码,我们自然而然也会加入一些额外的数据结构来记录这些任务。

// NetworkTopology Node to the set of TIPs
  Map<Node, List<TaskInProgress>> nonRunningMapCache;
  // Map of NetworkTopology Node to set of running TIPs
  Map<Node, Set<TaskInProgress>> runningMapCache;
  // A list of non-local, non-running maps
  final List<TaskInProgress> nonLocalMaps;
  // Set of failed, non-running maps sorted by #failures
  final SortedSet<TaskInProgress> failedMaps;
  // A set of non-local running maps
  Set<TaskInProgress> nonLocalRunningMaps;
  // A list of non-running reduce TIPs
  Set<TaskInProgress> nonRunningReduces;
  // A set of running reduce TIPs
  Set<TaskInProgress> runningReduces;

有了上面的认识,我们来看分配一个普通任务的过程。继续分析findNewMapTask方法。

首先获得心跳机器对应的节点,该节点显然是一个服务器节点(不是机架节点):

Node node = jobtracker.getNode(tts.getHost());

另外,注意tts表示TaskTrackerStatus,是心跳的那台TaskTracker主动发过来的。

获得了该节点后,就以该Node为索引,去刚才分析过的那个映射表nonRunningMapCache里面查找该节点对应的任务,这些任务是该节点的本地任务,所以应该说是调度的首选,如果不存在这类任务,那么就取出该节点的父节点对应的任务列表,也就是说,如果有TaskTracker对应的本地任务,那就最好;如果没有,那就找出那些与TaskTracker位于同一机架内的任务,也行,其代码为:

for (level = 0;level < maxLevelToSchedule; ++level) {
        List <TaskInProgress> cacheForLevel = nonRunningMapCache.get(key);
        if (cacheForLevel != null) {
          tip = findTaskFromList(cacheForLevel, tts, 
              numUniqueHosts,level == 0);
          if (tip != null) {
            // Add to running cache
            scheduleMap(tip);

            // remove the cache if its empty
            if (cacheForLevel.size() == 0) {
              nonRunningMapCache.remove(key);
            }

            return tip.getIdWithinJob();
          }
        }
        key = key.getParent();
      }

其中,level从0开始到maxLevelToSchedule表示按照本地、机架的顺序依次取出任务来进行分配。因为findNewMapTask每次返回一个任务号,所以如果存在本地任务,自然优先返回这类任务。

上面的maxLevelToSchedule是方法参数maxCacheLevel和maxLevel的较小者。从代码来看,默认都是2。也就是会获取Level=0,1两种情况下的任务。也就是本地或同一个机架内的任务。

当获得了一个任务后,会把它从nonRunningMapCache中移除掉,这个代码在findTaskFromList这个方法里,前面已经看过。

获得了任务,就执行scheduleMap这个方法,这个方法顾名思义就是调度任务的意思,实际上,所谓的调度任务,就是把这个任务加入到正在运行任务的那个数据结构中,没有特别的东西,完全只是记录改变了。前面看到,除了nonRunningMapCache这个映射表,还有runningMapCache和nonLocalRunningMaps两个数据结构,分别记录当前有位置信息的正在运行的Map任务,以及无位置信息的正在运行的Map任务。

任务调度的部分代码为(存在位置信息时):

for(String host: splitLocations) {
      Node node = jobtracker.getNode(host);

      for (int j = 0; j < maxLevel; ++j) {
        Set<TaskInProgress> hostMaps = runningMapCache.get(node);
        if (hostMaps == null) {
          // create a cache if needed
          hostMaps = new LinkedHashSet<TaskInProgress>();
          runningMapCache.put(node, hostMaps);
        }
        hostMaps.add(tip);
        node = node.getParent();
      }
    }

可见, 是对于所有Split的主机集合,获得该主机的节点,之后从runningMapCache中去找找有没有(比如已经处于运行状态了,有的任务可能调度多次,运行多个),如果没有就加进去。

另外,从上面的代码key = key.getParent()可以看出,如果没有本地性的任务,就按照距离由近及远寻找其他任务。这样,调度本地化(但不算完全数据本地性)的任务就结束了。

另外,从下面最后一行比较关键的代码可以看出,寻找本地任务和机架内节点任务的工作就结束了,程序不会往下执行:

if (node != null) {
      Node key = node;
      int level = 0;
      // maxCacheLevel might be greater than this.maxLevel if findNewMapTask is
      // called to schedule any task (local, rack-local, off-switch or
      // speculative) tasks or it might be NON_LOCAL_CACHE_LEVEL (i.e. -1) if
      // findNewMapTask is (i.e. -1) if findNewMapTask is to only schedule
      // off-switch/speculative tasks
      int maxLevelToSchedule = Math.min(maxCacheLevel, maxLevel);
      for (level = 0;level < maxLevelToSchedule; ++level) {
。。。。。。。。。。。
      // Check if we need to only schedule a local task (node-local/rack-local)
      if (level == maxCacheLevel) {
        return -1;
      }
    }

因为level从0到达maxLevelToSchedule的循环后,level的值等于maxLevelToSchedule,而因为传入的参数maxCacheLevel=maxLevel,maxLevel=2,也就是两者相同,level最后的值就等于maxCacheLevel,所以如果在本地和机架内没有找到任务,就返回-1,也就是没有找到。至于后面的代码,下面的分析可以看到也会得到执行,只不过是赋了新的参数,又重新调用了这个方法,执行后面的代码。

 

5,findNewMapTask这个核心方法暂时告一段落,实际上我们分析了前半部分,前半部分的功能是在本地和机架内寻找任务。该方法会返回一个任务号(return tip.getIdWithinJob(); 没有则返回-1,见上面的代码)。我们假设已经在本地或机架内找到了任务(如果没找到下面再分析),之后回到JobInProgress的方法obtainNewMapTaskCommon中。

接下来的代码为:

Task result = maps[target].getTaskToRun(tts.getTrackerName());
    if (result != null) {
      addRunningTaskToTIP(maps[target], result.getTaskID(), tts, true);
      // DO NOT reset for off-switch!
      if (maxCacheLevel != NON_LOCAL_CACHE_LEVEL) {
        resetSchedulingOpportunities();
      }
    }
    return result;

也就是创建要运行的任务。getTaskToRun这个方法会创建一个TaskAttemptID,并创建任务对象:

Task t = null;
    if (isMapTask()) {
      if(LOG.isDebugEnabled()) {
        LOG.debug("attempt " + numTaskFailures + " sending skippedRecords "
          + failedRanges.getIndicesCount());
      }
      t = new MapTask(jobFile, taskid, partition, splitInfo.getSplitIndex(),
                      numSlotsNeeded);
    } else {
      t = new ReduceTask(jobFile, taskid, partition, numMaps, 
                         numSlotsNeeded);
    }

这里需要注意不同的任务类。TaskInProgress是Job在初始化过程中就创建好了的,而MapTask是在分配任务时创建的,是可以序列化并最后会传到TaskTracker端的,而TaskInProgress只是JobTracker维护的。基本上可以这么理解,TaskInProgress用于任务在队列中等待的阶段;MapTask,ReduceTask则记录一个要执行的任务的主要参数,比如Job文件位置,任务ID,Split的位置信息等等。

接下来是JobInProgress对任务的各类状态、参数进行记录,在方法addRunningTaskToTIP中,比如任务ID啊,任务名啊,任务的本地特性,有下面一些类型:

enum Locality {
  NODE_LOCAL,
  GROUP_LOCAL,
  RACK_LOCAL,
  OFF_SWITCH
}

从上面可以看到,Hadoop这一版本还不支持数据中心之间的调度策略,不支持跨机房调度,它将跨越了一个机架的那些机器认为都一样。另外,任务是第一次执行还是推测执行(也就是太慢了被重新调度执行)有下面的枚举变量:

enum Avataar {
  VIRGIN,
  SPECULATIVE
}

Avataar中文是阿凡达,天神下凡的意思,主要有两种类型,码农的英文比较诡异,VIRGIN用来表示这是任务的第一次执行,SPECULATIVE表示二次调度执行,这是哪个码农取的名字?

此时,将创建的Task返回至JobQueueTaskScheduler的assignTasks方法。

 

6,上面我们假设在本地或机架内找到了任务,如果没有找到任务呢?那么,上面的getTaskToRun等等代码不会得到执行,而会直接返回null:

int target = findNewMapTask(tts, clusterSize, numUniqueHosts, maxCacheLevel, 
                                status.mapProgress());
    if (target == -1) {
      return null;
    }

此时,直接返回到JobQueueTaskScheduler的assignTasks方法。可以看出,如果经过obtainNewNodeOrRackLocalMapTask的执行返回null,也就是在本地和机架内没有找到任务,则会往下执行另一个方法:obtainNewNonLocalMapTask

 

t =  job.obtainNewNodeOrRackLocalMapTask(taskTrackerStatus, 
                numTaskTrackers, taskTrackerManager.getNumberOfUniqueHosts());
          if (t != null) {
            assignedTasks.add(t);
            ++numLocalMaps;
            。。。。。。。。。。。
          }          
          // Try to schedule a node-local or rack-local Map task
          t = 
            job.obtainNewNonLocalMapTask(taskTrackerStatus, numTaskTrackers,
                                   taskTrackerManager.getNumberOfUniqueHosts());

 

我们前面提过,任务有几种调度顺序,再来小结一下,首先是调度那些失败的任务,因为这些任务比较紧急;其次是未运行的任务以及运行得很慢的任务,在寻找未运行任务的过程中,首先查找本地任务,之后查找机架内的任务。obtainNewNodeOrRackLocalMapTask限定了只找两层节点,本地或父节点,父节点即机架内)这两种任务的查找我们已经分析过了,如果没找到就返回null,此时obtainNewNonLocalMapTask的执行实际上就是去寻找后面两种任务:执行较慢的任务,以及没有位置信息的任务。

这部分代码的不好理解之处就在于obtainNewNonLocalMapTask和obtainNewNodeOrRackLocalMapTask一样,都是调用了obtainNewMapTaskCommon这个方法,唯一的区别就是参数不同:

public synchronized Task obtainNewNodeOrRackLocalMapTask(
      TaskTrackerStatus tts, int clusterSize, int numUniqueHosts)
  throws IOException {
    return obtainNewMapTaskCommon(tts, clusterSize, numUniqueHosts, maxLevel);
  }
  
  public synchronized Task obtainNewNonLocalMapTask(TaskTrackerStatus tts,
                                                    int clusterSize, 
                                                    int numUniqueHosts)
      throws IOException {
    return obtainNewMapTaskCommon(tts, clusterSize, numUniqueHosts, 
        NON_LOCAL_CACHE_LEVEL);
  }

可以看出,上一个传的是maxLevel,默认是2,在本地和机架内查找;后一个传的参数是NON_LOCAL_CACHE_LEVEL,默认是-1,也就是不关心位置信息了。其实这个参数输入后,前面我们分析过的从本地开始逐层往上寻找任务的代码就不会得到执行,只会执行无位置信息和推测执行的部分代码。具体可见:

 

if (node != null) {
      Node key = node;
      int level = 0;
      int maxLevelToSchedule = Math.min(maxCacheLevel, maxLevel);
      for (level = 0;level < maxLevelToSchedule; ++level) {
。。。。。。。。。。。。。。。。。
      }
      
      // Check if we need to only schedule a local task (node-local/rack-local)
      if (level == maxCacheLevel) {
        return -1;
      }
    }

 

因为此时输入的参数maxCacheLevel=-1,所以maxLevelToSchedule=-1,因此就不会执行这个查找本地和机架内任务的循环体代码。而是直接跳出。执行接下来的代码。

 

 

7,如果在本地和机架内找不到任务,则需要扩大范围去找,应该按照什么顺序去找呢?这就是我们需要分析的findNewMapTask这个方法的接下来的代码。首先获得所有最高级别的节点,也就是说,直接获取最高层的那些节点(不管有几层了,最高层总是含有更多任务):

// collection of node at max level in the cache structure
    Collection<Node> nodesAtMaxLevel = jobtracker.getNodesAtMaxLevel();

这些节点比如就是机架等等层次较高的节点。这些节点可能是心跳TaskTracker所对应主机的父节点,或父节点的父节点等等。对于这些父节点,逐一去缓存中查找。不过因为在上面的查找过程(obtainNewNodeOrRackLocalMapTask)中,已经找了该节点对应的任务以及该节点所在机架对应的任务,所以需要跳过这些已经查找过的节点,去其它父节点中去找。查找方式和前面一样。这种找的策略Hadoop称为breadth-wise across parents at max level,是一种类似广度优先遍历的思想,比如其节点的叔叔节点、伯父节点(如果只有两层)、爷爷节点(如果有三层)去找。此时的寻找仍然是在缓存nonRunningMapCache中寻找的。

 

8、如果利用广度优先遍历的方法在nonRunningMapCache中都找不到呢?看起来更高层的节点都找了,会是什么原因?从代码来看主要原因是Split没有带着位置信息。按理来说Split是有位置信息的,但如果底层不是运行在HDFS上,或者其他一些原因(待考虑),也就无法得到Split的位置信息,此时nonRunningMapCache甚至可能会为空集。前面在分析createCache的时候暂时忽略了一点,就是如果作业上传了Split的位置信息,任务会加入到映射表nonRunningMapCache中,该映射表的索引是Node,值是任务列表;而如果没有Split的位置信息呢?会加入到另外一个任务列表中,也就是nonLocalMaps,表示缺少位置信息条件下的那些还未运行的Map任务的列表,自然不是一个映射表了,因为无法获知Map任务要处理的Split到底在哪台机器上,也就是没有Node节点信息,只是一个List对象:

    this.nonLocalMaps = new LinkedList<TaskInProgress>();

在createCache方法中,其代码为:

for (int i = 0; i < splits.length; i++) {
      String[] splitLocations = splits[i].getLocations();
      if (splitLocations == null || splitLocations.length == 0) {
        nonLocalMaps.add(maps[i]);
        continue;
      }

即对于所有Split,如果缺乏位置信息,就加入到这个列表中。因此,在分配任务时,按照先nonRunningMapCache后nonLocalMaps的顺序分配,其代码与上面一样:

// 3. Search non-local tips for a new task
    tip = findTaskFromList(nonLocalMaps, tts, numUniqueHosts, false);
    if (tip != null) {
      // Add to the running list
      scheduleMap(tip);

      LOG.info("Choosing a non-local task " + tip.getTIPId());
      return tip.getIdWithinJob();
    }

 

9,终于完成了最复杂,也是最普通的未运行任务分配过程的分析了,如果上面的任务都没找到,那么就进入下一类任务的分配,也就是推测执行的任务,前面已经提到,如果一个任务在运行,但是进度很慢,Hadoop会考虑将其多次调度,这样的话,可能可以降低最慢那个任务的影响。

与前面通用的方法findTaskFromList类似,查找推测任务的方法是findSpeculativeTask。如果明白了前面查找普通任务的顺序,这里也就简单了,和前面一样,只是要到正在运行的任务缓存中查找,这种缓存分为两类,即存在位置信息的和不存在位置信息的。首先在runningMapCache中寻找(前面是nonRunningMapCache),如果找到这种任务,就返回;如果没找到,就在nonLocalRunningMaps(前面是nonLocalMaps,我认为这个名字应该改为nonLocalnonRunningMaps,否则容易引起歧义)中寻找。而在runningMapCache中寻找的过程中,也是有两个阶段,首先是从TaskTracker对应的节点开始找,然后扩展到机架等等,如果没有,就广度优先,在父节点的兄弟节点、爷爷节点等去找。具体代码流程与寻找普通任务几乎一样。因此,唯一有较大区别的就是findSpeculativeTask这个方法。

比较一下这两个方法,可以看出,findSpeculativeTask多了avgProgress这个参数,表示当前这个Job的所有Map任务的平均执行进度,这个参数我们前面提过。currentTime表示当前时间。

private synchronized TaskInProgress findTaskFromList(
      Collection<TaskInProgress> tips, TaskTrackerStatus ttStatus,
      int numUniqueHosts, boolean removeFailedTip)

  protected synchronized TaskInProgress findSpeculativeTask(
      Collection<TaskInProgress> list, TaskTrackerStatus ttStatus,
      double avgProgress, long currentTime, boolean shouldRemove)

还是依次获取list中的任务,如果正在运行,则不在考虑之列:

// should never be true! (since we delete completed/failed tasks)
      if (!tip.isRunning() || !tip.isRunnable()) {
        iter.remove();
        continue;
      }

否则检测TaskInProgress这个任务是否需要推测执行,采用的方法是TaskInProgress这个类中的hasSpeculativeTask方法:

/**
   * Return whether the TIP has a speculative task to run.  We
   * only launch a speculative task if the current TIP is really
   * far behind, and has been behind for a non-trivial amount of 
   * time.
   */
  boolean hasSpeculativeTask(long currentTime, double averageProgress) {
    //
    // REMIND - mjc - these constants should be examined
    // in more depth eventually...
    //

    if (!skipping && activeTasks.size() <= MAX_TASK_EXECS &&
        (averageProgress - progress >= SPECULATIVE_GAP) &&
        (currentTime - startTime >= SPECULATIVE_LAG) 
        && completes == 0 && !isOnlyCommitPending()) {
      return true;
    }
    return false;
  }

其代码比较简单,主要是在几个条件下判断是否应该推测执行。首先看skipping,该标志记录了该任务是否不应该进行推测执行,其代码为:

/**
   * Get whether to start skipping mode. 
   */
  private boolean startSkipping() {
    if(maxSkipRecords>0 && 
        numTaskFailures>=SkipBadRecords.getAttemptsToStartSkipping(conf)) {
      return true;
    }
    return false;
  }

  public static int getAttemptsToStartSkipping(Configuration conf) {
    return conf.getInt(ATTEMPTS_TO_START_SKIPPING, 2);
  }

可以看出,如果失败的次数已经大于默认的2次,就应该跳过这个任务,因为可能是任务本身有问题,继续推测执行也没用。

另外一个条件是activeTasks.size() <= MAX_TASK_EXECS,其中MAX_TASK_EXECS=1,也就是activeTasks.size()<=1。

activeTasks记录了当前这个任务(类似一个类)正在执行(类似实例化对象)的数量,其定义为:

// Map from task Id -> TaskTracker Id, contains tasks that are
  // currently runnings
  private TreeMap<TaskAttemptID, String> activeTasks = new TreeMap<TaskAttemptID, String>();

是一个映射表,索引是TaskAttemptID,值是TaskTracker的ID。

前面已经分析过,一个任务可能有多个执行实例。每个实例用TaskAttemptID表示,一个例子是(从其它博客拷过来的):

”attempt_201108091551_0001_m_000000_0表示2011年8月9日15时51分启动的JobTracker中第0001号作业的第000000号map task的第0号task attempt“

从前面可以看出,如果activeTasks.size()>1,也就是说,已经有多个执行实例了,那就别再调度了,这个很容易理解。

另外一个条件是averageProgress - progress >= SPECULATIVE_GAP,SPECULATIVE_GAP等于0.2,averageProgress是整个Job的所有Map任务的平均执行进度,是作为参数传到这个方法中的,progress则表示某个任务的进度。一个任务的进度是怎么算出来的呢?是TaskInProgress类中的recomputeProgress这个方法计算出来的,从代码来看,主要过程为(省略了一些其他代码):

void recomputeProgress() {
    if (isComplete()) {
      this.progress = 1;
    } else if (failed) {
      this.progress = 0;
    } else {
      double bestProgress = 0;
      String bestState = "";
      Counters bestCounters = new Counters();
      for (Iterator<TaskAttemptID> it = taskStatuses.keySet().iterator(); it.hasNext();) {
        TaskAttemptID taskid = it.next();
        TaskStatus status = taskStatuses.get(taskid);
        if (status.getRunState() == TaskStatus.State.SUCCEEDED) {
          bestProgress = 1;
          break;
        } else if (status.getRunState() == TaskStatus.State.COMMIT_PENDING) {
          //for COMMIT_PENDING, we take the last state that we recorded
          //when the task was RUNNING
          bestProgress = this.progress;
        } else if (status.getRunState() == TaskStatus.State.RUNNING) {
          if (status.getProgress() >= bestProgress) {
            bestProgress = status.getProgress();
          }
        }
      }
      this.progress = bestProgress;
    }
  }

实际上也就是说,如果完成了,进度就是1;如果失败了,进度就是0;否则,对于该任务的所有TaskAttemptID,通过其status.getProgress()得到TaskAttemptID的进度,status是一个TaskStatus对象,描述了一个TaskAttempt的状态,Hadoop里面命名不是特别规范,这里如果用TaskAttemptStatus我觉得更好理解,要我来写,我会分别写TaskAttemptStatus和TaskStatus两个类,前一个记录每次执行的状态,后一个记录Map任务总体的状态(实际上就来自于前一个状态的融合)。总之,简而言之,这些TaskAttempt的进度的最大值就是当前这个任务的进度。

如果Job的所有Map任务的平均进度超过某个Map任务进度的20%,那么这个落后的Map任务就应该调度(当然还不一定,还取决于其它条件)进行推测执行。

再来看另一个条件:currentTime - startTime >= SPECULATIVE_LAG,这个条件含义很明显,就是一个任务在启动时可能人家是慢一点,但说不定是有一些其它原因,比如服务器IO不行刚开始都在读数据,比别人慢一些,但说不定CPU频率高而在处理时后发制人呢,不过如果超过了SPECULATIVE_LAG=60000,也就是1分钟了还是很慢,那就有问题了。

completes == 0这个条件也很简单,就是没有TaskAttempt执行完了。这种条件似乎与前面的条件存在冗余,前面已经说了,进度就是与平均进度拉开了至少80%,也就是说,最快的那个TaskAttempt也没完,这里再加一个这种判断是什么意思,如果有一个完成了,岂不是进度已经为1?可能为了保险起见把,或者这两个标志在计算因为理论上肯定有一定延时(哪怕很短),比如获知某个TaskAttempt执行完了,准备更新进度之前,这中间另一个线程要寻找推测执行的任务,发现进度并没达到1,如果不加完成标志,可能会引起冗余调度。个人猜想。

最后一个条件是!isOnlyCommitPending(),直观含义是没有处于准备提交的状态。其代码为:

public boolean isOnlyCommitPending() {
    for (TaskStatus t : taskStatuses.values()) {
      if (t.getRunState() == TaskStatus.State.COMMIT_PENDING) {
        return true;
      }
    }
    return false;
  }

一个TaskAttempt的执行阶段和状态有下面一些:

// what state is the task in?
  public static enum State {RUNNING, SUCCEEDED, FAILED, UNASSIGNED, KILLED, 
                            COMMIT_PENDING, FAILED_UNCLEAN, KILLED_UNCLEAN}

从状态来看,主要有运行中、成功、失败、未分配、被杀掉、准备提交、清理失败、被杀死但还未清理等等。准备提交应该就是指某一个在执行的任务其实已经执行完了,也就是等着最后的提交,这类任务就不要再重复执行了。

只要满足了上面的条件,这些任务就会被提取出来。

 

10,终于,主要的任务寻找过程已经分析完毕。接下来按照前面obtainNewNodeOrRackLocalMapTask一样,如果寻找到无位置信息的任务或者推测执行的任务,则构造MapTask对象返回。回到JobQueueTaskScheduler的assignTasks方法,如果找到了这类任务,那么不再循环找其它任务了,一次心跳只能返回一个这类任务,另外,在寻找本机和机架内任务的时候,如果满足了需要预留资源的条件,也只能一次心跳至多返回一个任务,我们再来看看其主要流程:

for (int i=0; i < availableMapSlots; ++i) {
      synchronized (jobQueue) {
        for (JobInProgress job : jobQueue) {
          if (job.getStatus().getRunState() != JobStatus.RUNNING) {
            continue;
          }
          Task t = null;          
          // Try to schedule a Map task with locality between node-local 
          // and rack-local
          t =   job.obtainNewNodeOrRackLocalMapTask(taskTrackerStatus, 
                numTaskTrackers, taskTrackerManager.getNumberOfUniqueHosts());
          if (t != null) {
            assignedTasks.add(t);
            ++numLocalMaps;
            if (exceededMapPadding) {
              break scheduleMaps;
            }           
            // Try all jobs again for the next Map task 
            break;
          }
          // Try to schedule a node-local or rack-local Map task
          t =  job.obtainNewNonLocalMapTask(taskTrackerStatus, numTaskTrackers,
                                   taskTrackerManager.getNumberOfUniqueHosts());
          if (t != null) {
            assignedTasks.add(t);
            ++numNonLocalMaps;
            // We assign at most 1 off-switch or speculative task
            // This is to prevent TaskTrackers from stealing local-tasks
            // from other TaskTrackers.
            break scheduleMaps;
          }
        }
      }
    }

可以看出,有两种情况会跳出break scheduleMaps;也就是不再执行任务分配工作,否则,会对该心跳TaskTracker的所有目前可用的availableMapSlots进行循环分配,现在可以看出,所谓任务分配,就是对于所有心跳TaskTracker的所有可用资源,逐一按照某种顺序寻找任务。

 

11,接下来是Reduce任务的分配,相对Map任务而言要简单一些。首先也是计算目前可用的Reduce Slot数目(就是可以分配几个Reduce):

//
    // Same thing, but for reduce tasks
    // However we _never_ assign more than 1 reduce task per heartbeat
    //
    final int trackerCurrentReduceCapacity = 
      Math.min((int)Math.ceil(reduceLoadFactor * trackerReduceCapacity), 
               trackerReduceCapacity);
    final int availableReduceSlots = 
      Math.min((trackerCurrentReduceCapacity - trackerRunningReduces), 1);

从availableReduceSlots来看,每次顶多分配一个Reduce任务给心跳TaskTracker。

同样,执行exceededReducePadding方法检测一下是否应该预留资源。调用的都是exceededPadding这个方法,与Map任务时相同。

之后,顺序取出JobInProgress队列里面的作业,执行obtainNewReduceTask方法。这个方法就没有Level之类的拓扑的概念,因为Map需要考虑数据本地性原则,而Reduce本身就需要利用Shuffle去远端机器取数据,所以不考虑网络拓扑。

在obtainNewReduceTask方法里,首先也是估计Reduce需要的磁盘空间资源,只是此时是输入空间的评估,Map是输出的评估:

long estimatedReduceInputSize = resourceEstimator.getEstimatedReduceInputSize()/2;
    if (((estimatedReduceInputSize) > 
      reduce_input_limit) && (reduce_input_limit > 0L)) {
      // make sure jobtracker lock is held
      LOG.info("Exceeded limit for reduce input size: Estimated:" + 
          estimatedReduceInputSize + " Limit: " + 
          reduce_input_limit + " Failing Job " + jobId);
      status.setFailureInfo("Job exceeded Reduce Input limit " 
          + " Limit:  " + reduce_input_limit + 
          " Estimated: " + estimatedReduceInputSize);
      jobtracker.failJob(this);
      return null;
    }

之后,判断目前是否可以调度Reduce任务,是否可以调度Reduce任务主要是看Map任务执行到了什么进度,因为Reduce需要等到所有Map任务执行完毕后才开始,所以,Map任务还没执行到一定程度的时候,调度过去也没用,只会消耗资源,判断方法是:

public synchronized boolean scheduleReduces() {
    return finishedMapTasks + failedMapTIPs >= completedMapsForReduceSlowstart;
  }

方法看起来很简单,就是目前完成的Map任务与失败的Map任务数量需要大于某个门限,这个门限值默认为:

// Calculate the minimum number of maps to be complete before 
    // we should start scheduling reduces
    completedMapsForReduceSlowstart = 
      (int)Math.ceil( (conf.getFloat("mapred.reduce.slowstart.completed.maps", 
                         DEFAULT_COMPLETED_MAPS_PERCENT_FOR_REDUCE_SLOWSTART) *  numMapTasks));

DEFAULT_COMPLETED_MAPS_PERCENT_FOR_REDUCE_SLOWSTART默认为0.05,也就是大致完成了5%的Map任务之后,可以开始调度Reduce任务了,这个数字也是个参考值,一般而言,有5%的Map任务已经结束了或者失败了(至少已经执行过了),剩下的可能很快也就快完毕了,此时调度Reduce过去似乎也差不多,但具体数值可能需要调优,不知道有没有什么理论依据。

如果可以开始调度,则执行以下代码,和Map类似:

int  target = findNewReduceTask(tts, clusterSize, numUniqueHosts, 
                                    status.reduceProgress());
    if (target == -1) {
      return null;
    }
    
    Task result = reduces[target].getTaskToRun(tts.getTrackerName());
    if (result != null) {
      addRunningTaskToTIP(reduces[target], result.getTaskID(), tts, true);
    }

看来,findNewReduceTask也是调度Reduce任务的核心方法。

有了前面的基础,这时候分析Reduce任务就简单不少了。

首先是从未运行的Reduce任务集合中寻找任务

// 1. check for a never-executed reduce tip
    // reducers don't have a cache and so pass -1 to explicitly call that out
    tip = findTaskFromList(nonRunningReduces, tts, numUniqueHosts, false);
    if (tip != null) {
      scheduleReduce(tip);
      return tip.getIdWithinJob();
    }

因为Reduce不考虑数据本地性了,所以这些未运行任务都放在一个集合中:

// A list of non-running reduce TIPs
  Set<TaskInProgress> nonRunningReduces;

如果没有找到,则到运行的Reduce任务集合中寻找那些需要推测执行的任务:

// 2. check for a reduce tip to be speculated
    if (hasSpeculativeReduces) {
      tip = findSpeculativeTask(runningReduces, tts, avgProgress, 
                                jobtracker.getClock().getTime(), false);
      if (tip != null) {
        scheduleReduce(tip);
        return tip.getIdWithinJob();
      }
    }

基本就是这两个阶段。里面涉及到两个方法:findTaskFromList、findSpeculativeTask。这两个方法在Map任务调度时已经分析过,两者共用,因此不用赘述了。

到此为止,assignTasks方法已经分析完毕,该方法将获得的MapTask和ReduceTask任务打包成一个数组List<Task>返回heartbeat方法:

List<Task> tasks = getSetupAndCleanupTasks(taskTrackerStatus);
        if (tasks == null ) {
          tasks = taskScheduler.assignTasks(taskTrackers.get(trackerName));
        }
        if (tasks != null) {
          for (Task task : tasks) {
            expireLaunchingTasks.addNewTask(task.getTaskID());
            if(LOG.isDebugEnabled()) {
              LOG.debug(trackerName + " -> LaunchTask: " + task.getTaskID());
            }
            actions.add(new LaunchTaskAction(task));
          }
        }

可见,如果已分配了任务,则对于每一个任务,将其加入到ExpireLaunchingTasks对象中,该对象维护了一个HashMap对象:

/**
     * This is a map of the tasks that have been assigned to task trackers,
     * but that have not yet been seen in a status report.
     * map: task-id -> time-assigned 
     */
    private Map<TaskAttemptID, Long> launchingTasks =
      new LinkedHashMap<TaskAttemptID, Long>();

并且,ExpireLaunchingTasks本身是一个线程类,主要用于在后台记录一个已经启动的任务是否超期了等等。

最后,创建LaunchTaskAction对象,将其丢入ArrayList<TaskTrackerAction>数组中,准备返回心跳时返回。

注意,LaunchTaskAction是一个可序列化对象,Task也是一个可序列化对象,最后来看看Task里面有些什么内容,可见,主要有Job配置文件、用户名、任务ID等等。

////////////////////////////////////////////
  // Fields
  ////////////////////////////////////////////

  private String jobFile;                         // job configuration file
  private String user;                            // user running the job
  private TaskAttemptID taskId;                   // unique, includes job id
  private int partition;                          // id within job
  TaskStatus taskStatus;                          // current status of the task
  protected JobStatus.State jobRunStateForCleanup;
  protected boolean jobCleanup = false;
  protected boolean jobSetup = false;
  protected boolean taskCleanup = false;
  
  //skip ranges based on failed ranges from previous attempts
  private SortedRanges skipRanges = new SortedRanges();
  private boolean skipping = false;
  private boolean writeSkipRecs = true;
  
  //currently processing record start index
  private volatile long currentRecStartIndex; 
  private Iterator<Long> currentRecIndexIterator = 
    skipRanges.skipRangeIterator();
  
  private ResourceCalculatorPlugin resourceCalculator = null;
  private long initCpuCumulativeTime = 0;

  protected JobConf conf;
  protected MapOutputFile mapOutputFile = new MapOutputFile();
  protected LocalDirAllocator lDirAlloc;
  private final static int MAX_RETRIES = 10;
  protected JobContext jobContext;
  protected TaskAttemptContext taskContext;
  protected org.apache.hadoop.mapreduce.OutputFormat<?,?> outputFormat;
  protected org.apache.hadoop.mapreduce.OutputCommitter committer;
  protected final Counters.Counter spilledRecordsCounter;
  private int numSlotsRequired;
  private String pidFile = "";
  protected TaskUmbilicalProtocol umbilical;
  protected SecretKey tokenSecret;
  protected JvmContext jvmContext;

 

12,最后我们总结一下Map任务分配的简单过程,因为这是任务调度中最复杂的部分:

(1)最外层的循环是心跳TaskTracker的所有可用资源,Hadoop用Slot描述一个TaskTracker的资源,比如2个Map Slot表示可以同时执行2个Map任务。对于每一个Slot,去寻找一个任务调度给它;

(2)内层循环是jobQueueJobInProgressListener这个Job队列里面的所有Job,对于每个Job,分两步:第一步,去查找该Job中那些失败的任务(按照失败次数越多越优先的原则),之后去查找与心跳TaskTracker位于同一主机或者同一机架的未运行的任务。第二步,去查找那些非本地的未运行的任务,以及正在处于运行中但是很慢的任务,在查找运行较慢的任务时,也遵循先本地,再机架,再全局,最后无位置信息的顺序。

 

本节分析了JobQueueTaskScheduler的任务调度策略,这也是Hadoop默认的调度策略,另外两种调度策略:CapacityTaskScheduler、FairScheduler。CapacityTaskScheduler是雅虎提出的,支持多个Job队列,这样的话可以避免单一队列必须先进先得到调度的问题;FairScheduler是Facebook提出的,目标是使得各个作业相对公平地获得集群资源,避免FIFO引起某些作业独占资源的问题,总的来说,这两种调度策略都可以防止单一作业占集群资源过多的问题,增加了一些优先级等等概念。具体的代码留作以后分析。