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需要完成的任务信息
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值