任务启动过程由TaskTracker完成。启动过程如下:首先判断是否是第一次收到某个作业的任务,若是就进行作业本地化,然后创建任务目录,否则直接创建任务目录。接下来启动JVM,并从TaskTracker获取任务,进行任务本地化并执行任务。任务执行后,若该JVM没有到重用次数上限,则再次从TaskTracker获取任务,重复上述过程。总体上讲,主要包括作业本地化、任务本地化和启动任务三步。本地化的目的是为任务运行创建一个环境,包括工作目录、下载运行所需文件和设置环境变量等。作业本地化由该作业第一个任务启动时执行。下面依次来看上述步骤。
我们先从TaskTracker(以下简称TT)收到心跳响应说起。在TT的offerService方法中,通过transmitHeartbeat方法收到心跳响应,并从中提取出需要执行的命令:
这样做的好处是在进行作业本地化时,不需要直接对rjob对象加锁,也就不会阻塞TT的其他线程,如MapEventsFetcherTread(而只会阻塞其他任务启动线程)。
作业本地化的过程如下(initializeJob内部):
2. 启动任务
任务启动过程可分为两步:JVM启动和任务启动。为了避免不同任务间互相干扰,TT为每个任务启动了独立的JVM。JVM的启动过程如下:
map和reduce各自启动TaskRunner,在run方法中,首先获取工作目录,环境变量,启动命令,命令参数和标准输入输出对象等:
final File workDir =
new File(new Path(localdirs[rand.nextInt(localdirs.length)],
TaskTracker.getTaskWorkDir(t.getUser(), taskid.getJobID().toString(),
taskid.toString(),
t.isTaskCleanupTask())).toString());
List classPaths = getClassPaths(conf, workDir,
taskDistributedCacheManager);
Vector vargs = getVMArgs(taskid, workDir, classPaths, logSize);
String setup = getVMSetupCmd();
File[] logFiles = prepareLogFiles(taskid, t.isTaskCleanupTask());
File stdout = logFiles[0];
File stderr = logFiles[1];
List setupCmds = new ArrayList();
for(Entry entry : env.entrySet()) {
StringBuffer sb = new StringBuffer();
sb.append("export ");
sb.append(entry.getKey());
sb.append("=\"");
sb.append(entry.getValue());
sb.append("\"");
setupCmds.add(sb.toString());
}
setupCmds.add(setup);
然后通过JVMManager启动一个JVM:
launchJvmAndWait(setupCmds, vargs, stdout, stderr, logSize, workDir);
注意,JvmManager使用不同的管理器管理不同类型的任务对应JVM:
public void launchJvm(TaskRunner t, JvmEnv env
) throws IOException, InterruptedException {
if (t.getTask().isMapTask()) {
mapJvmManager.reapJvm(t, env);
} else {
reduceJvmManager.reapJvm(t, env);
}
}
接下来以map任务为例,看JVM的启动过程,进入reapJvm方法:
该方法中,通过一个标志spawnNewJvm来表示是否需要启动新的JVM,默认是不需要。判断是否启动新JVM的依据是当前已经启动的JVM数量是否到达最大的slot数量:
boolean spawnNewJvm = false;
if (numJvmsSpawned >= maxJvms) {
...
}else {
spawnNewJvm = true;
}
若slot不足,则不能启动新的JVM。那么遍历已经启动的JVM列表,找到一个属于当前任务所在作业的JVM。注意,每个JVM只能重用给同一个作业同种类型的任务,且JVM的ID中体现了作业ID和任务类型。当找到一个符合要求的JVM且该JVM处于空闲状态且该JVM未到达复用次数上限(ranAll方法),立即返回:
JvmRunner jvmRunner = jvmIter.next().getValue();
JobID jId = jvmRunner.jvmId.getJobId();
//look for a free JVM for this job; if one exists then just break
if (jId.equals(jobId) && !jvmRunner.isBusy() && !jvmRunner.ranAll()){
setRunningTaskForJvm(jvmRunner.jvmId, t); //reserve the JVM
return;
}
如果当前JVM不符合条件,则判断其是否达到复用次数上限并且与新任务同属于一个作业,或者,它处于空闲状态但是与新任务不属于一个作业。若上述两个条件有一个成立,就杀掉该JVM,并启动新的JVM:
if ((jId.equals(jobId) && jvmRunner.ranAll()) ||
(!jId.equals(jobId) && !jvmRunner.isBusy())) {
runnerToKill = jvmRunner;
spawnNewJvm = true;
}
if (spawnNewJvm) {
if (runnerToKill != null) {
LOG.info("Killing JVM: " + runnerToKill.jvmId);
killJvmRunner(runnerToKill);
}
spawnNewJvm(jobId, env, t);
return;
}
真正的启动是由JvmRunner来完成的。该类是一个线程类,在run方法中调用了runChild方法:
public void runChild(JvmEnv env) throws IOException, InterruptedException{
int exitCode = 0;
try {
env.vargs.add(Integer.toString(jvmId.getId()));
TaskRunner runner = jvmToRunningTask.get(jvmId);
if (runner != null) {
Task task = runner.getTask();
//Launch the task controller to run task JVM
String user = task.getUser();
TaskAttemptID taskAttemptId = task.getTaskID();
String taskAttemptIdStr = task.isTaskCleanupTask() ?
(taskAttemptId.toString() + TaskTracker.TASK_CLEANUP_SUFFIX) :
taskAttemptId.toString();
exitCode = tracker.getTaskController().launchTask(user,
jvmId.jobId.toString(), taskAttemptIdStr, env.setup,
env.vargs, env.workDir, env.stdout.toString(),
env.stderr.toString());
}
} catch (IOException ioe) {
} finally { // handle the exit code
// although the process has exited before we get here,
// make sure the entire process group has also been killed.
kill();
updateOnJvmExit(jvmId, exitCode);
deleteWorkDir(tracker, firstTask);
}
}
最终,调用TaskController的launchTask对象启动任务。这里TaskController的实现是DefaultTaskController。在launchTask方法中,首先创建任务工作目录和日志目录:
// create the working-directory of the task
if (!currentWorkDirectory.mkdir()) {
throw new IOException("Mkdirs failed to create "
+ currentWorkDirectory.toString());
}
//mkdir the loglocation
String logLocation = TaskLog.getAttemptDir(jobId, attemptId).toString();
if (!localFs.mkdirs(new Path(logLocation))) {
throw new IOException("Mkdirs failed to create "
+ logLocation);
}
然后将任务启动命令写入启动脚本文件:
// get the JVM command line.
String cmdLine =
TaskLog.buildCommandLine(setup, jvmArguments,
new File(stdout), new File(stderr), logSize, true);
// write the command to a file in the
// task specific cache directory
// TODO copy to user dir
Path p = new Path(allocator.getLocalPathForWrite(
TaskTracker.getPrivateDirTaskScriptLocation(user, jobId, attemptId),
getConf()), COMMAND_FILE);
String commandFile = writeCommand(cmdLine, rawFs, p);
最后以命令“bash -c taskjvm.sh”启动任务:
shExec = new ShellCommandExecutor(new String[]{
"bash", "-c", commandFile},
currentWorkDirectory);
shExec.execute();
从脚本文件中可以看出,任务最终通过org.apache.hadoop.mapred.Child类运行,在main方法中有一个while循环,不停地向TaskTracker请求任务:
JvmTask myTask = umbilical.getTask(context);
if (myTask.shouldDie()) {
break;
} else {
if (myTask.getTask() == null) {
taskid = null;
currentJobSegmented = true;
if (++idleLoopCount >= SLEEP_LONGER_COUNT) {
//we sleep for a bigger interval when we don't receive
//tasks for a while
Thread.sleep(1500);
} else {
Thread.sleep(500);
}
continue;
}
}
若获取到任务,则要判断该任务所属作业是否不存在或被杀死。若没有获取到任务,则等待一段时间再询问。如果有了新任务,则进行任务本地化:首先根据作业的配置文件生成任务自己的配置文件;然后在工作目录中建立指向分布式缓存中所有数据文件的链接。
final JobConf job = new JobConf(task.getJobFile());
...
//setupWorkDir actually sets up the symlinks for the distributed
//cache. After a task exits we wipe the workdir clean, and hence
//the symlinks have to be rebuilt.
TaskRunner.setupWorkDir(job, new File(cwd));
最后调用Task的run方法开始执行任务:
taskFinal.run(job, umbilical); // run the task
至此任务启动完毕,任务开始执行。
二、任务执行
1. 执行过程概述
我们知道MapReduce中主要有两种任务:Map任务和Reduce任务。这一小节结合代码来详细分析这两种任务的执行过程。总的来说,一个Map任务又可以细分为5个阶段:Read,Map,Collect,Spill和Combine;一个Reduce任务也可以细分为5个阶段:Shuffle,Merge,Sort,Reduce和Write。
具体地,对于Map任务,首先通过用户提供的InputFormat将InputSplit解析成key/value对,然后交给map函数处理。接着使用Partitioner组件对map函数的输出进行分片,确定将每个键值对交给哪个Reduce任务进一步处理。然后将键值对及其分片信息写到缓冲区中,当缓冲区快满时,执行spill操作,将数据排序后写入磁盘。最后将磁盘上的数据合并成一个输出文件。
对于Reduce任务,首先通过HTTP请求获取来自各个Map任务输出文件中属于自己分片的数据,边拷贝边合并。完成拷贝后,按照key对数据进行一次排序。然后将数据交给reduce函数处理。最后,将处理结果输出到磁盘。
过程图解如下(图片来自: http://www.myexception.cn/open-source/428094.html):
下面详细分析上述过程。
2. Map任务执行过程
假设使用的旧MapReduce API,那么MapTask的run方法会调用runOldMapper方法:
if (useNewApi) {
runNewMapper(job, splitMetaInfo, umbilical, reporter);
} else {
runOldMapper(job, splitMetaInfo, umbilical, reporter);
}
runOldMapper方法生成InputSplit对象,再通过一个RecordReader对象,从各个InputSplit对象中读取数据,并解析成key/value对,传递给map函数:
public void run(RecordReader input, OutputCollector output,
Reporter reporter)
throws IOException {
try {
// allocate key & value instances that are re-used for all entries
K1 key = input.createKey();
V1 value = input.createValue();
while (input.next(key, value)) {
// map pair to output
mapper.map(key, value, output, reporter);
if(incrProcCount) {
reporter.incrCounter(SkipBadRecords.COUNTER_GROUP,
SkipBadRecords.COUNTER_MAP_PROCESSED_RECORDS, 1);
}
}
} finally {
mapper.close();
}
}
这里的mapper对象是使用用户指定的MapperClass,通过反射机制生成的。其中第行的语句执行map函数。注意,map的参数中有一个output,它的类型是OutputCollector,作用是生成map的输出。该对象的实例化如下:
MapOutputCollector collector = null;
if (numReduceTasks > 0) {
collector = new MapOutputBuffer(umbilical, job, reporter);
} else {
collector = new DirectMapOutputCollector(umbilical, job, reporter);
}
runner.run(in, new OldOutputCollector(collector, conf), reporter);
collector完成map任务的collect过程。如果一个作业有reduce过程,那么就是用MapOutputBuffer输出map结果,由reduce任务进一步处理;如果该作业只有map过程,那么就使用DirectMapOutputCollector来输出到HDFS。collector对象以参数的形式构造一个OldOutputCollector对象,后者再作为run方法的参数最终交给map函数。
collect方法由用户在map函数中调用,比如:
collector.collect(key, value, partitioner.getPartition(key, value, partitions));
collect方法接受需要输出的key和value值作为参数。在执行collect之前,通过Partitioner类的getPartition方法获得一对儿key/value所属分区,一个分区对应一个reduce任务处理的数据,分区数即为reduce任务的个数。分区的目的是将map任务的结果尽量均衡地分配给每个reduce任务。默认的分区方法是hash法:
public int getPartition(K key, V value,
int numReduceTasks) {
return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
}
分区算法的思想是负载均衡,MapReduce中还有一种实现是TotalOrderPartitioner。该实现基于数据采样,通过某种采样方法获得若干代表性的key值并排序,然后将这些采样数据分割成几等份,数量为reduce任务数。将分割点的key值保存在一个trie树(字典树)中,这样通过搜索该trie树即可确定一个key属于哪个分区。综上,该实现的关键在于采样算法,采样越具有代表性,负载越均衡。
分区结束后,一个key/value二元组变成一个
三元组。collect方法接下来将三元组写入MapOutputBuffer的一个环形缓冲区中。在缓冲区使用量达到一定阈值时,spillThread将数据写入临时文件,这是spill过程。环形缓冲区的使用使得collect和spill过程可以并发执行,事实上,collect和spill分别是缓冲区的生产者和消费者,稍后在代码中可以看到。
缓冲区的大小可以通过io.sort.mb参数配置,默认是100MB。MapOutputBuffer的缓冲区采用二级索引机制,对应三级缓冲区。第三级级称为kvbuffer,存储的是具体的key/value值。第二级称为kvindices,其中每一个元素为一个三元组
,每一元分别表示键值对所属分区,key在kvbuffer中的开始位置和value在kvbuffer中的开始位置。第一级称为kvoffsets,保存键值对信息(三元组)在kvindices中的偏移位置。由于kvoffsets每个元素只是一个整数,而kvindices的每个元素要占用三个整数,所以二者内存分配比例为1:3。为这两个数组分配的内存总大小的比例为io.sort.record,percent。kvbuffer默认最多使用95%的缓冲区。各部分缓冲区的分配如下:
final float spillper = job.getFloat("io.sort.spill.percent",(float)0.8);
final float recper = job.getFloat("io.sort.record.percent",(float)0.05);
final int sortmb = job.getInt("io.sort.mb", 100);
if (spillper > (float)1.0 || spillper < (float)0.0) {
throw new IOException("Invalid \"io.sort.spill.percent\": " + spillper);
}
if (recper > (float)1.0 || recper < (float)0.01) {
throw new IOException("Invalid \"io.sort.record.percent\": " + recper);
}
if ((sortmb & 0x7FF) != sortmb) {
throw new IOException("Invalid \"io.sort.mb\": " + sortmb);
}
sorter = ReflectionUtils.newInstance(
job.getClass("map.sort.class", QuickSort.class, IndexedSorter.class), job);
LOG.info("io.sort.mb = " + sortmb);
// buffers and accounting
int maxMemUsage = sortmb << 20;
int recordCapacity = (int)(maxMemUsage * recper);
recordCapacity -= recordCapacity % RECSIZE;
kvbuffer = new byte[maxMemUsage - recordCapacity];
bufvoid = kvbuffer.length;
recordCapacity /= RECSIZE;
kvoffsets = new int[recordCapacity];
kvindices = new int[recordCapacity * ACCTSIZE];
下面看看数据写入缓冲区的过程。
1. kvoffsets的写入。
这里定义了几个指针:kvstart表示存有数据内存段的初始位置,kvindex表示未存储数据内存段的初始位置,kvend用于spill时指示需要写入磁盘的范围为[kvstart, kvend),此时kvend=kvindex,而正常写入时kvstart=kvend。
下一个写入位置即为kvindex,则确定下一个kvindex位置:
final int kvnext = (kvindex + 1) % kvoffsets.length;
同时还要判断是否满足spill条件:
kvfull = kvnext == kvstart;
final boolean kvsoftlimit = ((kvnext > kvend)
? kvnext - kvend > softRecordLimit
: kvend - kvnext <= kvoffsets.length - softRecordLimit);
if (kvstart == kvend && kvsoftlimit) {
LOG.info("Spilling map output: record full = " + kvsoftlimit);
startSpill();
}
若使用空间超过了80%,则溢写。后面关注startSpill函数。
2. kvbuffer的写入。
操作该缓冲区的指针包括:bufstart,bufend,bufvoid,bufindex,bufmark等。其中bufstart,bufend和bufindex含义与kvstart,kvend和kvindex的含义相同。bufvoid指向kvbuffer中有效内存结束为止,kvmark表示最后写入一个完整的键值对结束的位置。
写入一个key和value后都要移动bufindex指针:
System.arraycopy(b, off, kvbuffer, bufindex, len);
bufindex += len;
一个完整的key/value写完后,要移动bufmark:
bufmark = bufindex;
kvbuffer缓冲区到达阈值在kvoffsets检查时已经发现,执行spill操作,写之前:
写过后:
bufstart = bufend;
以上是正常情况下的写入,再考虑几种特殊的情况:
首先是达到spill的阈值了,需要溢写:
final boolean bufsoftlimit = (bufindex > bufend)
? bufindex - bufend > softBufferLimit
: bufend - bufindex < bufvoid - softBufferLimit;
if (bufsoftlimit || (buffull && !wrap)) {
LOG.info("Spilling map output: buffer full= " + bufsoftlimit);
startSpill();
}
当写入某个key时,尾部剩余空间不足以容纳key值,则将一部分存储到缓冲区头部:
if (bufstart <= bufend && bufend <= bufindex) {
buffull = bufindex + len > bufvoid;
wrap = (bufvoid - bufindex) + bufstart > len;
}
以wrap变量判断是否需要写入头部。但是,由于key是排序的关键字,需要交给RawComparator排序,它要求key在内存中连续存储,因此不能跨行。为解决这个问题,将跨行的key移动到缓冲区头部:
int headbytelen = bufvoid - bufmark;
bufvoid = bufmark;
if (bufindex + headbytelen < bufstart) {
System.arraycopy(kvbuffer, 0, kvbuffer, headbytelen, bufindex);
System.arraycopy(kvbuffer, bufvoid, kvbuffer, 0, headbytelen);
bufindex += headbytelen;
} else {
byte[] keytmp = new byte[bufindex];
System.arraycopy(kvbuffer, 0, keytmp, 0, bufindex);
bufindex = 0;
out.write(kvbuffer, bufmark, headbytelen);
out.write(keytmp);
}
另外,如果某个key或value太多不能让如缓冲区,则抛出异常,将其输出到一个文件:
final int size = ((bufend <= bufindex)
? bufindex - bufend
: (bufvoid - bufend) + bufindex) + len;
bufstart = bufend = bufindex = bufmark = 0;
kvstart = kvend = kvindex = 0;
bufvoid = kvbuffer.length;
throw new MapBufferTooSmallException(size + " bytes");
插一句,
collect过程在将key写入缓冲区时调用的是keySerializer的serialize方法。那么该方法的内部是如何做的呢?首先keySerializer在初始化时是这样的:
keySerializer = serializationFactory.getSerializer(keyClass);
keySerializer.open(bb);
其中serializationFactory使用WritableSerialization类来生成序列化器。open方法的实现如下:
public void open(OutputStream out) {
if (out instanceof DataOutputStream) {
dataOut = (DataOutputStream) out;
} else {
dataOut = new DataOutputStream(out);
}
}
也就是通过out参数获得一个DataOutputStream对象dataOut。这里传入的bb为一个BlockingBuffer对象,它继承于DataOutputStream类,并使用MapOutputBuffer的Buffer对象构造。然后serailize方法如下:
public void serialize(Writable w) throws IOException {
w.write(dataOut);
}
这样write方法实际上调用的就是Buffer.write方法,并数据写入kvbuffer中,从而Buffer称为缓冲区的生产者。
下篇内容继续Map任务的结束以及Reduce任务的执行过程。