线程
线程是一个服务器构建的基本工具,你将会在很多lab中使用,在分布式系统中可以解决一些棘手的问题,在go中称线程为携程,其它的地方称之为线程。线程允许一个程序做在同一时间做很多事情,这些线程共享内存,每一个线程包含一个独有的状态信息:程序计数器, 寄存器,堆栈
为什么是线程?
使用线程可以达到并发的效果,而并发在分布式系统中是经常出现的。
1. IO并发 当等待另外一个服务器给予响应的时候,可以通过线程来处理下一个请求
2. 多核 线程可以并行的运行在多个CPU核心上
线程可以干什么?
线程允许一个程序逻辑上同时执行多件事情,这些线程共享内存,每一个线程有自己独立的状态: 程序计数器、寄存器、堆栈等。
可以运行多少个线程
对于应用程序来说要尽可能的多,go鼓励创建更多的线程,通常来说线程的数目是远远大于CPU核心数的,go的运行时调度器会负责将这些线程调度到这些可用的CPU核心上去运行。线程并不是免费的,创建线程的开销要比一个方法调用的开销要昂贵的多了。
线程带来的挑战
- 数据共享
- 两个线程修改相同的变量在同一时间?
- 一个线程读取数据,另外一个线程修改数据?
这些问题我们称之为races
,需要使用Go的sync.Mutex
来保护这些可变的共享数据。
- 线程之间的同步
比如: 等到所有的Map
线程完成,可以使用channel
来实现 - 死锁
线程1等待线程2结束,线程2等待线程1结束,相比于races死锁更容易探测到 - 锁粒度
- 粗粒度->简单,但是并发度低
- 细粒度->并发高,但是容易产生races和死锁
爬虫练习: 两个挑战
- 处理IO并发,当获取一个URL的同时要可以处理另外一个URL
- 每一个URL只处理一次
爬虫的解决方案 (见 crawler.go)
- 通过传入一个要获取的url列表来代替深度
顺序版本: 传递一个带获取的
url map
递归进行调用- 当获取url的时间较长的时候,没办法进行
IO
重叠 - 没有办法利用多核
- 当获取url的时间较长的时候,没办法进行
go协程和共享的带获取
url map
给每一个url都创建一个协程,这种情况存在race condition
,因为要获取的url map是共享的所有的协程都需要修改和读取这个共享的map
,需要加锁,通过go run -race crawler.go
可以用来检测这种race condition
的存在。多个协程如何知道都执行完成对于这个问题可以使用waitGroup
来完成go
协程和channel
channel
是一种线程同步的机制,多个协程可以安全的给channel
发送消息和接收消息,go
的内部是给channel
加锁了,当channel
满的时候会发笑消息会阻塞,当channel
空的时候接收消息会阻塞。
RPC(Remote Procedure Call)
RPC概述
分布式系统中的关键组件;所有的实验都会使用的RPC。
目标: 易于网络编程,隐藏客户端/服务器端之间通信的大部分细节,对于客户端来说就像普通的函数调用。
RPC让网络之间的通讯看起来就像fn
函数调用:
客户端:
z = fn(x, y)
服务器端:
fn(x, y) {
compute
return z
}
Go RPC example: kv.go
- 客户端通过 “
dials
” 向服务器端发起Call()
,看起来就像普通的函数调用 - 服务端在给每一个请求都创建一个独立的线程来处理请求,所以存在并发,需要对
keyvalue
加锁
RPC架构
RPC 消息传递过程:
Client Server
request--->
<---response
软件结构:
client app handlers
stubs dispatcher
RPC lib RPC lib
net ------------- net
RPC实现细节
哪个服务器端的函数(handler)会被调用?
需要在Go的Call方法中指定要调用的handler编码: 将数据编码成要发送的packets,可以对数组,指针和对象进行编码。
Go’s RPC是一个相当强大的库! 但是你无法将channels和函数进行编码传递。绑定: 客户端是如何知道和哪个服务器进行通信?
可能是客户端支持绑定服务器的名字,可能是有一个名称服务器负责将服务名称映射到一台最佳服务器上线程:
客户端可能会有很多线程,因为客户端同时可能会发起有多个RPC请求,与此同时服务器端的handle可能会很慢,因此通常服务器都是在一个独立的线程中去执行handle,避免主线程阻塞住。现实场景下会有哪些failures的场景?
例如: 网络丢包,网络连接broken,服务器端的handle执行很慢,服务器端crash了等等。
当发生了上面这些failures的时候,客户端的rpc库看起来很是什么样子的?
客户端将不会收到来自于服务器端的回复,客户端是不知道服务器端是否接受到请求。
RPC实现方案
最简单的方案: "at least once"
行为
RPC库等待服务器端的回复一段时间,如果没有收到回复就重新发送请求,重复这样的过程几次,如果仍然没有收到服务器端的回复,就会向上层的应用程序返回错误。
Q: 对应用程序而言"at least once"
是否容易处理?
"at least once"
存在的问题:
客户端发送”向指定银行账户扣除十美元,在”at least once”这种行为下就会存在问题。
Q: 客户端程序会出现什么样的错误?
Put("k", 10) -- an RPC to set key's value in a DB serve
Put("k", 20) -- client then does a 2nd Put to same key
上面两次rpc请求,如果前者因为某些原因没有收到服务器端的回复,导致重发,而后者已经得到了服务器端的响应, 这就导致了逻辑上的错误。
这个地方有点问题,如果上面两个操作是同步调用,其实就不存在逻辑错误,如果是异步调用,我觉得应用层应该知道这样的结果是无法保证上面两个操作是顺序的。所以这个不是主要问题。
Q: 什么情况下at-least-once
这种行为是OK的?
如果服务器端的handle
是幂等的,可以进行重复操作这样情况是OK的,比如: 一个read-only
的操作。如果应用程序可以自己处理重复的回复,那么这样也是可以的。
好的RPC行为: “at most once
”
服务器端rpc代码会探测到重复请求,然后返回之前请求的结果避免再次运行handler。
Q: 如何探测到重复的请求?
客户端在每一个请求中包含了一个唯一ID(XID),对于重发的请求使用相同的XID
服务器端处理如下:
if seen[xid]:
r = old[xid]
else
r = handler()
old[xid] = r
seen[xid] = true
Q: at-most-once
的复杂性
在lab 2中会提到其复杂性,还有如何保证生成唯一的XID?
大的随机数? 结合唯一的client ID
(ip地址
?),和一个序列号#
?
Q: 服务器端最终必须丢弃关于老的rpc请求的一些信息,什么时候丢弃是安全的?
- 仅仅允许客户端在同一时间发送一个rpc请求,等待序列号seq + 1的请求到达后,服务器端可以丢弃序列号小于seq的rpc请求。
- 客户端仅仅重试发送小于5分钟内的rpc请求,服务器端就可以丢弃超过5分钟的rpc请求了。
Q: 如何处理重复请求,当请求仍在执行中时?
服务器端不知道是否已经回复了,也不想执行两次。”pengding” 给每一个执行的rpc请求设置一个pengding标志,重复请求根据这个标志进行等待或者忽略。
Q: 如果一个施行at-most-once
行为的服务器crash了,或者重启了怎么办?
at-most-once
行为需要保存重复的rpc请求信息在内存中,服务器端crash或重启会导致这些信息丢失。或许服务器端应该将这些信息持久化到磁盘或许服务器端应该有复制,并且这些重复rpc请求信息也应该被复制
Q: 什么是 “exactly once
“行为?
at-most-once
加上无限重试和容错服务。
Go RPC实现
Go RPC is “at-most-once
”
- 打开TCP连接
- 写入一个请求到TCP连接
- RCP请求可能会重试,但是服务器端的TCP会过滤掉重复的请求
- 应用程序不需要写重试的代码,重试的工作是rpc库做的
- 如果没有得到回复,go的rpc调用会返回一个错误,其原因可能是如下几种:
- 可能是发送超时
- 可能是服务器端没有看到这个请求
- 可能是服务器端处理了请求,但是在回复的时候因为网络问题导致客户端没有收到回复
Go的RPC的at-most-once
行为不适合lab 1
,lab 1
中只适合单个rpc调用,如果worker没有响应,master就会重发rpc请求到其它的worker,但是原来的worker可能没有失败,一直在工作,go rpc库是无法探测到这种重复的请求,这个必须要在应用程序层面来处理这种重复请求。