MIT6.5840(6.824)-2023 lab1实验记录

lab1

任务描述

构建一个简化版分布式MapReduce框架,由coordinator和worker组成。系统运行时,仅存在一个单独的coordinator进程用于管理map和reduce任务的分配,但同时存在多个并行的worker进程进行map和reduce任务的处理。

进程间(也即worker和coordinator间)仅仅通过RPC通信,需要考虑网络通信中存在的丢包、延迟等问题。

代码阅读

需要完成的部分为src/mr目录下的三个文件。实验代码里也通过注释的方式,指出了需要补充代码的地方。

image-20230825140105603

需要阅读的文件为src/main目录下的mrsequential.gomrcoordinator.gomrworker.go以及src/mrapps目录下的文件。

mrsequential.go

首先阅读mrsequential.go文件,这是一个顺序执行的mapReduce框架程序,包含一个自定义类型ByKey,以及相应的方法,还包含了两个函数:mainloadPlugin。下面依次展开说明:

  • ByKey类型
// for sorting by key.
type ByKey []mr.KeyValue

// for sorting by key.
func (a ByKey) Len() int           { return len(a) }
func (a ByKey) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByKey) Less(i, j int) bool { return a[i].Key < a[j].Key }

上面是ByKey类型定义,其沿用了mr目录中worker.go文件中的定义,如下:

// Map functions return a slice of KeyValue.
type KeyValue struct {
	Key   string
	Value string
}

这里需要说明的有两点:

1、go中通过首字母大小写的方式来控制访问权限,无论是方法,变量,常量或是自定义的变量类型,如果首字母大写,则可以被外部包访问,反之则不可以。 而结构体中的字段名,如果首字母小写的话,则该字段无法被外部包访问和解析。

也就是说,如果你希望结构体中字段被外部包访问,那么,不仅结构体名称要首字母大写,相应字段也要大写。这里的KeyValueKeyValue的首字母都大写了。

2、**Go 方法是作用在接收者(receiver)上的一个函数,接收者是某种类型的变量。因此方法是一种特殊类型的函数。**一个类型加上它的方法等价于面向对象中的一个类。一个重要的区别是:在 Go 中,类型的代码和绑定在它上面的方法的代码可以不放置在一起,它们可以存在在不同的源文件,唯一的要求是:它们必须是同一个包的。

定义方法的一般格式如下:

func (recv receiver_type) methodName(parameter_list) (return_value_list) { ... }

一个实例如下:

type User struct {
  username string 
}

func (u User) SetUsername1(username string){
 u.username = username ;
}

func (u *User) SetUsername2(username string){
 u.username = username ;
}

其中,使用指针和不使用指针的区别可以理解如下:

func SetUsername1(u User, username string){
 u.username = username ;
}

func SetUsername2(u *User,username string){
 u.username = username ;
}

可以理解为,一个是值传递,一个是指针传递,值传递传递的是自身的一份拷贝,而指针传递传递自身。

ByKey类型中就定义了三种方法:LenSwapLess,根据注释,这里定义的三个方法主要是为了后续可以直接调用sort方法对ByKey类型进行排序。这也是sort包中的接口定义:

package sort
type Interface interface {
    Len() int            // 获取元素数量
    Less(i, j int) bool // i,j是序列元素的指数。
    Swap(i, j int)        // 交换元素
}
  • main函数

总体来说,整个main函数代码分为:函数读取、map处理、reduce处理三部分。

从上到下,首先是检查函数参数是否满足要求,参数中必须包含调用的mrapp和inputfiles两部分。然后使用loadPlugin函数读取要执行的map和reduce函数功能,这两个函数的代码在上述提到的mrapp中。然后,顺序执行map函数和reduce函数,具体的map和reduce过程不展开。

因为是顺序执行的,所以这里不涉及并行的一些问题,整体流程比较直观。

  • loadPlugin函数

