MIT6824-Lab1-MapReduce

MIT 6.824 分布式系统 Lab1 MapReduce

问题定义

一开始上来不知道要干什么。还是要先说回论文里的这张图。

image.png

系统中有个 Coordinator (Master) 和一堆 Worker. Worker 向 Coordinator 请求任务来执行并向 Coordinator 提交任务结果,Coordinator 负责任务的分发以及状态的记录。

系统的输入是一堆文件,每个文件对应一个 Map 任务。任务由 Worker 向 Coordinator 请求,由 Coordinator 向下分发。在分发 Map 任务时,Coordinator 同时会传递 Reduce 的任务数量(NReduce)给 Worker。每个 Worker 会生成 NReduce 个中间文件,将不同的 Key 哈希到不同文件里。最后 Hash 结果相同的一组文件形成一个 Reduce 任务交给 Worker 执行。

未命名绘图.drawio.png

调试技巧

Logger

在讨论解决方案之前,阐述一些调试技巧是很重要的。多线程的程序与单线程程序不同,Bug 不一定能百分百复现,并且不好断点调试,所以一种合理的打日志技巧在整门课程中就非常重要。

这里参考 6.824 助教写的这篇文章 Debugging by Pretty Printing (josejg.com) 构建一个 Log 模块来输出日志。

所有进程有一个自己的日志文件。

image.png

日志信息会分级输出到文件中。输出头包括时间,距启动时长,日志级别等。

image.png

在系统的一些关节点打上分级日志。如函数入参出参、状态转换结点、RPC 调用等。对于正常的 Debug 参数,可以打 DEBUG 标,可能出现的、可以被处理的意外情况打 WARN 标,绝对不允许出现的错误情况打 ERROR 标。

image.png

这样在执行过程中可以通过时间戳方便的还原现场。结合一些文本编辑器如 VSCode 看高亮日志非常方便。

image.png

实现方式很简单,用环境变量控制是否输出日志。

package logger

import (
	"fmt"
	"log"
	"os"
	"strconv"
	"time"
)

type logTopic string

const (
	DClient  logTopic = "CLIENT"
	DCommit  logTopic = "COMMIT"
	DDrop    logTopic = "DROP"
	DError   logTopic = "ERROR"
	DInfo    logTopic = "INFO"
	DLeader  logTopic = "LEADER"
	DLog     logTopic = "LOG1"
	DLog2    logTopic = "LOG2"
	DPersist logTopic = "PERSIST"
	DSnap    logTopic = "SNAP"
	DTerm    logTopic = "TERM"
	DTest    logTopic = "TEST"
	DTimer   logTopic = "TIMRER"
	DTrace   logTopic = "TRACE"
	DVote    logTopic = "VOTE"
	DWarn    logTopic = "WARN"
	DDebug   logTopic = "DEBUG"
)

func getVerbosity() int {
	v := os.Getenv("VERBOSE")
	level := 0
	if v != "" {
		var err error
		level, err = strconv.Atoi(v)
		if err != nil {
			log.Fatalf("Invalid verbosity %v", v)
		}
	}
	return level
}

var debugStart time.Time
var debugVerbosity int

func init() {
	debugVerbosity = getVerbosity()
	debugStart = time.Now()
	log.SetFlags(log.Flags() &^ (log.Ldate | log.Ltime))
	log.SetOutput(os.Stdout)
}

func Debug(topic logTopic, format string, a ...any) {
	if debugVerbosity >= 1 {
		current := time.Now()
		t := time.Since(debugStart).Microseconds()
		t /= 100

		prefix := fmt.Sprintf("%+v %8d %6v ", current.Format("2006-01-02 15:04:05.00000000"), t, string(topic))
		format = prefix + format
		log.Printf(format, a...)
	}
}

启动脚本

每次手动启动很多 Worker 的过程也很繁琐。写个脚本启动 Coordinator 和任意数量的 Worker。

先编译 coordinator,再 nohup 执行,最后 tail 日志在屏幕。VERBOSE 环境变量用于控制日志是否输出。TMPDIR 和 OUTDIR 是代码里中间文件和结果文件的保存路径。

#!/bin/bash

