MIT6.5840 Lab 1:MapReduce

相关:

实验目标:实现一个 MapReduce 框架。

实验中以 “coordinator” 来替代 paper 中的 “master”

顺序版 MapReduce

试执行

可以按照以下步骤来运行词频统计程序

$ cd ~/6.5840
$ cd src/main
$ go build -buildmode=plugin ../mrapps/wc.go  #编译到当前目录下,生成wc.so
$ rm mr-out*    #清理旧输出
$ go run mrsequential.go wc.so pg*.txt #编译并运行单机顺序版的MR框架主程序,传入刚刚编译好的词频统计插件,并传入待处理的输入文本文件。
$ sort mr-out-0
Note

  • mrapps 下的文件会报错:xx redeclared in this block,因为 src/mrapps/ 目录下的多个 Go 文件都声明了各自的 Map 和 Reduce 函数,而你可能尝试一次性编译整个目录,因此报错。
  • 这些 Go 文件分别表示一个独立的 MapReduce 应用插件,每次只能编译一个文件。一次性编译整个目录会导致重复声明错误

mrsequential.go 中的代码

实验采用的排序方法是 sort.Sort:定义一个自定义类型ByKey,并实现sort.Interface接口,从而实现ByKey 类型变量的排序。这是 Go 最原始的排序方式,目前在新版本中已被 slices 包取代。:具体见Go语言:从排序功能看接口、反射、闭包、泛型

实验代码中采用 os.Open + ioutil.ReadAll + file.Close进行“打开文件、读取全部内容、自动关闭文件”,这个过程已经被os.ReadFile(filename) 替代:Go语言中的标准库-基础输入输出

它的核心作用是:在单个进程中,按顺序模拟运行标准 MapReduce 的完整生命周期(动态加载插件 → 执行 Map → 按 Key 排序归并 → 执行 Reduce → 输出到文件)。

main 函数逻辑如下:

  1. 初始化与插件加载:
    1. 命令行参数校验。
    2. 用 loadPlugin 动态提取用户自定义的 Map 函数和 Reduce 函数。
  2. Map 阶段:
    1. 依次遍历命令行参数中给出的文件,读取文件内容。对于每个文件:
      1. 将内容和文件名传入 mapf 函数,获得一批中间键值对
      2. 将键值对合并到内存切片 intermediate[]
  3. Shuffle 阶段(分组与排序):
    1. 对内存中收集到的所有中间键值对按照 Key 排序。

通过排序,保证所有相同 Key 的数据在内存数组中处于连续紧邻的位置,为接下来的合并分组做准备。

真正的分布式 MapReduce 会将中间数据按 Hash 分发到 M×N 个 Bucket 中;而这里的串行版本直接将所有数据存在单一内存空间里统一排序。

  1. Reduce 阶段
    1. 每次循环处理一个 key(分组)
      1. 将所有 key 相同的键值对提取到 values[] 中
      2. 将 Key 和 values[]传入reducef 函数,进行化简。
      3. 将这个 key 的最终化简结果打印到指定文件中。进行下一个循环
为什么要用 loadPlugin 函数来动态加载插件?而不是用 import 来导入 map 和 reduce 函数?

  • src/mrapps下有许多 MapReduce 应用,每个应用的 Map 和 Reduce 函数都不一样(由用户自定义)。如果使用 import 来导入,例如import "6.5840/mrapps/wc",硬编码导入 wc 包,框架只能使用 wc 这个应用。如果要修改应用,必须修改框架源代码 + 重新编译。
  • 由于所有函数都被命名为 Map和Reduce,因此也无法在一个文件里通过import 把它们同时导进来。Go 不支持同一个包里出现同名符号
  • 用 loadPlugin 方式,框架代码只需编译一次,生成一个可执行文件。不同的业务逻辑独立编译成不同的插件,运行时只需通过命令行更换参数即可。

loadPlugin 函数(命令行参数为 wc.so,并以map 的加载为例)

  1. plugin.Open(): 将 wc.so 载入当前进程的内存地址空间
  2. p.Lookup("Map"):在符号表中查找 "Map" 内存地址,返回一个空接口类型 plugin.Symbol,表示一个通用的内存地址指针,Go 还不知道这个符号具体是什么类型的函数或变量。
  3. 类型断言:将 xmapf强转为特定的函数类型(与Map 的函数签名保持一致),返回函数指针,赋值给mapf。
