分布式计算模型MapReduce——Go语言实现【MIT 6.824 Lab1】

5,287 阅读8分钟

1.MapReduce论文阅读

1.1 什么是MapReduce

MapReduce论文地址:pdos.csail.mit.edu/6.824/paper…

笔者是在学习MIT6.824分布式系统的课程时初次接触到MapReduce,乍一看感觉就是分治的思想。

举个简单的例子,我作为曼联的球迷想要了解曼联的历史,于是我找来了曼联近三十年的相关新闻报道,想要知道谁是最具有话题性的球星,便统计起了相关新闻报道中每个球星名字出现的频次。但是如果让我一篇报道一篇报道地数那该眼花了,于是我决定邀请我的朋友们来一起了解足球历史。

假设我邀请了N个朋友,那么我就把这三十年的新闻报道分成了N份到这N个人手上,这样每个人统计完自己手上的新闻报道后,我们就会得到N份表格,每一份表格上记录了每位球星名字出现的次数。接着我又邀请了M个朋友,这M个朋友在这N份表格中每人统计一个球星名字总共出现的次数,比如A统计小贝名字在N份表格中出现的次数,B统计鲁小胖在N份表格中出现的次数,等等等等。最终我们就可以得到一份在过去三十年中曼联球星名字出现频次的表格数据啦!

从上面这个例子里我们可以看到如果想要MapReduce首先需要朋友足够多(开个玩笑),我们从理论的层面来了解一下MapReduce。MapReduce准确来说是一种编程模型

在上面这个例子中,实际场景下我们可以写一个程序来遍历新闻报道,也可以多线程地去跑程序来遍历新闻报道,但这样受到CPU能力的限制总归太慢。所以我们将程序部署到N台机器上,自然地我们还得在写一个程序部署到M台机器上来将N台机器的输出结果进行整合。既然都这么麻烦,那就让MapReduce来帮我们实现吧!

MapReduce替我们把一些我们写程序的时候不想考虑问题都给考虑了,像怎么分割输入数据在集群上的如何调度机器和硬件的异常错误处理机器之间的通信等等,MapReduce将这些个问题抽象了出来,将并行实现、容错处理、数据分布、负载均衡等等细节隐藏了起来。这样我们只需要实现单机场景下的程序即可,而输入文件怎么拆分,又怎么做整合,这些就不需要我们考虑了。

1.2 编程模型

在上面例子中我们可以看到,我们有输入集(新闻报道)输出集(名字出现频次的表格数据)以及中间结果集(N个朋友每人各自统计的表格数据)。从输入集到中间结果集的过程就是一个map的过程,而从中间结果集到输出集的过程就是一个reduce的过程。

我们将输入集看成是一个key/value对集合,key是每个新闻报道文档的名字,而value则是每篇新闻报道中的内容。同样的,中间结果集也是一个key/value对集合,经过map后,这里的key变成了球星名字,而value则是该球星名字出现的次数。由于是N个中间结果集的输出,所以对于N个中间结果集,key对应的value就会变成list(value),即value的列表。这个value列表输入reduce后,就会得到一个value的结果集,包含了每个球星出现的次数。归纳起来就是:

Input1 -> Map -> a,1 b,1 c,1
Input2 -> Map ->     b,1
Input3 -> Map -> a,1     c,1
                  |   |   |
                  |   |   -> Reduce -> c,2
                  |   -----> Reduce -> b,2
                  ---------> Reduce -> a,2
// 可以得到球星a,b,c出现次数都为2
// 表达式可以表达为
map(k1,v1) ->list(k2,v2)
reduce(k2,list(v2)) ->list(v2)

1.3 框架实现

上述是MapReduce的执行过程的一个示意图,其中UserPraogram 是我们用户编写的程序,master可以理解为调度程序。从左到右分别是输入集、Map操作、中间结果集、Reduce操作、输出集。图中序号(1)~(6)则展示了MapReduce调度执行的全流程。

(0)首先,用户程序里的MapReduce库的分割函数会将输入文件集切分成N份(分割函数和N的值可以由用户程序指定),其中每份大小16MB到64MB(同样可选参数控制大小)。