export VERBOSE=$1
export TMPDIR=/home/xuwenhao/src/6.824/build/tmp/
export OUTDIR=/home/xuwenhao/src/6.824/src/main/

go build -race -o /home/xuwenhao/src/6.824/build/bin/coordinator /home/xuwenhao/src/6.824/src/main/mrcoordinator.go

echo "build coordinator successfully!"

nohup /home/xuwenhao/src/6.824/build/bin/coordinator /home/xuwenhao/src/6.824/src/main/pg-*.txt > /home/xuwenhao/src/6.824/build/logs/coordinator.log 2>&1 &

tail -200f /home/xuwenhao/src/6.824/build/logs/coordinator.log

Worker 的脚本先编译插件,再编译 Worker,最后循环启动多个实例。VERBOSE 变量,具体编译的插件路径和运行的实例数量都从命令行传入。

#!/bin/bash

export VERBOSE=$1 
export TMPDIR=/home/xuwenhao/src/6.824/build/tmp/
export OUTDIR=/home/xuwenhao/src/6.824/src/main/

go build -race -buildmode=plugin -o /home/xuwenhao/src/6.824/build/bin/plugin.so $3

echo "build plugin successfully!"

go build -race -o /home/xuwenhao/src/6.824/build/bin/worker /home/xuwenhao/src/6.824/src/main/mrworker.go

echo "build worker successfully!"


for ((i=1;i<=$2;i++));do
	nohup /home/xuwenhao/src/6.824/build/bin/worker /home/xuwenhao/src/6.824/build/bin/plugin.so > /home/xuwenhao/src/6.824/build/logs/worker-$i.log 2>&1 &
done

用法:在项目仓库/src 路径下执行。

#coordinator
<path>/coordinator.sh 1

# worker
<path>/worker.sh 1 10 /home/xuwenhao/src/6.824/src/mrapps/wc.go 

模拟 Crash

用超时模拟 Crash。如果允许 crash,每次执行任务随机等待一段时间。

image.png

关键设计

整个系统的执行过程可以想象成一个状态机。有如下几种状态。

  1. ORIGIN:分发 Map 任务。
  2. MAPPING:所有 Map 任务已分发,无待分发 Map 任务,但未全部执行完毕。
  3. MAPPED:分发 Reduce 任务,所有 Map 任务执行完毕。
  4. REDUCING:所有 Reduce 任务已分发,无待分发 Reduce 任务,但未全部执行完毕。
  5. REDUCED:所有 Reduce 任务执行完毕,系统可退出。

此外还需要一些转移状态的函数,例如分发任务、标记状态、检测某种任务是否分发完毕或执行完毕等。

推荐具体的执计算的逻辑最后写。先调通系统的状态转换逻辑,并确保在 crash 的情况下系统仍然 work。 最后再去写读文件,调用 mapf 等操作的逻辑。这样会有一个清晰的脉络,同时出现 Bug 也比较好排查。

任务分发

任务分发可以用 Go 自带的 Channel 实现。将任务抽象成一个 Task 结构体,放入 Channel。每次 Worker 来请求时,从 Channel 中取出并发送给 Worker。

状态转换

分发任务、记录任务完成时记录对应数量,在数量达到某个阈值的时候更改系统状态。这些变量是临界变量,需要加锁。

错误处理

题目中说可能会遇到 Crash 的情况。如果 Worker 10 秒内没有标记任务完成,那么默认这次执行 crashed。所以我们需要有个探测是否 crash 的机制。普遍的做法给任务的状态加一个时间戳,开一个协程,轮询任务队列(注意任务队列也是临界资源,是有锁的),如果超过时间戳那么标记 crash 并重新分发。但这样会造成频繁的锁竞争。例如超时时间为 10 秒,在 10 秒到达之前没有必要去竞争这个锁。

这里使用回调的方式来实现。用一个 Channel 保存回调函数。当一个任务被分发,将超时的检测和处理逻辑放入一个函数中,传递给 Channel。另起一个 crash 处理的协程,从 Channel 中不断拿出回调函数,启动协程执行对应检测和处理逻辑。

标记完成

因为 crash 的存在,可能出现一个任务被分配给了多个 Worker 的情况,我们应该只信任最后拿到任务的 Worker 给出的结果。可以利用 MVCC 的思想,给每个任务一个递增的版本号,只有在版本号匹配时才标记任务完成。

