MapReduce
一直想补一下分布式相关的知识,趁着最近没有开发需求,做了一下 MIT6.824 的 Lab1,内容是实现一个 MapReduce 的玩具版本。Google 的 MapReduce 原论文给出了一张 overview 图,这张图总括了实现 MapReduce 的所有要素:
Figure 1. MapReduce overview
用三段话概括 MapReduce 系统运行的整体流程:
- Worker 向 Coordinator 获取任务,Coordinator 派发 map 任务给 Worker,Worker 完成 map 任务后将结果写入文件持久化存储,并将文件的地址返回给 Coordinator;
- 当所有的 map 任务都完成时,Worker 向 Coordinator 获取任务,Coordinator 派发 reduce 任务给 Worker,Worker 读取 map 任务生成的文件作为输入,完成 recude 任务后将结果写入文件持久化存储,并将文件的地址返回给 Coordinator;
- 当所有 reduce 任务都完成时,整个 MapReduce 流程结束。
分析
从上面三段话以及 Figure 1,我们可以提取出很多信息:
-
系统中存在三种实体:Coordinator,Worker 和任务 Task(map 或者 reduce)。Coordinator 和 Worker 沟通的媒介是任务 Task 以及执行任务的结果:
Figure 2. Coordinator 和 Worker 通信 -
Coordinator 存在三个状态:1. Mapping 状态:map 任务尚未全部完成;2. Reducing 状态:map 任务全部完成,reduce 任务尚未全部完成;3. Done 状态:reduce 任务全部完成。三种状态之间的转换是单向的:
Figure 3. Coordinator 状态转换 -
每个任务 Task 隐藏了三个状态:1. TaskPending 状态:任务尚未被分配给 Worker;2. TaskRunning 状态:任务已经被分配给 Worker 3. TaskDone 状态:Worker 执行完任务并返回结果文件地址给 Coordinator。目前看来 Task 的三种状态之间的转换是单向的:
Figure 4. Task 状态转换⚠️⚠️ Task 的状态非常重要,原因之一是 Coordinator 只需要分配 TaskPending 状态的任务给 Worker;还有一个更重要的原因: Task 状态的改变驱使 Coordinator 状态的改变:
Figure 5. 状态驱动
完美路径
通过上节的分析,我们可以确定在编码时要实现的内容:
- Coordinator 需要维护自身的三个状态,此外还需要维护 map Task 集合和 reduce Task 集合;
- Task 应该有 map 和 reduce 两种类型以及上面提到的三种状态,此外还需要给它一个 id,这样我们才能分辨出各个任务;
- Worker 所做的工作其实就是一个循环:向 Coordinator 获取任务,完成后任务上报给 Coordinator,再继续向 Coordinator 获取任务 ...
Coordinator 与 Task
下图展示了 Mapping 阶段的 Coordinator 的工作流程,Reducing 阶段的工作流程类似。其中有几个地方涉及到了并发访问,我们要使用 Go 语言提供的各种并发控制机制来保证并发安全:
Figure 6. MapReduce Mapping
-
Worker 向 Coordinator 获取任务时,Coordinator 需要读取自身当前状态 来确定是选择 map 任务还是 reduce 任务;Worker 向 Coordinator 汇报任务完成时,若 Coordinator 判定当前所有 map Task/reduce Task 已完成,则需要改变自身当前状态到 Reducing/Done。
Coordinator 的状态涉及到并发读写,最简单的方式就是给 Coordinator 状态字段配一把互斥锁
sync.Mutex
,每次读或者写时都加上锁。但是用互斥锁来保护一个字段有点杀鸡焉用牛刀的感觉,这里我们可以使用原子操作来代替锁 :读时使用atomic.LoadInt32
原子性地读;写时使用atomic.StoreInt32
原子性的写。这是我们第一处将锁消除的地方。 -
Worker 向 Coordinator 获取任务时,Coordinator 需要遍历 map Task 集合读取每个任务的状态 ,找出一个状态为 TaskPending 的任务,将其状态改为 TaskRunning ,然后返回给 Worker;Worker 向 Coordinator 汇报任务完成时,Coordinator 需要改变对应任务的状态为 TaskDone。
Task 的状态涉及到并发读写。最简单的方式是直接给 map Task 集合配一把互斥锁,锁的范围很大。稍微优化一下,我们可以给每个 Task 配一把互斥锁,每次读或者写 Task 的状态时都加上锁。还可以再优化一下,不锁整个 Task,而是给 Task 的状态字段配一把互斥锁。又回到了上面的问题,用互斥锁来保护一个字段有点杀鸡焉用牛刀的感觉,我们可以使用原子操作来代替锁 :改变对应任务的状态为 TaskDone 时,使用
atomic.StoreInt32
即可。但这里有意思的事情发生了,我们不能单纯地使用 Load 和 Store 实现场景 "找出一个状态为 TaskPending 的任务,将其状态改为 TaskRunning",举个例子:
Figure 7. 两个 goroutine 获取到同一个 task虽然 Load 和 Store 两个原子操作分别是原子性的,但是两个操作先后执行的组合状态就不符合原子性了。
这里我们需要使用一种新的原子操作 CAS (Compare And Swap),CAS 的输入有三个参数:ptr,expectedValue 和 newValue,顾名思义 ptr 表示我们要操作的数据的指针,在我们的场景中是 Task 状态字段的指针;expectedValue 和 newValue 表示当 ptr 指向的值是 expectedValue 时,将 ptr 指向的值改为 newValue。最终 CAS 会返回一个 bool 值,表示是否将值设置为了 newValue。
借助 CAS,我们可以原子性的实现 "当 Task 状态为 TaskPending时,将其设置为 TaskRunning":
govar task *Task = nil for t := range mapTask集合 { if atomic.CompareAndSwapInt32(&task.status, TaskPending, TaskRunning) { // 找到了一个 TaskPending 的任务,并将其状态改为了 TaskRunning task = t break } }
这里是第二处我们将锁消除的地方,也是最后一处。
Worker
根据上面的分析,Worker 负责工作比较简单:向 Coordinator 请求任务并执行,将执行结果写入文件后返回文件地址给 Coordinator。
实际上 Worker 还负责一些其他工作:
- Worker 主动阻塞:Worker 向 Coordinator 获取任务,若 Coordinator 没有 TaskPending 的 map 或者 reduce 任务,会向 Worker 返回一个 Spin 任务,Worker 接收到 Spin 任务后会主动阻塞一段时间后再次向 Coordinator 发起任务请求;
- Worker 主动退出:1. Worker 向 Coordinator 请求任务出错时会多次重试,如果仍然请求任务出错,就认为无法与 Coordinator 通信,主动退出;2. Coordinator 处于 Done 状态时,Worker 向 Coordinator 获取任务,Coordinator 会向 Worker 返回一个 Exit 任务,Worker 接收到 Exit 任务后主动退出。
Worker 可能崩溃
上面所有的讨论与分析都是基于一个完美的前提:Worker 不会崩溃❗️❗️一旦某个 Worker 发生崩溃,它所负责的 map 或者 reduce 任务就永远处于 TaskRunning 状态,整个 MapReduce 的流程永远不会结束。
我们不可能预知某个 Worker 将来是否会崩溃,所以只能在系统运行时主观判断 Worker 负责的任务是否还在健康运行:为任务执行设置一个超时时间 T,当 Coordinator 发现某个任务执行时间(从 Worker 请求任务,Coordinator 将任务分配出去后开始计算)超过 T 时,就认定这个任务已经不能正常运行了。Coordinator 将此任务的状态从 TaskRunning 改为 TaskPending,以便后续 Worker 请求任务时可以把此任务分配出去。
此时,Task 三种状态之间的转换不再是单向了,情况变得稍微复杂了一些:
Figure 8. Task 状态转换(考虑 Worker 崩溃)
监控机制
我们现在需要设计一个监控机制,用来监控任务是正常执行完毕还是超时。我们可以借助 time.Timer
和 select
实现:给 Task 新增 running chan struct{}
字段,每个任务 task 被 Coordinator 派发出去时,开启一个新的协程go task.Monitor
监听 timer 超时和 <-running
:
go
// 伪代码 Worker 请求任务,Coordinator 派发任务
func (c *Coordinator) RequestTask() *Task {
var task *Task = nil
if atomic.LoadInt32(&c.status) == Mapping {
task = 从mapTask集合获取TaskPending任务()
if task != nil {
// 监控 task
go task.Monitor(time.Second*10)
return task
}
// ...
}
// ...
}
go
// 伪代码 监控任务状态
func (t *Task) Monitor(timeout time.Duration) {
timer := time.NewTimer(timeout)
select {
case <-timer.C:
// 超时,将 t 的状态从 TaskRunning 改为 TaskPending
atomic.CompareAndSwapInt32(&t.status, TaskRunning, TaskPending)
case <-t.running:
// 正常执行完毕,关闭计时器并退出
timer.Stop()
}
}
当 Worker 上报给 Coordinator 任务 t 执行完毕时,Coordinator 将任务 t 的状态改为 TaskDone,并关闭 t.running,使得 case <-t.running
成立:
go
// 伪代码 Worker 上报任务完成
func (c *Coordinator) TaskDone(t *Task) {
if atomic.CompareAndSwapInt32(&t.status, TaskPending, TaskDone) ||
atomic.CompareAndSwapInt32(&t.status, TaskRunning, TaskDone) {
close(t.running) // 使得 Monitor 中 case <-t.running 成立
// ...
// ...
}
}
总结一下监听思路:两个出口,任务超时出口由超时触发,任务执行完毕出口由 Worker 上报任务完成触发。
监控机制并发细节
-
超时情况下为什么使用 CAS
atomic.CompareAndSwapInt32(&t.status, TaskRunning, TaskPending)
改变任务状态,而不是直接使用 Storeatomic.StoreInt32(&t.status, TaskPending)
将任务状态改为 TaskPending?因为任务超时的时候状态可能是 TaskDone,既然任务已经完成,没必要再改为 TaskPending 状态了。举个例子:
- Coordinator 派发 map1 给 Worker1,但是 Worker1 性能比较差,未能在超时时间内完成此任务,但 Worker1 并不是真崩溃了,还在继续运行 map1;
- Coordinator 认为 map1 不能健康运行了,就将任务状态改为 TaskPending,分配给后来的 Worker2;
- Worker2 运行 map1 任务也超时,准备将任务状态改为 TaskPending,恰好此刻 Worker1 完成任务上报给 Coordinator,Coordinator 将 map1 任务状态改为 TaskDone。那么
atomic.CompareAndSwapInt32(&t.status, TaskRunning, TaskPending)
就会执行失败,不会覆盖 TaskDone,⚠️ 但atomic.StoreInt32(&t.status, TaskPending)
就会将 TaskDone 覆盖为 TaskPending。
-
为什么采用 CAS 完成任务从 TaskPending/TaskRunning 到 TaskDone 的改变,而不是直接使用 Store?
为了避免 t.running 被多次关闭。举个例子:
- Coordinator 派发 map1 给 Worker1,但是 Worker1 性能比较差,未能在超时时间内完成此任务,但 Worker1 并不是真崩溃了,还在继续运行 map1;
- Coordinator 认为 map1 不能健康运行了,就将任务状态改为 TaskPending,分配给后来的 Worker2;
- Worker1,Worker2 相继完成任务上报给 Coordinator,相继调用两次
atomic.StoreInt32(&t.status, TaskDone)
确实不影响 t 的状态为 TaskDone,但 t.running 只能 close 一次,不然会 panic。我们借助 CAS 可以保证只有一个 goroutine 可以将状态从 TaskPending/TaskRunning 转变为 TaskDone,使得后面的close(t.running)
只执行一次。(当然也可以使用sync.Once
等其他方式来实现 channel 只被关闭一次)
-
会有 Task 从 TaskPending 直接变为 TaskDone 吗?
- Coordinator 派发 map1 给 Worker1,但是 Worker1 性能比较差,未能在超时时间内完成此任务,但 Worker1 并不是真崩溃了,还在继续运行 map1;
- Coordinator 认为 map1 不能健康运行了,就将任务状态改为 TaskPending,没有 Worker 向 Coordinator 请求任务,map1 保持 TaskPending 状态;
- Worker1 完成任务上报给 Coordinator,Coordinator 将 map1 任务状态改为 TaskDone。
总体流程
总体流程可以分为两个大阶段:1. 请求任务 2. 监控任务。我们借助原子操作替代掉锁,并且引入监控机制来保证 Worker 在崩溃的情况下,整体流程可以继续推进。
Figure 9. MapReduce 请求任务
Figure 10. MapReduce 监控任务
Figure 11. MapReduce 整体流程
最后
I do and I understand!