loadPlugin函数涉及到go中plugin包的使用,该包底层用c编写,根据其使用方式,大致可知,调用plugin.Open(path string)方法会根据path路径读取以插件模式编译的文件,返回值为*plugin.Plugin, error,成功调用时,会返回一个指向Plugin类型的指针,该类型仅包含一个Lookup(symName string)方法,返回值为plugin.Symbol, error,该方法根据名称symName在插件文件中搜索对应符号,符号可以是任何导出的变量或函数。如果找不到符号,它会报告一个错误。该方法对于多个goroutine并发使用是安全的。如果找到符号,其返回值为Symbol类型,类型定义为type Symbol any,因此,其是指向变量或函数的指针。

最后,要将其转换为定义好的函数类型:

p, err := plugin.Open(filename)
xmapf, err := p.Lookup("Map")
mapf := xmapf.(func(string, string) []mr.KeyValue)
mrcoordinator.go

代码很少,整体逻辑也很简单,其中,最关键的是m := mr.MakeCoordinator(os.Args[1:], 10)这一行代码,调用了mr/coordinator中函数MakeCoordinator,实际上就是用于创建coordinator进程,然后直到coordinator进程结束(所有任务完成),整个程序结束。

mrworker.go

代码更少,整体逻辑和mrcoordinator.go类似,关键在于mr.Worker(mapf, reducef)这行代码,同样是调用了mr/workerWorker函数,创建worker进程。与coordinator不同之处在于,Worker进程是更为"灵活"的进程,其可能会因为某些原因随时停止,也可能在分布式MapReduce程序运行过程中随时参加进来。

任务解读

目标是构建一个分布式的MapReduce框架,包含一个coordinator进程负责总体协调,多个并行的worker进程处理具体的map和reduce任务,根据上面阅读的代码和实验文档,梳理一下需要完成的工作和细节

  • 正常情况下,由MakeCoordinator创建的coordinator会一直运行直到mapReduce任务完成,也就是说其会全程参与并负责任务分配。
  • Worker函数创建的worker进程,应该类似于分布式系统follower,可随时参与,随时退出。
  • 当新worker参与进来时,coordinator应该能发现(或被告知),当coordinator进程结束(任务结束或异常退出)时,worker进程应该能自动结束。
  • coordinator无法知道worker的状态(正常运行、退出、缓慢运行),需要设定超时时间(文档中设定为10s),一旦超过这个时间,分配给超时worker的任务就会被回收,并分配给空闲worker。
  • 因为coordinator会并发地跟所有worker通信,为了避免出现一个任务被同时分配给两个worker等情况出现,coordinator在修改数据时,需要用互斥锁(好像可以使用channel)。此外,由于任务超时会被回收,那么coordinator收到超时worker发送回的任务完成消息时,应该忽略。
  • 系统整体上应该分为两个阶段,第一阶段是map任务处理阶段,另一阶段是reduce任务处理阶段。在所有的map任务结束之前,不能开启reduce阶段;开始reduce阶段后,也不会回退到map阶段。

然后补充一下具体实现时要注意的细节:

  • RPC调用的参数,结构体的字段值要大写
  • RPC正常返回时,返回值为nil
  • go中的for-range循环,传递的是副本,而不是原本的数据,因此需要对原来的数据进行修改时,用下标访问的方法

我的实现

1、按照实验文档的提示,首先从Worker函数入手,其作用是创建一个worker进程,为了让coordinator感知到有新worker进入,worker应当主动告知coordinator(毕竟coordinator只是server,不具备与worker主动通信能力),因此,这里需要实现第一个RPC。

关于RPC内容,目前只作了大致了解,可以类比CS模型理解,分为服务器端和客户端,coordinator即服务器端,其需要注册成为server并提供RPC服务,worker可以调用这些服务并于coordinator通信。以worker注册为例,coordinator提供了 RegisterWorker这个RPC服务如下:

// 仅仅用于进行worker注册
func (c *Coordinator) RegisterWorker(args *MyArgs, reply *MyReply) error {
	c.mutex.Lock()
	defer c.mutex.Unlock()
	c.workerCount++
	reply.WorkerID = c.workerCount
	reply.NReduce = c.nReduce
	return nil
}

worker在调用时,调用的参数包含在args中,coordinator收到请求后,返回的信息包含在reply中,这里的MyArgsMyReply是自定义的结构体,用于存放RPC通信需要的数据

