MIT6.824课程2020版MapReduce实现

395 阅读8分钟

背景

世界名课,MIT分布式经典实验。

image.png

课程网站 nil.csail.mit.edu/6.824/2020/…

MapReduce 论文

模板

代码仓库 clone 下来之后即使什么不写,仅从模板中就能学习到很多知识。

1. golang中的rpc实践

在 mr/master.go 中,使用 rpc.Register() 注册了一个 Example 函数。

func (m *Master) Example(args *ExampleArgs, reply *ExampleReply) error {
   reply.Y = args.X + 1
   return nil
}

从代码中可以看出,这正是goLang原生rpc所规定的函数样式。有两个入参,一个是请求结构体 request, 一个是响应结构体 *response, 以及一个返回值 error。另外函数要绑定到主结构体 master 上。

我们知道rpc底层基础tcp协议,但应用层协议就多了,可以用http,可以自己手搓,实验中给的样例是前者,且因为是本地通信直接用unix,怎么简单怎么来。

// start a thread that listens for RPCs from worker.go
func (m *Master) server() {
   rpc.Register(m)
   rpc.HandleHTTP()
   //l, e := net.Listen("tcp", ":1234")
   sockname := masterSock()
   os.Remove(sockname)
   l, e := net.Listen("unix", sockname)
   if e != nil {
      log.Fatal("listen error:", e)
   }
   go http.Serve(l, nil)
}

2. go plugin

在 mrapps/ 目录下的有几个 go 文件,实验guidance让这么编译。

go build -buildmode=plugin ../mrapps/wc.go

  1. 在 Windows 平台上是不支持的,直接报错。官方原话 Currently plugins are only supported on Linux, FreeBSD, and macOS. Please report any issues.
  2. 产生的 .so 文件类似于C++的链接文件,在其他文件中动态装载[运行时]。
func loadPlugin(filename string) (func(string, string) []mr.KeyValue, func(string, []string) string) {
   p, err := plugin.Open(filename)
   if err != nil {
      log.Fatalf("cannot load plugin %v", filename)
   }
   xmapf, err := p.Lookup("Map")
   if err != nil {
      log.Fatalf("cannot find Map in %v", filename)
   }
   mapf := xmapf.(func(string, string) []mr.KeyValue)
   xreducef, err := p.Lookup("Reduce")
   if err != nil {
      log.Fatalf("cannot find Reduce in %v", filename)
   }
   reducef := xreducef.(func(string, []string) string)

   return mapf, reducef
}
  1. 编译插件使用的 go 版本要和载入插件的项目使用的 go 版本一致,不然各种报错,兼容性很差。

MapReduce 原理

具体分几步,在论文中有,总结下来用这个图来描述很适合,也写过一个原理分析

image.png

实现预备

1.文件读写

读文件没什么说的,在写文件时,不存在先创建要使用 Create 模式, 保证写任务的幂等性要每次写前先清空文件使用 TRUNC 模式, 操作权限选择 777。

resFile, err := os.OpenFile("mr-out-", os.O_WRONLY|os.O_TRUNC|os.O_CREATE, os.ModePerm)
if err != nil {
   return err
}
defer resFile.Close()

2. 多路归并

从上图中可以看到,在 Reduce 阶段的 merge sort 时,每个 Reduce 任务都取了所有的 N 分区文件,例如红色的4个0号分区,由于分区内部是有序的,要合并成一个有序的分区,使用的是多路归并算法。

最经典实现的可以举例:LeetCode 合并排序链表

3. 并发控制

在master进行分配任务、检测所有任务是否已完成等场景下,需要进行并发控制,可以使用 golang 原生锁,简单好用。

func fun(){
m.JobListMutex.Lock()
defer m.JobListMutex.Unlock()
// other code
}

但需要注意的是锁是不可重入的,意味着不能多次调用 lock.Lock()

具体实现

先画一个执行的流程图,主要有三部分: 服务端、服务端后台任务、客户端。根据流程图用代码实现。

流程图.jpg

服务端定义

服务端不需要太多东西,只需要维护map和reduce任务队列,以及一个阶段标志位即可。

