MIT 6.824 MapReduce(实现思路)

0 阅读23分钟

目录

       前言

        一、开始前准备

        二、实验环境搭建

        三、lab

        四、测试

        五、遇到的问题和收获


前言

lab链接:6.5840 Lab 1: MapReduce

看完课程前4节课开始构思实现,完整代码在文末提供的gitee地址

一、开始前准备

先学一手go语言的基础知识

Go 语言教程 | 菜鸟教程

8小时转职Golang工程师(如果你想低成本学习Go语言)_哔哩哔哩_bilibili

【尚硅谷】Golang入门到实战教程丨一套精通GO语言_哔哩哔哩_bilibili

做实验前推荐看的

2020 MIT 6.824 分布式系统_哔哩哔哩_bilibili

www.cnblogs.com/fuzhe1989/p…

www.bilibili.com/video/BV1Vb…

我是直接看完前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的执行过程大致如下:

  1. 输入分片:首先把输入文件切成M个分片(每个分片大小可以自己设置),然后启动一堆程序副本,其中一个是master(也叫协调者Coordinator),剩下全是worker。
  2. 分配任务:master把M个map任务和R个reduce任务分配给空闲的worker。
  3. Map阶段:拿到map任务的worker,把分片文件解析成一个个key/value对,传给用户写的Map函数,Map函数输出的中间key/value对先暂存在内存里,之后会刷到本地磁盘。
  4. 中间结果处理:worker用分区函数(比如hash(key) % R)把这些中间数据分成R个区,然后把中间文件的位置告诉master。master再通知负责reduce的worker去取数据,reduce worker通过远程读从map worker的磁盘上拉取属于自己分区的中间数据。等所有中间数据都拉取完,reduce worker会对它们按键排序,把相同key的value凑一起——因为很多不同的key可能落到同一个reduce任务,不排序没法分组。如果数据量太大内存装不下,还得做外部排序。
  5. Reduce阶段:reduce worker遍历排好序的中间数据,每遇到一个新key,就把这个key和它对应的所有value传给用户写的Reduce函数,Reduce函数的结果追加到这个reduce分区对应的最终输出文件里。
  6. 收尾:所有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。最后希望有错误的地方多指正指正~