MIT6.824-Lab1-Cutomized and Simplified MapReduce Library

简介

Mit6.824主要教授分布式系统(Distributed System)相关的知识,提供了多个实验。记录此系列作为教程。
以mit6.824 2022 Spring的课程作为示例。下面链接是课程安排
http://nil.csail.mit.edu/6.824/2022/schedule.html
注意,课程中自带了很多资料,如QA页、程序示例、授课视频等,但是视频在youtube上。b站也有翻译的。

本文

网上大多数教程都是急功近利,直接根据代码讲实现。本文从做实验的步骤,手把手教你完成mit6.824.
做实验肯定要学些东西。本文相较于网上直接提供代码来说,更看重整体实验步骤的讲解和思路的讲解。
如果你想直接要代码放简历上,可以马上在git中拉取到我的代码:
https://github.com/CoderOFLee/DHS_mit_course/tree/master

Lab1

Lab1是要求完成自定义的mapreduce库。完成该实验前,首先应该了解mapreduce基本框架。
https://blog.csdn.net/m0_51173898/article/details/137092578?spm=1001.2014.3001.5502

1. Guidance

要完成一个实验,首先要看实验手册,实验手册里面有很多内容和参考意见。
http://nil.csail.mit.edu/6.824/2022/labs/lab-mr.html\

环境装配

Go

  1. 简介
    2022年的课程开始要求使用Go作为开发语言。Go有几个良好的特性可以辅助DS的开发。
    • 并发友好: go对于线程的友好性能够帮助开发者极快速地开发
    • rpc库封装良好:go中有对rpc良好封装的标准库,开发rpc接口十分便捷
    • 垃圾回收机制:go的垃圾回收器能够简化开发者对于内存泄漏的关注度,降低开发难度。
  2. 安装
    https://blog.csdn.net/m0_51173898/article/details/137092676?spm=1001.2014.3001.5502

Ubuntu

  1. 简介
    实验要求在Linux或者Unix环境下进行测试和运行。但是国内的开发同学们,大多都用的windows系统吧hh。这儿提供一种虚拟机的配置方法。
  2. 虚拟机
    https://blog.csdn.net/weixin_43525386/article/details/108920902

Ubuntu上的go

使用go version激活apt,使用显示的sudo apt命令就可以下载了。

拉取实验代码

git clone git://g.csail.mit.edu/6.824-golabs-2022 6.824

串行测试

go build -race -buildmode=plugin ../mrapps/wc.go
go run -race mrcoordinator.go pg-*.txt

注意,如果你在windowns上使用,这个命令是无效的。因为go的plugin功能还不支持在windows上使用。
但是在Linux上调试十分麻烦,所以需要进行下述步骤:

  1. 将mrapps下所有文件的包名改为mrapps,并且按照提示把所有重复函数进行重命名。
  2. 将main/mrworker.go中的mapf和reducef显示配置。
  3. 将rpc的接口换成提供的tcp接口,而非socket接口
  4. 使用如下命令执行
go run -race mrcoordinator.go pg-*.txt

YourJob

实验指导这一part中已经很清晰的给出了思路。先将原文贴在这儿:

Your job is to implement a distributed MapReduce, consisting of two programs, the coordinator and the worker. There will be just one coordinator process, and one or more worker processes executing in parallel. In a real system the workers would run on a bunch of different machines, but for this lab you'll run them all on a single machine. The workers will talk to the coordinator via RPC. Each worker process will ask the coordinator for a task, read the task's input from one or more files, execute the task, and write the task's output to one or more files. The coordinator should notice if a worker hasn't completed its task in a reasonable amount of time (for this lab, use ten seconds), and give the same task to a different worker.