具体实现

给出几个关键实现。全部代码见 GitHub xuwhao/6.824: MIT 6.824 2022 solutions. (github.com)

TaskContext

对于一个任务,我们需要维护它的一些基本信息和状态,封装成一个任务的上下文。TaskContext 是临界资源,需要加锁,是典型的读者写者模型。这里用 XS 锁来减小锁竞争。

type TaskID int
type TaskType int

const (
    UNDEFIDED TaskType = iota
    MAP
    REDUCE
    WAITING
    EXIT
)

type Phase int

const (
    ORIGIN   Phase = iota // assign map tasks
    MAPPING               // all map tasks assigned but not done
    MAPPED                // all map tasks have been done, assign reducing tasks
    REDUCING              //all reduce tasks assigned but not done
    REDUCED               // all reduce tasks have been done
    DONE
)

type TaskContext struct {
    Lock      *sync.RWMutex
    Task      *Task
    TaskPhase Phase
}

func (ctx *TaskContext) String() string {
    return fmt.Sprintf("{%p, {Task: %v, TaskPhase: %d}", ctx, ctx.Task, ctx.TaskPhase)
}

type Task struct {
    Type       TaskType
    Id         TaskID
    NReduce    int
    InputFiles []string
    Version    int
}

func (task *Task) String() string {
    return fmt.Sprintf("{%p, {Type: %d, Id: %d, NReduce: %d, InputFiles: %+v, Version: %d}}", task, task.Type, task.Id, task.NReduce, task.InputFiles, task.Version)
}

任务分发

执行任务的时间一般都比分发任务的时间长,所以会有比较多的等待任务(Waiting Task)分发。在分发等待任务时,对系统数据没有任何修改,只是会读取系统目前的状态变量这一临界资源,所以这里也用 XS 锁。

当 S 锁解除,加 X 锁的时候要重新检验状态数据,也就是二次检验,不然可能出现解锁的一瞬间数据被修改导致 race condition。

func (c *Coordinator) AssignTask(_ Task, reply *Task) error {
	c.Lock.RLock()
	logger.Debug(logger.DDebug, "assign task, coordinator phase %d", c.Phase)

	switch c.Phase {
	case ORIGIN:
		c.Lock.RUnlock()

		c.Lock.Lock()
		if c.Phase == ORIGIN {
			c.assignMRTask(reply, MAPPING, "map")
			if c.Processing > 0 && c.Processing+c.ReduceID == c.NMap { // All map tasks were assigned but not all were completed
				c.Phase = MAPPING
				logger.Debug(logger.DDebug, "coordinator enter MAPPING phase, processing %d, ReduceID %d", c.Processing, c.ReduceID)
			}
		}
		c.Lock.Unlock()
		return nil

	case MAPPED:
		c.Lock.RUnlock()

		c.Lock.Lock()
		if c.Phase == MAPPED {
			c.assignMRTask(reply, REDUCING, "reduce")
			if c.Processing > 0 && c.Processing+c.DoneCnt == c.NReduce { // All reduce tasks were assigned but not all were completed
				c.Phase = REDUCING
				logger.Debug(logger.DDebug, "coordinator enter REDUCING phase, processing %d, DoneCnt %d", c.Processing, c.DoneCnt)
			}
		}
		c.Lock.Unlock()
		return nil

	case MAPPING, REDUCING:
		reply.Type = WAITING
		reply.Id = -1
		logger.Debug(logger.DDebug, "assign WAITING task, %+v", reply)
	case REDUCED:
		reply.Type = EXIT
		reply.Id = -2
		logger.Debug(logger.DDebug, "assign EXIT task, %+v", reply)
	}

	c.Lock.RUnlock()
	return nil
}

具体的分发 Map 或 Reduce 任务的函数。在函数的最后将一个匿名函数作为回调放入 callbackChannel,在任务被下发后,于令一个协程中被调用。注意这里也需要二次检测。Task 的上下文通过闭包(closure)直接拿。

