目录
前言
lab链接:6.5840 Lab 1: MapReduce
看完课程前4节课开始构思实现,完整代码在文末提供的gitee地址
一、开始前准备
先学一手go语言的基础知识
做实验前推荐看的
我是直接看完前4节课然后看的刘丹冰8小时转go教程,然后再看mapduce论文,然后就直接开始实现了
二、实验环境搭建
我是直接在ubuntu22.04下安装的golang,用的1.24版本,也可以用其他环境或者版本,看自己的喜好
然后把实验拉下来
git clone git://g.csail.mit.edu/6.5840-golabs-2026 6.5840
然后可以先通过命令行跑一遍一个提供的非并行版mrsequential.go
cd 6.5840
cd src/main
# 将wc.go编译成插件形式,生成wc.so
go build -race -buildmode=plugin ../mrapps/wc.go
rm mr-out*
# 进行并发检测,并将编译后生成的wc.so插件,以参数形式加入mrsequential.go,并运行
go run -race mrsequential.go wc.so pg*.txt
# 查看生成的文件
more mr-out-0
如果没问题会输出以下信息
A 509
ABOUT 2
ACT 8
ACTRESS 1
...
三、lab
1、实验前分析
先看看论文给出的流程图
编辑
在这幅图里,MapReduce的执行过程大致如下:
- 输入分片:首先把输入文件切成M个分片(每个分片大小可以自己设置),然后启动一堆程序副本,其中一个是master(也叫协调者Coordinator),剩下全是worker。
- 分配任务:master把M个map任务和R个reduce任务分配给空闲的worker。
- Map阶段:拿到map任务的worker,把分片文件解析成一个个key/value对,传给用户写的Map函数,Map函数输出的中间key/value对先暂存在内存里,之后会刷到本地磁盘。
- 中间结果处理:worker用分区函数(比如hash(key) % R)把这些中间数据分成R个区,然后把中间文件的位置告诉master。master再通知负责reduce的worker去取数据,reduce worker通过远程读从map worker的磁盘上拉取属于自己分区的中间数据。等所有中间数据都拉取完,reduce worker会对它们按键排序,把相同key的value凑一起——因为很多不同的key可能落到同一个reduce任务,不排序没法分组。如果数据量太大内存装不下,还得做外部排序。
- Reduce阶段:reduce worker遍历排好序的中间数据,每遇到一个新key,就把这个key和它对应的所有value传给用户写的Reduce函数,Reduce函数的结果追加到这个reduce分区对应的最终输出文件里。
- 收尾:所有map和reduce任务都干完后,master唤醒用户程序,这时程序里的MapReduce调用才真正返回。
简单理解就是:一个Coordinator负责调度,map worker先把文件解析成k/v对,处理后写到本地磁盘,然后把元信息(比如文件位置)报给master,master再派reduce worker去取数据、做聚合,最后输出结果。
2.具体设计实现
-定好 Coordinator 和 Worker 之间的 RPC 协议 → 实现 Coordinator 的状态与调度 → 实现 Worker 的拉任务与执行
2.1 rpc.go
在开始前我们需要先看看给的RPC调用的例子,可以给我们的实现提供一些思路
在运行main/mrworker.go 会进入到 mr/Worker的这个方法中。可以在这个方法中调用RPC的例子方法:CallExample()。
编辑
// an example RPC handler.
//
// the RPC argument and reply types are defined in rpc.go.
func (c *Coordinator) Example(args *ExampleArgs, reply *ExampleReply) error {
reply.Y = args.X + 1
return nil
}
// example function to show how to make an RPC call to the coordinator.
//
// the RPC argument and reply types are defined in rpc.go.
func CallExample() {
// declare an argument structure.
args := ExampleArgs{}
// fill in the argument(s).
args.X = 99
// declare a reply structure.
reply := ExampleReply{}
// send the RPC request, wait for the reply.
// the "Coordinator.Example" tells the
// receiving server that we'd like to call
// the Example() method of struct Coordinator.
ok := call("Coordinator.Example", &args, &reply)
if ok {
// reply.Y should be 100.
fmt.Printf("reply.Y %v\n", reply.Y)
} else {
fmt.Printf("call failed!\n")
}
}
// send an RPC request to the coordinator, wait for the response.
// usually returns true.
// returns false if something goes wrong.
func call(rpcname string, args interface{}, reply interface{}) bool {
// c, err := rpc.DialHTTP("tcp", "127.0.0.1"+":1234")
c, err := rpc.DialHTTP("unix", coordSockName)
if err != nil {
// coordinator 未启动时每 10 秒最多打一次,避免刷屏
if time.Since(lastDialLog) >= 10*time.Second {
log.Printf("dialing coordinator: %v (will retry)", err)
lastDialLog = time.Now()
}
return false
}
defer c.Close()
if err := c.Call(rpcname, args, reply); err == nil {
return true
}
log.Printf("%d: call failed err %v", os.Getpid(), err)
return false
}
从中我们可以得到,调用master的Example方法后得到结果,返回,这就是master与worker的简单交互
-我是做过rpc的项目,习惯直接定义出请求/回复结构,然后再实现两端
任务类型枚举
//任务类型
type TaskType int
const (
TaskTypeMap TaskType = iota //map任务
TaskTypeReduce //reduce任务
TaskTypeWait //等待任务
TaskTypeExit //退出任务
)
worker请求参数
// Worker 此时不知道会拿到什么任务,所以参数为空即可。
type TaskRequestArgs struct{}
worker请求响应参数
/ 由 Coordinator 根据当前状态填充,Worker 根据 Type 和字段执行对应任务。
type TaskRequestReply struct {
Type TaskType // 分配到的任务类型:Map / Reduce / Wait / Exit
// Map 任务时有效
FileName string // 文件名
MapID int // map任务ID
// Reduce 任务时有效
ReduceID int // reduce任务ID
// 两种任务都可能用到
NMap int // 总 map 数,Reduce 时用来读 mr-0-Y, mr-1-Y, ...
NReduce int // 总 reduce 数,Map 时用来写 mr-X-0, mr-X-1, ...
}
worker上报某个任务已经完成
type TaskResponseArgs struct {
Type TaskType // 完成的是 Map 还是 Reduce
TaskID int // Map 时为 MapID,Reduce 时为 ReduceID
}
上报任务完成的回复
type TaskResponseReply struct{}
为什么这样设计呢
1.TaskRequestArgs 为什么是空结构
worker在请求获取任务时不需要带任何的参数,具体的参数由Reply决定,这样就不需要根据不同的任务请求设置不同的结构体,并且可以保持语义的清晰,更加易于理解
2.TaskRequestReply 里为什么有 Type,通过状态机枚举直接的表示type类型
- FileName:只有 Type=Map 时有效,Worker 要打开这个文件读内容并传给用户 mapf。一个 Map 任务 = 一个输入文件。
- MapID:当前 Map 任务的编号,用于写中间文件 mr-MapID-ReduceID,保证不同 Map 写不同文件、重试时覆盖同一 MapID。
- ReduceID:当前 Reduce 任务的编号,用于读 mr-*-ReduceID、写 mr-out-ReduceID。
- NMap / NReduce:Map 时要按 reduce 分桶,需要 NReduce;Reduce 时要读所有 map 的输出,需要 NMap。放在 Reply 里一次下发,Worker 不必再问。
3.为什么还要 TaskTypeWait
-在当前任务没有完成时,Coordinator不能给任务,也不可以立即切换到Reduce
-并且在这个状态不返回,worker会一直阻塞RPC上面,返回的话用这个状态表示wait,而不是出错了
worker收到这个状态会等待一段时间,然后再请求
4.为什么上报用 TaskResponseArgs{Type, TaskID} 就够了
Coordinator 只需要知道:哪个类型的哪个任务完成了。
Map 完成 → Type=Map, TaskID=MapID;Reduce 完成 → Type=Reduce, TaskID=ReduceID。
不需要传文件名、输出路径等:完成状态由 Coordinator 用 MapID/ReduceID 更新状态数组即可
TaskResponseReply 为空是因为 RPC 必须有两边类型,但不需要带业务数据。
2.2 Coordinator 状态与结构
任务状态枚举
每个任务有三种状态:Idle(可分配)、InProcess(正在执行)、Completed(已完成)。整体阶段:Map → Reduce → Done,必须等当前阶段全部完成才能进入下一阶段。
type TaskStatus int //任务状态
//type CurrentTaskStatus int //当前任务状态
type CurrentTaskPhase int //当前任务阶段
//任务状态
const (
TaskStatusIdle TaskStatus = iota // 空闲状态
TaskStatusInProcess // 正在处理状态
TaskStatusCompleted // 已完成状态
)
// //当前任务状态
// const (
// CurrentTaskStatusIdle CurrentTaskStatus = iota // 空闲状态
// CurrentTaskStatusInProcess // 正在处理状态
// CurrentTaskStatusCompleted // 已完成状态
// )
//当前任务阶段
const (
CurrentTaskPhaseMap CurrentTaskPhase = iota // map阶段
CurrentTaskPhaseReduce // reduce阶段
CurrentTaskPhaseDone // 完成阶段
)
Coordinator结构体
需要保存:输入文件列表、任务状态数组、每个任务的开始时间(用于超时重试)、当前阶段。所有会被多个 goroutine 访问的字段用一把 Mutex 保护。
type Coordinator struct {
// Your definitions here.
Mutex sync.Mutex // 互斥锁
Files []string // 输入文件列表
NMap int // map任务数量
NReduce int // reduce任务数量
MapTaskStatus []TaskStatus // map任务状态
ReduceTaskStatus []TaskStatus // reduce任务状态
//任务开始时间
MapTaskStartTime []time.Time // map任务开始时间
ReduceTaskStartTime []time.Time // reduce任务开始时间
//CurrentTaskStatus CurrentTaskStatus // 当前任务状态
CurrentTaskPhase CurrentTaskPhase // 当前任务阶段
}
coordinator.go 需要实现的函数(状态与创建/启动)
1.创建 Coordinator,初始化 Files、NMap、NReduce、MapTaskStatus、ReduceTaskStatus、MapTaskStartTime、ReduceTaskStartTime、CurrentTaskPhase。然后 go c.watchTasks() 启动超时检测,再调用 c.server(sockname) 注册 RPC 并监听,最后 return coordinator。main 里会循环调用 Done() 直到返回 true。
// create a Coordinator.
// main/mrcoordinator.go calls this function.
// nReduce is the number of reduce tasks to use.
func MakeCoordinator(sockname string, files []string, nReduce int) *Coordinator {
// Your code here.
flen:=len(files)
coordinator:=Coordinator{
Files: files,
NMap: flen,
NReduce: nReduce,
MapTaskStatus: make([]TaskStatus, flen),
ReduceTaskStatus: make([]TaskStatus, nReduce),
MapTaskStartTime:make([]time.Time,flen),
ReduceTaskStartTime:make([]time.Time,nReduce),
//CurrentTaskStatus: CurrentTaskStatusIdle,
CurrentTaskPhase: CurrentTaskPhaseMap,
}
//启动gorouting管理超时任务
go coordinator.watchTasks()
coordinator.server(sockname)
// //启动gorouting管理超时任务
// go coordinator.watchTasks()
return &coordinator
}
2.main 进程轮询用:加锁读 CurrentTaskPhase == CurrentTaskPhaseDone,返回 true 表示作业全部完成。
// main/mrcoordinator.go calls Done() periodically to find out
// if the entire job has finished.
func (c *Coordinator) Done() bool {
c.Mutex.Lock()
defer c.Mutex.Unlock()
ret := false
// Your code here.
if(c.CurrentTaskPhase == CurrentTaskPhaseDone){
ret = true;
}
return ret
}
2.3 Coordinator 的两个 RPC 处理
需要实现的 RPC 方法(我的实现是一个函数实现所有功能,只是为了方便,最好还是细分出多个函数,解耦一下):
1.func (c *Coordinator) RequestTask(args *TaskRequestArgs, reply *TaskRequestReply) error Worker 调用,用于请求一个任务。根据当前 Phase 和任务状态,在 reply 里填 Type(Map/Reduce/Wait/Exit)及 MapID、FileName、ReduceID、NMap、NReduce 等,全程需加锁。
//对外的任务请求接口
func (c *Coordinator) RequestTask(args *TaskRequestArgs, reply *TaskRequestReply) error{
c.Mutex.Lock()
defer c.Mutex.Unlock()
if(c.CurrentTaskPhase == CurrentTaskPhaseDone){
// 确认已经进入 Done 阶段:
// log.Printf("RequestTask: phase=Done, replying Exit")
reply.Type = TaskTypeExit;//设置为退出状态
return nil;
}
if(c.CurrentTaskPhase == CurrentTaskPhaseMap){//map阶段
var count int = 0;
var flag bool = false;
// 查看当前所有 Map 任务状态:
// log.Printf("RequestTask[Map]: MapStatus=%v", c.MapTaskStatus)
//找一个空闲的map任务
for i:=0;i<c.NMap;i++{
if(c.MapTaskStatus[i] == TaskStatusIdle){
flag = true;
c.MapTaskStatus[i] = TaskStatusInProcess;//设置为正在处理状态
//设置状态后记录时间
c.MapTaskStartTime[i]=time.Now()
reply.Type = TaskTypeMap
reply.MapID = i
reply.FileName = c.Files[i]
reply.NMap = c.NMap
reply.NReduce = c.NReduce
return nil
}else if(c.MapTaskStatus[i] == TaskStatusCompleted){
count++;
}
}
// 先判断:是否全部 map 都已完成,是则进入 reduce 阶段(不要先 return Wait)
if count == c.NMap {
c.CurrentTaskPhase = CurrentTaskPhaseReduce
reply.NMap = c.NMap
reply.NReduce = c.NReduce
// 不 return,继续往下执行 Reduce 分配
} else if !flag {
// 没有空闲的 map 且尚未全部完成,才返回等待
reply.Type = TaskTypeWait
return nil
}
}
//reduce阶段-如果上面没有return,则说明是map阶段结束,进入reduce阶段
if(c.CurrentTaskPhase == CurrentTaskPhaseReduce){//reduce阶段
var count int = 0;
for i:=0;i<c.NReduce;i++{
if(c.ReduceTaskStatus[i] == TaskStatusIdle){
c.ReduceTaskStatus[i] = TaskStatusInProcess;//设置为正在处理状态
//设置状态后记录时间
c.ReduceTaskStartTime[i]=time.Now()
reply.Type = TaskTypeReduce
reply.ReduceID = i
reply.NMap = c.NMap
reply.NReduce = c.NReduce
return nil
}else if(c.ReduceTaskStatus[i] == TaskStatusCompleted){
count++;
}
}
if(count != c.NReduce){//如果还有未完成的reduce任务,则返回等待状态,让worker等待
reply.Type = TaskTypeWait
}else{
//所有reduce任务都已完成,则进入完成阶段
c.CurrentTaskPhase = CurrentTaskPhaseDone;//设置为完成阶段
//c.CurrentTaskStatus = CurrentTaskStatusIdle;//设置为空闲状态
reply.Type = TaskTypeExit;//设置为退出状态
//不需要return
}
}
return nil
}
RequestTask 逻辑说明:
若 Phase == Done:直接 reply.Type = TaskTypeExit,return。
Map 阶段:
- 遍历 Map 任务,若有 Idle(空闲)则分配:置为 InProcess、记 MapTaskStartTime[i]、构建 Reply(Type=Map, MapID, FileName, NMap, NReduce)return。
- 若无 Idle,先统计 Completed 数量;若 count == NMap,说明本阶段全部完成,把 Phase 改为 Reduce,不 return,继续往下走到 Reduce 分支(可能在本轮就分配一个 Reduce 或 Exit,否则 设置任务类型为等待,然后返回。
Reduce 阶段:
- 若有 Idle 的 Reduce,分配并记开始时间,填 Reply,return。
- 若没有 Idle,统计 Completed;若 count == NReduce,Phase 改为 Done,reply.Type = TaskTypeExit,否则 设置任务类型为等待。
这里需要注意的是,一定要先判断是否全部完成再决定是切阶段还是返回 Wait,否则最后一个完成任务的 Worker 可能只拿到 Wait,阶段切换会拖到下一轮。
2.func (c *Coordinator) ReportTask(args *TaskResponseArgs, reply *TaskResponseReply) error
Worker 调用,上报某个任务已完成。根据 args.Type 和 args.TaskID 把对应 Map 或 Reduce 任务标成 Completed,需加锁并做 TaskID 范围检查。
//worker报告任务完成接口
func (c *Coordinator) ReportTask(args *TaskResponseArgs, reply *TaskResponseReply) error{
c.Mutex.Lock()
defer c.Mutex.Unlock()
//根据任务类型设置任务状态
if(args.Type == TaskTypeMap){
if(args.TaskID < 0 || args.TaskID >= c.NMap){
return nil
}
// 查看 Map 完成上报:
// log.Printf("ReportTask: Map %d done", args.TaskID)
c.MapTaskStatus[args.TaskID] = TaskStatusCompleted;//设置为已完成状态
}else if(args.Type == TaskTypeReduce){
if(args.TaskID < 0 || args.TaskID >= c.NReduce){
return nil
}
// 查看 Reduce 完成上报:
// log.Printf("ReportTask: Reduce %d done", args.TaskID)
c.ReduceTaskStatus[args.TaskID] = TaskStatusCompleted;//设置为已完成状态
}
return nil
}
ReportTask 逻辑说明:根据 rpc请求的类型和任务id 把对应任务标成 Completed(先做 TaskID 范围检查,避免越界)。
2.4 Coordinator 超时重试(watchTasks)
在 MakeCoordinator 里用 go c.watchTasks() 启动。内部 for 循环:先 time.Sleep(500*time.Millisecond)-每0.5秒检查一次超时,然后加锁,若 CurrentTaskPhase == Done 则 return,否则若在 Map 阶段则遍历 Map 任务、超时的 InProcess 置回 Idle,若在 Reduce 阶段则对 Reduce 任务做同样处理,解锁。不修改已完成任务,只把执行超时的任务重新变成可分配。
func (c *Coordinator) watchTasks() {
for {
time.Sleep(500 * time.Millisecond) // 每半秒检查一次
c.Mutex.Lock()
// 如果全部完成,退出协程
if c.CurrentTaskPhase == CurrentTaskPhaseDone {
c.Mutex.Unlock()
return
}
// 检查 Map 超时
if c.CurrentTaskPhase == CurrentTaskPhaseMap {
for i := 0; i < c.NMap; i++ {
if c.MapTaskStatus[i] == TaskStatusInProcess && time.Since(c.MapTaskStartTime[i]) > 30*time.Second {
log.Printf("检测到 Map 任务 %d 超时,重置为 Idle", i)
c.MapTaskStatus[i] = TaskStatusIdle
}
}
}
// 检查 Reduce 超时
if c.CurrentTaskPhase == CurrentTaskPhaseReduce {
for i := 0; i < c.NReduce; i++ {
if c.ReduceTaskStatus[i] == TaskStatusInProcess && time.Since(c.ReduceTaskStartTime[i]) > 30*time.Second {
log.Printf("检测到 Reduce 任务 %d 超时,重置为 Idle", i)
c.ReduceTaskStatus[i] = TaskStatusIdle
}
}
}
c.Mutex.Unlock()
}
}
逻辑说明:后台 goroutine 每 500ms(0.5s) 检查一次:
若当前是 Map 阶段,对每个 InProcess 的 Map 任务,
若 当前map在处理阶段并且time.Since(MapTaskStartTime[i]) > 30*time.Second,则把该任务重置为 Idle(不删已完成状态)。
Reduce 阶段同理。这样 Worker 崩溃或卡死后,该任务会被重新分配给别人执行。
全部完成后 CurrentTaskPhase == Done,watchTasks 直接 return 退出循环。
2.5 Worker 主循环(worker.go)
实现 func Worker(sockname string, mapf func(string, string) []KeyValue,reducef func(string, []string) string)
main 里加载 plugin 后调用的入口。先保存 sockname(如赋给包变量 coordSockName)供 call使用;然后死循环:call("Coordinator.RequestTask", &args, &reply) 要任务,失败则 sleep 1 秒再 continue,根据 reply.Type 分支:Exit 则 break;Map 则调 onMapTask,成功再 ReportTask;Reduce 则 onReduceTask,成功再 ReportTask;Wait 则 sleep 200ms 再请求。
// main/mrworker.go calls this function.
func Worker(sockname string, mapf func(string, string) []KeyValue,reducef func(string, []string) string) {
coordSockName = sockname
// Your worker implementation here.
for{
args:=TaskRequestArgs{}
reply:=TaskRequestReply{}
ret:=call("Coordinator.RequestTask", &args, &reply)
// 观察每次分配到的任务类型和 ID:
// log.Printf("Worker: got task type=%v mapID=%d reduceID=%d", reply.Type, reply.MapID, reply.ReduceID)
//如果调用失败,则等待1秒后继续
if(!ret){
time.Sleep(1 * time.Second)
continue;
}
//如果返回类型为退出,则退出循环
if(reply.Type == TaskTypeExit){
break;
}
//如果返回类型为map,则执行map任务;仅成功时才上报,失败则不报让 coordinator 可重试
if reply.Type == TaskTypeMap {
if err := onMapTask(&reply, mapf); err != nil {
log.Printf("map task %d failed: %v", reply.MapID, err)
continue
}
ret := call("Coordinator.ReportTask",
&TaskResponseArgs{Type: TaskTypeMap, TaskID: reply.MapID},
&TaskResponseReply{})
if !ret {
continue
}
continue
}
//如果返回类型为reduce,则执行reduce任务;仅成功时才上报
if reply.Type == TaskTypeReduce {
if err := onReduceTask(&reply, reducef); err != nil {
log.Printf("reduce task %d failed: %v", reply.ReduceID, err)
continue
}
ret:=call("Coordinator.ReportTask",
&TaskResponseArgs{Type: TaskTypeReduce, TaskID: reply.ReduceID},
&TaskResponseReply{})
//如果调用失败
if(!ret){
continue;
}
continue;
}
//如果返回类型为等待
if reply.Type == TaskTypeWait {
time.Sleep(200 * time.Millisecond)
continue
}
}
// uncomment to send the Example RPC to the coordinator.
// CallExample()
}
逻辑说明:死循环去call("Coordinator.RequestTask", &args, &reply)→ 若 RPC 失败则 sleep 1 秒再继续。
根据 reply.Type的类型:
TaskTypeExit:break 退出循环,进程结束。
TaskTypeMap:调用 onMapTask(&reply, mapf),只有成功(err==nil)时才去call("Coordinator.ReportTask", ...),失败则 continue(不上报,让 Coordinator 超时重试)。 TaskTypeReduce:同理,只有 onReduceTask 成功才上报。
TaskTypeWait:sleep 200ms 再继续请求。
2.6 Worker 的 Map 实现(onMapTask)
执行一个 Map 任务:用 TaskRequestReply 里的 FileName、MapID、NReduce。打开文件读内容,调 mapf 得到 k/v 列表,按 ihash(key) % NReduce 分桶,对每个桶写临时文件再 Rename 为 mr-MapID-Y,任一步失败则返回 error,不 ReportTask,让 coordinator 超时重试。
辅助:ihash(key string) int(论文里有,用 hash/fnv 得到 key 的 hash,再对 NReduce 取模用于分桶),若需对中间结果排序,可实现 ByKey 的 Len/Swap/Less 供 sort.Sort用。
func onMapTask(task_req_reply *TaskRequestReply, mapf func(string, string) []KeyValue)error {
//查看 map 任务详情:
// log.Printf("onMapTask: MapID=%d file=%s NReduce=%d", task_req_reply.MapID, task_req_reply.FileName, task_req_reply.NReduce)
//使用os.Open()打开文件读取
file, err := os.Open(task_req_reply.FileName)
if err != nil {
//返回错误信息
return fmt.Errorf("open file failed: %v", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
//返回错误信息
return fmt.Errorf("read file failed: %v", err)
}
kv_all:=mapf(task_req_reply.FileName, string(content))//调用mapf函数处理文件内容
//根据reduse数量分桶
map_table:=make([][]KeyValue,task_req_reply.NReduce)
for _,kv:=range kv_all{
index:=ihash(kv.Key) % task_req_reply.NReduce
map_table[index] = append(map_table[index], kv)
}
// 对每个 reduce 写一个中间文件:mr-MapID-ReduceID
for r, kvs := range map_table {
midfilename := fmt.Sprintf("mr-%d-%d", task_req_reply.MapID, r)
tmpname := fmt.Sprintf("%s-%d", midfilename, os.Getpid())
//创建临时文件
ofile, err := os.Create(tmpname)
if err != nil {
return fmt.Errorf("cannot create %v: %v", tmpname, err)
}
//创建json编码器
enc := json.NewEncoder(ofile)
//编码kv 把kv从桶里面逐行写入临时文件
for _, kv := range kvs {
if err := enc.Encode(&kv); err != nil {
//关闭文件
ofile.Close()
//返回错误信息
return fmt.Errorf("encode kv failed: %v", err)
}
}
//关闭文件
if err := ofile.Close(); err != nil {
return fmt.Errorf("close file %v failed: %v", tmpname, err)
}
// 原子替换 将临时文件重命名为中间文件-使用os.Rename()函数实现原子操作
if err := os.Rename(tmpname, midfilename); err != nil {
return fmt.Errorf("rename %v to %v failed: %v", tmpname, midfilename, err)
}
}
return nil
}
逻辑说明:打开 FileName,读内容,调用户 mapf(FileName, content) 得到 k/v 列表,按 ihash(key) % NReduce 分桶,对每个桶写临时文件(如 mr-MapID-Y-pid),写完后 os.Rename 为 mr-MapID-Y。这样写是原子的,崩溃不会留下半写文件。
2.7 Worker 的 Reduce 实现(onReduceTask)
执行一个 Reduce 任务:用 reply 里的 ReduceID、NMap。遍历 m=0..NMap-1 读 mr-m-ReduceID(Open 失败就 continue,兼容 crash 后部分 map 未完成),解码成 KeyValue,按 key 排序后按 key 分组,每组调 reducef(key, values),写临时文件再 Rename 为 mr-out-ReduceID。输出格式与 mrsequential 一致:fmt.Fprintf(ofile, "%v %v\n", key, output)。
func onReduceTask(reply *TaskRequestReply, reducef func(string, []string) string) error {
//查看 reduce 任务详情:
// log.Printf("onReduceTask: ReduceID=%d NMap=%d", reply.ReduceID, reply.NMap)
//定义中间文件数组
intermediate := []KeyValue{}
// 从所有 Map 任务的输出中汇总本 Reduce 需要的键值
for m := 0; m < reply.NMap; m++ {
iname := fmt.Sprintf("mr-%d-%d", m, reply.ReduceID)
file, err := os.Open(iname)
if err != nil {
// 某些 map 可能失败/未生成,对 crash 容错时 coordinator 会重试,这里忽略不存在的文件
continue
}
dec := json.NewDecoder(file)
for {
var kv KeyValue
if err := dec.Decode(&kv); err != nil {
if err == io.EOF {
break
}
file.Close()
return fmt.Errorf("decode intermediate %v failed: %v", iname, err)
}
intermediate = append(intermediate, kv)
}
file.Close()
}
// 按 key 排序
sort.Sort(ByKey(intermediate))
// 输出结果到 mr-out-ReduceID
oname := fmt.Sprintf("mr-out-%d", reply.ReduceID)
tmpname := fmt.Sprintf("%s-%d", oname, os.Getpid())
ofile, err := os.Create(tmpname)
if err != nil {
return fmt.Errorf("cannot create %v: %v", tmpname, err)
}
i := 0
for i < len(intermediate) {
j := i + 1
for j < len(intermediate) && intermediate[j].Key == intermediate[i].Key {
j++
}
values := []string{}
for k := i; k < j; k++ {
values = append(values, intermediate[k].Value)
}
sort.Strings(values) // 保证与 mrsequential 一致,indexer 等对 value 顺序敏感
output := reducef(intermediate[i].Key, values)
// 这一行输出格式必须与顺序版一致
fmt.Fprintf(ofile, "%v %v\n", intermediate[i].Key, output)
i = j
}
if err := ofile.Close(); err != nil {
return fmt.Errorf("close %v failed: %v", tmpname, err)
}
if err := os.Rename(tmpname, oname); err != nil {
return fmt.Errorf("rename %v to %v failed: %v", tmpname, oname, err)
}
return nil
}
逻辑说明:读所有 mr-0-ReduceID, mr-1-ReduceID, ...(若某个文件 Open 失败就 continue,兼容 crash 后部分 map 尚未重试完成的情况),用 json 解码成 KeyValue,按 key 排序,按 key 分组后对每组调 reducef(key, values),输出先写临时文件再 Rename 为 mr-out-ReduceID。输出格式要与 mrsequential 一致:fmt.Fprintf(ofile, "%v %v\n", key, output)。
2.8 call() 与失败处理
向 Coordinator 发 RPC。用 rpc.DialHTTP("unix", coordSockName) 建连接,失败则 return false(并可对 dial 失败做日志限流),成功则 c.Call(rpcname, args, reply),Call 失败也 return false。主循环根据返回值决定是否重试,不要在这里 log.Fatal,否则 Worker 进程会直接退出。
// send an RPC request to the coordinator, wait for the response.
// usually returns true.
// returns false if something goes wrong.
func call(rpcname string, args interface{}, reply interface{}) bool {
// c, err := rpc.DialHTTP("tcp", "127.0.0.1"+":1234")
c, err := rpc.DialHTTP("unix", coordSockName)
if err != nil {
// coordinator 未启动时每 10 秒最多打一次,避免刷屏
if time.Since(lastDialLog) >= 10*time.Second {
log.Printf("dialing coordinator: %v (will retry)", err)
lastDialLog = time.Now()
}
return false
}
defer c.Close()
if err := c.Call(rpcname, args, reply); err == nil {
return true
}
log.Printf("%d: call failed err %v", os.Getpid(), err)
return false
}
逻辑说明:call() 里 dial 失败不要 log.Fatal,而是 return false,让主循环重试,可以对 dial 失败做日志限流(例如每 10 秒最多打一次),避免 coordinator 没启动时刷屏。
四、测试
在src目录下,执行
make mr
或者进入src/mr
go test -v -race
测试包括:TestWc、TestIndexer、TestMapParallel、TestReduceParallel、TestJobCount、TestEarlyExit、TestCrashWorker。
在我第一次实现完后,测试出现三个test fail(例如 TestIndexer 输出与 correct 不一致、TestJobCount map 次数不对、TestCrashWorker 输出不对等)
TestJobCount:失败也进行上报
-Worker 在 onMapTask/onReduceTask 返回 error 时仍然调用了 ReportTask,coordinator 误以为该任务完成,导致实际执行次数和上报次数不一致,jobcount 统计错。
解决办法:只有执行成功(err==nil)才调用 ReportTask,失败则 continue,让该任务保持 InProcess,由 coordinator 超时后重试。
TestJobCount:本机只有 7 个输入文件(可能是误删了,之前我自己研究的时候)
-测试期望输出里恰好出现一次 "a 8"(表示 8 个 map 各跑一次)。若 main 下只有 7 个 pg 文件,永远得不到 "a 8"。
解决办法:在 main 下复制一份改名为 pg-extra.txt 凑够 8 个。
阶段切换顺序
-RequestTask 里若先写没有 Idle 就 return Wait,再写若 count==NMap 则切 Reduce,最后一个完成 map 的 Worker 可能只拿到 Wait,阶段要等下一轮才切,容易踩坑。
解决办法:先统计 Completed,若 count==NMap(或 NReduce)再切阶段或返回 Exit,否则再判断没有 Idle 就 Wait。
原子写
-若直接写 mr-X-Y 或 mr-out-R,写一半进程崩会留下半写文件,reduce 或合并时会读到脏数据。
解决办法:先写临时文件(如带 pid 后缀),写完后 os.Rename 成正式文件名,Rename 在本机文件系统是原子的。
TestCrashWorker:Reduce 读中间文件
-crash 测试会杀 worker,部分 map 可能还没写出就被杀,对应 mr-m-R暂时不存在。若 Reduce 对 Open 失败直接 return error,任务会失败且不会重试。
解决办法:对 Open 失败的文件 continue 跳过,只读当前已存在的中间文件,等超时重试的 map 完成后,该 reduce 任务可能被重新分配,再跑时就能读到完整数据。
超时时间
-jobcount 的 Map 会 sleep 2~5 秒,加上 -race 更慢,超时设太短(如 10 秒)容易误判超时把任务重置,导致同一 map 跑多次、jobcount 得到 "a 9" 等。
解决办法:适当调大超时(如 30 秒)
call() 失败不能 Fatal
-若 dial 或 RPC 失败就 log.Fatal,Worker 进程会直接退出,测试里 coordinator 可能稍晚启动或网络抖动,希望 Worker 重试。
解决办法:return false,主循环里 sleep 后继续 RequestTask。
Worker 单独跑时 dial 刷屏
-只起 worker 不起 coordinator 时,会不断重连,每秒打一次 dial 错误。
解决:对 dial 失败做日志限流(如每 10 秒最多打一行),避免刷屏。
这是最终测试通过的输出
lcz@iv-yef3xahqtc5i3z5jzmr5:~/mit6.5840/6.5840/src$ make mr go build -race -o main/mrsequential main/mrsequential.go go build -race -o main/mrcoordinator main/mrcoordinator.go go build -race -o main/mrworker main/mrworker.go& (cd mrapps && go build -race -buildmode=plugin wc.go) || exit 1 (cd mrapps && go build -race -buildmode=plugin indexer.go) || exit 1 (cd mrapps && go build -race -buildmode=plugin mtiming.go) || exit 1 (cd mrapps && go build -race -buildmode=plugin rtiming.go) || exit 1 (cd mrapps && go build -race -buildmode=plugin jobcount.go) || exit 1 (cd mrapps && go build -race -buildmode=plugin early_exit.go) || exit 1 (cd mrapps && go build -race -buildmode=plugin crash.go) || exit 1 (cd mrapps && go build -race -buildmode=plugin nocrash.go) || exit 1 cd mr;
go test -v -race
=== RUN TestWc --- PASS: TestWc (10.58s)
=== RUN TestIndexer --- PASS: TestIndexer (6.78s)
=== RUN TestMapParallel --- PASS: TestMapParallel (8.02s)
=== RUN TestReduceParallel --- PASS: TestReduceParallel (9.03s)
=== RUN TestJobCount --- PASS: TestJobCount (12.03s)
=== RUN TestEarlyExit --- PASS: TestEarlyExit (7.03s)
=== RUN TestCrashWorker
2026/03/02 21:01:29 检测到 Map 任务 0 超时,重置为 Idle
2026/03/02 21:01:29 检测到 Map 任务 1 超时,重置为 Idle
2026/03/02 21:01:29 检测到 Map 任务 4 超时,重置为 Idle
2026/03/02 21:02:01 检测到 Reduce 任务 0 超时,重置为 Idle
2026/03/02 21:02:01 检测到 Reduce 任务 2 超时,重置为 Idle
2026/03/02 21:02:01 检测到 Reduce 任务 5 超时,重置为 Idle
2026/03/02 21:02:01 检测到 Reduce 任务 9 超时,重置为 Idle
2026/03/02 21:02:31 检测到 Reduce 任务 9 超时,重置为 Idle
--- PASS: TestCrashWorker (97.14s) PASS ok 6.5840/mr 151.614s
五、遇到的问题和收获
决定做这个lab1时是在第一次实习的过程中,从开始到结束包括go的入门、看课程视频、阅读论文和博客的编写,断断续续耗时十来天。从一开始感觉无从下手到后面的恍然大悟,在这个自己设计实现的过程中收获还是非常不错的,接下来一个月的话实现是继续实现lab2。在这篇博客中我主要是讲解我的思路,这个课的初衷是让我们有自己的思考,希望我的思路可以带给你们思路,然后按照自己的思路实现出属于自己的lab1。最后希望有错误的地方多指正指正~
- 完整实现:mit6.5840: 用来记录mit6.5840(原6.824)的实现历程
- 觉得有收获的可以帮忙点个star~