实现RPC的关键在于args和reply字段的确定,worker注册时,不需要任何参数,因此args为空,返回时,worker需要掌握的信息有:map函数和reduce函数、nReduce(reduce任务的数量,在map过程会用到)、唯一标识符(为了标记任务与worker的对应关系,因为一个任务会同时被多个worker做,但仅最新分配的worker有效)。

这里我在写代码时,还添加了一个细节,worker在向coordinator注册时,可能RPC返回error,我实现是尝试5次失败后,worker就自动结束

但在我的实现中,所有的RPC过程用的是相同的args和reply结构体,因此这里先不给出args和reply结构体代码。

worker向coordinator进行了报道,那么在coordinator端同样要对worker进行登记,此过程即给worker分配唯一标识符的过程。

2、插曲——结构体实现。第一步中已经涉及到了很多不同数据的传递,阅读实验代码可知,存在Coordinator和Worker结构体需要完善,也是存储信息的地方。

首先从coordinator结构体入手,因为在MakeCoordinator函数中已经传递了部分信息:待处理的文件名列表和reduce任务的数量。显然可知,需要一个字段存储reduce任务数量,还需要一个字段存储互斥锁。为了记录每个map任务和reduce任务的完成和分配情况,我定义了两个字段mapTask和reduceTask,均为数组类型(实际上是切片?),其基本类型是Task,定义如下:

type Task struct {
	state     int       // 用于标记任务处理状态 0 - 待处理;1 - 处理中; 2 - 结束
	filename  string    // 用于标记任务对应的文件名
	workerID  int       // 用于标记worker
	startTime time.Time // 用于记录任务开始时间
}

mapTask数量即文件数量,reduceTask数量即nReduce。此外,为了给worker分配唯一标识符,我这里采用了一个字段用于计数,随着worker的加入,其值单调递增;为了标记系统所处的阶段,我还添加了一个字段,其值为0表明处于map阶段,为1表明处于reduce阶段,为2则表明所有任务结束。coordinator结构体代码如下:

type Coordinator struct {
	// Your definitions here.
	mapTask     []Task     // map任务列表
	reduceTask  []Task     // reduce任务列表
	nReduce     int        // reduce个数
	workerCount int        // 用于worker编号
	mutex       sync.Mutex // 用于实现互斥操作
	taskPhase   int        // 用于记录当前状态 0-map; 1- reduce;2-finsh
}

worker结构体相对来说简单些,因为它需要考虑自己就行。显然,需要一个字段记录唯一标识符,还需要两个字段分别记录map函数和reduce函数。当worker执行map任务时,还要根据reduce任务数量对输出进行分区,所以需要字段记录reduce任务数量;此外,worker需要一个字段标明自己执行的任务。worker结构体代码如下:

type worker struct {
	workerID int // worker的id
	nReduce  int // reduce数量
	taskNum  int // reduce任务的编号,或map任务对应task数组index
	mapf     func(string, string) []KeyValue
	reducef  func(string, []string) string
}

3、接着分析,当worker向coordinator注册后,其应该处于随时待命状态。这里的随时待命状态是一种主动的状态,根据RPC通信的特点,coordinator不能主动联系worker,因此需要worker不断询问coordinator是否有任务可以干。在我的实现中,我将其抽离为一个单独的函数,函数内部不断执行for循环,每轮循环都会向coordinator请求一次任务。

这里需要注意,for循环不能不间断,否则某些情况下,coordinator会被一个worker占满,我这里采用了每次请求任务间间隔一秒的方式。

请求任务需要实现一个对应的RPC方法,首先确定请求参数,worker只需要自报家门(唯一标识符),然后是返回参数,worker应该收到其需要处理的任务并知道任务类型(map任务还是reduce任务,因为不同类型任务处理过程不一样)。因此请求参数需要添加一个字段记录唯一标识符,返回参数需要添加字段记录任务和任务类型,这里我用了一个结构体记录,如下:

