0 介绍
最近开始学习MIT 6.824,这是一门讲解分布式系统的课程,同时也提供了一系列有挑战性的lab。这篇博客讲述一下lab1 MapReduce算法的实现,由于课程要求不能公开代码,这里就不上很多代码了,以算法分析为主。不得不说国外的课程还是很硬核的,lab1要求两周内完成,在这两周内要求读数篇论文、学会golang、编写代码通过测试,当然如果能完成的话收获也很大。
1 MapReduce介绍
MapReduce是一个比较有名的分布式计算模型,由谷歌在2003年的一篇论文里提出,用于处理海量数据的问题,比如单词统计、倒排索引、url访问量排序等等。它的本质思想是归并,在实际实现中会有一个master主机负责调度任务,多个worker主机负责任务执行和返回任务状态。worker将数据map到不同的分区,然后使用reduce规约。map和reduce的方法可以由用户定义。以单词统计为例,现在有比如1TB的文本,要求统计出文本中每个单词的出现次数。显然这么多数据我们没法一次装入内存,所以要另寻他法。使用MapReduce的思路如下,:
- 将文本分片,分成很多16M~64M的小文本(设有m个),计划有n个reduce分区,master启动,等待worker申请任务
- worker启动之后,会一刻不停地向master申请任务。首先申请map任务,worker申请后拿到一个小文本,将小文本中的每个单词映射到对应的分区(如单词的哈希值 hashValue % n)。
- 在所有map任务完成后,worker申请reduce任务,通过刚才的映射,同样的单词必然在同一分区,所以worker读取每个分区的映射文本就可以统计单词数了。
图示如下:
原理是清晰的,下面分析一下lab中难点
2 Master
Master如前所述负责任务调度,它主要负责两件事,一是给worker分配任务,二是负责维护每个任务的状态。给worker分配任务如何理解?很简单,在任务列表中找到一个处于未分配的任务即可;维护任务状态如何理解?当一个worker拿走一个任务后,master需要将它标记为processing,如果worker在规定的时间内完成,并且返回任务成功的消息,就标记为任务完成;假如worker宕机了,没有在规定的时间内完成任务,master需要觉察这件事,并将任务重新标记为未分配,在本lab里我选的方法是开启一个监控线程,每隔lab推荐的10s检查一下哪些任务还在processing中,标为未分配即可。
const (
taskUnAssigned = 0
taskProcessing = 1
taskFinished = 2
)
type Master struct {
mapNum int //需要执行的map任务数
reduceNum int //需要执行的reduce任务数
mapFinished int //完成的map任务数
reduceFinished int //完成的reduce任务数
mapTaskStatus []int //map任务状态
reduceTaskStatus []int //reduce任务状态
mapFileNames []string //需要映射的文件名称数组
mutex sync.Mutex //加锁保护共享数据,分配任务、修改任务状态时使用
}
3 Worker
MapReduce的worker负责具体的小任务完成,即map和reduce。worker首先通过rpc调用,请master分配任务,请求的struct格式如下
const (
unAssigned = 0
mapTask = 1
reduceTask = 2
allDone = 3
)
type TaskInfo struct {
TaskType int //任务类型
FilePath string //文件列表
TaskId int //任务编号
MapNum int //map任务总数
ReduceNum int //reduce任务总数
}
得到任务信息后,map的执行步骤如下:
- 打开目标文件,将文本输入到用户自定义的map函数里
- 用户自定义的map函数会返回一个key-value struct数组
- 根据key的哈希值确定reduce区域,将key-value对以json格式写入文件中,格式mr-tem-map任务编号-reduce区域
需要值得注意的是第三步,如果有多进程对同一文件进行读写的话可能会有错误发生,特别是一些worker出现crash又恢复继续工作的情况。lab提示了论文里介绍的一种trick,就是写文件时先写入临时文件(ioutil.TempFile()),然后原子的移动到目的文件夹(os.Rename())。
reduce的执行步骤如下:
- 根据任务的编号id,首先读取mr-tem-*-id,然后根据key值排序
- 调用用户定义的reduce函数
- 结果写出到mr-out-id文件
第三步也要考虑读写错误的情况,依然使用前面提到的trick。
4 结果
测试全部通过,顺便一提,根据运行时间可知这个任务是一个IO密集型的任务,cpu使用时间大概占了总体运行时间的10%,大部分时间其实是在等IO,所以我认为若要进行优化,要从IO入手。
附录 lab提示
- 完成所有的map任务之后再做reduce。
- map和reduce函数在对应的.so文件里,由用户自定义。
- 在本lab中,所有worker进程共享一个文件系统,也就是说所有worker是跑在同一台机器上的。如果说worker是在不同的机器上,我们就需要GFS这种分布式文件系统了
- worker的map任务会需要某种方式来存储中间态的k-v对,方便在做reduce任务的时候读回来。可以考虑使用Go语言的encoding/json包,例子:
enc := json.NewEncoder(file) //file是文件指针
for _, kv := ... {
err := enc.Encode(&kv)
读回来:
dec := json.NewDecoder(file)
for {
var kv KeyValue
if err := dec.Decode(&kv); err != nil {
break
}
kva = append(kva, kv)
}
- master是一个并发服务器,记得给共享数据加锁,比如master要维护任务分配状态的话就需要加锁。
- Wokrder工作的时候有时候需要等待,比如当map没有全部完成的时候,word申请reduce任务的话就需要等待。一种解决方法是在每个request间隔睡眠;另一种解决方法是使用条件变量,让发起request的那条thread阻塞直到等待结束。
- 可以使用ioutil.TemFile来创建临时文件,os.Rename来原子性的重命名临时文件,这样是为了防止worker执行任务时出现crash,导致文件写一般又被别的进程污染。可以执行考虑每个任务创建一些临时文件,直到它写完成为一个完整的文件再rename,这个技巧是原论文里提出来的,非常重要,测试样例的crash-test将会模拟worker crash掉的情况。