(1)(2)接着会开始大量地拷贝用户程序,拷贝的程序中有一个是master,其他的程序为worker。这个master就是工头,会将N个Map任务和M个Reduce任务分配给worker。

(3)worker读取被分割的文件集中的一个或多个分片,从输入中分析出key/value对,作为用户程序中的map函数的入参,输出中间结果集并缓存在内存中。

(4)缓存在内存中的中间结果集会被周期性地存储到本地磁盘上,通过分割函数写入M个区域,这M个区域地位置会被传递给master,master则会将这些信息告诉被分配了Reduce任务的worker。

(5)分配了reduce 的worker们会RPC读取中间结果集,并采取排序的方式将具有相同key的内容聚合在一起。

(6)排过序后,就会将中间结果集中的每一个唯一的key以及对应的value集合(list(value))传递给用户程序的reduce函数作为入参,最终输出到输出文件集中。

1.4 容错处理

前面聊了那么多可以看出MapReduce是为大规模而生的,通过安排成百上千的worker来处理大量的数据,那么就难免会有worker"罢工"(服务器崩溃或宕机),又或者是master由于巨大的管理压力而出差错,这都是在设计MapReduce时要考虑的问题。

1.4.1 worker故障

在运行过程中,master既然给worker分配了任务,就会想要了解到worker们的工作情况,所以master会周期性地ping每一个worker。如果在一个时间段内没有收到worker的反馈,那么就会判定该worker失效了。而之前已经安排给这个worker的任务呢,则判定为没有完成,置为初始的空闲状态,可以分配给其他的worker去处理。

假设有worker Aworker B,worker A工作到一般崩溃了,worker A的任务由worker B来接手。此时worker A的map任务生成的中间结果集是存储在worker A本地的,但由于worker A已经崩溃无法访问,所以worker B需要重新执行worker A已经执行过的map任务来生成中间结果集,并且通知其他的执行reduce的worker不要再读取worker A的数据了,都来读取worker B的数据;而如果是worker A的reduce任务,由于生成的输出集已经存储到全局的文件系统,所以无需再次执行。

1.4.2 master故障

master的任务失败则可以通过定期地记录checkpoint每隔一段时间记录master的数据)来解决,也就是说如果当前的master任务失败了,可以读取最近一次的checkpoint来启动另一个master进程。当然,由于只有一个master,当master失败了那么需要告知客户端master当前不可用,mapreduce中止,客户端可以根据情况重新执行。

1.5 存储

在上述实现当中,需要对中间结果和输出集进行存储,而在大型集群当中,网络带宽是较为缺乏的资源,为了防止网络带宽成为性能上的瓶颈,通常会采用存储在本地磁盘来节省网络带宽资源。MapReduce的存储基于的是GFS(Google File System)文件系统,基本的实现是把文件分成64MB的一些块,每个块会有副本存储在不同的机器上已保证数据安全。当MapReduce的worker处理任务失败了,master会就近安排一个worker机器来执行,保证资源消耗较少。

2.动手写MapReduce

MIT6.824 Lab1 MapReduce 地址:pdos.csail.mit.edu/6.824/labs/…

2.1 环境搭建

2.1.1 Linux环境

使用的环境采用的WSL(Windows System for Linux),ubuntu16.04搭配xshell使用。

WSL配置和使用参考文章:zhuanlan.zhihu.com/p/90173113

搭配xshell使用参考文章:www.jianshu.com/p/039411d2c…

如果重启后遇到xshell连接不上ubuntu的问题可以重启ssh后重试,即打开ubuntu后输入

sudo service ssh --full-restart

2.1.2 go语言环境

Golang官网下载:golang.org/dl/

1)打开官网下载对应版本,我这里下载的是1.14.4

2)在ubunut~下建个go文件夹,下载压缩包

mkdir ~/go
cd ~/go
wget https://dl.google.com/go/go1.14.4.linux-amd64.tar.gz

3)将压缩包解压缩到/usr/local目录下

tar -C /usr/local -zxvf  go1.14.4.linux-amd64.tar.gz

