从 Linux 内核看读写锁设计

2024-01-0400 分钟
type
status
date
slug
summary
tags
category
icon
password
前段时间看了《Linux内核设计与实现》,第 10 章「内核同步方法」中提到了几种内核中的读写锁。它们分别代表了几种比较典型的读写锁设计,非常值得学习,这里记录一下,讨论是基于 2.6 内核和 x86 体系结构的基础上进行的

读/写自旋锁

内核代码中,要定义读/写自旋锁,通过下面的宏进行初始化:
使用上读锁和写锁是分开加锁的
不能像这样将一个读锁升级成写锁,这会导致死锁;并且写锁也是不可重入的:
 
读/写自旋锁的汇编级别实现比较类似信号量,都是在寄存器上对值做增减,但为了区分读锁和写锁,读锁是固定减 1,写锁则是固定减去一个很大的 magic number,通过结果值比较就能判断锁持有情况
代码分别位于 include/asm-x86_64/rwlock.h/include/asm-x86_64/spinlock.h 中,这两块涉及的核心代码融合一下大致如下:
可以看出,读/写自旋锁是读优先的,会导致写饥饿。当有一个或多个读者持有读锁时,写操作无法获取锁,如果此时读锁被长时间占有,写锁将一直自旋等待,此时自旋会导致一个核心上的高昂开销
 
这里有一个处理上的细节,在真正开始自旋获取到写锁之前,就已经互斥了(减去 magic number),这时候新的读锁是无法获取的,这避免了写锁和读锁的争抢,能稍微缓解下写饥饿问题,例如这样的情况:
 

读/写信号量

读/写信号量的使用上和读/写自旋锁是类似的,但功能上要强大一些,分别支持静态和动态的初始化方法
读/写信号量支持 trylock 操作和动态地将写锁降级为读锁
 
include/asm-x86_64/rwsem.h 中定义了汇编级别上的实现,和前面读/写自旋锁是类似的,但没有自旋,这里就不细说,内核源码这里也直接附带注释了:
 
信号量是睡眠锁,读锁和写锁在获取锁失败时最后都会进入到 rwsem_down_failed_common(位于 lib/rwsem.c) 中,这里会将进程加入等待队列中,然后重新调度进程
 
值得一提的是 trylockdowngrade_write 操作,这两个操作是读/写自旋锁中没有的
两个 trylock 是类似的,都是用 cmpxchg 指令来做 CAS(compare-and-swap) 操作。写锁的 trylock 比较简单,因为是互斥的,所以只需要对初始值做 CAS 即可。而读锁可能被持有多个,所以它的 trylock 需要先将 sem->count 赋值给 tmp,再自增 tmp ,利用 tmp 的值进行 CAS
downgrade_write 的实现则类似写锁解锁,然后判断是否有必要唤醒等待队列中的项,这里其实和写锁解锁(up_write)的主要差别就是给 sem->count 加上的偏移量少了 1(可以回去看前面几个 RWSEM_ 开头的宏定义),而这个 1 就是读锁占的值
总结一下,读/写信号量和读/写自旋锁类似,两者语义上是相同的,也都是读优先的,只不过信号量是睡眠锁,当有长时间获取不到锁的情况时,不会导致过多的 CPU 开销
 

顺序锁

顺序锁和前面两者有个重要的区别,顺序锁是写优先的,让我们来分析下它是如何实现的
首先还是使用方式上:
一个使用例子是内核的 jiffies,它存储了机器启动到当前的时钟节拍,每次时钟中断时都会更新这个值,所以是一个高频写入的场景,get_jiffies_64() 函数用来获取这个值,它的实现是这样的
顺序锁在读取时需要一个循环,这是为了判断在这个过程中是否有发生写入,如果没有,那么读取就是安全的,否则需要重试
实现上,代码位于 /include/linux/seqlock.h,有比较清晰的注释:
可以看到,顺序锁是基于一个自旋锁实现的。但额外依赖一个序列计数器,当获取写锁时,这个序列值会增加。读取数据时要先调用 read_seqbegin,它会返回这个序列值,读取完成后通过 read_seqretry 检查传入的值 iv,满足以下两个条件则说明读是安全的:
  • 如果 iv 是偶数(初始值为 0,写锁会加 1)则说明不是处在一个写操作进行的过程中
  • iv 和序列值相同(相同值异或结果为 0)说明没有写操作发生过
这两者都满足,读取的值就是有效的
 
所以,顺序锁是一种乐观锁,是不存在「读锁」的,而是通过类似版本号的机制来读,因此只要没有其他写者,随时都可以获取到写锁,以此实现写优先

下一篇

2023 年度总结

Loading...