互斥性与幂等性
互斥性与幂等性⌗
我觉得这两个特性是在系统分析和设计时必须考虑的问题。开始之前,先简单解释下两者的概念。
什么是互斥性⌗
要解释互斥性就不得不先提一下 临界资源,引用百科的介绍。
一次仅允许一个进程使用的资源称为临界资源。
很显然,当多方共享临界资源时,那么这些使用方是互斥的。 举一些场景:
- 多个人同时修改同一个文档
- 多个线程一起修改一个并发不安全的Map
假设程序不保证互斥性: 让大家一起修改,那么:
- 场景一:最后只会保留最后一个处理的修改,其他的修改都会丢失
- 场景二:在多线程同时写入内存时会因为竞争关系出现数据错乱,有兴趣的同学可以看下这篇文章 为什么你的并发程序不安全。
这些显然是我们不愿看见的情况,所以保证互斥性对于临界资源的使用是非常重要的,保证互斥性的过程其实也是保证并发安全性的过程。
这里再多说下一些进程内的情况,比如多个线程对一个变量的读写,一写多读,需不要保证互斥性? 答案是要。 这里涉及到两个原因:
- 哪怕你的语句是一条汇编的原子操作(比如MOV xxxx, xxxx) 都不一定是原子操作。
- 多线程操作时,可能位于不同CPU上执行,修改对其他在其他CPU上执行的线程来说并不是马上可见的
基于以上原因,那怕是对一个bit的操作,也需要考虑互斥性。
什么是幂等性⌗
幂等原本是数学的概念,表示某个运算执行任意多次与一次的结果相同。 后来引用到系统设计中,指具备该性质的接口在相同的参数下,调用一次与多次结果相同。 显然,有一些类别的接口天生就是幂等的,比如 查询 和 删除。 幂等性的重要性在于消除重复提交来带的副作用。 考虑下面的场景
- 某个人在同一页面多次提交对文档的修改(假设页面没有二次防押)
- 某个秒杀环节中,通过消息中间件来分摊处理的压力,但是消息中间件由于各种不可控因素投递了两次相同的消息
假设接口不保证幂等性,那么:
- 场景一:加大了存储系统的负担,如果同一时间很多这样操作的用户,可能会引发服务雪崩。
- 场景二: 如果消费的逻辑没有保证幂等,那么就会生成两条相同的订单
互斥性的解决方案⌗
引用下在美团技术沙龙的 分布式系统互斥性与幂等性问题的分析与解决 出现的一句话
基本上所有的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案。
我觉得这句话很棒,在 «七周七并发模式» 书中出现的并发模式比如 CSP, Actor其实本质上也都是把对共享资源的请求串行化了,比如 CSP 是将请求都放入 Channel 中,而 Actor 则放入了 MailBox。
基于这个前提,我们来看下保证互斥性的一些解决方案:
多线程(协程)下的互斥性⌗
这个应该是我们编程时最常见的情况了,考虑以下的场景:
- 线程(协程)A和B,共享一个静态Map(非线程安全的)
在这个场景下,A和B如果不满足互斥性,那么就有可能产生预料之外的结果。因为我们的Map是线程不安全的。 要保证互斥性,有以下方式
- 锁
- CSP(go)
- 其他并发模型,比如Actor(erlang), 函数式(clojure),由于我没有实践项目经验,此处不讨论,有兴趣的读者可以自行了解。
注意:并发模型只是一种思想,括号内表示该语言原生支持此并发模型。就算没有原生支持该模型的语言,也几乎都有社区提供了类库实现并发模型。
锁⌗
基本上大多数语言都提供了锁的机制,常见的锁可以分为以下两类
- 互斥锁
- 读写锁
互斥锁 互斥锁表示在使用方占有资源时,拒绝其他请求方的 读 和 写,互斥锁当然很保险,不过假设在读多写少的场景中,我们当然不希望每次都会因为读操作而阻塞其他请求方的读操作,因为他们都是无副作用的,所以诞生了下面的读写锁。 这里以Go语言为例,代码如下:
package demo
import (
"sync"
"testing"
)
var (
_globalMap = map[string]string{}
_mux = sync.Mutex{}
)
func mutexReadValue(key string) string {
_mux.Lock()
defer _mux.Unlock()
return _globalMap[key]
}
func mutexWriteValue(key, value string) {
_mux.Lock()
defer _mux.Unlock()
_globalMap[key] = value
}
func TestMutex(t *testing.T) {
workers := 1000000
wg := sync.WaitGroup{}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
mutexReadValue("key")
// do something
mutexWriteValue("key", "value")
wg.Done()
}()
}
wg.Wait()
}
读写锁 读写锁顾名思义分成了读锁和写锁。 读锁允许多个请求方同时获取,而写锁是排他的,同一时间只允许同一个请求方获得写锁。当资源存在读锁时,写锁只能等待读锁释放才能获取,反之亦然。
读写锁解决了互斥锁在读多写少的情况下,所造成的不必要阻塞 这里以Go语言为例,代码如下:
package demo
import (
"sync"
"testing"
)
var (
_rwglobalMap = map[string]string{}
_rwmux = sync.RWMutex{}
)
func rwmutexReadValue(key string) string {
_rwmux.RLock()
defer _rwmux.RUnlock()
return _globalMap[key]
}
func rwmutexWriteValue(key, value string) {
_rwmux.Lock()
defer _rwmux.Unlock()
_globalMap[key] = value
}
func TestRwMutex(t *testing.T) {
workers := 1000000
wg := sync.WaitGroup{}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
rwmutexReadValue("key")
// do something
rwmutexWriteValue("key", "value")
wg.Done()
}()
}
wg.Wait()
}
CSP⌗
CSP( Communicating sequential processes ) 是一种指导并发编程的思想,有关其详细的介绍参见 Wiki,这里只简单说下,CSP的核心思想在于让各个 worker(进程、线程or协程)
使用 Channel
来通信而避免共享内存。有人会疑惑,通过通信来交换信息怎么就能够避免共享内存了?
画了一个简单的草图如下:
可以看见,对临界资源(Critical Resource)的访问只有唯一的一个worker,我这里把它叫做guard
。其他所有worker对临界资源的访问(查询or更新)都是通过与 guard 通信来完成,这样其实对临界资源的访问自然而然就被序列化为串行的了。
还是以Go语言为例,我们解决刚才场景中问题的代码如下:
值得注意的是,代码里面实现的这种模式其实效率并没有直接加锁的性能更好,因为这里把读取也串行化了,并且针对每一个读取的请求都新创建一个Channel来传送返回值。所以代码还有很多优化空间,这里只是想展示下如何使用CSP
来避免共享内存。但并不是所有场景下CSP
或者Actor
都优于传统的锁模式,还请注意场景的选择。
多进程( 相同or不同主机 )下的互斥性⌗
这种情况相对来说在业务侧会比较常见的,回到我们最开始提出的问题:
- 多个人同时修改同一个文档
在这种情况下,有以下几个方式来保证互斥性:
- 乐观锁
- 悲观锁
~其实还有别的一些方法来保证互斥性,比如 PV操作
,但我这里只想讨论一些通更用的解决方案~
乐观锁⌗
乐观锁的思想很简单,就是为我们的资源信息添加一个版本字段,每次更新时都更新这个字段,当更新时发现版本信息不匹配时,拒绝本次更新。
很多时候最简单的乐观锁实现方法就是新增一个更新时间
的字段,更新时发现更新时间已经变化时则拒绝。
在这个场景下,我们可以有如下解决方式: 1、A 与 B 同时开始编辑文档 2、A 先保存文档 3、当 B 保存文档时发现更新时间与自己加载文档时不同了,此时保存失败
至于后面要不要自动合并 B 的修改,取决于业务。
悲观锁⌗
悲观锁的思想也很简单,当上一个更新没有完毕时,拒绝下一个更新。
从技术角度来说,我们可以使用 分布式锁
来实现悲观锁,当某个使用者获得文档的锁才能编辑文档。
在这个场景下,我们可以: 1、A 与 B 同时开始编辑文档 2、A 率先获得锁,A 开始正常编辑 3、B 争抢锁失败,提示有人正在编辑,等待其他人编辑完毕
关于 分布式锁
,我们需要注意几个点:
TTL
: 锁必须要有 TTL,避免持有锁的线程 crash时得不到释放,根据不同的实现,TTL可以在Set时指定(如Redis),也可以作为Value的成员指定(比如Mongo,Mysql),但是要注意部分存储的TTL功能并不完善,比如Mongo会有定时线程删除过期的记录,因此依然可以查询到;而Redis除了定时删除外还做了惰性删除,在读取时过滤掉已过期的数据。可重入
: 在持有者crash之后,重新恢复时如果有能够标识自己的ID,那么可以重新持有这把锁而避免了等待TTL误抢占
: 在多方抢占时,一定要注意锁不能分配给多方,如果是Redis,可以直接使用 NX,而其他存储可以使用主键冲突、SetWhere等方式来保证误释放
: 如果持有者执行超过了TTL,要注意不能释放掉其他方的锁,解决方法就是在锁上加入唯一标识,只有当唯一标识对应上时才能释放,redis可以使用lua脚本来保证原子,而其他存储可以使用SetWhere。锁续期
: 要彻底解决4的case,在开源项目中有种方案就是起一个后台线程不断对锁进行续期(redlock)一致性
: 如果锁的实现是一个高可用、非强一致的存储,那会有种case是主节点关掉,然后锁信息尚未同步到从节点,此时从节点升级为主节点,则有可能出现多把锁同时持有的case,这个问题比较难解,在redlock中提供了一种思路是在5个master上去获取锁,然后多数派成功则成功,个人觉得这种实现成本太高,不如直接使用一个强一致的db来的成本更低。