mapf := xmapf.(func(string, string) []mr.KeyValue)
  1. 提取出的 mapf 作为普通的 Go 函数变量返回。

实验任务概览

实验内容

写两个程序:coordinator.go 和 worker.go

官方给出了两个初始代码,帮忙起步:coordinator(main/mrcoordinator.go)和 worker(main/mrworker.go)。这两个文件不能修改

我们需要把实现写在 mr/coordinator.go、mr/worker.go、mr/rpc.go

运行示例

以word-count 应用为例。

  1. 编译 word-count 插件
cd MIT6.5840/6.5840/src/main
go build -buildmode=plugin ../mrapps/wc.go
  1. 在其中一个命令行窗口,运行 coordinator
    1. sock123 参数指定了一个套接字(socket),协调器(coordinator)通过它来接收来自 Worker 节点的 RPC 请求。
    2. pg-*.txt 参数则是输入文件;每个文件代表一个“数据分片”(split),并且会作为单个 Map 任务的输入。
cd MIT6.5840/6.5840/src/main
rm mr-out*
go run mrcoordinator.go sock123 pg-*.txt
  1. 在另一个命令行窗口,运行一些 worker
cd MIT6.5840/6.5840/src/main
go run mrworker.go wc.so sock123
  1. workers 和 coordinator 完成后,输出结果在 mr-out-*。输出结果应该和src/main/mrsequential.go 的实现结果是一致的。

测试

官方提供了测试代码(位于 mr/mr_test.go),可以在src 目录下运行测试:

cd src
make mr

预期输出如下:

=== RUN   TestWc
--- PASS: TestWc (8.64s)
=== RUN   TestIndexer
--- PASS: TestIndexer (5.90s)
=== RUN   TestMapParallel         # 测试 Map 是否并行
--- PASS: TestMapParallel (7.05s)
=== RUN   TestReduceParallel      # 测试 Reduce 是否并行
--- PASS: TestReduceParallel (8.05s)
=== RUN   TestJobCount            # 测试 Job 计数
--- PASS: TestJobCount (10.04s)
=== RUN   TestEarlyExit
--- PASS: TestEarlyExit (6.05s)
=== RUN   TestCrashWorker         # 测试 Worker 崩溃容错
2026/01/22 14:58:14 *re*-starting map ... # 看到重新启动任务的日志
--- PASS: TestCrashWorker (40.18s)
PASS
ok      6.5840/mr       86.932s   # 总用时 1 分多钟

如果出现 2026/02/11 16:21:32 dialing:dial unix /var/tmp/5840-mr-501: connect: connection refused 这种报错日志,是正常的。当所有任务做完后,Coordinator 进程会先退出并关闭 RPC 服务。此时可能还有一些“不知情”的 Worker 还在尝试通过 RPC 连 Coordinator,就会报“连接被拒绝”。

规则

建议:

我的实现流程

我的实现是基于 MapReduce 设计流程的。根据设计,来思考实现方式。
注意:

首先要考虑从哪入手。我考虑过两个方向:

  1. 从顶向下:先查看课程给出的所有函数,分析它们的调用关系与参数,再依次填充每个函数的内部逻辑。这样做的问题是,实现会退化成纯粹的工程问题——各个函数之间的调用关系是交叉的,孤立地逐个填充函数,很容易让思路陷入混乱。
  2. 按流程推进。根据 MapReduce 设计流程,按顺序推进函数逻辑。简单的说,我遇到什么新问题,我就写什么代码。

初始数据:src/main/pg-*.txt

设置参数(这些参数都是已经设定的、无需修改的):


阶段一:输入分片与任务分配

注意:实验中 Coordinator 等价于论文中的 Master

这个阶段中,我们需要做什么?

首先要看我们有什么。实验通过通过命令 go run mrcoordinator.go sock123 pg-*.txt 创建一个 Coordinator 进程。因此,Coordinator 所拥有的数据是:

Note

在操作系统的视角里,mrcoordinator.go 和 coordinator.go 在编译后,被打包进了同一个二进制可执行文件中。进程中始终只有一个 Coordinator 进程。而mr.MakeCoordinator 只是创建了一个 Coordinator 对象(结构体对象)