// 分发具体的 Map 或 Reduce 任务
func (c *Coordinator) assignMRTask(reply *Task, taskPhase Phase, prompt string) {

	ctx := <-c.taskChannel

	ctx.Lock.Lock()
	ctx.TaskPhase = taskPhase
	*reply = *(ctx.Task)
	logger.Debug(logger.DDebug, "assign "+prompt+" task context %+v, reply %+v", ctx, reply)
	ctx.Lock.Unlock()

	c.Processing++

	// crash test callback for crash handler
	// 如果 crash 了,调整 version,回退 phase,再将 task 放入channel 等待下一次分发
	// 匿名函数会在 crashHandler 协程中被调用
	c.callbackChannel <- func() {
		timer := time.NewTimer(time.Second * EXPIRE)
		<-timer.C
		ctx.Lock.RLock()
		if ctx.TaskPhase != taskPhase+1 {
			ctx.Lock.RUnlock()

			ctx.Lock.Lock()
			if ctx.TaskPhase != taskPhase+1 { // task is not done
				c.Lock.Lock() // redo when AssignTask return
				c.Processing--
				if c.Processing < 0 {
					logger.Debug(logger.DError, "task callback "+prompt+", c.Processing less than 0, processing %d, ReduceID %d, DoneCnt %d", c.Processing, c.ReduceID, c.DoneCnt)
				}
				c.Phase = taskPhase - 1 // MAPPING - 1 = ORIGIN, REDUCEING -1 = MAPPED
				ctx.TaskPhase = taskPhase - 1
				ctx.Task.Version += 1
				c.taskChannel <- ctx
				logger.Debug(logger.DWarn, "task crashed! "+prompt+" task context %+v, processing %d, ReduceID %d, DoneCnt %d", ctx, c.Processing, c.ReduceID, c.DoneCnt)
				c.Lock.Unlock()
			}
			ctx.Lock.Unlock()
			return
		}
		ctx.Lock.RUnlock()
	}
}

错误处理

每当一个任务被下发给 Worker, c.callbackChannel 中就会被放入一个匿名回调函数。在 c.crashHandler() 协程中会不断的从 c.callbackChannel 中取回调函数并启动协程执行。这样保证一个任务在超时限制之前不会去因为检测 crash 而竞争任务上下文的锁,提高了程序的并发程度。

func (c *Coordinator) assignMRTask(reply *Task, taskPhase Phase, prompt string) {

	// set tast context...

	// crash test callback for crash handler
	// 如果 crash 了,调整 version,回退 phase,再将 task 放入channel 等待下一次分发
	// 匿名函数会在 crashHandler 协程中被调用
	c.callbackChannel <- func() {
		timer := time.NewTimer(time.Second * EXPIRE) // set timeout
		<-timer.C // 阻塞一定时间
		ctx.Lock.RLock()
		if ctx.TaskPhase != taskPhase+1 { // 判断任务是否完成
			ctx.Lock.RUnlock()

			ctx.Lock.Lock()
			if ctx.TaskPhase != taskPhase+1 { // task is not done
				c.Lock.Lock() // 回退任务状态
				c.Processing--
				if c.Processing < 0 {
					logger.Debug(logger.DError, "task callback "+prompt+", c.Processing less than 0, processing %d, ReduceID %d, DoneCnt %d", c.Processing, c.ReduceID, c.DoneCnt)
				}
				c.Phase = taskPhase - 1 // MAPPING - 1 = ORIGIN, REDUCEING -1 = MAPPED
				ctx.TaskPhase = taskPhase - 1
				ctx.Task.Version += 1
				c.taskChannel <- ctx // 重分配
				logger.Debug(logger.DWarn, "task crashed! "+prompt+" task context %+v, processing %d, ReduceID %d, DoneCnt %d", ctx, c.Processing, c.ReduceID, c.DoneCnt)
				c.Lock.Unlock()
			}
			ctx.Lock.Unlock()
			return
		}
		ctx.Lock.RUnlock()
	}
}

// crashHandler start another goroutine to detect a crash or tiemout worker
func (c *Coordinator) crashHandler() {
	go func() {
		for callback := range c.callbackChannel {
			go callback()
		}
	}()
}

请求任务

Worker 通过 RPC 调用 AssignTask。这有个坑,我不知道是 Go RPC 库本身的问题还是什么,会出现 Worker RPC 调用没有报错但是返回全空的数据。所以 Worker 拿到结果后不要用各个类型的默认值做判断。Task.Type 加了个 UNDEFINED 的值对应上述情况。