这一段的意思就是,每个worker都需要通过rpc接口向coordinator索要任务。Coordinator应该能够注意到straggler的存在,也就是说任务超时可重新分配。
这一段很清晰的指出,coordinator并不需要知道每个worker之间的拓扑关系,也不用实验远程文件读取的RPC接口,简化了MapReduce文章中的实现。
所以就有了如下思路。
根据软工开发模型,可以对问题中进行对象分类。分别是Coordinator和Worker。下面从这两类进行设计。

From Code Sample

实验代码框架中提供了部分实验代码。分析其调用流程,才能知晓每个文件都是在干嘛.
根据脚本test-mr.sh中可以看到,它启动了一个mrcoordinator和若干个mrworker。查看两个文件,可以看到这两个的主体是从plugin插件中找到map和reduce函数,然后调用mr/mrcoordinator 和 mr/mrworker。
步入这两个文件,可以看到是coordinator和worker的实现函数,整个实验要补充的就是这两块区域。

2. Coordinator

What to do

首先要知晓Coordinator的职责所在————接收worker的任务索要请求并返回任务,接收worker执行完毕的信号并处理人物列表。其他的什么超时昂,都是基于此。\

What to save

任务(TaskAll)

知道了职责所在,就可以设计数据结构了。要完成派发任务的职责,那么就需要存储这些任务。每个任务里面需要些什么才能完成整个流程呢?首先能想到的就是类型(Type)、状态(Status)和标识号(ID)
任务的类型无非就是Map和Reduce两种,这两种任务的本质都是,读取文件、处理内容、写入结果。Map函数只读取一个文件,即框架中的slice,而Reduce函数需要读取很多个文件,即每个map-worker生成的对应partiton的文件。所以一个任务还需要存储对应的文件位置(Files)
在实验要求中,还要求Coordinator能够对straggler进行判定,那么我们还需要对每个任务记录它被分配的时间(start),来方便后续判定是否超时。
综上,可以定义任务的数据类型:

type TaskAll struct {
	TaskType int
	TaskID   int
	Filzaie  []string
	status   int
	start    int64
}

协调者(Coordinator)

协调者需要分配任务,那他就必须在确定的内存中存储一个 任务列表(tasks) ,才能完成这些任务。
由于worker是并行的,那么很可能同时接收到多个任务索要请求。如果在处理一个请求的图中被另一个抢占CPU,那么就无法保证分配任务的正确分配。所以还需要一个 互斥锁(mu) 来保证原子性。
Reduce通常是在Map任务全部完成之后才能开始执行,所以Coordinator还需要记录一下当前整个系统的运行 阶段(phase)
对于每个阶段,Coordinator需要继续下该阶段总的 任务数(total),以及当前 已完成(done) 的,才能进行阶段转换。
综上,Coordinator的结构如下:

type Coordinator struct {
	tasks []TaskAll
	mu    sync.Mutex
	phase int
	done  int
	total []int
}

3. Worker

工作者(Worker)需要不断通过RPC向协调者索要任务,然后执行map或者reduce函数。这个职责看上去并不需要存储数据,所以工作者只需要每次正确地处理好分配的任务就可以。

4. RPC

RPC并不是一个对象,它在整个系统运作中充当一个通信通道的作用。根据上面协调者和工作者之间的交流,可以定义下面几个RPC接口和包。在定义的时候,相当于只需要关注接口名、请求包结构和回复包结构。

Get

Get接口是Coordinator提供给Worker的索要任务的接口。

  1. 请求包
    不重要的,可以使用过一个统一的dummy结构体。但是这儿可以扩展,可以提交自己的ID。但是在这个实验中没有必要。
  2. 回复包
    Coordinator需要根据task的分配情况返回一个任务。在上述协调者的设计中,确定了TaskAll的结构体,它包含了一个任务的所有信息。但是Worker并不需要所有的任务信息,也需要任务以外的信息,所以需要重新设计包。
    不论是Map任务还是Reduce任务,都需要 任务ID、任务类型、文件位置 三个域的信息。如果是Map任务,还额外需要清晰 Reduce任务的个数 来进行洗牌分区(Shuffle & Partition)。可以定义一个Task的结构如下:
