前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >System|并发|Rethinking Lock

System|并发|Rethinking Lock

作者头像
朝闻君
发布2021-11-22 10:50:45
3250
发布2021-11-22 10:50:45
举报
文章被收录于专栏:用户9199536的专栏

并发控制是多核系统中最重要的问题,人们常常使用锁进行实现,然而,在保证正确性的同时,人们发现随着核数的上升,锁的性能可拓展性断崖却制约了并发的上限。因此,多核架构下很多创新的并发控制算法应运而生。

本文将从悲观锁、乐观锁等两方面介绍它们所使用的并发控制算法实现

目录

悲观锁

  • 互斥: 自旋锁、排号锁
  • 读写冲突: 读写锁、RCU、MVCC-2PL
  • 多核: CLH锁、MCS锁、Queue Spinlock、cohort锁

乐观锁

  • CAS、OCC、MVCC-OCC、Silo OCC

悲观锁

自旋锁

自旋锁是最简单的实现,仰仗于硬件提供的原子性操作CAS(compare and swap)。

  • Intel中通过锁总线保证,相当于硬件版的悲观锁
  • ARM中则通过Load-linked & Store-conditional(LL/SC)保证,相当于硬件版的乐观锁
代码语言:javascript
复制
void lock(int *lock) {
while(atomic_CAS(lock, 0, 1)!= 0)
/* Busy-looping */ ;
}
void unlock(int *lock) {
*lock = 0;
}

然而,自旋锁的问题在于,所有的竞争者都是一视同仁的,运气差的竞争者可能一辈子获取不到锁导致饥饿,从而破坏了互斥访问/有限等待/空闲让进中的有限等待。

排号锁

排号锁的思想类似于我们餐厅排队,叫号时进行消费,采取FIFO的方式,仰仗于硬件提供的原子性操作FAA(fetch and add)。由于FIFO,因此解决了有限等待的问题。

代码语言:javascript
复制
void lock(int *lock) {
volatile unsigned my_ticket =
atomic_FAA(&lock->next, 1);
while(lock->owner != my_ticket)
/* busy waiting */;
}
void unlock(int *lock) {
lock->owner ++;
}

读写锁

互斥锁的条件太过于苛刻,我们希望临界区的读者之间并行,读者与写者之间互斥,从而提高读的性能。

  • 偏向写者: 有写者等待读者时,读者不能进入临界区(更公平)
  • 偏向读者: 有写者等待读者时,读者可以进入临界区(更并行)

偏向读者

Read Copy Update(RCU)

读写锁只能保证读并行,但是如果写者进入临界区便无法读取。RCU解决了这个问题

  • 当写操作时,对副本进行修改(写互斥),写操作完成后,原子性地将指针指向修改的副本
  • 当读操作进入临界区时引用计数++,离开临界区时引用计数--,引用计数为0后回收旧拷贝

MVCC-2PL

MVCC指的是通过为每个数据单元赋予版本号的方式,使得版本号更大的数据对于版本号更小的事务不可见。这个隔离级别名为快照隔离,避免了读写冲突。与此同时,面对写写冲突,采用传统的2PL方式避免,2PL指的是第一阶段只获得锁,第二阶段只释放锁,而不交错。

mysql中,MVCC的实现通过为每个tuple赋予版本号以及其上个版本地址

InnoDB

CLH锁

让我们回到自旋锁,可拓展性断崖出现了!最重要的问题在于,所有的核都想要对于lock进行修改,对单一缓存行的竞争导致严重的性能开销。

代码语言:javascript
复制
while(atomic_CAS(lock, 0, 1)!= 0);

因为在缓存一致性的MSI协议中,我们需要存储缓存行对每个核的状态,而状态通过bit存储在全局目录项中。而当多核同时竞争这个目录项时性能会剧烈下降。

综上,在单一的数据结构上进行加锁是不具备可拓展性的,我们必须为每个竞争者维护其本地的状态,从而减少单一缓存行的竞争,这便成了队列锁

CLH锁基于链表,申请线程不断轮询前驱的状态(volatile),如果发现前驱释放了锁就结束自旋。当然,自旋是个傻事,所以在Java的实现中采用park和unpark避免其占用CPU。

当然,CLH锁也会导致所有的申请线程对于队尾竞争,但是因为入队的开销远远小于轮询的开销,并非关键路径,因此在关键路径上避免对单一缓存行的高度竞争

