阅读下面的内容前请自己先本地运行go代码的环境配置好,并自己最好先大致过一遍MapReduce论文和实验说明文档(文档都在课程首页可以下载到)。
环境配置提示:一种方法是把6.824目录设置为$GOPATH;另一种方法是使用module的方式——在src目录下使用go mod init example.com/src
生成mod文件(网址和路径自己取,只要最后一层目录是src即可),然后把src代码中所有import ../mr
替换成import 网址/src/mr
。
实验目标
- 实现一个简易版的mapreduce框架;
- 通过课程代码下src/main/test-mr.sh脚本的测试。
实验指导
下面我对完成本实验我自己的思路做一个梳理:
- mapreduce框架提供了什么功能?
阅读论文MapReduce: Simplified Data Processing on Large Clusters我们知道,mapreduce是一种编程模型。大体上来说,对于一个特定的问题,用户需要将这个解决这个问题的过程分解成map和reduce两个阶段,并且这两个阶段包含一些小的子任务(map/reduce),然后用户需要编写map和reduce函数(函数的输入和输出参数类型都是固定的)。把map和reduce函数告诉mapreduce框架,该框架就能在分布式环境下解决这个问题。换句话说,mapreduce框架要完成的是何时以何种方式使用用户编写的map和reduce函数达到分布式解决问题的目的。
我们实现的简易版的mapreduce框架和论文中描述的整个过程是有所区别的,最大的区别是我们的实验是单机上运行多个master和reduce进程来模拟分布式运行的情况,因此map任务执行的结果可以直接保存在本地后,reduce直接从本地再读取即可;rpc通信的时候也不需要ip地址。
不过整体上依然是相同的,每个worker都可以执行map和reduce工作,一种比较靠谱和简单的实现方式是让worker向master请求工作,由master决定分配什么工作给该worker。 - 实验代码已经提供了什么,我们自己需要做什么?
与本次实验相关的代码是src/main
、src/mr
、src/mrapps
;src/mrapps
下是各种使用框架的用户编写好的map和reduce函数,比如wc.go是针对统计文件中各单词出现次数问题的map和reduce函数;src/mr
下是我们要编写的master.go、rpc.go、worker.go代码,也就是简易版的mapreduce框架的代码,src/mr就是该简易框架的包;之后要运行的主程序会调用这个mr包的代码;src/main
下的mrmaster.go是master的主进程代码,运行一次该代码就产生一个master进程,mrworker.go是worker的主进程代码,运行一次该代码就产生一个worker进程。因此,我们在写程序的过程中可以使用这里的主进程代码进行编写编输出打印信息或者调试。
该main目录下的代码运行方法如下:
a. 编写mr下的代码后,开启一个终端,进入src/main目录,运行go run mrmaster.go pg-*.txt
启动master进程;
b. 开启另一个终端,进入src/main目录,重新编译wc.go为插件形式(这样特定问题wc.go的map和reduce函数才可以在mrworker进程中,通过加载插件的方式传入给mapreduce框架,从而执行map和reduce任务):go build -buildmode=plugin ../mrapps/wc.go
,此时main目录下会得到一个wc.so的库文件。注意的是,以后每次修改mr下的代码后都要重新编译该插件;
c. 继续在b中的终端启动worker进程:go run mrworker.go wc.so
;
d. 如果mr框架写完,那么main目录下会生成mr-out-[0,9]的10个文件,其中就是最后的结果。查看这10个文件的结果可以使用cat mr-out-* | sort | more > wc_results.txt
将10个文件的结果合并并排序后输出到wc_results.txt文档中查看。
src/test-mr.sh是最后的测试脚本,用于完成框架后进行测试,如果都测试通过那么该实验就完成。
- 如何实现src/mr简易框架?
下面我按照自己思考的过程来讲述实现过程,相信你看完后能明白必要的结构体和函数是如何一步步被设计出来的(为了不断思考主线,结构体和函数的设计我以注释的形式穿插在文本中):- 查看mrmaster和mrworker的代码,可以看到他们分别调用mr包产生一个master和worker对象;对象往往需要执行一些初始化工作(比如初始化变量),具体需要初始化什么变量暂时放一下看后面需要;
- 初始化结束worker应该向master请求任务,具体请求的途径是通过rpc调用[1]; worker拿到任务后开始解析任务;
- 首先,worker应该要知道这个任务是map还是reduce类型的任务[2]。
- 假如是map类型的任务,那么worker首先需要知道该任务需要处理的文件名[3];
- 然后worker需要用自己的mapf函数读取该文件的内容并进行计算,根据mapf定义我们知道需要返回一个KeyValue的切片(使用者可以把切片初步理解为一个动态数组),其中包含了很多kv对(k是某个string单词,v是string的”1“)[4];
- worker得到的中间结果[]KeyValue要存在文件中(实验文档建议通过json库)等待reduce任务稍后进一步处理,并且一个mapTask要把结果存成nReduce份(nReduce是reduce任务的数量,mrmaster中指定);划分的标准是
ihash(key)%nReduce
,并且给文件命名中包含对应要处理某个中间结果的reducerID以让之后的reduce任务知道自己要读取哪些中间结果;
由于worker有多个,为了区别出不同map任务得到的中间结果,我们可以把该map任务的编号MapTaskID也加入文件名,因此实验文档中建议中间结果命名为mr-mapTaskID-reduceID; - 假如是reduce任务,就根据自己的reduceTaskID,读取对应的中间结果文件,然后调用reducef函数计算得到最终的结果;然后把结果写入一个文件mr-out-reduceTaskID中;
通过对worker的分析,我们知道Master结构体中需要一个保存所以map任务的数组mapTasks,一个保存所有reduce任务的数组reduceTasks;因此初始化的时候应该对这些变量进行初始化。此外,master还需要实现远程调用函数ReqTask进行任务的分配。为了当map任务分配完直接分配reduce任务而不是再遍历一次看看map任务是否都完成,我们可以再在Master结构体中增加一个变量taskPhase,当map任务都分配完后就标记该变量为REDUCEPHASE,那么分配任务时只需要查看该变量就知道从哪个数组中取任务进行分配;
- 查看mrmaster和mrworker的代码,可以看到他们分别调用mr包产生一个master和worker对象;对象往往需要执行一些初始化工作(比如初始化变量),具体需要初始化什么变量暂时放一下看后面需要;
- 初始化结束worker应该向master请求任务,具体请求的途径是通过rpc调用[1]; worker拿到任务后开始解析任务;
- 首先,worker应该要知道这个任务是map还是reduce类型的任务[2]。
- 假如是map类型的任务,那么worker首先需要知道该任务需要处理的文件名[3];
- 然后worker需要用自己的mapf函数读取该文件的内容并进行计算,根据mapf定义我们知道需要返回一个KeyValue的切片(使用者可以把切片初步理解为一个动态数组),其中包含了很多kv对(k是某个string单词,v是string的”1“)[4];
- worker得到的中间结果[]KeyValue要存在文件中(实验文档建议通过json库)等待reduce任务稍后进一步处理,并且一个mapTask要把结果存成nReduce份(nReduce是reduce任务的数量,mrmaster中指定);划分的标准是
ihash(key)%nReduce
,并且给文件命名中包含对应要处理某个中间结果的reducerID以让之后的reduce任务知道自己要读取哪些中间结果;
由于worker有多个,为了区别出不同map任务得到的中间结果,我们可以把该map任务的编号MapTaskID也加入文件名,因此实验文档中建议中间结果命名为mr-mapTaskID-reduceID;[5] - 假如是reduce任务,就根据自己的reduceTaskID,读取对应的中间结果文件,然后调用reducef函数计算得到最终的结果;然后把结果写入一个文件mr-out-reduceTaskID中;[6]
- 通过对worker的分析,我们知道Master结构体中需要一个保存所以map任务的数组mapTasks,一个保存所有reduce任务的数组reduceTasks;因此初始化的时候应该对这些变量进行初始化。此外,master还需要实现远程调用函数ReqTask进行任务的分配。为了当map任务分配完直接分配reduce任务而不是再遍历一次看看map任务是否都完成,我们可以再在Master结构体中增加一个变量taskPhase,当map任务都分配完后就标记该变量为REDUCEPHASE,那么分配任务时只需要查看该变量就知道从哪个数组中取任务进行分配;
- 至此,该程序存在4个问题:
- master如何知道map任务都已经完成了?因此,worker在完成map任务后要向master报告,master将该任务标记为完成[7]。
- master和worker如何退出?worker的退出方法在文档中给出了两种参考,一种是master退出后worker的rpc失败退出,另一种是master在看到map和reduce任务都已经完成后,发出一个假任务标志,worker收到后主动退出,我的实现决定采用后者;master的退出是由Done函数实现,我们可以给Master中增加一个taskPhase为EXITPHASE,那么在Done函数中就查看这个taskPhase变量即可。
- 如果所有的map任务已经分配,但是还没有全部完成,此时又有worker过来请求任务怎么办?我们可以返回一个特殊的任务NONETASK,当worker收到此任务后等待1s后再向master请求任务。
- 如果有worker中途crash掉了(在该实验说明中,指该worker处理时间超过10s仍未向master报告任务处理完成),master虽然可以在10s后把该任务再分配给其他来要任务的worker,但是之前的worker可能写到一半的中间结果或者最后结果文件就残留了不正确的答案,如何处理?实验文档中给出了一个解决方法是worker写结果时到文件时先写入临时文件;待向master汇报后,由master来修改临时文件名为中间结果/最后结果的文件名,并标记任务完成。
- 至此,mr框架的思路就实现了。不过,还有可能存在一个问题,本次实验的测试中不予考虑,但是我的实现中也做了考虑:假设虽然超过10s后,master以为该worker crash掉了,但是可能只是该worker这次处理慢了而已,可能15s后就向master报告自己已经处理完成。这种情况下,master应该要拒绝将临时结果的文件名重命名;因此我在Task任务结构体中还增加了一个FailedWorkers切片变量,用于存放master认为无法完成该任务的workerID,对于不再该切片中的worker的完成报告才给予接受和重命名[8]。
- 由于master多个函数中都要访问Master结构体中的公共变量,比如mapTasks中的任务状态等,因此在必要的地方需要增加互斥锁[9]。