互斥性与幂等性

我觉得这两个特性是在系统分析和设计时必须考虑的问题。开始之前,先简单解释下两者的概念。

什么是互斥性

要解释互斥性就不得不先提一下 临界资源,引用百科的介绍。

一次仅允许一个进程使用的资源称为临界资源。

很显然,当多方共享临界资源时,那么这些使用方是互斥的。 举一些场景:

  • 多个人同时修改同一个文档
  • 多个线程一起修改一个并发不安全的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 争抢锁失败,提示有人正在编辑,等待其他人编辑完毕

关于 分布式锁,我们需要注意几个点:

  1. TTL: 锁必须要有 TTL,避免持有锁的线程 crash时得不到释放,根据不同的实现,TTL可以在Set时指定(如Redis),也可以作为Value的成员指定(比如Mongo,Mysql),但是要注意部分存储的TTL功能并不完善,比如Mongo会有定时线程删除过期的记录,因此依然可以查询到;而Redis除了定时删除外还做了惰性删除,在读取时过滤掉已过期的数据。
  2. 可重入: 在持有者crash之后,重新恢复时如果有能够标识自己的ID,那么可以重新持有这把锁而避免了等待TTL
  3. 误抢占: 在多方抢占时,一定要注意锁不能分配给多方,如果是Redis,可以直接使用 NX,而其他存储可以使用主键冲突、SetWhere等方式来保证
  4. 误释放: 如果持有者执行超过了TTL,要注意不能释放掉其他方的锁,解决方法就是在锁上加入唯一标识,只有当唯一标识对应上时才能释放,redis可以使用lua脚本来保证原子,而其他存储可以使用SetWhere。
  5. 锁续期: 要彻底解决4的case,在开源项目中有种方案就是起一个后台线程不断对锁进行续期(redlock)
  6. 一致性: 如果锁的实现是一个高可用、非强一致的存储,那会有种case是主节点关掉,然后锁信息尚未同步到从节点,此时从节点升级为主节点,则有可能出现多把锁同时持有的case,这个问题比较难解,在redlock中提供了一种思路是在5个master上去获取锁,然后多数派成功则成功,个人觉得这种实现成本太高,不如直接使用一个强一致的db来的成本更低。