4)添加/usr/loacl/go/bin目录到PATH变量中,添加到/etc/profile$HOME/.profile,前者是为系统的每个用户进行配置,后者则只配置当前用户,默认情况下配置环境变量执行后者。

vim ~/.bashrc
# 在最后一行添加
export GOROOT=/usr/local/go
export PATH=$PATH:$GOROOT/bin
# 保存退出后生效配置文件
source ~/.bashrc

2.2 WordCount——第一个MapReduce程序

根据前面我们对MapReduce的一个基本了解,一个MapReduce程序主要由三个部分组成:Map程序、Reduce程序和调度MapReduce的程序。

接下来将由MIT6.824课程提供的代码示例开始编写我们的第一个MapReduce程序。

2.2.1 示例代码——串行实现

1)我们首先可以先将示例代码clone下来

$ git clone git://g.csail.mit.edu/6.824-golabs-2020 6.824
$ cd 6.824

2)接下来看一下目录结构

src 
├─mrapps
│   ├─wc.go
│   ├─ indexer.go
│   └─...
├─main
│   ├─mrsequential.go
│   ├─mrmaster.go
|   ├─mrworker.go
|   ├─pg*.txt
|   └─...
├─mr
    ├─master.go
    ├─worker.go
    └─rpc.go
  • mrapps:包括一些已经编写好map和reduce程序的示例代码,如wc.go是计数程序,indexer.go是一个文本索引器程序
  • main:包括一个串行调度mapreduce的程序mrsequential.go以及一些输入数据pg*.go
  • mr:我们包括了mster.go、worker.go、rpc.go,是我们需要实现的并行版本的mapreduce调度器

3)试试执行一下串行版本的调度器实现计数程序

$ cd ~/6.824
$ cd src/main
$ go build -buildmode=plugin ../mrapps/wc.go
$ rm mr-out*
$ go run mrsequential.go wc.so pg*.txt
$ more mr-out-0

// go build -buildmode=plugin ../mrapps/wc.go这段会执行构建一个插件wc.so以便mrsequential.go调用。
// go run mrsequential.go wc.so pg*.txt表示编译并运行mrsequential.go。其中wc.so是插件,pg*是输入数据集。
// more mr-out-0可以查看输出数据集,其中mr-out-0即输出数据。

ps:如果想要简单地了解go语言的话可以上 tour.go-zh.org/list 这个网站。这个网站相当于一个简单的go语言指南网站,看完差不多只要花个2小时,就能有个初步的了解。

4)执行了上面的语句之后一定很好奇代码层面是怎么实现的!先来看看wc.go计数程序里map和reduce的实现。

// wc.go

// map程序
// map程序对于每一个输入文件会执行一次
// 第一个参数是输入文件的名称,第二个参数是输入文件的完整内容
// 输出是key/value对的切片集合(相当于数组,数组里每一个对象是key/value对)
func Map(filename string, contents string) []mr.KeyValue {
    // 定义一个方法,判断输入r是否是一个字母字符(可以用来切分文章中的单词遇到空格返回false)
    // 输入是一个rune的数据类型(等同于int32,常用来处理unicode或utf-8字符)
    // 返回一个布尔值,r是字母字符则返回false,否则返回true
    ff := func(r rune) bool { return !unicode.IsLetter(r) }
    
    // 将文章内容切分成单词数组
    // 例如"hello world"就会切分成["hello" "world"]
    // FieldsFunc是一个可以按照自定义规则切分字符的函数
    words := strings.FieldsFunc(contents, ff)
    
    // 生成一个key/value对集合,其中key是文章中的单词,value都为1
    kva := []mr.KeyValue{}
    for _, w := range words {
        kv := mr.KeyValue{w, "1"}
        kva = append(kva, kv)
    }
    return kva
}
// reduce程序
// 所有map任务生成的中间结果集会作为reduce的入参被执行一次
// 入参key是单词,values是key出现次数的集合,values中每一个的值都为1,所以values的大小就是单词key出现的次数
// 可参考下图:
// Input1 -> Map -> a,1 b,1 c,1
// Input2 -> Map ->     b,1
// Input3 -> Map -> a,1     c,1
//                  |   |   |
//                  |   |   -> Reduce -> c,2
//                  |   -----> Reduce -> b,2
//                  ---------> Reduce -> a,2
func Reduce(key string, values []string) string {
    // strconv函数将int转化为string返回 
    return strconv.Itoa(len(values))
}

