1

说明

本系列文章是对大名鼎鼎的 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、指针等各种数据格式为可以传输的格式

image.png

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
服务端:

image.png

一些“最多一次”的复杂性

这些会出现在 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

Go FAQ

Q:为什么6.824选择golang作为实验使用的语言?

A:在几年前,6.824使用C++,并且效果很好。Go对于6.824的实验来说,因为一些原因会更优越。Go是垃圾回收并且类型安全的,这样可以消除一些常见的编程错误。Go对线程(协程)有很好的支持,有良好的RPC包,可以直接用在6.824的实验中。线程和垃圾回收之间合作良好,而且垃圾回收可以避免程序员去寻找最后一个不使用对象的线程。当然,也有其他语言具有这些特征,可以很好地用在6.824中,比如Java。

Q:协程是否并行运行?可以通过它们提升性能吗?

A:Go的协程与其他语言的线程是一样的。Go runtime在所有可用的CPU核上,并行执行协程。如果CPU核数比运行中的协程数少,则runtime会在多个核上通过时间片切换的方式运行协程。

Q:Go channel是怎么工作的?Go怎么确保多个协程之间共用channel时,channel是同步的?

A:你可以去 chan.go 查看源码,不过并不易懂。
在更高的层面上看,chan是一个包含缓存和锁的结构。在channel上发送消息需要获得锁,等待(可能是释放CPU)知道一些线程在获取或提交消息。获取消息也需要获得锁,等待发送者。你可以通过Go的sync.Mutex和sync.Cond实现自己的channel。

Q:我使用channel来唤醒另一个协程,通过在channel上发送布尔值。但是如果另一个协程已经在运行(也就是说并没有在channel上接收消息),发送的协程会阻塞。我该怎么做?

A:可以尝试条件变量(Go的sync.Cond),而不是channel。条件变量在提醒等待某些条件的协程方面工作得很好。channel是同步的,因此当你不确定是否有另一个协程在等待从channel上接收消息时,channel是很棘手的。

Q:我怎么在一个协程中等待来自于多个不同channel中任一个channel的消息呢?试图从任何一个channel上,都可能因为没有消息而阻塞,从而没法从其他channel上读。

A:尝试为每个channel创建一个单独的协程,这样每个协程都会阻塞在自己的channel上。虽然这并不通用,但是常常是最简单的方法。
或者尝试Go的select。

Q:我们什么时候可以使用sync.WaitGroup替代channel?反之亦然?

A:WaitGroup是为特殊用途的;它仅在等待一堆活动结束时有用。channel是更通用的;例如,你可以通过channel传递值。你也可以通过channel等待多个协程,但是相比WatiGroup需要多写一些代码。

Q:我需要实现每秒执行一个任务。最简单的方法是什么?

A:为周期性执行的任务创建一个协程。它应该使用time.Sleep()等待一秒钟,然后执行任务,然后再等待,如此循环。
(⊙o⊙)…感觉可以使用cron库

Q:我们如何知道大量的协程的消耗是否已经抵消了它们带来的并发优势?

A:这个看情况。如果你的机器有16核,你需要CPU并行,你应该使用16个可执行的协程。如果需要0.1s获取web页面,而你的网络可以1s传输100个web页面,你可能需要10个协程并发地获取页面,来更好地利用网络容量。实验上看,随着你增加协程的数目,一段时间内,你会看到吞吐量增加,然后这种增式会趋于平缓;在这个转折点上,你可以获得最优的性价比。

Q:怎么创建一个在网络上连接的Go channel?怎么设定发送消息的协议呢?

A:一个Go channel只能在单个程序内工作;channel之间不能与其他机器的程序通信。
可以看看Go RPC包,可以通过网络访问其他的程序。

Q:有哪些需要知道的重要/有用的Go特有的并发编程模式?

A:这里有一个Go专家提供的讲义 concurrency slide

Q:切片是怎么实现的?

A:切分是一个对象,包括数组的指针以及开始和结束的索引。这种设计允许多个切片共享底层的数组,每个切片都可以展示数组不同范围内的值。
这是一个扩展的讨论:go-slices-usage-and-internals
我经常使用切片,而不是数组。一个Go的切片比数组更灵活,因为数组的大小是指定的:函数使用切片作为参数,切片可以是任意长度。

Q:Go的常用调试工具是什么?

A:fmt.Printf()
据我所知,Go没有很好的调试器,不过 gdb 可以一试。
在任何情况下,对于大多数bug,我发现fmt.Printf()是个非常有效的调试工具。

Q:什么时候应该用同步的RPC调用,什么时候用异步的?

A:大部分代码需要在RPC回复后继续执行;因此同步的RPC更常用。
但是有时候,客户端需要启动多个并发的RPC,这种情况下异步更好。或者客户端在等待RPC完成的时候,想要做其他事情,因为RPC有很长的超时时间,可能由于服务端很远,或者服务端有时候不可达。
我在Go中没有使用过异步的RPC。当我想要发送RPC请求但不想等待结果,我创建一个协程,这个协程使用同步的Call()。

Q:Go在工业界有使用吗?

A:你可以看到不同编程语言的使用估计:tiobe-index


raesnow
4 声望2 粉丝

也曾梦想仗剑闯天涯,