[MIT6.824] MapReduce Lab 无锁实现

MapReduce

一直想补一下分布式相关的知识,趁着最近没有开发需求,做了一下 MIT6.824 的 Lab1,内容是实现一个 MapReduce 的玩具版本。Google 的 MapReduce 原论文给出了一张 overview 图,这张图总括了实现 MapReduce 的所有要素:

Figure 1. MapReduce overview

用三段话概括 MapReduce 系统运行的整体流程:

  1. Worker 向 Coordinator 获取任务,Coordinator 派发 map 任务给 Worker,Worker 完成 map 任务后将结果写入文件持久化存储,并将文件的地址返回给 Coordinator;
  2. 当所有的 map 任务都完成时,Worker 向 Coordinator 获取任务,Coordinator 派发 reduce 任务给 Worker,Worker 读取 map 任务生成的文件作为输入,完成 recude 任务后将结果写入文件持久化存储,并将文件的地址返回给 Coordinator;
  3. 当所有 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"

    go 复制代码
    var 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.Timerselect 实现:给 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 上报任务完成触发。

监控机制并发细节

  1. 超时情况下为什么使用 CAS atomic.CompareAndSwapInt32(&t.status, TaskRunning, TaskPending) 改变任务状态,而不是直接使用 Store atomic.StoreInt32(&t.status, TaskPending) 将任务状态改为 TaskPending?

    因为任务超时的时候状态可能是 TaskDone,既然任务已经完成,没必要再改为 TaskPending 状态了。举个例子:

    1. Coordinator 派发 map1 给 Worker1,但是 Worker1 性能比较差,未能在超时时间内完成此任务,但 Worker1 并不是真崩溃了,还在继续运行 map1;
    2. Coordinator 认为 map1 不能健康运行了,就将任务状态改为 TaskPending,分配给后来的 Worker2;
    3. Worker2 运行 map1 任务也超时,准备将任务状态改为 TaskPending,恰好此刻 Worker1 完成任务上报给 Coordinator,Coordinator 将 map1 任务状态改为 TaskDone。那么 atomic.CompareAndSwapInt32(&t.status, TaskRunning, TaskPending) 就会执行失败,不会覆盖 TaskDone,⚠️ 但 atomic.StoreInt32(&t.status, TaskPending) 就会将 TaskDone 覆盖为 TaskPending。
  2. 为什么采用 CAS 完成任务从 TaskPending/TaskRunning 到 TaskDone 的改变,而不是直接使用 Store?

    为了避免 t.running 被多次关闭。举个例子:

    1. Coordinator 派发 map1 给 Worker1,但是 Worker1 性能比较差,未能在超时时间内完成此任务,但 Worker1 并不是真崩溃了,还在继续运行 map1;
    2. Coordinator 认为 map1 不能健康运行了,就将任务状态改为 TaskPending,分配给后来的 Worker2;
    3. 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 只被关闭一次)
  3. 会有 Task 从 TaskPending 直接变为 TaskDone 吗?

    1. Coordinator 派发 map1 给 Worker1,但是 Worker1 性能比较差,未能在超时时间内完成此任务,但 Worker1 并不是真崩溃了,还在继续运行 map1;
    2. Coordinator 认为 map1 不能健康运行了,就将任务状态改为 TaskPending,没有 Worker 向 Coordinator 请求任务,map1 保持 TaskPending 状态;
    3. Worker1 完成任务上报给 Coordinator,Coordinator 将 map1 任务状态改为 TaskDone。

总体流程

总体流程可以分为两个大阶段:1. 请求任务 2. 监控任务。我们借助原子操作替代掉锁,并且引入监控机制来保证 Worker 在崩溃的情况下,整体流程可以继续推进。

Figure 9. MapReduce 请求任务


Figure 10. MapReduce 监控任务


Figure 11. MapReduce 整体流程

最后

I do and I understand!

相关推荐
斯普信专业组1 小时前
深度解析FastDFS:构建高效分布式文件存储的实战指南(上)
分布式·fastdfs
代码吐槽菌2 小时前
基于SSM的毕业论文管理系统【附源码】
java·开发语言·数据库·后端·ssm
豌豆花下猫2 小时前
Python 潮流周刊#78:async/await 是糟糕的设计(摘要)
后端·python·ai
YMWM_2 小时前
第一章 Go语言简介
开发语言·后端·golang
jikuaidi6yuan2 小时前
鸿蒙系统(HarmonyOS)分布式任务调度
分布式·华为·harmonyos
码蜂窝编程官方2 小时前
【含开题报告+文档+PPT+源码】基于SpringBoot+Vue的虎鲸旅游攻略网的设计与实现
java·vue.js·spring boot·后端·spring·旅游
天冬忘忧2 小时前
Kafka 生产者全面解析:从基础原理到高级实践
大数据·分布式·kafka
hummhumm3 小时前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
J老熊3 小时前
JavaFX:简介、使用场景、常见问题及对比其他框架分析
java·开发语言·后端·面试·系统架构·软件工程
AuroraI'ncoding3 小时前
时间请求参数、响应
java·后端·spring