MIT6.824-Lab1-Part III: Distributing MapReduce tasks

504 阅读6分钟

1.概述

1.1 schedule()

在这部分实验要将之前串行版本的MapReduce tasks改成并发模式,只需要实现 mapreduce/schedule.go中的 schedule()函数,其他文件不做更改。 主机在MapReduce作业期间调用schedule()两次,一次用于Map阶段,一次用于Reduce阶段。schedule()的工作是将tasks分发给可用的 workers。通常tasks会比 workers多,因此schedule()必须为每个 worker提供一系列task,但每个worker一次只能执行一个task。 schedule()应该等到所有tasks都完成后再返回。

func schedule(jobName string, mapFiles []string, nReduce int, phase jobPhase, registerChan chan string) {
    var ntasks int
    var n_other int // number of inputs (for reduce) or outputs (for map)
    switch phase {
    case mapPhase:
        ntasks = len(mapFiles)
        n_other = nReduce
    case reducePhase:
        ntasks = nReduce
        n_other = len(mapFiles)
    }

    fmt.Printf("Schedule: %v %v tasks (%d I/Os)\n", ntasks, phase, n_other)

    // All ntasks tasks have to be scheduled on workers. Once all tasks
    // have completed successfully, schedule() should return.
    //
    // Your code here (Part III, Part IV).
    //
    fmt.Printf("Schedule: %v done\n", phase)
}

schedule()通过读取registerChan参数来获取 workers 信息。该通道为每个worker生成一个字符串,包含worker的RPC address。有些workers可能在调度schedule()之前已经存在,而某些workers可能在schedule()运行时启动,但所有workers都会出现在 registerChan上。schedule()应该使用所有worker,包括启动后出现的worker。

1.2 DoTaskArgs

schedule()通过向worker 发送Worker.DoTask RPC来告诉worker执行task,每次只能向一个给定的worker发送一个RPC。该RPC的参数被定义在MapReduce / common_rpc.go中的DoTaskArgs。其中File参数只在Map tasks中使用,表示要读取的输入文件名称。 schedule()可以在mapFiles中找到这些文件名。

type DoTaskArgs struct {
    JobName    string
    File       string   // 只在Map tasks中使用,表示要读取的输入文件名称
    Phase      jobPhase // 标志当前是map还是reduce阶段
    TaskNumber int      // 此task在当前阶段的索引

    // NumOtherPhase是其他阶段的task总数
    // mappers需要这个来计算output bins, reducers 需要这个来知道要收集多少输入文件
    NumOtherPhase int
}

1.3 call()

使用mapreduce / common_rpc.go中的call()函数 将RPC发送给worker。第一个参数是worker的 address,从registerChan读取。第二个参数应该是“Worker.DoTask”,表示通过rpc调用worker的DoTask方法,第三个参数应该是DoTaskArgs结构,最后一个参数应该是nil。

func call(srv string, rpcname string,
    args interface{}, reply interface{}) bool {
    c, errx := rpc.Dial("unix", srv)
    if errx != nil {
        return false
    }
    defer c.Close()

    err := c.Call(rpcname, args, reply)
    if err == nil {
        return true
    }

    fmt.Println(err)
    return false
}

1.4 Worker、 Worker.DoTask

再来看下Worker的结构和方法,定义在worker.go中

type Worker struct {
    sync.Mutex

    name        string
    Map         func(string, string) []KeyValue
    Reduce      func(string, []string) string
    nRPC        int // quit after this many RPCs; protected by mutex
    nTasks      int // total tasks executed; protected by mutex
    concurrent  int // number of parallel DoTasks in this worker; mutex
    l           net.Listener
    parallelism *Parallelism
}

他有下列几个方法:

  • register:worker的register方法中使用RPC远程调用了Master.Register,将该worker注册到master中。master维护了一个workers []string,记录worker的address,master还负责把worker address发送到通道中(上文提到的registerChan),供schedule()读取。
  • RunWorker:和master建立连接,注册它的address到master中(调用上方的register函数),等待schedul()安排tasks。
  • DoTask:在schedul()函数中会调用此方法给worker安排task。根据传入的phase参数,他会去执行doMap或doReduce方法。在worker中还维护了一个Parallelism结构,跟踪worker是否并发执行。
type Parallelism struct {
    mu  sync.Mutex
    now int32
    max int32
}

其中max字段记录该worker运行的最大task数量,通过锁机制保证了并发。由于在各个函数间传递的是&Parallelism(地址),所以大家修改的是同一个Parallelism。

  • Shutdown:当所有tasks都完成后,master会调用worker的Shutdown,worker应该返回所处理过的tasks数量。

2.调用流程