接下来看mrsequential.go调度程序

// mrsequential.go

// ByKey是用于将中间结果集进行排序用的
// 排序后key相同的就会排在一起,就方便作为reduce的入参
type ByKey []mr.KeyValue

func (a ByKey) Len() int           { return len(a) }
func (a ByKey) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByKey) Less(i, j int) bool { return a[i].Key < a[j].Key }

func main() {
    
    // 运行时的参数个数若少于3则报错并退出程序
    // 在go run mrsequential.go ../mrapps/wc.so pg*.txt中
    // 参数0:mrsequential.go
    // 参数1:wc.so
    // 参数2:pg*.txt(这里其实输入文件算是多个参数)
    if len(os.Args) < 3 {
        fmt.Fprintf(os.Stderr, "Usage: mrsequential ../mrapps/xxx.so inputfiles...\n")
	    os.Exit(1)
	}

    // --------------------------map任务开始--------------------------
    // 从参数1(wc.so)中读取map程序和reduce程序
    mapf, reducef := loadPlugin(os.Args[1])
    
    // 读取每一个文件作为map程序的入参,并输出中间结果
    intermediate := []mr.KeyValue{}
    for _, filename := range os.Args[2:] {
        // 打开文件
        file, err := os.Open(filename)
        if err != nil {
            log.Fatalf("cannot open %v", filename)
        }
        // 读取文件内容
        content, err := ioutil.ReadAll(file)
        if err != nil {
            log.Fatalf("cannot read %v", filename)
        }
        file.Close()
        // 输入map程序
        kva := mapf(filename, string(content))
        // 中间结果
        intermediate = append(intermediate, kva...)
    }
    // --------------------------map任务结束--------------------------

    // 对中间结果进行排序
    sort.Sort(ByKey(intermediate))
    
    // 创建输出文件mr-out-0
    oname := "mr-out-0"
    ofile, _ := os.Create(oname)
    
    // --------------------------reduce任务开始--------------------------
    // 由于中间结果集是有序的,所以相同的key/value对会连续放置在一起
    // 只需要将key相同的中间结果集作为reduce程序的输入即可
    i := 0
    for i < len(intermediate) {
        // i表示key相同的单词的第一个的位置
        // j表示key相同的单词的最后一个的后一位
        j := i + 1
        for j < len(intermediate) && intermediate[j].Key == intermediate[i].Key {
            j++
        }
        values := []string{}
        // 遍历从i到j之间的key/value对,全部都是同样的key并且value都为1
        // 并作为reduce的入参
        for k := i; k < j; k++ {
            values = append(values, intermediate[k].Value)
        }
        output := reducef(intermediate[i].Key, values)

        // 输出reduce的结果到mr-out-0文件中
        fmt.Fprintf(ofile, "%v %v\n", intermediate[i].Key, output)

        i = j
	}
    // --------------------------reduce任务结束--------------------------

    ofile.Close()
}

// 加载插件中的方法
// 入参插件文件名
// 返回值为map方法和reduce方法
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)
    }
    // 查找插件中Map方法,并赋给mapf
    xmapf, err := p.Lookup("Map")
    if err != nil {
        log.Fatalf("cannot find Map in %v", filename)
    }
    mapf := xmapf.(func(string, string) []mr.KeyValue)
    
    // 查找插件中的Reduce方法并赋给reducef
    xreducef, err := p.Lookup("Reduce")
    if err != nil {
        log.Fatalf("cannot find Reduce in %v", filename)
    }
    reducef := xreducef.(func(string, []string) string)
    
    //返回map和reduce方法
    return mapf, reducef
}

2.2.2 分布式场景实现——实现master和worker