代码语言:javascript
复制
public abstract class AbstractQueuedSynchronizer{
static final class Node {
    volatile int waitStatus;
    volatile Node prev;
    volatile Node next;
    volatile Thread thread;
    Node nextWaiter; 
}
private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;
}

MCS锁

CLH锁的问题在于,其对前驱自旋,而非本地变量,因此在NUMA架构下由于访问非本地的内存而更慢。MCS锁选择对于本地状态自旋。

Queue Spinlock

Reference: https://lwn.net/Articles/561775/ 兰新宇:Linux中的spinlock机制[三] - qspinlock

Qspinlock的思想在于,竞争者仅有两个的时候,状态保存在qspinlock上,而多于两者时,转化为MCS锁。从而保证了高竞争和低竞争情况下分别具有高性能。

Cohort锁

然而MCS并不能真正解决NUMA的问题,尽管锁能控制自己所访问的状态是本地的,但是却无法控制临界区访问的数据也是本地的。

Lock Cohorting的思路在于: 在一定时间内将访存局限在本地。

分为两种锁,分别是本地锁和全局锁。

  • 申请时先获得本地锁,再获得全局锁。
  • 当全局锁释放时,传给本地等待队列的下一位。
  • 直到一个NUMA节点全部结束后才能由其他节点获取锁。

Lock Cohorting: A General Technique for Designing NUMA Locks


乐观锁

CAS

JAVA中所有锁的底层本质上都是CAS,也就是上文提到的硬件原子性支持。

在执行修改前获取对象的值作为预期,然后在修改时进行验证,如果此时对象的值不符合预期,说明存在写写冲突不能修改;否则正常修改对象。

在Java中var1代表对象地址,var2表示字段偏移量,var4表示预期值,var5表示修改值。

字段偏移量通过Object.class.getDeclaredField("fieldname"))获得

代码语言:javascript
复制
    public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
    public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

有个ABA现象,就是如果只看预期值的话,万一预期A但是途中被修改成了B最终又被改成A,就会误判没有发生修改。方法就是加个版本号,改一次++,或者用标记表示是否被修改。

OCC

OCC假设并发的写数目较少,而并发读较多,这玩意儿其实就是大号CAS。

  • Phase 1: Read read set存放读的数据; write set缓存写的数据
  • Phase 2: Validation 验证是否有read set的数据被修改
  • Phase 3: Write 如果验证通过则进行COMMIT写入write set,否则ABORT

事实上为了防止Phase2和Phase3之间又插入写,还是要加悲观锁的,但是加锁的时间比较短(事务主要在Phase 1),所以能提高并发。

MVCC-OCC

通过比较数据的值判断是否发生修改显然开销太大了,因此在MVCC的基础上,不需要验证数据是否被修改,只需要验证版本号是否被修改即可。

  • Phase 1: Read read set存放读的版本; write set缓存写的数据
  • Phase 2: Validation 验证是否有read set的版本被修改
  • Phase 3: Write 如果验证通过则进行COMMIT写入write set,否则ABORT

Silo OCC

Silo OCC的优化在于其本身并没有全局级别的版本号,仅仅保证相关的事务串行即可。epoch是绝对的时间戳,而sequence仅仅是相对的顺序。事务的TID先比较Epoch再比较Seq。

  • Pre-commit execution

read set存放(tuple -> tid_read),write set则存放(tuple -> value_to_write)。

  • Commit

一阶段 全write 置位lock bit(由全局的线程来做防止死锁),获取当前epoch

二阶段 全read检查TID是否改变(已被写)或者lock bit(正在被写)

为了避免幻读,在范围查询时还会查询整个B+ Tree叶节点的version。

三阶段 生成新的TID,并写入数据

  1. 必须大于所有read/write set
  2. 必须比本线程之前生成的TID大
  3. 使用一阶段的epoch

这三条规则并没有要求事务生成绝对的全局顺序,而仅仅保证数据相关事务的串行化,因此在多核环境下避免对单一缓存行的高度竞争,保证可拓展性。https://cloud.tencent.com/developer/article/1903592

总结

Reference: SJTU IPADS OS-10/11 CSE-15 Java SDK Linux Kernel Silo OCC

如果只考虑软件而不考虑硬件的话,解决掉读写冲突之后锁机制几乎已经完美了。但是因为硬件的局限,多核情况下却出现了性能断崖,因此出现了很多提高locality而减少globality的并发控制算法。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目录
  • 悲观锁
  • 乐观锁
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档