type TaskInfo struct {
	TaskIndex int    // 当前task对应在coordinator中task数组索引, 在map中对应文件名, 在reduce任务中对应reduceNums
	TaskType  int    // 任务类型 0-无意义;1-map; 2-reduce
	FileName  string // map任务待处理的文件名, 或者reduce任务处理完后的临时文件名
}

coordinator端收到来自worker的请求任务RPC后,分为三类情况讨论:一是存在没开始的任务,这个时候,coordinator只需要挑出没做完的任务,并将其类型和编号(map任务还需要知道处理的文件名,reduce任务只需要根据编号就能确定)返回,并在coordinator结构体中修改对应的mapTask或reduceTask字段中的值;二是所有的任务都被分配了,但都在运行中,这个时候,只需要返回空任务类型;三是所有任务结束,这个时候,我直接返回error,让worker退出。

worker退出的方法,在当前的实现中,只有一种方法,即worker联系不上coordinator,即RPC返回error。相当于间接告知worker说coordinator已经跑路了,你也可以走了。(或许还能通过channel让coordinator主动告知worker其跑路的消息)

worker收到coordinator的请求结果后,根据返回参数,也可以分为三类情况讨论,跟上述的三种情况一一对应,简单说:有事就干、没做完就间隔问有事做不、联系不上就溜。

4、插曲——worker执行任务的过程。首先讨论worker做map任务的过程,worker向coordinator发起请求任务后,会得到一个返回参数,有用的信息如下:

NReduce  int      // reduce的数量
TaskIndex int    // 当前task对应在coordinator中task数组索引
TaskType  int    // 任务类型 0-无意义;1-map; 2-reduce
FileName  string // map任务待处理的文件名

worker知道了自己做的是map任务,知道了map结果要根据key拆分成NReduce个文件,知道了自己做的是第TaskIndex个map任务,对应的文件名是FileName。

那么根据mrsequential.go中的实现,直接照搬代码。先读取FileName对应的文件内容,再调用map函数对内容进行处理,再将处理后的数据,根据key的hash值,划分为NReduce份,每一份存到一个临时文件中。这里使用临时文件的原因实验文档中有提到,因为worker的成果在被coordinator承认之前,都没用,因此,worker做完自己的工作,还需要向coordinator汇报。

这里是我们需要实现的最后一个RPC方法,用于worker告知coordinator自己完成的任务和成果。请求参数需要包含worker的唯一标识符,任务信息(需要包含TaskIndex和TaskType),以及最重要的,worker的工作成果(即临时文件名列表,而且要知道每一个文件名是哪个reduce任务的中间结果)

这里也有一个细节,如果任务结束RPC的请求参数只包含TaskIndex和唯一标识符,那么coordinator可能收到因为网络延迟而迟到的 TaskIndex和唯一标识符完全一致的map任务的任务结束RPC请求

返回参数则不包含任何字段,但RPC调用的返回值需要被考虑,如果为error,那么就是coordinator跑路了,需要worker自行结束。

然后讨论reduce任务的执行过程,对于reduce任务,请求任务RPC的返回参数中的有效信息如下:

TaskIndex int    // 当前task对应在coordinator中task数组索引
TaskType  int    // 任务类型 0-无意义;1-map; 2-reduce

worker知道自己做的是reduce任务,知道自己做的是第TaskIndex个reduce任务。相较于map任务的执行过程,reduce任务相对来说复杂些。worker需要从所有map生成的中间文件中,读取TaskIndex对应的文件,好在实验文档中给了提示,我们的中间文件以mr-x-y命名,x是map任务在mapTask中的索引,y是reduce任务标识符(同样也是reduce任务在reduceTask中的索引)。那么,worker只需要读取所有后缀为-TaskIndex的中间文件就行。最后生成的同样是临时文件

任务完成的RPC同样需要告知自己的唯一标识符、完成的任务编号和类型、完成的成果(临时文件)。

5、至此,在整个流程中,worker部分的实现就结束了,剩余的是coordinator收到worker任务结束RPC的处理部分,同样分map和reduce讨论。

当coordinator收到worker的map任务结束RPC时,要判断两点,一是这个map任务是否是由该worker处理(根据唯一标识符判断),二是这个任务是否在处理中。满足这两个条件后,将请求参数中的临时文件保存为正式文件,即实验文档中提到的重命名,同时修改mapTask中对应Task的状态为已完成。不满足任何一条,则忽略这个返回值,但同样要返回nil,而不是error