大家可以看到上面的代码就是一个简单串行的实现。但mapreduce的强大在于分布式场景下master和worker的实现,接下来我们通过master和worker的实现来展示。

交互演示如下:

可以看到一个基本思路是:

  • worker和master之间采用RPC数据传输来进行交互,其中的交互有:
    • reqTask<-->HandleTaskReq:worker向master请求任务,任务中应该包含**任务的阶段(map/reduce)、任务的编号、输入文件名、输入文件的总个数、reduce处理的总个数(中间结果集要分成几个reduce任务来进行处理)**等信息。
    • reportTask<-->HandleTask:worker处理完map/reduce任务后,向master报告任务处理情况,返回的信息只需要包含任务编号和是否完成标志即可。
  • master首先启动需要进行一系列的初始化操作:
    • 初始化任务信息。
    • 将任务放入任务队列中。
    • 开启RPC监听。
  • master会监听worker的RPC请求,并进行相应的处理:
    • HandleTaskReq:处理worker的请求任务RPC调用,会将任务从任务队列中取出发送给worker。并在本地记录任务状态执行中,以及记录任务开始执行的时间用于worker处理超时情况下可以将任务重新放回任务队列中。
    • HandleTaskReport:处理worker的报告任务情况RPC调用,判断任务是否完成,若完成则将本地记录任务状态改为已完成,否则将任务重新放回任务队列中,并将本地记录任务状态改为队列中。
  • master需要有一个定时任务检测任务运行情况,包括:
    • 任务是否超时,若超时需要将任务重新放入任务队列中。
    • map阶段的任务是否已全部完成,若完成则进入reduce阶段,并进行reduce阶段初始化操作。
    • reduce阶段的任务是否已全部完成,若完成则结束。

2.2.3 具体代码实现

让我们开始编写程序,其中包括几个程序文件,分别是:

  • /mr/rpc.go——RPC请求传输文件的定义

  • /main/mrmaster.go——master启动程序

  • /main/mrworker.go——worker启动程序

  • /mr/master.go——master的具体实现

  • /mr/worker.go——worker的具体实现

首先需要对RPC请求结构进行定义,在这里我们需要定义的有三个部分:任务报文、请求任务报文、报告任务报文,定义如下:

// rpc.go

type TaskStatus string
type TaskPhase string
type TimeDuration time.Duration

// 任务状态常量
const (
    // 就绪
	TaskStatusReady   TaskStatus = "ready"
	// 队列中
    TaskStatusQueue   TaskStatus = "queue"
    // 执行中
	TaskStatusRunning TaskStatus = "running"
	// 已完成
    TaskStatusFinish  TaskStatus = "finish"
	// 任务错误
    TaskStatusErr     TaskStatus = "error"
)

//任务阶段常量
const (
	MapPhase    TaskPhase = "map"
	ReducePhase TaskPhase = "reduce"
)

// 任务定义
type Task struct {
	// 操作阶段:map/reduce
	TaskPhase TaskPhase
	// map个数
	MapNum int
	// reduce个数
	ReduceNum int
	// 任务序号
	TaskIndex int
	// 文件名
	FileName string
	// 是否完成
	IsDone bool
}

// 请求任务参数
type ReqTaskArgs struct {
	// 当前worker存活,可以执行任务
	WorkerStatus bool
}

// 请求任务返回值
type ReqTaskReply struct {
	// 返回一个任务
	Task Task
	// 是否完成所有任务
	TaskDone bool
}

// 报告任务参数
type ReportTaskArgs struct {
	// 当前worker存活,可以执行任务
	WorkerStatus bool
	// 任务序号
	TaskIndex int
	// 是否完成
	IsDone bool
}

// 报告任务返回值
type ReportTaskReply struct {
	// master响应是否处理成功
	MasterAck bool
}

mrmastermrworker分别是master和worker的启动器:

  • mrmaster会调用/mr/master.goMakeMaster()来启动master,输入参数为文件名集合和nReduce(表示map阶段应该分割中间key至nReduce个reduce任务)。启动master后循环调用Done()判断任务是否完成。
  • mrworker会加载map和reduce函数传入Worker()中来启动worker。