type Task struct {
	TaskType int
	TaskID   int
	File     []string
	Reducer  int
}

Finish

Finish接口是Coordinator提供给Worker的告知任务已完成的接口。

  1. 请求包
    Woker要向Coordinator提供足够的信息,让其知晓是哪个任务完成了( ID )、任务的输出在哪儿(File)。所以可以定义请求结构体如下:
type Finish struct {
	TaskID int
	File   []string
}
  1. 回复包
    提升健壮性可以增加回复机制,但是这个实验中没必要。使用同一的dummy结构体代替。

5. Implementation

通过上述的抽象设计,现在需要对每个抽象开发实例化的函数。

初始化协调者(mr/coordinator.go/MakeCoordinator)

这个函数初始化了Coordinator结构体。通过如下进行实现,每一行的作用写在注释中。 注意,Reduce任务和Map任务的ID是各自为序的。

func MakeCoordinator(files []string, nReduce int) *Coordinator {
	c := Coordinator{}
	c.mu = sync.Mutex{}
	// 添加Map任务,有多少个输入文件,就有多少个map任务,每个文件都是一个slice
	for i := 0; i < len(files); i++ {
		c.tasks = append(c.tasks, TaskAll{MAP, i, []string{files[i]}, UNDO, 0})
	}
	// 添加Reduce任务,有多少个Reduce分区,就有多少个Reduce任务
	for i := 0; i < nReduce; i++ {
		c.tasks = append(c.tasks, TaskAll{REDUCE, i, []string{}, UNDO, 0})
	}
	// 初始化执行阶段为Map
	c.phase = PhaseMAP
	c.done = 0
	c.total = []int{len(files), nReduce}
	// 完成coordinator的rpc接口注册
	c.server()
	return &c
}

初始化工作者(mr/worker.go/Worker)

Worker一旦启动,就分为两个状态,空闲和运行中。空闲的时候,需要不断向Coordinator索要任务,运行中则是在执行map/reduce任务。只要没有发生crash和程序错误,Worker就应该不断地往运行中的状态尝试转换。
但是Coordinator可能把任务分配完了,此时Worker再索要就没有任务可分配了,那Worker就可以直接退出…………了吗?
显然是不能的。因为任务可能超时,也有可能执行任务的Worker宕掉了。所以worker应该在一个死循环中,不断地索要任务,有任务就执行,执行完毕后继续索要。所以该函数可以定义如下:

func Worker(mapf func(string, string) []KeyValue,
	reducef func(string, []string) string) {
	for {
		code := worker(mapf, reducef)
		if code == -1 {
			// worker发生了错误
			os.Exit(-1)
		} else if code == 0 {
			// 所有任务都完成了,包括reduce任务,整个任务都结束了
			break
		} else if code == 2 {
			// 没有任务,所有任务都被分配了,等待一段时间再索要
			time.Sleep(500 * time.Millisecond)
		} else { // code == 1
			// 正常退出,可以马上索要任务,也可以休息一会
		}
	}
}

任务索要RPC接口(mr/coordinator.go/Get)

这个接口需要复杂操作,先分段进行解释,在小节末给出完成代码。\

首先要进行上锁,保证原子性。

c.mu.Lock()
defer c.mu.Unlock()

在分配任务时,由于每个任务完成的时间是不确定的,后分发的任务可能先完成,所以不能根据一个简单的位置标识来进行派发,每次都要遍历整个任务列表。由于所有任务都被初始化在任务列表中,需要根据不同的阶段来确定不同的范围。

total := c.total[c.phase]
offset := 0
if c.phase == PhaseReduce {
	offset = c.total[PhaseMAP]
}
for i := 0; i < total; i++ {
	// 注意:这儿角标i已经被修改,需要在退出循环的时候改变回去
	// i就相当于各自为序的TaskID,加上offset就是在任务列表中的位置
	i += offset
	...
	i -= offset
}