我们想要完成的是输入分片与任务分配。对于输入分片:

根据 MapReduce 设计流程,我们需要完成物理存储分块和逻辑分配映射两个任务。

Note

在工业级 MapReduce 中,并不是靠文件名来区分 map task,因为一个大文件会被切成很多个 task,MapReduce 传输的是某个字节区间的元数据。但在该实验中采用文件名传输,这是为了降低实验过程难度而做的简化。

因此,输入分片任务无需我们完成,实验已经提供了假设和前提(既是简化,也是约束)。我们只需考虑任务分配。

从直觉上看,任务分配有两种方向:

  1. 推送(Push):由 coordinator 主动把任务分配给 worker。
    • coordinator 必须实时掌握每一个 worker 的状态,对于分布式系统来说,状态同步、网络变化,都是难以维护的。这可能意味着要实现更复杂的容错。
  2. 拉取(Pull):当有 worker 空闲的时候通知 coordinator,再由 coordinator 将任务分配给该 worker。
    • 它天然实现了负载均衡(任务分配由 Worker 的处理速度驱动,而不需要 Coordinator 进行计算)。降低了 Coordinator 对 Worker 状态的依赖性。

因此,我们采用 Pull 的方式进行任务分配。但问题是:

  1. “任务”在代码里是如何表达的?
  2. Worker 是如何通知 Coordinator 的?Coordinator 是如何进行任务分配的?

“任务”如何被表达?

任务的定义

由于“任务”是全局性的,Coodinator 需要维护每个任务的状态,而 Worker 只需要知道当前任务的信息就足够了。因此“任务”的定义是在 coordinator.go 中实现的。

于是,我们观察 coordinator.go,会发现它包含:

可以理解为:结构体对象用于存储数据与维护状态、结构体对象的方法用于执行动作、函数用于编排流程。

在结构体对象中,我们要存储和维护所有任务的列表:

//其中mu、MapCount、ReduceCount都是后来定义的,在这个阶段,我只定义了Tasks
type Coordinator struct {
	mu          sync.Mutex //互斥锁,保护所有字段
	Tasks       []Task
	MapCount    int // 未完成的 Map 任务数
	ReduceCount int // 未完成的 Reduce 任务数
}

对于每个任务,无论是 Map 任务还是 Reduce 任务,都有一致的结构和状态。因此我们再定义 Task 类型结构体:

//Task类型的定义同样是我在实现的过程中慢慢修改,不断完善的。比如AssignedAt字段是我在写容错阶段的代码时才加上的。
type Task struct {
	TaskID     int
	TaskType   TaskType
	Status     TaskStatus
	FileName   string
	NReduce    int       //Map任务需要知道NReduce才能将键值对分配到对应的哈希桶
	AssignedAt time.Time //任务分配时间
}

最初我考虑过用 MapTask 和 ReduceTask 两个不同的结构体。但它们有大量的代码冗余,且在 Coordinator 眼里,Map 任务和 Reduce 任务本质上都是一个“需要被分发、跟踪超时、等待完成的有限状态机”。


任务的创建

自然的,我们要接着考虑把数据存入 Coordinator 中。即创建任务。代码逻辑如下:

  1. 初始化任务列表(即切片)
    • 这里我用的初始化方法是 s := make([]T, 0, 100),即声明时指定长度和容量。这是工程上的最佳实践。
  2. 给每个输入文件创建一个 map 任务
  3. 创建 nReduce 个 reduce 任务

任务分配如何进行?

Worker 是如何通知 Coordinator 的?Coordinator 是如何进行任务分配的?由于 Worker 和 Coordinator 是不同的进程,所以任务分配,实则是进程间通信问题。

Note

进程间通信问题,包含单机内进程通信,和跨网络节点通信。

  • 单机内进程通信的方法用于同一台物理机上不同进程之间交换数据。主要通信方式有:管道、信号、消息队列、共享内存、信号量、内存映射文件。
  • 跨网络节点通信的方法,用于进程分布在不同机器时。主要通信方式有:套接字(Socket)、远程过程调用(RPC)、消息队列(MQ)、REST/HTTP 等