master实现思路:

  • 定义master的结构
    • 任务队列,用于控制任务的请求
    • 记录输入文件集合
    • 维护map/reduce数目
    • 任务阶段,用于标志任务进行阶段
    • 任务状态,用于记录全局任务状态
    • 互斥锁,用于资源锁定
  • 了解Go语言中RPC的实现:通过定义func (m *Master) method(args *Args, reply *Reply) error 来实现,当监听到RPC请求后,会根据方法名进入到对应的方法中。实现任务请求和任务报告的处理函数即可。
  • Done()函数中实现对不同场景的判断,需要注意的是,Go语言中的switch是不需要填写break的,默认会break。

worker实现思路:

  • 实现主线程,循环请求任务、执行任务、报告任务。
  • 实现请求任务和执行任务的RPC调用。
  • 实现map任务执行程序,需要根据key值输出到nReduce个文件中。文件名的格式为mr-x-y,x为map任务编号,y为ihash(key)%nreduce后的值。另外,中间文件的存储可以采用JSON格式。
  • 实现reduce任务执行程序,同map任务执行类似,读取文件内容后,对key值进行排序,执行reduce函数。

核心实现如下:

// master.go

// 任务状态定义
type TaskState struct {
	// 状态
	Status TaskStatus
	// 开始执行时间
	StartTime time.Time
}

// Master结构定义
type Master struct {
	// 任务队列
	TaskChan chan Task
	// 输入文件
	Files []string
	// map数目
	MapNum int
	// reduce数目
	ReduceNum int
	// 任务阶段
	TaskPhase TaskPhase
	// 任务状态
	TaskState []TaskState
	// 互斥锁
	Mutex sync.Mutex
	// 是否完成
	IsDone bool
}

// 启动Master
func MakeMaster(files []string, nReduce int) *Master {
	m := Master{}

	// 初始化Master
	m.IsDone = false
	m.Files = files
	m.MapNum = len(files)
	m.ReduceNum = nReduce
	m.TaskPhase = MapPhase
	m.TaskState = make([]TaskState, m.MapNum)
	m.TaskChan = make(chan Task, 10)
	for k := range m.TaskState {
		m.TaskState[k].Status = TaskStatusReady
	}

	// 开启线程监听
	m.server()

	return &m
}

// 启动一个线程监听worker.go的RPC请求
func (m *Master) server() {
	rpc.Register(m)
	rpc.HandleHTTP()
	//l, e := net.Listen("tcp", "127.0.0.1:1234")
	os.Remove("mr-socket")
	l, e := net.Listen("unix", "mr-socket")
	if e != nil {
		log.Fatal("listen error:", e)
	}
	go http.Serve(l, nil)
}

// 处理任务请求
func (m *Master) HandleTaskReq(args *ReqTaskArgs, reply *ReqTaskReply) error {
	fmt.Println("开始处理任务请求...")
	if !args.WorkerStatus {
		return errors.New("当前worker已下线")
	}
	// 任务出队列
	task, ok := <-m.TaskChan
	if ok == true {
		reply.Task = task
		// 任务状态置为执行中
		m.TaskState[task.TaskIndex].Status = TaskStatusRunning
		// 记录任务开始执行时间
		m.TaskState[task.TaskIndex].StartTime = time.Now()
	} else {
		// 若队列中已经没有任务,则任务全部完成,结束
		reply.TaskDone = true
	}
	return nil
}

// 处理任务报告
func (m *Master) HandleTaskReport(args *ReportTaskArgs, reply *ReportTaskReply) error {
	fmt.Println("开始处理任务报告...")
	if !args.WorkerStatus {
		reply.MasterAck = false
		return errors.New("当前worker已下线")
	}
	if args.IsDone == true {
		// 任务已完成
		m.TaskState[args.TaskIndex].Status = TaskStatusFinish
	} else {
		// 任务执行错误
		m.TaskState[args.TaskIndex].Status = TaskStatusErr
	}
	reply.MasterAck = true
	return nil
}