对于遍历到的任务,需要对他的状态进行判定。任务状态无非就三个:UNDO(未分配)、WAITING(待完成)、DONE(已完成)。对于已完成的任务直接跳过,未分配的直接分配,待完成的是否需要分配?这儿就涉及到容错。实验要求协调者能够完成对straggler的解决,所以待完成的任务,我们需要判定其是否超时,如果超时,直接分配。

if (c.tasks[i].status == UNDO) ||
   (c.tasks[i].status == WAITING &&
    time.Now().Unix()-c.tasks[i].start >= TimeLimit) {
	resp.TaskID = c.tasks[i].TaskID
	resp.TaskType = c.tasks[i].TaskType
	resp.Reducer = c.total[PhaseReduce]
	resp.File = c.tasks[i].File
	// recorde info
	c.tasks[i].status = WAITING
	c.tasks[i].start = time.Now().Unix()
}

可能你会有疑惑。那如果straggler在新worker还没完成的时候就完成了怎么办?coordinator只考虑首先回复finish的那一位。由于真正的MR是运行在不同主机上的,所以中间文件的存储位置也不同。所以不会出现文件在同一主机中反复改写的问题。

对所有任务遍历完都没有找到可以分配的任务的话,就需要返回一个特定设计的任务类型了。

resp.TaskID = -1
	resp.TaskType = NONE
	resp.Reducer = -1
	resp.File = []string{}

完整代码如下:

func (c *Coordinator) Get(arg Ignore, resp *Task) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	total := c.total[c.phase]
	offset := 0
	if c.phase == PhaseReduce {
		offset = c.total[PhaseMAP]
	}
	for i := 0; i < total; i++ {
		i += offset
		if (c.tasks[i].status == UNDO) ||
			(c.tasks[i].status == WAITING &&
				time.Now().Unix()-c.tasks[i].start >= TimeLimit) {
			resp.TaskID = c.tasks[i].TaskID
			resp.TaskType = c.tasks[i].TaskType
			resp.Reducer = c.total[PhaseReduce]
			resp.File = c.tasks[i].File
			c.tasks[i].status = WAITING
			c.tasks[i].start = time.Now().Unix()
			return nil
		}
		i -= offset
	}
	resp.TaskID = -1
	resp.TaskType = NONE
	resp.Reducer = -1
	resp.File = []string{}
	return nil
}

任务完成告知RPC接口(mr/coordinator.go/Finish)

仍然需要加锁,然后递增当前阶段完成任务计数器。

c.mu.Lock()
	defer c.mu.Unlock()
	c.done += 1

每个阶段的处理不同。在Map阶段,把当前任务标记后,还需要对往Reduce任务中添加文件位置,还需要判定是否需要阶段转换。

if c.phase == PhaseMAP {
	c.tasks[arg.TaskID].status = DONE
	for i := 0; i < c.total[PhaseReduce]; i++ {
		c.tasks[c.total[PhaseMAP]+i].File = append(c.tasks[c.total[PhaseMAP]+i].File, arg.File[i])
	}
	if c.done == c.total[PhaseMAP] {
		c.done = 0
		c.phase = PhaseReduce
	}
} 

在Reduce阶段,只需要记录任务已完成即可。欸?不用退出任务吗?这儿实验框架已经设计好,开启一个定时Done任务来判定整个任务是否完成,如果完成就退出Coordinator,Worker无法跟Coordinator通信也会退出,但是这个退出看上去就像是bug一样。所以可以添加机制,在Coordiantor判断退出时,给所有worker广播退出信息。但是这样就需要Worker也提供远程接口,这样整体的设计更偏向由Coordinator派发任务,而不是Worker索要任务,本实验于此相反,不考虑广播退出。

else if c.phase == PhaseReduce {
	c.tasks[arg.TaskID+c.total[PhaseMAP]].status = DONE
}