type Master struct {
   // Your definitions here.
   Files        []string // 原始输入的文件列表
   ReduceNumber int      //  Reduce 的数量   Map任务的数量就是 files 的长度

   MapJobList    []*Job     // 分发给 worker 的 map 任务
   ReduceJobList []*Job     // 分发给 worker 的 reduce 任务
   JobListMutex  sync.Mutex // 访问or修改任务状态时要加锁 可以减少锁的粒度

   CurrentStates int32 // 所处的阶段枚举, map、reduce、都完成
}

在rpc服务启动前,先根据原始输入的文件列表进行初始化 map 任务。 比较重要的是对"任务"的定义。 任务定义其实可以划分成两部分。

一部分是任务静态信息,如文件名称,任务类型等。一部分是运行时信息,例如是否成功完成,上次的运行时间是多少等。

type Job struct {
   // 静态信息
   FileName     string // map任务输入文件名
   ListIndex    int    // 在任务列表中的 index 下标
   ReduceID     int    // reduce 任务号  从0-(N-1)  和上面一个属性重复了
   ReduceNumber int    // reduce 数量个数,分区用到
   JobType      int    // 任务类型  map 还是 reduce
   // 动态信息
   JobFinished bool  // 任务是否正确完成
   StartTime   int64 // 任务的分配时间[初始为0],如果下次检查是否可分配时超过2s,就当失败处理,重新分配
   FetchCount  int   // 任务分配次数,统计信息
   // 规定: map产生的中间文件的名字格式是  输入文件名_分区号    reduce 产生的最终文件是 mr-out-reduce任务号
}

客户端定义

根据流程图,需要定义"索取任务"和"提交任务"两个rpc函数和一个"执行任务"函数。

rpc函数的请求和返回体最简化定义

// JobFetchReq 任务获取的请求体
type JobFetchReq struct {
   // 不需要有东西,服务端不记录客户端信息
}

type JobFetchResp struct {
   NeedWait bool // 是否需要等待下次轮询任务, 因为服务端可能已经分发完map任务,但Map阶段还没结束[map任务正在被执行]
   Job
}

// JobDoneReq 任务完成提交的请求体
type JobDoneReq struct {
   Job
}

// JobDoneResp 任务完成提交的返回体
type JobDoneResp struct {
   // 不需要有东西
}

任务分发逻辑如下。 因为任务的处理是幂等的,所以第一次运行或者之前超时没完成的任务会被重新分配。注意 NeedWait 的逻辑。

func (m *Master) JobFetch(req *JobFetchReq, resp *JobFetchResp) error {
   m.JobListMutex.Lock()
   defer m.JobListMutex.Unlock()
   curTime := time.Now().Unix()
   // 看看有哪些没完成的任务,分配出去
   jobList := m.MapJobList
   switch atomic.LoadInt32(&m.CurrentStates) {
   case AllFinished:
      return nil
   case InMap:
      jobList = m.MapJobList
   case InReduce:
      jobList = m.ReduceJobList
   }
   for _, job := range jobList {
      // 任务没完成,且是第一次运行或者之前超时了
      if !job.JobFinished && (job.StartTime == 0 || curTime-job.StartTime > int64(JobTimeoutSecond)) {
         job.FetchCount++
         job.StartTime = curTime
         resp.Job = *job
         return nil
      }
   }
   // 需要等待流转到 reduce 的情况
   if atomic.LoadInt32(&m.CurrentStates) == InMap {
      resp.NeedWait = true
   }
   return nil
}

提交任务就比较简单了,客户端告诉服务端是哪种任务的哪一个任务,赋值finished状态。

func (m *Master) JobDone(req *JobDoneReq, resp *JobDoneResp) error {
   m.JobListMutex.Lock()
   defer m.JobListMutex.Unlock()
   finished := req.JobFinished
   switch atomic.LoadInt32(&m.CurrentStates) {
   case InMap:
      m.MapJobList[req.ListIndex].JobFinished = finished
   case InReduce:
      m.ReduceJobList[req.ListIndex].JobFinished = finished
   }
   return nil
}

主Worker的循环逻辑, 一直要任务,做任务,提交任务,直到master告知Reduce阶段也结束了[用了一个没用的 FetchCount 字段表示,应该用专门字段的]。