这个实验将执行两个测试,TestParallelBasic(验证运行是否正确)和TestParallelCheck(验证是否并发执行) 。在TestParallelBasic中首先会生成20个824-mrinput-xx.txt的输入文件,接着调用Distributed()函数,启动schedule(),运行map tasks和reduce tasks,然后新开两个协程去启动两个workers。最后检查生成的输出文件是否正确,每个worker至少执行了一个task。因此map tasks数量为20,reduce tasks数量为10,workers数量为2。

func TestParallelBasic(t *testing.T) {
    mr := setup()//该函数中会调用Distributed()
    for i := 0; i < 2; i++ {
        go RunWorker(mr.address, port("worker"+strconv.Itoa(i)),
            MapFunc, ReduceFunc, -1, nil)
    }
    mr.Wait()
    check(t, mr.files)
    checkWorker(t, mr.stats)
    cleanup(mr)
}
//先运行 map tasks,然后运行reduce tasks
func Distributed(jobName string, files []string, nreduce int, master string) (mr *Master) {
    mr = newMaster(master)
    mr.startRPCServer()
    //会先运行 map tasks(mapPhase),然后运行reduce tasks(reducePhase),具体逻辑在Master.run函数中
    go mr.run(jobName, files, nreduce,
        func(phase jobPhase) {
            //创建一个无缓冲的通道
            ch := make(chan string)
            //将所有现有的和新注册的workers信息发送到ch通道中,schedule通过通道ch获取workers信息
            go mr.forwardRegistrations(ch)
            //本次要实现内容,将tasks分发给可用的 workers
            schedule(mr.jobName, mr.files, mr.nReduce, phase, ch)
        },
        func() {
            mr.stats = mr.killWorkers()
            mr.stopRPCServer()
        })
    return
}

TestParallelCheck 则是验证所编写的调度程序是否并行地执行task,同样开启了两个worker,检验worker中的parallelism.max(运行的worker数量最大值)是否小于2,若小于则失败。

func TestParallelCheck(t *testing.T) {
    mr := setup()
    parallelism := &Parallelism{}
    for i := 0; i < 2; i++ {
        go RunWorker(mr.address, port("worker"+strconv.Itoa(i)),
            MapFunc, ReduceFunc, -1, parallelism)
    }
    mr.Wait()
    check(t, mr.files)
    checkWorker(t, mr.stats)

    parallelism.mu.Lock()
    if parallelism.max < 2 {
        t.Fatalf("workers did not execute in parallel")
    }
    parallelism.mu.Unlock()

    cleanup(mr)
}

3.schedule()

schedule()的参数中mapFiles是输入文件名称列表,每个maptask处理一个。nReduce是reduce tasks的数量,registerChan通道传递所有worker的RPC address。局部变量ntasks表示当前阶段的tasks数量,若是map阶段则为输入文件数量,若是reduce阶段则为nReduce参数。
了解了大概的运行流程,schedule()中要做的就是开启多个线程,读取通道registerChan获取worker的address。构造DoTaskArgs参数。调用call()方法向worker 发送Worker.DoTask RPC,告诉worker执行task。在测试程序中开启了两个worker:worker0和worker1,而registerChan是一个无缓冲的通道,每次通道上只有一个worker address,因此可以将处理完task的worker的 address再放回registerChan,供下一个线程读取,以此实现两个worker并发运行。
一旦所有该阶段的tasks成功完成,schedule()就返回,这个功能可以使用sync.WaitGroup来实现。 我的实现如下:

func schedule(jobName string, mapFiles []string, nReduce int, phase jobPhase, registerChan chan string) {
    var ntasks int
    var n_other int // number of inputs (for reduce) or outputs (for map)
    switch phase {
    case mapPhase:
        ntasks = len(mapFiles)
        n_other = nReduce
    case reducePhase:
        ntasks = nReduce
        n_other = len(mapFiles)
    }

    fmt.Printf("Schedule: %v %v tasks (%d I/Os)\n", ntasks, phase, n_other)

    var wg sync.WaitGroup
    wg.Add(ntasks)
    for i:=0;i<ntasks;i++{
        //开启线程并发调用
        go func(taskNum int) {
            //从chan获取可用的worker
            for w := range registerChan {
                //构造DoTaskArgs参数
                var arg DoTaskArgs
                switch phase {
                case mapPhase:
                    arg = DoTaskArgs{JobName:jobName,File:mapFiles[taskNum],Phase:mapPhase,TaskNumber:taskNum,NumOtherPhase:n_other}
                case reducePhase:
                    arg = DoTaskArgs{JobName:jobName,File:"",Phase:reducePhase,TaskNumber:taskNum,NumOtherPhase:n_other}
                }
                call(w,"Worker.DoTask",arg,nil)
                wg.Done()
                registerChan <- w//将worker address放回registerChan
                break
            }
        }(i)
    }
    wg.Wait()
    return
}

4.测试运行

运行下面命令来测试所编写的实验代码。这将依次执行两个测试,TestParallelBasic和TestParallelCheck 。

go test -run TestParallel

得到类似下面结果程序运行成功