完整代码如下:

func (c *Coordinator) Finish(arg Finish, resp *Ignore) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.done += 1
	if c.phase == PhaseMAP {
		c.tasks[arg.TaskID].status = DONE
		for i := 0; i < c.total[PhaseReduce]; i++ {
			c.tasks[c.total[PhaseMAP]+i].File = append(c.tasks[c.total[PhaseMAP]+i].File, arg.File[i])
		}
		if c.done == c.total[PhaseMAP] {
			c.done = 0
			c.phase = PhaseReduce
		}
	} else if c.phase == PhaseReduce {
		c.tasks[arg.TaskID+c.total[PhaseMAP]].status = DONE
	}
	return nil
}

退出判定(mr/coordinator.go/Done)

func (c *Coordinator) Done() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	for i := 0; i < len(c.tasks); i++ {
		if c.tasks[i].status != DONE {
			return false
		}
	}
	return true
}

Shuffle的哈希分区和排序(mr/worker.go/ihas;mar/worker.go/ByKey)

type KeyValue struct {
	Key   string
	Value string
}
type ByKey []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 ihash(key string) int {
	h := fnv.New32a()
	h.Write([]byte(key))
	return int(h.Sum32() & 0x7fffffff)
}

工作函数(mr/worker.go/worker)

这是实际工作函数,每次只处理一个任务。根据Get设计的返回,这个函数需要对map、reduce和none三种情况进行处理。

func worker(mapf func(string, string) []KeyValue, reducef func(string, []string) string) int {
	task := Task{}
	ignore := Ignore{A: 0}
	success := call("Coordinator.Get", ignore, &task)
	if !success {
		return 0
	}

	if task.TaskType == MAP {
		...
	} else if task.TaskType == REDUCE {
		...
	} else if task.TaskID == NONE {
		return 2
	}

	return 1
}
Map处理

读取任务目标文件,获取输入。并调用map函数进行中间KV的计算。

bcontents, err := os.ReadFile(task.File[0])
if err != nil {
	return -1
}
contents := string(bcontents)
intermediate := mapf(task.File[0], contents)

创建目标输出文件。注意,这儿不管有没有落在该分区的key,都创建一个文件,是为了占位,以方便Finish添加reduce的文件位置,而不需判断分区。

var files []*os.File
var intermidateFile []string
for i := 0; i < task.Reducer; i++ {
	partition := "Task" + strconv.Itoa(task.TaskID) + "-Partition" + strconv.Itoa(i)
	intermidateFile = append(intermidateFile, partition)
	newFile, err := os.Create(partition)
	if err != nil {
		return -1
	}
	files = append(files, newFile)
}
defer func() {
	for i := 0; i < len(files); i++ {
		files[i].Close()
	}
}()

Shuffle中间KV,然后按照分区写入。

sort.Sort(ByKey(intermediate))
for _, kv := range intermediate {
	partition := ihash(kv.Key) % task.Reducer
	_, err = files[partition].WriteString(kv.Key + " " + kv.Value + "\n")
	if err != nil {
		os.Exit(-1)
	}
}

告知Coordinator完成该任务。

finished := Finish{
	TaskID: task.TaskID,
	File:   intermidateFile,
}
call("Coordinator.Finish", finished, &ignore)
Reduce处理

Reduce需要读取多个位置的某一分区中间文件。keys帮助排序输出。

ret := map[string][]string{}
keys := []string{}
for i := 0; i < len(task.File); i++ {
	fd, _ := os.Open(task.File[i])
	reader := bufio.NewReader(fd)
	line, _, err := reader.ReadLine()
	for ; err != io.EOF; line, _, err = reader.ReadLine() {
		splits := strings.Split(strings.TrimRight(string(line), "\n"), " ")
		key := splits[0]
		value := splits[1]
		if _, ok := ret[key]; ok {
			ret[key] = append(ret[key], value)
		} else {
			ret[key] = []string{value}
			keys = append(keys, key)
		}
	}
	fd.Close()
}
sort.Strings(keys)

