简介
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
- 简介
2022年的课程开始要求使用Go作为开发语言。Go有几个良好的特性可以辅助DS的开发。- 并发友好: go对于线程的友好性能够帮助开发者极快速地开发
- rpc库封装良好:go中有对rpc良好封装的标准库,开发rpc接口十分便捷
- 垃圾回收机制:go的垃圾回收器能够简化开发者对于内存泄漏的关注度,降低开发难度。
- 安装
https://blog.csdn.net/m0_51173898/article/details/137092676?spm=1001.2014.3001.5502
Ubuntu
- 简介
实验要求在Linux或者Unix环境下进行测试和运行。但是国内的开发同学们,大多都用的windows系统吧hh。这儿提供一种虚拟机的配置方法。 - 虚拟机
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上调试十分麻烦,所以需要进行下述步骤:
- 将mrapps下所有文件的包名改为mrapps,并且按照提示把所有重复函数进行重命名。
- 将main/mrworker.go中的mapf和reducef显示配置。
- 将rpc的接口换成提供的tcp接口,而非socket接口
- 使用如下命令执行
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的索要任务的接口。
- 请求包
不重要的,可以使用过一个统一的dummy结构体。但是这儿可以扩展,可以提交自己的ID。但是在这个实验中没有必要。 - 回复包
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的告知任务已完成的接口。
- 请求包
Woker要向Coordinator提供足够的信息,让其知晓是哪个任务完成了( ID )、任务的输出在哪儿(File)。所以可以定义请求结构体如下:
type Finish struct {
TaskID int
File []string
}
- 回复包
提升健壮性可以增加回复机制,但是这个实验中没必要。使用同一的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测试无法通过。