Coordinator 和 Worker 通信发生在以下两个情境:

它的通信特性是点对点、请求 - 响应、同步。我们选择直连调用的技术路线。这种技术路线下,可以选择的技术方案有:RPC 和 REST/HTTP。REST 把系统抽象成一组可操作的资源,RPC 把系统抽象成一组可调用的函数(即一组行为)。MapReduce 的通信语义是行为上的,比如“申请分配任务”、“告知任务已完成”,如果用资源来表达会产生错配,因此我们采用 RPC 的技术方案。

go 语言中,RPC 有多种实现,它们有相同的通信特性,但有不同的工程实现。

gRPC 有四种模式,其中 gRPC Unary 是 RPC 模型的直接实现,其她三种模式是 gRPC 借用了 RPC 的接口定义能力,但超出了 RPC 概念所定义的边界。

在实验中,我们的主要目的是实现逻辑而非关注通信细节,net/rpc 让我们把重心放在把设计工程化上。

在 net/rpc 中,通信的实现步骤

  1. 定义消息结构:用 Go 结构体定义请求和响应的数据结构。
  2. 编写服务端处理方法:编写业务逻辑,方法签名必须满足 net/rpc 的约定:
func (t *T) MethodName(args *ArgsType, reply *ReplyType) error

net/rpc 并没有在源码里定义一个 interface,没有具体的接口定义。它的做法是在注册时通过反射扫描,找出所有满足此约定的方法,将其注册为可远程调用的方法。

  1. 注册并启动服务端:将对象注册到 rpc.Server,监听连接等待连接。
  • 多台机器上,一般选择监听 TCP 端口等待连接;但由于实验是本地进行,采用的是 Unix Domian Socket 本地通信,不占用端口,避免多进程并发测试时端口冲突
  1. 客户端建立连接并调用:用 rpc.Dial/rpc.DialHTTP 连接到服务端,通过 Call 方法发起同步调用,传入方法名、请求和响应指针,阻塞等待结果返回。
  • RPC 支持短连接和长连接。实验中采用的是短连接,每次 RPC 都 DialHTTP + Call + Close

RPC 能够让 Worker 像调用本地函数一样调用远程方法。在任务分配时,Worker 调用 Coordinator 的 AssignTask 方法,Coordinator 在方法内选择一个任务填入响应结构体,返回给 Worker。

实验中,注册并启动服务端这一步在已经被封装进 server方法,无需实现;客户端连接服务器、发起调用的具体细节也被封装进call 函数,无需实现;

我们只需实现:

1.定义消息结构

通信实现的是:Coordinator 将任务分配给 Worker。因此传输的消息如下:

type AssignTaskArgs struct {
}
type AssignTaskReply struct {
	Task Task
}
2.Coordinator 分配任务

接下来,一旦有 Worker 调用 AssignTask(即向服务端 Coordinator 发起通信),Coordinator 会分配一个未完成的 Map 任务给它(作为响应参数)。代码逻辑如下:

  1. 如果还有 Map 任务没有完成,先分配 Map 任务
    • 循环找到一个未分配的 Map 任务。如果所有 Map 任务都分配完,则给 Worker 分配一个空任务
  2. 所有 Map 任务完成后,再分配 Reduce 任务
    • 循环找到一个未分配的 Reduce 任务。如果所有 Reduce 任务都分配完,则给 Worker 分配一个空任务
  3. 如果全部任务都已完成,则返回关闭任务

任务全都分配完,并不等于任务全都已完成

这里需要注意两点:

Note

如果整个函数体都是临界区,则可以在函数开头加锁:

c.mu.Lock()         //加锁
defer c.mu.Unlock() //解锁

如果函数中只有一部分代码是临界区,则可以手动加锁解锁:

c.mu.Lock()         //加锁
//临界区代码
c.mu.Unlock() //解锁
3.Worker 请求任务

前面提到,实验已经把调用细节封装进 call 函数中,我们只需要传入方法名、请求和响应参数,就可以实现通信。

worker.go 中已经给出了请求调用的实例代码 CallExample,模仿它写一个真实的请求分配任务的代码即可。

func getTask() (Task) {
	var args AssignTaskArgs
	var reply AssignTaskReply
	ok := call("Coordinator.AssignTask", &args, &reply)
	if !ok {
		return Task{}
	}
	return reply.Task

}