根据排序好的keys,进行输出

fd, err := os.Create("mr-out-" + strconv.Itoa(task.TaskID))
defer fd.Close()
if err != nil {
	return -1
}
for _, k := range keys {
	_, err := fd.WriteString(k + " " + reducef(k, ret[k]) + "\n")
	if err != nil {
		return -1
	}
}

告知Coordinator完成任务

finished := Finish{
	TaskID: task.TaskID,
	File:   []string{"mr-out-" + strconv.Itoa(task.TaskID)},
}
call("Coordinator.Finish", finished, &ignore)

完整代码如下:

func worker(mapf func(string, string) []KeyValue,
	reducef func(string, []string) string) int {
	task := Task{}
	ignore := Ignore{A: 0}
	success := call("Coordinator.Get", ignore, &task)
	if !success {
		return 0
	}

	if task.TaskType == MAP {
		bcontents, err := os.ReadFile(task.File[0])
		if err != nil {
			return -1
		}
		contents := string(bcontents)
		intermediate := mapf(task.File[0], contents)
		var files []*os.File
		var intermidateFile []string
		for i := 0; i < task.Reducer; i++ {
			partition := "Task" + strconv.Itoa(task.TaskID) + "-Partition" + strconv.Itoa(i)
			intermidateFile = append(intermidateFile, partition)
			newFile, err := os.Create(partition)
			if err != nil {
				return -1
			}
			files = append(files, newFile)
		}
		defer func() {
			for i := 0; i < len(files); i++ {
				files[i].Close()
			}
		}()
		sort.Sort(ByKey(intermediate))
		for _, kv := range intermediate {
			partition := ihash(kv.Key) % task.Reducer
			_, err = files[partition].WriteString(kv.Key + " " + kv.Value + "\n")
			if err != nil {
				//fmt.Println("WORKER:--", "writing in file wrong because ", err.Error())
			}
		}
		finished := Finish{
			TaskID: task.TaskID,
			File:   intermidateFile,
		}
		call("Coordinator.Finish", finished, &ignore)
	} else if task.TaskType == REDUCE {
		ret := map[string][]string{}
		keys := []string{}
		for i := 0; i < len(task.File); i++ {
			fd, _ := os.Open(task.File[i])
			reader := bufio.NewReader(fd)
			line, _, err := reader.ReadLine()
			for ; err != io.EOF; line, _, err = reader.ReadLine() {
				splits := strings.Split(strings.TrimRight(string(line), "\n"), " ")
				key := splits[0]
				value := splits[1]
				if _, ok := ret[key]; ok {
					ret[key] = append(ret[key], value)
				} else {
					ret[key] = []string{value}
					keys = append(keys, key)
				}
			}
			fd.Close()
		}
		sort.Strings(keys)
		fd, err := os.Create("mr-out-" + strconv.Itoa(task.TaskID))
		defer fd.Close()
		if err != nil {
			return -1
		}
		for _, k := range keys {
			_, err := fd.WriteString(k + " " + reducef(k, ret[k]) + "\n")
			if err != nil {
			}
		}
		finished := Finish{
			TaskID: task.TaskID,
			File:   []string{"mr-out-" + strconv.Itoa(task.TaskID)},
		}
		call("Coordinator.Finish", finished, &ignore)
	} else if task.TaskID == NONE {
		return 2
	}
	return 1
}

6. Summary

至此,lab1完美结束。当然还有一些可以思考的问题。比如真正的mapReduce真的是worker索要任务吗?如何处理主机的拓扑关系呢?文件的远程调用如何实现呢?
这些可能在后续实验中,会得到思考和解决。

7. CAUTION

注意,在windows跑需要使用tcp接口。在虚拟机跑测试的时候需要用unix的socket接口,不然crash测试无法通过。

  • 12
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值