还有个坑是,结果变量应该是 0 分配的。Hints 原文。

When passing a pointer to a reply struct to the RPC system, the object that *reply points to should be zero-allocated. The code for RPC calls should always look like

reply := SomeType{}
call(..., &reply)

without setting any fields of reply before the call. If you don't follow this requirement, there will be a problem when you pre-initialize a reply field to the non-default value for that datatype, and the server on which the RPC executes sets that reply field to the default value; you will observe that the write doesn't appear to take effect, and that on the caller side, the non-default value remains.

// Worker main/mrworker.go calls this function.
func Worker(mapf func(string, string) []KeyValue, reducef func(string, []string) string) {

	var err error

	caller := RPCCaller[Task, *Task]{RPCName: GetTask, DebugFmt: "got Task"}
	DoneCaller := RPCCaller[Task, *Task]{RPCName: MarkDone, DebugFmt: "mark done"}

	crash := false // for debuging
	rand.Seed(time.Now().UnixNano())

	doing := true
	for doing {
		emptyArgs, task := Task{}, Task{}
		err = caller.remoteCall(emptyArgs, &task)
		if err != nil {
			continue
		}

		switch task.Type {
		case MAP:
			if crash { // just for testing
				delay := rand.Intn(6) + 1
				logger.Debug(logger.DInfo, "sleep %d s", delay)
				time.Sleep(time.Second * time.Duration(delay))
			}

			err = ExecuteMapTask(mapf, &task)
			if err != nil {
				logger.Debug(logger.DError, "execute map task failed, task %+v, err %w", &task, err)
				continue
			}

			doneTask := Task{}
			err = DoneCaller.remoteCall(task, &doneTask)
			if err != nil {
				continue
			}
		case REDUCE:
			if crash { // just for testing
				delay := rand.Intn(6) + 1
				logger.Debug(logger.DInfo, "sleep %d s", delay)
				time.Sleep(time.Second * time.Duration(delay))
			}

			err = ExecuteReduceTask(reducef, &task)
			if err != nil {
				logger.Debug(logger.DError, "execute reduce task failed, task %+v, err %w", &task, err)
				continue
			}

			doneTask := Task{}
			err = DoneCaller.remoteCall(task, &doneTask)
			if err != nil {
				continue
			}
		case WAITING:
			// todo
			time.Sleep(time.Second * 3)
		case EXIT:
			doing = false
		case UNDEFIDED:
			logger.Debug(logger.DError, "got UNDEFIDED task %+v", &task)
		}
		time.Sleep(time.Second)
	}
	logger.Debug(logger.DInfo, "bye bye!")
}

状态转换

前文提到一共有 ORINGIN、MAPPING、MAPPED、REDUCING 和 REDUCED 五个状态。
所以需要 4 个状态转换的指令或函数。在我们分配任务时,发现所有任务已经分配,但不是所有任务已经完成时就需要等待。所以很显然 ORIGIN 转 MAPPING 和 MAPPED 转 REDUCING 是在 AssignTask 中转换。

