背景
世界名课,MIT分布式经典实验。
课程网站 nil.csail.mit.edu/6.824/2020/…
模板
代码仓库 clone 下来之后即使什么不写,仅从模板中就能学习到很多知识。
1. golang中的rpc实践
在 mr/master.go 中,使用 rpc.Register() 注册了一个 Example 函数。
func (m *Master) Example(args *ExampleArgs, reply *ExampleReply) error {
reply.Y = args.X + 1
return nil
}
从代码中可以看出,这正是goLang原生rpc所规定的函数样式。有两个入参,一个是请求结构体 request, 一个是响应结构体 *response, 以及一个返回值 error。另外函数要绑定到主结构体 master 上。
我们知道rpc底层基础tcp协议,但应用层协议就多了,可以用http,可以自己手搓,实验中给的样例是前者,且因为是本地通信直接用unix,怎么简单怎么来。
// start a thread that listens for RPCs from worker.go
func (m *Master) server() {
rpc.Register(m)
rpc.HandleHTTP()
//l, e := net.Listen("tcp", ":1234")
sockname := masterSock()
os.Remove(sockname)
l, e := net.Listen("unix", sockname)
if e != nil {
log.Fatal("listen error:", e)
}
go http.Serve(l, nil)
}
2. go plugin
在 mrapps/ 目录下的有几个 go 文件,实验guidance让这么编译。
go build -buildmode=plugin ../mrapps/wc.go
- 在 Windows 平台上是不支持的,直接报错。官方原话 Currently plugins are only supported on Linux, FreeBSD, and macOS. Please report any issues.
- 产生的 .so 文件类似于C++的链接文件,在其他文件中动态装载[运行时]。
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)
}
xmapf, err := p.Lookup("Map")
if err != nil {
log.Fatalf("cannot find Map in %v", filename)
}
mapf := xmapf.(func(string, string) []mr.KeyValue)
xreducef, err := p.Lookup("Reduce")
if err != nil {
log.Fatalf("cannot find Reduce in %v", filename)
}
reducef := xreducef.(func(string, []string) string)
return mapf, reducef
}
- 编译插件使用的 go 版本要和载入插件的项目使用的 go 版本一致,不然各种报错,兼容性很差。
MapReduce 原理
具体分几步,在论文中有,总结下来用这个图来描述很适合,也写过一个原理分析。
实现预备
1.文件读写
读文件没什么说的,在写文件时,不存在先创建要使用 Create 模式, 保证写任务的幂等性要每次写前先清空文件使用 TRUNC 模式, 操作权限选择 777。
resFile, err := os.OpenFile("mr-out-", os.O_WRONLY|os.O_TRUNC|os.O_CREATE, os.ModePerm)
if err != nil {
return err
}
defer resFile.Close()
2. 多路归并
从上图中可以看到,在 Reduce 阶段的 merge sort 时,每个 Reduce 任务都取了所有的 N 分区文件,例如红色的4个0号分区,由于分区内部是有序的,要合并成一个有序的分区,使用的是多路归并算法。
最经典实现的可以举例:LeetCode 合并排序链表
3. 并发控制
在master进行分配任务、检测所有任务是否已完成等场景下,需要进行并发控制,可以使用 golang 原生锁,简单好用。
func fun(){
m.JobListMutex.Lock()
defer m.JobListMutex.Unlock()
// other code
}
但需要注意的是锁是不可重入的,意味着不能多次调用 lock.Lock()
具体实现
先画一个执行的流程图,主要有三部分: 服务端、服务端后台任务、客户端。根据流程图用代码实现。
服务端定义
服务端不需要太多东西,只需要维护map和reduce任务队列,以及一个阶段标志位即可。
type Master struct {
// Your definitions here.
Files []string // 原始输入的文件列表
ReduceNumber int // Reduce 的数量 Map任务的数量就是 files 的长度
MapJobList []*Job // 分发给 worker 的 map 任务
ReduceJobList []*Job // 分发给 worker 的 reduce 任务
JobListMutex sync.Mutex // 访问or修改任务状态时要加锁 可以减少锁的粒度
CurrentStates int32 // 所处的阶段枚举, map、reduce、都完成
}
在rpc服务启动前,先根据原始输入的文件列表进行初始化 map 任务。 比较重要的是对"任务"的定义。 任务定义其实可以划分成两部分。
一部分是任务静态信息,如文件名称,任务类型等。一部分是运行时信息,例如是否成功完成,上次的运行时间是多少等。
type Job struct {
// 静态信息
FileName string // map任务输入文件名
ListIndex int // 在任务列表中的 index 下标
ReduceID int // reduce 任务号 从0-(N-1) 和上面一个属性重复了
ReduceNumber int // reduce 数量个数,分区用到
JobType int // 任务类型 map 还是 reduce
// 动态信息
JobFinished bool // 任务是否正确完成
StartTime int64 // 任务的分配时间[初始为0],如果下次检查是否可分配时超过2s,就当失败处理,重新分配
FetchCount int // 任务分配次数,统计信息
// 规定: map产生的中间文件的名字格式是 输入文件名_分区号 reduce 产生的最终文件是 mr-out-reduce任务号
}
客户端定义
根据流程图,需要定义"索取任务"和"提交任务"两个rpc函数和一个"执行任务"函数。
rpc函数的请求和返回体最简化定义
// JobFetchReq 任务获取的请求体
type JobFetchReq struct {
// 不需要有东西,服务端不记录客户端信息
}
type JobFetchResp struct {
NeedWait bool // 是否需要等待下次轮询任务, 因为服务端可能已经分发完map任务,但Map阶段还没结束[map任务正在被执行]
Job
}
// JobDoneReq 任务完成提交的请求体
type JobDoneReq struct {
Job
}
// JobDoneResp 任务完成提交的返回体
type JobDoneResp struct {
// 不需要有东西
}
任务分发逻辑如下。 因为任务的处理是幂等的,所以第一次运行或者之前超时没完成的任务会被重新分配。注意 NeedWait 的逻辑。
func (m *Master) JobFetch(req *JobFetchReq, resp *JobFetchResp) error {
m.JobListMutex.Lock()
defer m.JobListMutex.Unlock()
curTime := time.Now().Unix()
// 看看有哪些没完成的任务,分配出去
jobList := m.MapJobList
switch atomic.LoadInt32(&m.CurrentStates) {
case AllFinished:
return nil
case InMap:
jobList = m.MapJobList
case InReduce:
jobList = m.ReduceJobList
}
for _, job := range jobList {
// 任务没完成,且是第一次运行或者之前超时了
if !job.JobFinished && (job.StartTime == 0 || curTime-job.StartTime > int64(JobTimeoutSecond)) {
job.FetchCount++
job.StartTime = curTime
resp.Job = *job
return nil
}
}
// 需要等待流转到 reduce 的情况
if atomic.LoadInt32(&m.CurrentStates) == InMap {
resp.NeedWait = true
}
return nil
}
提交任务就比较简单了,客户端告诉服务端是哪种任务的哪一个任务,赋值finished状态。
func (m *Master) JobDone(req *JobDoneReq, resp *JobDoneResp) error {
m.JobListMutex.Lock()
defer m.JobListMutex.Unlock()
finished := req.JobFinished
switch atomic.LoadInt32(&m.CurrentStates) {
case InMap:
m.MapJobList[req.ListIndex].JobFinished = finished
case InReduce:
m.ReduceJobList[req.ListIndex].JobFinished = finished
}
return nil
}
主Worker的循环逻辑, 一直要任务,做任务,提交任务,直到master告知Reduce阶段也结束了[用了一个没用的 FetchCount 字段表示,应该用专门字段的]。
//
// main/mrworker.go calls this function.
//
func Worker(mapf func(string, string) []KeyValue,
reducef func(string, []string) string) {
// Your worker implementation here.
// uncomment to send the Example RPC to the master.
//CallExample()
startTime := int64(0)
for true {
startTime = time.Now().UnixNano()
// 索要任务,得到的可能是 Map 或者 Reduce 的任务
job := CallFetchJob()
// 需要等待流转到 reduce
if job.NeedWait {
time.Sleep(BackgroundInterval)
continue
}
// 任务都做完了,停吧
if job.FetchCount == 0 {
fmt.Println(logTime() + WorkerLogPrefix + "任务都做完了,worker退出")
break
}
// 做任务
job.DoJob(mapf, reducef)
// 做完了,提交
CallCommitJob(&JobDoneReq{job.Job})
fmt.Println(WorkerLogPrefix+"一次worker循环耗时[毫秒]:", (time.Now().UnixNano()-startTime)/1e6)
}
}
至于具体的 DoJob 函数逻辑虽然比较长但不难,就放在这里参考。
服务端后台线程
启动 rpc 服务之后,就 go Background() 启动后台线程,主要是检测任务是否都完成,推动进入下一个阶段状态。每隔300毫秒扫描一次
func (m *Master) Background() {
for atomic.LoadInt32(&m.CurrentStates) != AllFinished {
// 循环遍历任务
m.JobListMutex.Lock()
isAllJobDone := true
leftCount := 0
switch atomic.LoadInt32(&m.CurrentStates) {
case InMap:
for _, job := range m.MapJobList {
if !job.JobFinished {
isAllJobDone = false
leftCount++
}
}
fmt.Printf(logTime()+MasterLogPrefix+"—————————————— 还剩 %v 个 map 任务\n", leftCount)
// map 任务都做完了,流转到 reduce 状态, 而且要生成 reduce 任务
if isAllJobDone {
leftCount = 0
atomic.StoreInt32(&m.CurrentStates, InReduce)
m.generateReduceMap()
fmt.Printf(MasterLogPrefix+"background: CurrentStates change from %v to %v\n", InMap, InReduce)
}
case InReduce:
for _, job := range m.ReduceJobList {
if !job.JobFinished {
isAllJobDone = false
leftCount++
}
}
fmt.Printf(logTime()+MasterLogPrefix+"—————————————— 还剩 %v 个 reduce 任务\n", leftCount)
// reduce 任务都做完了,流转到 结束 状态
if isAllJobDone {
atomic.StoreInt32(&m.CurrentStates, AllFinished)
fmt.Printf(MasterLogPrefix+"background: CurrentStates change from %v to %v\n", InReduce, AllFinished)
}
}
m.JobListMutex.Unlock()
time.Sleep(BackgroundInterval)
}
}
以上就是基本的代码实现,在运行给定的 main/test-mr.sh 后,能够 All pass. 顺便说一下这个 sh 文件的代码写的也很不错, 值得学习和借鉴。github.com/francisLee7…
在实现上有几个注意点[踩坑]:
- 目录问题。在读写文件时要注意当前go文件运行所处的目录,可能会有 ../ 的前缀要特殊处理。
- 文件读取问题。在读取文件内容序列化成给定的
KeyValue结构体时,注意字符串处理。 一行一行的字符串在 wordCount 场景中的格式是这样 "apple 3" 可以用中间的空格做 split。但在后面的几个场景里,一行可能有多个空格,而且还有可能是空行,都要处理到位。另外文件读写的时候要注意最后的末尾回车。 - 多路归并。 实现的时候需要一个"哨兵值",来表示字符串的最大值作为循环的初始哨兵。这里我用的 zzzzzzzzzz 这样的字符串[一开始用的大写ZZZ运行不对,后来才发现大写的ASCII码比小写的小]
- 有很多可以优化性能的地方。例如减小锁的粒度,例如客户端回告任务后立刻检查全局状态的流转等等。
写在最后
在有一定Golang和MR理论的基础上,实现实验一用了大约3小时左右[包括调试1小时],总体看实验的设计非常好,难度适中而且步步可循,同时也留下了一些思考题。
- 如何容错,在任务失败的时候。
- 单点故障,master挂掉。
- 负载均衡,怎么分配总体耗时最短。
- MR参数, reduce的数量。