在主函数逻辑中,一个 Worker 的生命周期是:

  1. 空闲
  2. 无限循环请求分配任务(每次循环 getTask 一次)
    1. 分配到 Map 或 Reduce 任务
      1. 调用对应的任务执行函数(mapTask/reduceTask)
      2. 执行完毕,通知 Coordinator(我把这部分的代码放在了任务执行函数内部,因此目前阶段无需实现)
    2. 分配到空任务,等待一段时间后重新请求
    3. 分配到关闭任务,Worker 退出进程。

因此主函数里写一个循环,每次循环 getTask 一次,接着根据不同任务类型来处理任务。

阶段二:Map

这一阶段,我们要写的是 worker.go 中的 Map 任务执行函数,即mapTask。

MapReduce 设计流程和 MIT6.5840 实验实现的差异

根本差异在于:实验假设数据可以全部转入内存,而在工程实现中,假设数据远超于内存容量。

  1. mapTask 应该要先把 Block 数据块解析为键值对,以偏移量的形式传给 Map 函数(因为同一个文件可能会被切分为多个 Map 任务)。但由于实验中的 Map 函数简化了,所需参数为文件名,因此跳过这一步。
  2. 在真正的分布式实现(如 Hadoop)中,为了支持海量数据,Map 函数会通过 emit 逐条将中间结果交给框架,而不是一次性返回整个列表。而实验简化了这一步,让 Map 全量返回所有的中间键值对。正是因为这一步的差异,导致实验中 mapTask 的实现和原始设计中的实现截然不同。
  • 工程实现中,由于海量数据 ->必须使用流式 emit->引入了内存缓冲区 ->达到阈值后溢写 ->每次溢写时进行分区和局部排序 ->产生多个溢写文件 ->最终对多个溢写文件进行多路归并排序
  • 在实验中,从第一步就断开了,因此我们需要独立推导符合实验要求的流程。不过虽然实现的时机存在巨大差异,要达成的目的是一致的:获取中间键值对、对其分区与排序、写入中间文件。
  1. 根据文件名读取文件内容,将它们作为参数传入 Map 函数,获得中间键值对 kva(key-value array)
  2. 对中间键值对排序 + 分区
  3. 将中间键值对存入 mr-X-Y 文件(一个分区对应一个中间文件)
  4. 通知 Coordinator 任务已完成。

第一步的实现十分简单,我们讨论步骤二、三、四的实现。

排序与分区

我们已经拿到了该 Map 任务的全量中间键值对(即切片变量 kva),存储在内存中。

对于中间键值对,可以采取两种方案:

假设中间键值对总数为 N,分区(Reduce)数量为 R,且数据均匀分布。

  1. 先全量排序再分区。排序的时间复杂度为 O(N logN),分区的时间复杂度为O(N),总时间复杂度为O(N logN)
  2. 先分区再局部排序。分区时间复杂度为 O(N),排序的时间复杂度为R * O((N/R) log(N/R)),总时间复杂度约为O(N log(N/R))

因此,mapTask 中这一部分的实现逻辑如下:

  1. 遍历全部中间键值对,对其进行分区,存入对应的 buckets[i]
  2. 遍历每个 buckets,对桶内的切片调用 slices.SortFunc 进行排序。(采用这种排序方法的原因见Go语言:从排序功能看接口、反射、闭包、泛型)

写入中间文件

目前为止,我们得到了各个分区的有序数组 buckets[i]。要将数据写入文件,有许多种方法,应该如何决策呢?