// 循环调用 Done() 来判定任务是否完成
func (m *Master) Done() bool {
	ret := false

	finished := true
	m.Mutex.Lock()
	defer m.Mutex.Unlock()
	for key, ts := range m.TaskState {
		switch ts.Status {
		case TaskStatusReady:
			// 任务就绪
			finished = false
			m.addTask(key)
		case TaskStatusQueue:
			// 任务队列中
			finished = false
		case TaskStatusRunning:
			// 任务执行中
			finished = false
			m.checkTask(key)
		case TaskStatusFinish:
			// 任务已完成
		case TaskStatusErr:
			// 任务错误
			finished = false
			m.addTask(key)
		default:
			panic("任务状态异常...")
		}
	}
	// 任务完成
	if finished {
		// 判断阶段
		// map则初始化reduce阶段
		// reduce则结束
		if m.TaskPhase == MapPhase {
			m.initReduceTask()
		} else {
			m.IsDone = true
			close(m.TaskChan)
		}
	} else {
		m.IsDone = false
	}
	ret = m.IsDone
	return ret
}

// 初始化reduce阶段
func (m *Master) initReduceTask() {
	m.TaskPhase = ReducePhase
	m.IsDone = false
	m.TaskState = make([]TaskState, m.ReduceNum)
	for k := range m.TaskState {
		m.TaskState[k].Status = TaskStatusReady
	}
}

// 将任务放入任务队列中
func (m *Master) addTask(taskIndex int) {
	// 构造任务信息
	m.TaskState[taskIndex].Status = TaskStatusQueue
	task := Task{
		FileName:  "",
		MapNum:    len(m.Files),
		ReduceNum: m.ReduceNum,
		TaskIndex: taskIndex,
		TaskPhase: m.TaskPhase,
		IsDone:    false,
	}
	if m.TaskPhase == MapPhase {
		task.FileName = m.Files[taskIndex]
	}
	// 放入任务队列
	m.TaskChan <- task
}

// 检查任务处理是否超时
func (m *Master) checkTask(taskIndex int) {
	timeDuration := time.Now().Sub(m.TaskState[taskIndex].StartTime)
	if timeDuration > MaxTaskRunTime {
		// 任务超时重新加入队列
		m.addTask(taskIndex)
	}
}

// worker.go

type KeyValue struct {
	Key   string
	Value string
}

// use ihash(key) % NReduce to choose the reduce
// task number for each KeyValue emitted by MapPhase.
func ihash(key string) int {
	h := fnv.New32a()
	h.Write([]byte(key))
	return int(h.Sum32() & 0x7fffffff)
}

// Worker 主线程,循环请求任务以及报告任务
func Worker(mapf func(string, string) []KeyValue,
	reducef func(string, []string) string) {

	for {
		// 请求任务
		reply := ReqTaskReply{}
		reply = reqTask()
		if reply.TaskDone {
			break
		}
		// 执行任务
		err := doTask(mapf, reducef, reply.Task)
		if err != nil {
			reportTask(reply.Task.TaskIndex, false)
		}
		// 报告任务结果
		reportTask(reply.Task.TaskIndex, true)
	}
	return
}

// 请求任务
func reqTask() ReqTaskReply {
	// 声明参数并赋值
	args := ReqTaskArgs{}
	args.WorkerStatus = true

	reply := ReqTaskReply{}

	// RPC调用
	if ok := call("Master.HandleTaskReq", &args, &reply); !ok {
		log.Fatal("请求任务失败...")
	}

	return reply
}

// 报告任务结果
func reportTask(taskIndex int, isDone bool) ReportTaskReply {
	// 声明参数并赋值
	args := ReportTaskArgs{}
	args.IsDone = isDone
	args.TaskIndex = taskIndex
	args.WorkerStatus = true

	reply := ReportTaskReply{}

	// RPC调用
	if ok := call("Master.HandleTaskReport", &args, &reply); !ok {
		log.Fatal("报告任务失败...")
	}
	return reply

}

