MIT 6.824 Lab1: MapReduce 总结

661 阅读4分钟

简介

Lab1 中要完成的任务是实现一个简单的 MapReduce 框架,并通过 src/main/test-mr.sh 中的所有测试。 开始做 Lab 之前一定要仔细阅读论文实验说明,里面很多实现规则和一些有用的提示。

实现

流程梳理

  1. 通读框架代码,知道自己要完成的任务是哪些。
  2. 在 Coordinator 和 Worker 初始化完后,Worker 就开始通过 RPC 请求来向 Coordinator 请求任务。
  3. Worker 拿到任务之后,去解析任务的类型,分别执行 Map 和 Reduce 函数。
  4. 那么 Worker 在完成任务后,同样需要通知 Coordinator 任务已经完成。

结构体设计

Coordinator 需要给 Worker 发布任务,所以肯定需要一个用来存储任务的数据结构,我这里选用的是 go 的 channel 来实现这个待发布任务队列,并且使用了一个 map 来存储所有任务的信息。然后需要存储的是 Map 和 Reduce 任务的输入;添加一个 nReduce 参数来记录有多少个 Reduce 任务;使用phase字段来记录这次 mr 的进行阶段;由于想实现一个无锁的程序,所以添加了几个通道来实现同步。

type Coordinator struct {
   taskQueue         chan int
   taskMeta          map[int]*Task
   mapTaskInputs     []string
   reduceTaskInputs  map[int][]string
   nReduce           int
   phase             int
   askTaskQueue      chan chan *Task
   finishTaskQueue   chan finishTask
   timeoutCheckQueue chan struct{}
   done              chan struct{}
}

接下来是 Task 的设计,我并没有对 Work 进行一个状态的管理,而是直接对 Task 进行状态管理,有三种任务状态:Idle、InProcess、Finished;设计了 4 种任务类型:Map、Reduce、Wait、End,Wait 是在没有任务时,通知 Worker 等待,而 End 是在 mr 结束之后,通知 Worder 退出。

type Task struct {
    MapTaskInput    string // Map 任务的输入
    MapResult       map[int]string // Map 任务的结果,用于 RPC 返回
    ReduceTaskInput []string // Reduce 任务的输入
    TaskId          int // 任务 Id
    NReduce         int 
    StartTime       time.Time // 开始时间,用于计算任务是否超时
    Typ             int // 任务类型
    Status          int // 任务状态
}

实现细节

Worker

Worker 的实现较为简单,主要是使用了一个轮询来不停的向 Coordinator 请求任务并且执行任务:

func Worker(mapf func(string, string) []KeyValue,
   reducef func(string, []string) string) {
   for {
      task := getTask()
      switch task.Typ {
      case Map:
         task.mapper(mapf)
      case Reduce:
         task.reducer(reducef)
      case Wait:
         time.Sleep(5 * time.Second)
      case End:
         return
      }
   }
}
  • 如果是 Map 类型的任务,那么 Worker 调用 mapf 函数对文件进行计算,一个 Map 任务要通过ihash函数将结果划分为 nReduce 份,并且将中间结果的输出文件起名为 mr-X-Y,X 和 Y 分别对应 Map 任务 ID 和 Reduce 任务 ID。
  • 如果是 Reduce 类型的任务,那么 Worker 调用 reducef 函数对文件进行计算,并将结果输出到 mr-out-Y 中。
  • 如果是 Wait 任务则休眠五秒钟,等待新任务。
  • 如果是 End 任务,则 Worker 退出。
  • 所有任务的输出都应该先写入到一个临时文件中,最后再利用操作系统原子性的指令将文件重命名。
  • 可以用实验说明中提供的 JSON 格式来进行 K/V 对的读写。
  • mapper、reducer 函数参考 main/mrsequential实现即可。

Coordinator

由于想要一个无锁实现,所以所有对结构体的操作都是在一个协程中使用 for-select 结构完成的,这样很容易可以达成同步。

func (c *Coordinator) workerListener() {
ForStart:
    for {
        select {
        case done := <-c.askTaskQueue:
            // ... 省略部分代码
            // 从 Worker 处收到任务请求的 RPC,如果有任务则往请求任务的通道中
            // 放进一个 Map/Reduce 任务,否则返回一个 Wait/End 任务
        case <-c.timeoutCheckQueue:
            // ... 省略部分代码
            // 间隔一段时间进行超时检查,每个任务的执行时间只有十秒,如果超过十秒
            // 就把这个任务的状态设为空闲,并重新放进待执行的任务队列中
        case f := <-c.finishTaskQueue:
            // ... 省略部分代码
            // 获得任务完成的 RPC,这时应该去判断完成了什么任务、有没有重复完成
            // 并且 Reduce 任务一定是在 Map 任务全部执行完成后才能开始
            // Map 任务全部完成后,重新初始化存储任务的 Map,并生成 Reduce 任务
            // 如果所有任务都完成了,那么可以通知 Coordinator 执行结束
        }
    }
}

我并没有采用每一个任务被发放出去都开启一个协程去监听它是否超时,而是每隔一段时间,遍历一遍任务列表来检查是否超时。

func (c *Coordinator) timeoutListener() {
    for {
        <-time.After(5 * time.Second)
        c.timeoutCheckQueue <- struct{}{} // 每间隔五秒向超时检查通道发送消息
    }
}

两个 RPC 方法也是收到 RPC 请求则向对应的 channel 里发送一条消息,让工作协程来全权执行。

func (c *Coordinator) ReleaseTask(args *RpcArgs, task *Task) error {
    done := make(chan *Task)
    c.askTaskQueue <- done
    *task = *(<-done) // 阻塞在这里直到 done 通道中获取了一个任务
    return nil
}

type finishTask struct {
    task *Task
    done chan struct{}
}

func (c *Coordinator) FinishTask(task *Task, reply *RpcReply) error {
    f := finishTask{task: task, done: make(chan struct{})}
    c.finishTaskQueue <- f
    <-f.done // 阻塞在这里,直到工作协程完成处理
    return nil
}

执行结果

完成代码后,src/main下有一个test-mr.sh可以进行单次测试,也可以使用test-mr-many.sh来进行多次测试,只要在指令最后加上执行次数参数即可。