写数据时需要考虑的两个维度

  1. 数据表示和序列化,即“数据存成什么格式?”。具体来说,它关注内存中的 Go 对象(如结构体、切片)如何转换为二进制字节流([]byte)。只负责数据的物理表达和结构还原,不关心数据最终是写入文件、还是发向网络。
  • 序列化为文本字符流。根据“如何划分字段”,由分为两种不同的策略:
    • 结构化标记流(JSON、XML)。通过显式的语法标记(如 {}、<>)来定义数据结构。自带层次结构、提供自动转义机制。
    • 扁平分隔流(如 CSV、自定义格式)。通过固定的位置或分隔符来切分字段。轻量、解析快,但需要手动实现复杂的特殊字符处理逻辑。
  • 序列化为二进制流。它的序列化速度最快、体积最小,但不支持文件追加,人类不可读。
  1. I/O 调度与刷盘策略,即“字节流怎么写进磁盘?”。它关注的是,拿到序列化后的字节流后,以什么方式向操作系统发起 sys_write 系统调用。
  • 一次性写入(os.WriteFile),适用于数据量不大的场景。有 OOM,即内存溢出风险
  • 标准写入(通过 os 包打开或创建文件,获得文件对象后再进行操作。),灵活可控,适用于大多数场景。写 1 个字节就触发 1 次 sys_write,CPU 频繁在用户态和内核态切换。
  • 缓冲写入(bufio.Writer),适用于大文件或高频写入。先在内存 bufio 里开辟一块缓冲区(如 64KB),攒满一块再一次性发起 sys_write

如果追求极致性能,序列化为二进制格式是最佳的选择。但人类不可读。而实验中,我们需要查看中间文件来进行调试,最终输出文件也要求是人类可读的格式以通过测试。因此,我们只能为序列化为文本字符流。

由于我们编写的是通用的框架,用户输入的文件是不可控的,文件中可能存在 \n、Tab 键等特殊字符。牺牲部分性能,获取代码的健壮性与稳定性,在实验中是合理的选择。因此我们最终采取的序列化方案是 JSON 格式。在 Go 语言中,使用 json.NewEncoder 和 json.NewDecoder 方法(即 Go 标准包 encoding/json)

此外,由于我们处理的是大量数据,应当选择缓冲写入的方式。

encoding/json 与缓冲读写

  • json.Encoder 内部实现的缓冲并不是为了减少系统调用而设计的,它的作用是保证“原子性”,即序列化出一个 JSON 对象到内存,再一次性调用 Write() 将整个对象写入io.Writer。因此如果有 N 个中间键值对,那就会调用 N 次Write(),我们需要手动实现缓冲写。
  • json.Decoder 内部自带缓冲读。它内部通过 bufio.Reader维护了一个缓冲区,从底层的io.Reader 中预读数据。因此无需手动实现缓冲读。

通知 Coordinator 任务已完成

Map 任务完成后,需要发起 RPC 通知 Coordinator 任务已完成。

阶段三:Shuffle、Sort 与 Reduce

当所有 Map 任务完成后,才会开始这一阶段,

worker 从 Coordinator 获取任务,如果任务类型是 Reduce,reduceTask 函数要完成的任务是:

  1. 从各个中间文件中读取自己分区的数据。
  2. 将多个中间文件中的数据进行排序,局部有序变成全局有序。
  3. 按 key 分组,并将分组结果依次传入自定义的 Reduce 函数,获取 key 值对应的结果
  4. 所有结果写入最终文件

Map 阶段生成的每个文件都是有序的,因此对于第一步、第二步,实则完成的是同一个目标:将 N 个有序的小文件,合并成 1 个全局有序的大文件。这是多路归并问题。

多文件多路归并的实现

  1. 打开所有文件:同时打开 N 个中间分区文件,为每个文件创建一个 json.Decoder(流式读取)。
  2. 初始化堆(Heap):从每个已排序的文件中读取第一条记录,将这些记录放入一个最小堆中(按 Key 排序)
  3. 从堆顶弹出最小的记录,输出该记录。
  • 从该记录来源的文件中读取下一条记录,推入堆中。
  • 如果该记录来源文件没有下一条记录了,则关闭该文件。堆的大小会 -1
  1. 重复步骤 3,直到所有文件的所有记录都被处理完毕。

在实现多路归并的过程中,我们发现,每次弹出的记录是有序的,也就是 key 值相同的键值对会接连弹出。因此,我们可以在归并的同时,直接进行分组、reduce

