lab1
任务描述
构建一个简化版分布式MapReduce框架,由coordinator和worker组成。系统运行时,仅存在一个单独的coordinator进程用于管理map和reduce任务的分配,但同时存在多个并行的worker进程进行map和reduce任务的处理。
进程间(也即worker和coordinator间)仅仅通过RPC通信,需要考虑网络通信中存在的丢包、延迟等问题。
代码阅读
需要完成的部分为src/mr
目录下的三个文件。实验代码里也通过注释的方式,指出了需要补充代码的地方。
需要阅读的文件为src/main
目录下的mrsequential.go
、mrcoordinator.go
、mrworker.go
以及src/mrapps
目录下的文件。
mrsequential.go
首先阅读mrsequential.go
文件,这是一个顺序执行的mapReduce框架程序,包含一个自定义类型ByKey
,以及相应的方法,还包含了两个函数:main
和loadPlugin
。下面依次展开说明:
- 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中通过首字母大小写的方式来控制访问权限,无论是方法,变量,常量或是自定义的变量类型,如果首字母大写,则可以被外部包访问,反之则不可以。 而结构体中的字段名,如果首字母小写的话,则该字段无法被外部包访问和解析。
也就是说,如果你希望结构体中字段被外部包访问,那么,不仅结构体名称要首字母大写,相应字段也要大写。这里的KeyValue
和Key
、Value
的首字母都大写了。
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
类型中就定义了三种方法:Len
、Swap
、Less
,根据注释,这里定义的三个方法主要是为了后续可以直接调用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/worker
中Worker
函数,创建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中,这里的
MyArgs
和MyReply
是自定义的结构体,用于存放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需要完成的任务信息
}