// 执行任务
func doTask(mapf func(string, string) []KeyValue, reducef func(string, []string) string, task Task) error {
	if task.TaskPhase == MapPhase {
		err := DoMapTask(mapf, task.FileName, task.TaskIndex, task.ReduceNum)
		return err
	} else if task.TaskPhase == ReducePhase {
		err := DoReduceTask(reducef, task.MapNum, task.TaskIndex)
		return err
	} else {
		log.Fatal("请求任务的任务阶段返回值异常...")
		return errors.New("请求任务的任务阶段返回值异常")
	}
	return nil
}

// 执行map任务
func DoMapTask(mapf func(string, string) []KeyValue, fileName string, mapTaskIndex int, reduceNum int) error {

	fmt.Println("开始处理Map任务...")
	// 打开文件
	file, err := os.Open(fileName)
	if err != nil {
		log.Fatalf("cannot open %v", fileName)
		return err
	}
	// 读取文件内容
	content, err := ioutil.ReadAll(file)
	if err != nil {
		log.Fatalf("cannot read %v", fileName)
		return err
	}
	file.Close()
	// 输入map程序
	kva := mapf(fileName, string(content))
	for i := 0; i < reduceNum; i++ {
		// 中间输出文件名mr-X-Y
		intermediateFileName := intermediateName(mapTaskIndex, i)
		fmt.Printf("doMap文件名%s创建\n", intermediateFileName)
		// 创建中间输出文件,并存储为JSON格式
		file, _ := os.Create(intermediateFileName)
		enc := json.NewEncoder(file)
		for _, kv := range kva {
			if ihash(kv.Key)%reduceNum == i {
				enc.Encode(&kv)
			}
		}
		file.Close()
	}
	return nil
}

// 执行reduce任务
func DoReduceTask(reducef func(string, []string) string, mapNum int, reduceTaskIndex int) error {
	fmt.Println("开始处理Reduce任务...")
	// map:string->[]string
	res := make(map[string][]string)
	for i := 0; i < mapNum; i++ {
		// 打开中间文件
		intermediateFileName := intermediateName(i, reduceTaskIndex)
		file, err := os.Open(intermediateFileName)
		if err != nil {
			log.Fatalf("cannot open %v", intermediateFileName)
			return err
		}
		// 反序列化JSON格式文件
		dec := json.NewDecoder(file)
		// 读取文件内容
		for {
			var kv KeyValue
			err := dec.Decode(&kv)
			if err != nil {
				break
			}
			_, ok := res[kv.Key]
			if !ok {
				res[kv.Key] = make([]string, 0)
			}
			res[kv.Key] = append(res[kv.Key], kv.Value)
		}
		file.Close()
	}
	// 提取key值,用于排序
	var keys []string
	for k := range res {
		keys = append(keys, k)
	}
	// key值排序
	sort.Strings(keys)
	outputFileName := outputName(reduceTaskIndex)
	fmt.Printf("doReduce输出%s文件名\n", outputFileName)
	outputFile, _ := os.Create(outputFileName)
	for _, k := range keys {
		output := reducef(k, res[k])
		// 输出reduce的结果到mr-out-X文件中
		fmt.Fprintf(outputFile, "%v %v\n", k, output)
	}
	outputFile.Close()

	return nil
}

最后,可以通过测试脚本(/main/test-mr.sh)来对程序进行验证,或者通过运行程序来验证。

// 脚本验证
$ cd ~/6.824
$ cd src/main
$ sh test-mr.sh
// 运行程序验证
$ cd ~/6.824
$ cd src/main
$ go build -buildmode=plugin ../mrapps/wc.go
$ rm mr-out*
$ go run mrmaster.go pg*.txt
$ go run mrworker.go ../mrapps/wc.so

// 程序运行结束后查看结果
$ cat mr-out-* | sort | more

总结

以上,对初此学习mapreduce进行了个总结记录,期间阅读了论文,学习了整个mapreduce的思想,体会到了分布式的魅力,并由浅入深地学习了golang,最终完成了Lab1,还是挺有成就感的。最后,有任何疑惑或错误欢迎指正交流!

附上代码实现地址:

代码实现github地址:github.com/Januslll/mi…