因此整个 reduceTask 函数的流程为:

  1. 【初始化】匹配并打开所有中间文件,为每个文件创建一个 json.Decoder,用于逐行读取 JSON 格式的键值对
  2. 【初始化】创建一个小根堆(优先队列),进行堆初始化(所有文件的第一个键值对入堆)
  3. 【初始化】创建临时输出文件 mr-out-{TaskID}.tmp* 与缓冲写入器。(最终输出文件将通过原子重命名生成,以避免写入过程中因程序崩溃而留下不完整文件。)
  4. 【初始化】维护一个 groups,用于暂存当前正在处理的同一 Key 的所有 Value。
  5. 循环归并与分组:循环执行以下操作,直到堆为空:
    • 弹出堆顶元素:从堆中弹出 Key 最小的记录(当前所有文件中最小的一条)。
    • 判断 Key 是否变化:
      • 若当前 Key 与 groups.Key 不同,说明上一分组已完整,调用 reducef 处理该分组,将结果写入输出缓冲区,然后重置分组为当前 Key。
      • 若相同,则将当前 Value 追加到 groups.Values 中。
    • 补充下一行数据:从该记录来源的文件中尝试读取下一行键值对。若读取成功,则将其重新推入堆;若遇到 io.EOF,则关闭该文件并不再补充新元素(堆大小会随之减少)。
  6. 【收尾】处理最后一个分组
  7. 【收尾】所有分组处理完成后,将缓冲区中的剩余数据写入磁盘,然后关闭临时文件。
  8. 【原子提交】将临时文件重命名为最终文件 mr-out-{TaskID}。
  9. 向 Coordinator 报告任务已完成。
为什么 map 任务输出结果不需要原子重命名,而 reduce 任务的输出结果需要原子重命名?

  • 如果 Map 中间文件写入失败,遗留一个不完整的中间文件在 Worker 本地。Coordinator 能够检测到任务失败,重新调度该任务到另一个 Worker。Reduce 读取中间文件时,只会从成功执行 Map 任务的 Worker 磁盘上读取数据。
  • 如果 Reduce 的文件写入失败,遗留一个不完整的最终文件在全局文件系统(GFS)中。Coordinator 能够检测到 Reduce 任务失败并重新调度,但在这之前,不完整的最终文件还未被覆盖,它可能被用户或测试脚本读取。

encoding/json 库:全量读取文件和流式读取文件

encoding/json 库提供了两套 API,分别是全量读取文件和流式读取文件(json.Unmarshal / json.NewDecoder+Decode)。

  • 如果选择全量读取,内存占用≈文件大小的字节切片 + 反序列化后的目标变量(≈文件大小),即两倍的文件大小。
  • 如果选择流式读取,内存占用=解析器内部的缓冲区大小(约 4KB)+ 当前正在解析的单条业务变量

阶段四:收尾

当所有任务结束后,MapCount和 ReduceCount都为 0。mrcoordinator周期性调用Done() 了来判断 Coordinator 任务是否已经完成。

横切关注点

日志

日志打印

log/slog 还是 fmt.Println?

  • fmt.Println:是面向用户的交互与标准输出。常用于向终端用户打印提示信息、帮助文档、操作结果等。
  • log/slog:是面向系统的运维与诊断。
    因此,如果是临时排查,写完就删,则用 fmt.Println。如果是长期保留,需要在不同环境切换控制日志输出,则用 log

log 还是 slog?

  • 传统 log 是纯文本输出。如果要收集日志中的数据用于统计,需要写复杂的正则表达式。它不带日志级别,只能通过特殊函数来控制程序(可以决定打印日志后是否要退出程序)。
  • slog 是键值对格式。它自带日志级别,且所有数据都是明确的键值对。

日志级别是控制日志输出的重要手段。通过设置不同的日志级别,我们可以灵活地控制日志的详细程度。在 Go 语言中,常见的日志级别有 DEBUG、INFO、WARN、ERROR 和FATAL。不同级别的日志用于记录不同类型的信息,例如:

  • DEBUG:用于记录详细的调试信息,仅在开发环境中启用。例如变量、函数入参等
  • INFO:用于记录正常的业务流程信息,例如服务启动、配置加载、任务完成、请求的处理、数据的加载等。
  • WARN:用于记录可能存在的问题或异常情况,但不影响系统的正常运行。例如用户输入了非法参数、限流触发、某个请求超时但已重试。
  • ERROR:用于记录严重的错误信息,这些错误可能导致系统无法正常运行。例如数据库连接端口
  • FATAL:用于记录非常严重的错误信息,这些错误会导致程序立即退出。

