说明
本系列文章是对大名鼎鼎的 MIT6.824分布式系统课程 的翻译补充和学习总结,算是自己一边学习一边记录了。
如有疏漏错误,还请指正:)
持续更新ing。。。
翻译&补充
内容
Go中的线程和RPC,看一下实验
为什么选择Go?
对线程良好的支持
方便的RPC
类型安全
垃圾回收(没有内存释放后再被使用的问题)
线程+垃圾回收非常棒
相对简单
在教程学习后,请阅读 effective go
线程
很有用的构造工具,但是有时候会很棘手
在Go里叫做“goroutine”;其他地方叫做线程
线程 = “执行线程”
线程允许一个程序同时做很多事情
每一个线程依序执行,就像没有线程的普通程序一样
线程之间共享内存
每个线程包括自己的状态:程序计数器,寄存器和栈
为什么选择线程?
它们能够解决并发,在分布式系统中很有用
I/O并发
- 客户端并行地发送请求到许多服务器,并等待回复;
- 服务端处理多个客户端的请求,每个请求会阻塞;
- 在等待客户端X从磁盘读取数据的请求时,会处理客户端Y的请求。
多核的性能:在多个核上并行地执行代码
方便性:在后台,每隔1秒,检查每个worker是否依旧存活。
有没有线程的替代品?
有的:在一个单独的线程里,用代码明确地加入活动;通常称为“事件驱动”。
维持每个活动的状态信息,比如每个客户端请求
一个“事件”的过程如下:
- 检查每个活动的新输入(比如,服务器的回应)
- 执行每个活动的下个步骤
- 更新状态
事件驱动实现I/O并发:
- 避免线程的消耗(可能会比较大)
- 但是并没有使用到多核带来的加速
- 对代码来说很痛苦
线程带来的挑战:
共享数据(临界资源):
- 比如,如果两个线程同时执行 n = n + 1 会怎么样?或者一个线程增加一个线程读数据?
- --> 使用锁(Go里的sync.Mutex)
- --> 避免共享会修改的数据
线程中的协同工作:
- 比如,一个线程生产数据,另一个线程消费。怎么让消费者等待(释放CPU)?怎么让生产者通知消费者?
- --> 使用go中的channel,或sync.Cond,或 WaitGroup
死锁:
- 加锁有环形依赖,或通信有环形依赖(比如:RPC或Go的channel)
让我们看教程中的网络爬虫,把它作为一个线程示例
为什么是网络爬虫?
目前是获取所有的网页,比如:用于建立索引
网页和链接组成图
多个链接指向相同的页面
图中有环形
爬虫带来的挑战:
需要使用I/O并发:
- 网络延迟比网络容量更受限
- 需要同时获取多个URL。为了增加每秒的URL获取数目,需要使用并发的多线程
每个URL仅“获取一次”
- 避免浪费网络带宽
- 对远程服务器更友好
- 需要记住每个访问过的URL
知道何时结束
我们看下两种解决方式【课程表页面的crawler.go】
Serial 爬虫:
通过递归Serial调用实现深度遍历
“fetched” map避免了重复,打破环形;一个简单的map,通过传递引用,调用者可以看到并调用者的更新
但是,一个时刻只能获取一个页面;我们可以直接在Serial()函数前加“go”吗?我们可以试下,看看会发生什么?
ConcurrentMutex 爬虫:
创建一个线程用于获取每个页面,启动很多并发的获取,加快获取速度
“go func”创建并执行协程,func可以是匿名方法
多个线程共享“fetched” map,所以同一时刻只有一个线程可以获取任意的指定页面
为什么使用Mutex(Lock()和Unlock())?
原因一:
* 两个不同的web页面可能包含相同的URL链接 * 两个线程可能会同步获取这两个页面 * T1读取fetched[url],T2也读取一样内容 * 它们会同时发现这个url没有获取(already==false) * 它们会同时获取,这是错误的 * 锁会让检查和更新操作原子化,所以只有一个线程看到already==false
原因二:
* 从内部来看,map是个复杂的数据结构(tree?就还是可扩展的hash?) * 并发更新会破坏内部变量 * 并发读写会导致读失败
如果我注释掉 Lock()/Unlock(),会怎么样?
* go run crawler.go会工作吗? * go run -race crawler.go 会检测出资源竞争,即使输出是正确的
ConcurrentMutex爬虫怎么判断结束?
- sync.WaitGroup
- Wait()会等待所有的Add()数目和Done()一直,即,等待所有的子线程结束[图表:环形URL图上的协程树];每个树的节点上都有一个WaitGroup
这个爬虫会创建多少个并发的线程?
ConcurrentChannel 爬虫:
一个Go channel:
- 一个channel是一个对象,ch := make(chan int)
- 一个channel可以实现一个线程向另个一个线程发送数据,ch <- x,发送者等待,直到有协程接收,y := <- ch,for y := range ch,接受者等待,直到有协程发送
- channel既支持通信又支持同步
- 多个线程可以在一个channel上通信
- channel很廉价
- 记住:发送者会阻塞,直到接受者收到,“synchronnous”,小心死锁
ConcurrentChannel master()
- master() 创建一个worker协程用来获取每个页面
- worker() 发送页面的URL切片到channel上,多个wroker在同一个channel上发送
- master() 从channel上读取URL切片
什么情况下master在等待?master等待的时候占用CPU时间了吗?
不需要对fetched map加锁,因为它并没有被共享!
master怎么知道结束了?
- 保持worker的数目在n以内
- 每个worker只在channel上发送一条数据
为什么多个线程使用相同的channel不会资源竞争呢?
worker线程写入URL切片,master线程读取切片,却不使用锁,会有资源竞争吗?
- worker只在发送“前”写入切片
- master只在收到“后”读取切片
所以他们不会同时使用切片
什么时候使用共享和锁,什么时候使用channel?
大多数问题用两种方式都可以解决
使用哪个取决于程序员的思考:
* 状态 -- 共享和锁
* 通信 -- channel
对于6.824的试验,建议使用共享和锁存储状态,使用sync.Cond或channel或time.Sleep()用来等待和通知
远程过程调用(RPC)
分布式系统机制最关键的一块,所有试验都在使用RPC
目标:客户端能和服务器的通信更易于编程
隐藏网络协议的细节
转换string、数组、map、指针等各种数据格式为可以传输的格式
Go示例:课程表页面的 kv.go
一个键值对存储服务器 -- Put(key,value), Get(key)->value
使用Go的RPC库
Common:
- 定义每个服务器handler的Args和Reply结构
Client:
- connect()的Dial()创建一个服务器的TCP连接
- get()和put()是client的“stubs”
Call()向RPC库请求执行调用
- 需要指定服务器方法名、参数,reply的地址
- 库会marshall参数,发送请求,等待,并unmarshall回复
- Call()的返回值说明是否获得回复
- 通常你需要有reply.Err,用于说明服务级别的失败
Server:
- Go需要服务器定义一个包含RPC handler方法的对象
- 服务器使用RPC库注册对象
- 服务器接受TCP连接,传递给RPC库
RPC库执行如下内容:
- 读取每一个请求
- 为请求创建一个新的协程
- unmarshall请求
- 查找命名的对象(在Register()创建的table中)
- 调用对象的命名方法(dispatch)
- marshall回复
- 将回复写到TCP连接中
服务的Get()和Put() handler
- 需要加锁,因为RPC库为每个请求创建一个新的协程
- 读取参数,修改回复
一些细节:
绑定:客户端怎么知道与哪台服务器通信?
- 对于Go的RPC,服务器 名称/端口 是Dial的参数
- 大系统一般会有自己的命名服务器或配置服务器
Marshalling:数据格式化并打包
- Go的RPC库可以传递string、数组、对象、map和指针
- Go通过传递指针来复制指向的数据
- 不可以传递channel和方法
RPC问题:对于失败的处理?
例如:丢包,网络断开,服务器响应太慢,服务器崩溃
对于客户端的RPC库,失败看起来是什么样的?
客户端没有收到服务端的回复
客户端不知道服务端是否看到请求!
[各种情况下的失败情况图表]
- 服务端可能没有看到请求
- 服务端可能执行了,但是在发送回复前崩溃
- 服务端可能执行了,但是在发送请求时网络断开
最简单的错误处理机制:“尽最大努力”(“最少一次”)
Call()会等待回应一段时间
如果没有回应达到,重新发送请求
重试几次
如果还是失败,则放弃并返回错误
Q:“尽最大努力”在应用中是不是很容易处理?
一个特殊的糟糕的场景:
客户端执行
Put("k", 10);
Put("k", 20);
都成功了
Get("k")会出现什么情况?
[图表:超时,重新发送,原始请求到达晚了]
Q:“尽最大努力”是不是总能正确工作?
只读操作
幂等操作,例如:DB检查是否数据已经插入
更好的RPC行为是:“最多一次”
策略:服务端RPC检查重复请求,返回前一次回复,而并非重新执行
Q:怎么检测出重复的请求?
客户端发送的请求中包含唯一的ID(XID),重新发送时使用相同XID
服务端:
一些“最多一次”的复杂性
这些会出现在 lab 3
如果两个客户端使用相同的XID,会怎么样?
- 大随机数
- 将唯一的客户端ID(IP地址?)和序列号连接?
服务端最后需要丢弃过时的RPC信息
- 什么时候丢弃是安全的?
策略:
- 每一个客户端有一个唯一ID(可能是大随机数)
- 每一个客户端顺序编号RPC请求
- 客户端对于每个RPC,都存在一个X,收到所有X编号之前的回应
- 和TCP的请求与回应有些相似
- 或者只允许客户端在同一时刻只处理一个RPC,这样,当服务端收到seq+1的请求时,可以丢弃小于等于seq的请求
怎么处理重复的请求,当原始请求正在执行?
- 服务端暂时不回复
- 策略:对于正在执行的RPC,使用”pengding“标志;等待或者无视重复请求
“最多一次”的服务端崩溃后重启,怎么办?
如果“最多一次”的重复请求信息存储在内存中,服务端会忘记,并在重启后接受重复请求
或许应该将这些重复请求信息存储到硬盘
或许应该采用备份服务器,来备份这些重复请求信息
Go RPC是一种“最多一次”的简单实现
开启TCP连接
将请求写入TCP连接
Go RPC不重发请求,所以服务端不会看到重复请求
Go RPC如果没有收到回应,则返回错误
- 可能是超时(TCP)
- 可能是服务端没有看到请求
- 可能是服务端处理请求,但是服务端或网络在发送回复前出错
“有且仅有一次”怎么样?
有限的重试 + 重复检测 + 容错服务
Lab 3
有疑问加站长微信联系(非本文作者)