实现Raft时 对于加锁和避免死锁的思考

    科技2022-07-21  124

    在用go实现raft之时,有众多的变量需要加锁,遇到了很多问题,以下为这些思考的总结


    考虑极端的两种情况

    为每个变量加锁

    优点

    省事:每个变量都有锁,各个变量的访问都很安全

    缺点

    性能差:访问和更改每个变量都需要加锁和解锁依旧有场景不能满足:有时候我们需要同时保护多个变量不被修改和读取,直到某个行为完全完成。

    整个大类使用一个变量

    优点

    省事:整个类的所有行为都串行执行,非常安全,也不会有死锁。

    缺点

    性能差:完全丧失多线程的性能提升,整个类的所有方法都不得不串行执行可能产生死锁:比如一个类对外暴露了方法A和方法B,两个方法都是先加锁再执行,如果方法A调用了方法B,则产生死锁。

    稍微总结一下可以看出,我们希望能尽可能避免死锁,尽可能减小锁的粒度来提升性能,同时也要保证有便捷的方式来同时锁住多个变量。想要实现这些目的,就不可能省事(o_o …

    以下提出我所思考到的几种解决方案,相互不一定冲突,亦只作为抛砖引玉,渴望大佬们赏光与赐教。

    切分对象

    对于一个有十几个变量的对象,将相对独立的功能,或者几个内聚性比较强的变量,拆成一个独立的子对象。子对象对原本的对象提供方法,不提供变量。

    优点

    代码清晰:将一个大类拆分成小类,也能使代码更加简洁。不容易产生死锁:如果拆分的好,确实不容易产生死锁。

    缺点

    拆分难:上面也说了,拆分好才能解决死锁问题。拆的不好,反而隐藏了死锁问题。

    传参确定是否要上锁

    这个直接举例子吧

    type Obj struct { mu sync.Mutex } func (obj *Obj) FuncA (isLock ...bool){ if len(isLock) == 0 || isLock[0] { obj.mu.Lock() defer obj.mu.Unlock() } ... } func (obj *Obj) FuncB (isLock ...bool){ if len(isLock) == 0 || isLock[0] { obj.mu.Lock() defer obj.mu.Unlock() } ... } func (obj *Obj) FunC (isLock ...bool){ if len(isLock) == 0 || isLock[0] { rf.raftTermState.mu.Lock(); defer rf.raftTermState.mu.Unlock() } obj.FuncA(false) obj.FuncB(false) ... }

    优点

    明确了调用层级:不管是直接调用FuncA,还是通过FuncC来调用FuncA,都不用担心产生死锁。

    缺点

    重复代码太多:可以看到,上面三个函数的代码都有重复的代码,虽然可以像FuncC一样写在一行里,但还是不利于维护占用可变传参:因为一个函数只能有一个不定长的参数,所以如果某个函数本来就有不定长的传参,那么就没法把isLock这个参数隐藏起来。当然,不作为一个可变参数也是可以的,这样代码能更加清晰。增加参数:众所周知,参数一向是越少越好

    版本控制

    最经典最常见的版本控制,应该是在关系型数据库中使用到的MVCC(我没怎么了解过其实现),但是基于这一思想,想到了一些别的使用方式。

    当我需要发一个http或者RPC请求时,如果等网络IO完才释放锁,那么性能一定很差。

    加锁 -> 将要发送的数据取出 -> 释放锁 -> 发送网络请求 -> 收到回复之后再加锁 -> 修改状态 -> 释放锁。

    这一过程也可以视为将自身的数据存在了请求(Request)之中(将自身的数据拍快照,存在Request之中),简化了实现的思路,性能上也还行。

    优点

    理解起来方便:MVCC大家都比较熟悉,在实现编码中,这一思想也能简化编码。简单实现方便:比如上门的发送网络请求的例子,实现起来还是很方便的

    缺点

    高性能实现麻烦:简单的数据,可以拷贝然后使用。但是对于大量数据(比如数据库级别的),这样显然是不行的。并不能解决所有问题:两个例子:1. 数据库基于MVCC实现的可重复读,依旧没有解决“幻读”的问题。2. 在发送请求的过程中,原数据发生了改变,导致根据响应修改状态时出现错误。
    Processed: 0.011, SQL: 8