我在项目中采用 slog 进行日志打印,效果如下: Pasted image 20260821174337.png

最初,coordinator 和 worker 运行后,控制台没有任何输出,我不知道这些进程正在做什么,卡在哪一步。因此,我需要添加日志,来确认每个进程都在正常运行。

由于课程的要求:

  1. 输出结果要干净
  2. 不能修改 main 函数所在的文件
    因此,我把 logger 的初始化放在了 coordinator.go 和 worker.go 的入口。并且通过设置 slog.HandlerOptions 来一键显隐不同的日志等级(非常好用!)

由于我需要进行多进程的监控,因此我初始化了一个带有 PID 的 logger。

//初始化logger
func initLogger() {
	handler :=slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug})
	logger := slog.New(handler).With("Coordinator.PID", os.Getpid())
	//设置为全局默认Logger
	slog.SetDefault(logger)
	slog.Info("Coordinator 已成功启动")	
}

运行 Coordinator 和 Worker,输出日志如下:

Worker 进程:

Pasted image 20260820204359.png

Coordinator 进程

Pasted image 20260820204439.png


并发控制

根据日志输出可知,代码能够实现基本的流程。但在 makr mr 后,出现 DATA RACE 报错。

报错指出:main 协程在调用 Coordinator.Done() 读取数据时,RPC 协程(Goroutine 28)正在调用 Coordinator.DoneTask() 修改数据,且两边没有加锁互斥,访问了同一个内存地址(0x00c000210198)。

因此,我虽然完成了基本的业务逻辑代码,但没有做好临界区的处理。那么有哪些需要处理的临界区变量?

为了防止多个协程同时读写计数器、修改任务状态。我们需要给 Coordinator 实例加上互斥锁。对数据进行读写时,都需要加锁

type Coordinator struct {
	mu sync.Mutex   //互斥锁,保护所有字段
	Tasks []Task
	MapCount int
	ReduceCount int
}

例如在分配任务时:

func (c *Coordinator) AssignTask(args *AssignTaskArgs, reply *AssignTaskReply) error {
	c.mu.Lock()         //加锁
	defer c.mu.Unlock() //解锁
	...	
}

容错处理

TestJobCount 测试失败

Note

在 MapReduce 中,两个 worker 同时执行同一个 map 任务主要发生在以下两种场景。

  • 推测执行(优化速度。并发执行)。由于机器负载不均、硬件老化或网络延迟等,一个作业中的某些任务可能远慢于其他任务,成为整个作业的瓶颈。为了避免这种情况拖慢整个作业,框架会为该任务启动一个备份任务
  • 任务失败重试(确保可靠性。顺序执行)。这种重试是顺序的,即先等失败的任务结束后,再重新开始一个新任务。
特性 任务失败重试 (Fault-tolerance Retry) 推测执行 (Speculative Execution)
主要目的 保证可靠性:确保即使 Worker 故障,任务也能最终完成。 优化性能:为“拖后腿”的慢任务启动备份,加速整体进度。
触发条件 任务失败:Worker 崩溃、无响应或超过 10 秒未完成。 任务缓慢:任务仍在运行,但进度远落后于同类任务。
执行时序 先后执行:旧任务失败后,新任务才启动。 并行执行:备份任务与原始任务同时运行,先完成的被采纳。
在 6.5840 中 必须实现(核心要求)。 可选实现(实验不要求)。

在实验中要求超过 10 秒没有完成的任务即为超时。这是属于任务失败重试的情况。超过 10 秒没有完成这个任务,就可以视为这个 worker 已经崩溃了。这种情况下应该被归为容错,而不是性能优化。Coordinator 会把该任务的状态设置为空闲。

如果是推测执行及并行的情况,coordinator 会知道原来的 worker 是活着的并且正常,但它认为对方太慢了,所以会把这个任务分配给另外一个 worker,并且采纳最先返回的结果,然后主动通过 RPC 通知慢的那个 worker 放弃任务。

代码中的错误与调试

for _, task := range c.Tasks{
	if task.TaskType == t && task.Status == Idle{
		task.AssignedAt = time.Now()  //这里的task仅仅是一个副本,并不会修改真正的task的值
		task.Status = Running
		return task
	}
}