func (c *Coordinator) AssignTask(_ Task, reply *Task) error {
	c.Lock.RLock()
	logger.Debug(logger.DDebug, "assign task, coordinator phase %d", c.Phase)

	switch c.Phase {
	case ORIGIN:
		c.Lock.RUnlock()

		c.Lock.Lock()
		if c.Phase == ORIGIN {
			c.assignMRTask(reply, MAPPING, "map")
			if c.Processing > 0 && c.Processing+c.ReduceID == c.NMap { // All map tasks were assigned but not all were completed
				c.Phase = MAPPING
				logger.Debug(logger.DDebug, "coordinator enter MAPPING phase, processing %d, ReduceID %d", c.Processing, c.ReduceID)
			}
		}
		c.Lock.Unlock()
		return nil

	case MAPPED:
		c.Lock.RUnlock()

		c.Lock.Lock()
		if c.Phase == MAPPED {
			c.assignMRTask(reply, REDUCING, "reduce")
			if c.Processing > 0 && c.Processing+c.DoneCnt == c.NReduce { // All reduce tasks were assigned but not all were completed
				c.Phase = REDUCING
				logger.Debug(logger.DDebug, "coordinator enter REDUCING phase, processing %d, DoneCnt %d", c.Processing, c.DoneCnt)
			}
		}
		c.Lock.Unlock()
		return nil
		
	// ... other cases
	
	c.Lock.RUnlock()
	return nil
}

当 Worker 执行完毕任务,会调用 MarkDone 函数通知 Coordinator 标记任务已完成。当分发的所有 Map 或 Reduce 任务已经完成时,需要将状态改为 MAPPED 或 REDUCED。

func (c *Coordinator) MarkDone(args Task, reply *Task) error {
	ctx, _ := c.ContextMap.Get(args.Id)

	ctx.Lock.Lock() // use X lock because of the lower probability of worker crashed
	defer ctx.Lock.Unlock()

	logger.Debug(logger.DDebug, "try to mark task context done, args %+v, ctx %+v", args, ctx)
	if args.Version == ctx.Task.Version { // if not match, task args is assigned to other worker because caller is timeout or crashed
		ctx.TaskPhase++
		c.Lock.Lock()
		c.Processing--
		if args.Type == MAP {
			c.ReduceID++
			logger.Debug(logger.DDebug, "mark done map task context [%v], processing %d, ReduceID %d, coordinator phase %d", ctx, c.Processing, c.ReduceID, c.Phase)
			if c.ReduceID == c.NMap && c.Processing == 0 { // all map tasks done
				c.Phase = MAPPED
				logger.Debug(logger.DDebug, "coordinator enter MAPPED phase, processing %d, ReduceID %d", c.Processing, c.ReduceID)

				// make reduce tasks that added to c.taskChannel and contextMap
				for i := 0; i < c.NReduce; i++ {
					files := []string{}
					for j := 0; j < c.NMap; j++ {
						file := fmt.Sprintf(TMPDIR+PREFIX+"%d-%d", j, i)
						files = append(files, file)
					}
					tk := NewTaskContext(REDUCE, TaskID(c.ReduceID), files, c.NReduce, MAPPED)
					c.ContextMap.Add(TaskID(c.ReduceID), tk)
					c.taskChannel <- tk
					c.ReduceID++
				}
			}
		} else {
			c.DoneCnt++
			logger.Debug(logger.DDebug, "mark done reduce task context [%v], processing %d, DoneCnt %d, coordinator phase %d", ctx, c.Processing, c.DoneCnt, c.Phase)
			if c.DoneCnt == c.NReduce && c.Processing == 0 { // all reduce tasks done
				c.Phase = REDUCED
				logger.Debug(logger.DDebug, "coordinator enter REDUCED phase, processing %d, ReduceID %d", c.Processing, c.ReduceID)
			}
		}
		c.Lock.Unlock()
	}
	*reply = *(ctx.Task)
	return nil
}

Map/Reduce

执行 Map/Reduce 任务的过程我们借鉴 mrsequential.go。

对于 Map 任务,读取输入文件,依次调用传入的 mapf 函数,将结果 sort 后 hash 到对应 reduce 的中间文件中。原论文中这里是要求排序的。我理解这个排序是为了减少 Reduce 任务的压力。因为 map 阶段将结果存在了多个中间文件中,reduce 需要聚合 key 相等的 value,所以在 map 阶段 sort 以后可以保证单文件内是有序的,这样 reduce 阶段的排序效率不至于退化到 $O(n^2)$ .

中间结果的命名格式是 mr-x-y. 其中 x 是 map 的任务号,y 是 reduce 任务号。假设共有 NMAP 个 map 任务,每个 map 任务都会生成 NReduce 个中间文件。一共 NReduce 个 reduce 任务,每个 reduce 任务会读取 NMAP 个中间文件,输出一个文件作为结果。

func ExecuteMapTask(mapf func(string, string) []KeyValue, task *Task) error {

	intermediate := []KeyValue{}

	for _, filename := range task.InputFiles {
		file, err := os.Open(filename)
		if err != nil {
			logger.Debug(logger.DError, "map can not open file %+v", file)
			return err
		}
		content, err := io.ReadAll(file)
		if err != nil {
			logger.Debug(logger.DError, "map can not read %+v", file)
			return err
		}
		file.Close()
		kvPairs := mapf(filename, string(content))
		intermediate = append(intermediate, kvPairs...)
	}

	sort.Sort(ByKey(intermediate))

	// buckets[i] contains KeyValues of map-task.Id-i
	buckets := make([][]KeyValue, task.NReduce)
	for i := 0; i < task.NReduce; i++ {
		buckets[i] = []KeyValue{}
	}

	// hash KeyValues into corresponding bucket
	for _, kv := range intermediate {
		reduceKey := ihash(kv.Key) % task.NReduce
		buckets[reduceKey] = append(buckets[reduceKey], kv)
	}

	// write buckets into file
	for i := 0; i < task.NReduce; i++ {
		tmpFile, err := ioutil.TempFile(TMPDIR, "mr-tmp-map-")
		if err != nil {
			logger.Debug(logger.DError, "map create temp file failed, i %d", i)
			removePreviousFile(task.Id, i)
			return err
		}

		encoder := json.NewEncoder(tmpFile)
		err = encoder.Encode(&buckets[i])
		if err != nil {
			logger.Debug(logger.DError, "map encode data failed, i %d, data %+v", i, buckets[i])
			tmpFile.Close()
			os.Remove(tmpFile.Name())
			removePreviousFile(task.Id, i)
			return err
		}

		tmpFile.Close()
		err = os.Rename(tmpFile.Name(), fmt.Sprintf(TMPDIR+PREFIX+"%d-%d", task.Id, i))
		if err != nil {
			logger.Debug(logger.DError, "map rename temp file failed, i %d", i)
			os.Remove(tmpFile.Name())
			removePreviousFile(task.Id, i)
			return err
		}
	}

	return nil
}

func removePreviousFile(id TaskID, n int) {
	for i := 0; i < n; i++ {
		os.Remove(fmt.Sprintf(TMPDIR+PREFIX+"%d-%d", id, i))
	}
}

对于 Reduce 任务,确保中间结果全局有序,聚合后写入一个结果文件。所有的文件写入采用 OS 提供的重命名原语保证文件全局唯一。

func ExecuteReduceTask(reducef func(string, []string) string, task *Task) error {

	// shuffle
	intermediate := []KeyValue{}
	for i := 0; i < len(task.InputFiles); i++ {
		inputFile, err := os.Open(task.InputFiles[i])
		if err != nil {
			logger.Debug(logger.DError, "reduce can not open file %+v", inputFile)
			return err
		}

		decoder := json.NewDecoder(inputFile)
		for {
			content := []KeyValue{}
			if err := decoder.Decode(&content); err != nil {
				break
			}
			intermediate = append(intermediate, content...)
		}
		inputFile.Close()
	}

	// make intermediate ordered in global scope
	sort.Sort(ByKey(intermediate))

	// call reducef for each key
	outputs := []byte{}
	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)
		}
		output := reducef(intermediate[i].Key, values)
		outputs = append(outputs, []byte(fmt.Sprintf("%v %v\n", intermediate[i].Key, output))...)
		i = j
	}

	// write to file
	ofile, err := ioutil.TempFile(TMPDIR, "mr-tmp-reduce-")
	if err != nil {
		logger.Debug(logger.DError, "reduce create output temp file failed")
		return err
	}

	_, err = ofile.Write(outputs)
	if err != nil {
		logger.Debug(logger.DError, "reduce write output failed")
		return err
	}
	ofile.Close()
	os.Rename(ofile.Name(), fmt.Sprintf(OUTDIR+"mr-out-%d", int(task.Id)-len(task.InputFiles)))

	return nil
}

总结

分布式的 MapReduce 其核心在于任务的分发与协调。值得研究的点也不少。

  • 如何减少任务协调过程中的锁竞争。
  • 如何确保任务的稳定性。
  • 是否在任务 crash 的时候可以保存 crash 的日志用于排错。
  • 当任务的调度具有优先级的时候怎么解决?用一个 DAG 来表示任务的调度依赖关系,根据依赖关系找出关键路径进行并行调度不知是否可行。
posted @ 2023-02-03 10:30  满眼星辰  阅读(136)  评论(0)    收藏  举报