//
// main/mrworker.go calls this function.
//
func Worker(mapf func(string, string) []KeyValue,
   reducef func(string, []string) string) {

   // Your worker implementation here.

   // uncomment to send the Example RPC to the master.
   //CallExample()
   startTime := int64(0)
   for true {
      startTime = time.Now().UnixNano()
      // 索要任务,得到的可能是 Map 或者 Reduce 的任务
      job := CallFetchJob()
      // 需要等待流转到 reduce
      if job.NeedWait {
         time.Sleep(BackgroundInterval)
         continue
      }
      // 任务都做完了,停吧
      if job.FetchCount == 0 {
         fmt.Println(logTime() + WorkerLogPrefix + "任务都做完了,worker退出")
         break
      }
      // 做任务
      job.DoJob(mapf, reducef)
      // 做完了,提交
      CallCommitJob(&JobDoneReq{job.Job})
      fmt.Println(WorkerLogPrefix+"一次worker循环耗时[毫秒]:", (time.Now().UnixNano()-startTime)/1e6)
   }
}

至于具体的 DoJob 函数逻辑虽然比较长但不难,就放在这里参考

服务端后台线程

启动 rpc 服务之后,就 go Background() 启动后台线程,主要是检测任务是否都完成,推动进入下一个阶段状态。每隔300毫秒扫描一次

func (m *Master) Background() {
   for atomic.LoadInt32(&m.CurrentStates) != AllFinished {
      // 循环遍历任务
      m.JobListMutex.Lock()
      isAllJobDone := true
      leftCount := 0
      switch atomic.LoadInt32(&m.CurrentStates) {
      case InMap:
         for _, job := range m.MapJobList {
            if !job.JobFinished {
               isAllJobDone = false
               leftCount++
            }
         }
         fmt.Printf(logTime()+MasterLogPrefix+"—————————————— 还剩 %v 个 map 任务\n", leftCount)
         // map 任务都做完了,流转到 reduce 状态, 而且要生成 reduce 任务
         if isAllJobDone {
            leftCount = 0
            atomic.StoreInt32(&m.CurrentStates, InReduce)
            m.generateReduceMap()
            fmt.Printf(MasterLogPrefix+"background: CurrentStates change from %v to %v\n", InMap, InReduce)
         }
      case InReduce:
         for _, job := range m.ReduceJobList {
            if !job.JobFinished {
               isAllJobDone = false
               leftCount++
            }
         }
         fmt.Printf(logTime()+MasterLogPrefix+"—————————————— 还剩 %v 个 reduce 任务\n", leftCount)
         // reduce 任务都做完了,流转到 结束 状态
         if isAllJobDone {
            atomic.StoreInt32(&m.CurrentStates, AllFinished)
            fmt.Printf(MasterLogPrefix+"background: CurrentStates change from %v to %v\n", InReduce, AllFinished)
         }
      }
      m.JobListMutex.Unlock()
      time.Sleep(BackgroundInterval)
   }
}

以上就是基本的代码实现,在运行给定的 main/test-mr.sh 后,能够 All pass. 顺便说一下这个 sh 文件的代码写的也很不错, 值得学习和借鉴。github.com/francisLee7…

在实现上有几个注意点[踩坑]:

  1. 目录问题。在读写文件时要注意当前go文件运行所处的目录,可能会有 ../ 的前缀要特殊处理。
  2. 文件读取问题。在读取文件内容序列化成给定的 KeyValue 结构体时,注意字符串处理。 一行一行的字符串在 wordCount 场景中的格式是这样 "apple 3" 可以用中间的空格做 split。但在后面的几个场景里,一行可能有多个空格,而且还有可能是空行,都要处理到位。另外文件读写的时候要注意最后的末尾回车。
  3. 多路归并。 实现的时候需要一个"哨兵值",来表示字符串的最大值作为循环的初始哨兵。这里我用的 zzzzzzzzzz 这样的字符串[一开始用的大写ZZZ运行不对,后来才发现大写的ASCII码比小写的小]
  4. 有很多可以优化性能的地方。例如减小锁的粒度,例如客户端回告任务后立刻检查全局状态的流转等等。

写在最后

在有一定Golang和MR理论的基础上,实现实验一用了大约3小时左右[包括调试1小时],总体看实验的设计非常好,难度适中而且步步可循,同时也留下了一些思考题。

  1. 如何容错,在任务失败的时候。
  2. 单点故障,master挂掉。
  3. 负载均衡,怎么分配总体耗时最短。
  4. MR参数, reduce的数量。