当coordinator收到worker的reduce任务结束RPC时,处理过程与map过程类似,区别只在于,map中会收到一个临时文件列表(NReduce个临时文件),而reduce只会收到一个。

6、处理流程结束,但还留下几个问题,怎么确定整个mapReduce任务是否完成?如何判断map或reduce任务是否超时?。在mrcoordinator.go有提到,coordinator进程会在所有任务完成后结束。看看其代码:

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintf(os.Stderr, "Usage: mrcoordinator inputfiles...\n")
		os.Exit(1)
	}

	m := mr.MakeCoordinator(os.Args[1:], 10)
	for m.Done() == false {
		time.Sleep(time.Second)
	}

	time.Sleep(time.Second)
}

可知,循环中一直在调用m.Done()查询其状态,这也是我们需要完成的最后一个部分。在Coordinator结构体中,我们用了一个taskPhase字段指明当前系统状态,那么我们只需要将该字段与系统实际状态对应就行。我的实现是在Done函数体内部进行检查:一是检查map和reduce任务的完成状态,如果所有的map任务做完,那么修改taskPhase字段指明当前处于reduce阶段,同理,如果所有reduce任务完成,修改该字段指明系统处于结束状态(coordinator跑路状态);二是检查任务的超时情况,任何超时的map和reduce任务都会被回收,由于Done函数的调用频率是一秒一次,而超时时间是十秒,因此勉强也能用。

7、补充——RPC的args和reply结构体定义:

type TaskInfo struct {
	TaskIndex int    // 当前task对应在coordinator中task数组索引, 在map中对应文件名, 在reduce任务中对应reduceNums
	TaskType  int    // 任务类型 0-无意义;1-map; 2-reduce
	FileName  string // map任务待处理的文件名, 或者reduce任务得到临时文件名
}

// Add your RPC definitions here.
type MyArgs struct {
	MapFileNames []string // map处理完后的文件名列表
	WorkerID     int      // 当前的workID
	TaskInfo     TaskInfo // worker返回的已完成任务的信息
}

type MyReply struct {
	WorkerID int      // 服务器分配的workerID
	NReduce  int      // reducer的数量
	TaskInfo TaskInfo // 返回给worker需要完成的任务信息
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
MIT 6.824 课程的 Lab1 是关于 Map 的实现,这里单介绍一下实现过程。 MapReduce 是一种布式计算模型,它可以用来处理大规模数据集。MapReduce 的核心想是将数据划分为多个块,每个块都可以在不同的节点上并行处理,然后将结果合并在一起。 在 Lab1 中,我们需要实现 MapReduce 的基本功能,包括 Map 函数、Reduce 函数、分区函数、排序函数以及对作业的整体控制等。 首先,我们需要实现 Map 函数。Map 函数会读取输入文件,并将其解析成一系列键值对。对于每个键值对,Map 函数会将其传递给用户定义的 Map 函数,生成一些新的键值对。这些新的键值对会被分派到不同的 Reduce 任务中,进行进一步的处理。 接着,我们需要实现 Reduce 函数。Reduce 函数接收到所有具有相同键的键值对,并将它们合并成一个结果。Reduce 函数将结果写入输出文件。 然后,我们需要实现分区函数和排序函数。分区函数将 Map 函数生成的键值对映射到不同的 Reduce 任务中。排序函数将键值对按键进行排序,确保同一键的所有值都被传递给同一个 Reduce 任务。 最后,我们需要实现整个作业的控制逻辑。这包括读取输入文件、调用 Map 函数、分区、排序、调用 Reduce 函数以及写入输出文件。 Lab1 的实现可以使用 Go 语言、Python 或者其他编程语言。我们可以使用本地文件系统或者分布式文件系统(比如 HDFS)来存储输入和输出文件。 总体来说,Lab1 是一个比较简单的 MapReduce 实现,但它奠定了 MapReduce 的基础,为后续的 Lab 提供了良好的基础。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值