读写锁引入
在前面小节分析了spin_lock的实现,可以知道spin_lock只允许一个thread进入临界区,而且对进入临界区中的操作不做细分。但是在实际中,对临界区的操作分为读和写。如果按照spin_lock的实现,当多个read thread都想进入临界区读取的时候,这时候只有一个read thread进入到临界区,这样效率和性能明显下降。所以就针对某些操作read thread占绝大多数的情况下,提出了读写锁的概念。
读写锁的基本原理
加锁操作
- 假设当前临界区没有任何进程,这时候read进程或者write进程都可以进来,但是只能是其一
- 如果当前临界区只有一个read进程,这时候任意的read进程都可以进入,但是write进程不能进入
- 如果当前临界区只有一个write进程,这时候任何read/write进程都无法进入。只能自旋等待
- 如果当前当前临界区有好多个read进程,同时read进程依然还会进入,这时候进入的write进程只能等待。直到临界区一个read进程都没有,才可进入
解锁操作
- 如果在read进程离开临界区的时候,需要根据情况决定write进程是否需要进入。只有当临界区没有read进程了,write进程方可进入。
- 如果在write进程离开临界区的时候,无论write进程或者read进程都可进入临界区,因为write进程是排它的。
读写锁的定义
typedef struct {
arch_rwlock_t raw_lock;
} rwlock_t;
typedef struct {
volatile unsigned int lock;
} arch_rwlock_t;
可以看到读写锁与spin_lock的定义最终相同,只是名字不同罢了。
读写锁API
加锁API
- read_lock(lock)/write_lock(lock) #获取指定的锁
- read_trylock(lock)/write_trylock(lock) #尝试获取锁,如果失败不spin,直接返回
- read_lock_irq(lock)/write_lock_irq(lock) #获取指定的锁,同时关掉本地cpu中断
- read_lock_irqsave(lock, flags)/write_lock_irqsave(lock, flags) #保存本地cpu的irq标志,然后关掉cpu中断,获取指定锁
- read_lock_bh(lock)/read_lock_bh(lock) #获取指定的锁,同时关掉中断下半部(bottom half)
解锁API
- read_unlock(lock)/write_unlock(lock) #释放指定的锁
- read_unlock_irq(lock)/write_unlock_irq(lock) #释放指定的锁,同时使能cpu中断
- read_unlock_irqrestore/write_unlock_irqrestore #释放锁,同时使能cpu中断,恢复cpu的标识
- read_unlock_bh/write_unlock_bh #释放锁,同时使能cpu中断的下半部
读写锁的实现
写入者加锁操作:
/*
* Write lock implementation.
*
* Write locks set bit 31. Unlocking, is done by writing 0 since the lock is
* exclusively held.
*
* The memory barriers are implicit with the load-acquire and store-release
* instructions.
*/
static inline void arch_write_lock(arch_rwlock_t *rw)
{
unsigned int tmp;
asm volatile(
" sevl\n"
"1: wfe\n"
"2: ldaxr %w0, %1\n"
" cbnz %w0, 1b\n"
" stxr %w0, %w2, %1\n"
" cbnz %w0, 2b\n"
: "=&r" (tmp), "+Q" (rw->lock)
: "r" (0x80000000)
: "memory");
}
通过注释: write操作的上锁操作是给bit31写1, 解锁操作就是给bit31写0
" sevl\n"
"1: wfe\n"
使cpu进入低功耗模式
2: ldaxr %w0, %1\n
读取锁的值,赋值给tmp变量
cbnz %w0, 1b
如果tmp的值不为0, 跳转到标号1重新执行。不等于0说明有read/write进程正在持有锁,所以需要进入低功耗等待。
stxr %w0, %w2, %1
将锁的bit31设置为1, 然后将设置结果放入tmp中。
cbnz %w0, 2b
如果tmp的值不为0,说明上条指令执行失败,跳转到标号2继续执行。
可以看到,对于wirte操作,只要临界区有read/write进程存在,就需要自旋等待,直到临界区没有任何进程存在。
写入者解锁操作:
static inline void arch_write_unlock(arch_rwlock_t *rw)
{
asm volatile(
" stlr %w1, %0\n"
: "=Q" (rw->lock) : "r" (0) : "memory");
}
写操作很简单,就是将锁的值全部清为0而已。
读取者加锁操作:
/*
* Read lock implementation.
*
* It exclusively loads the lock value, increments it and stores the new value
* back if positive and the CPU still exclusively owns the location. If the
* value is negative, the lock is already held.
*
* During unlocking there may be multiple active read locks but no write lock.
*
* The memory barriers are implicit with the load-acquire and store-release
* instructions.
*/
static inline void arch_read_lock(arch_rwlock_t *rw)
{
unsigned int tmp, tmp2;
asm volatile(
" sevl\n"
"1: wfe\n"
"2: ldaxr %w0, %2\n"
" add %w0, %w0, #1\n"
" tbnz %w0, #31, 1b\n"
" stxr %w1, %w0, %2\n"
" cbnz %w1, 2b\n"
: "=&r" (tmp), "=&r" (tmp2), "+Q" (rw->lock)
:
: "memory");
}
读取者进入临界区先要判断是否有write进程在临界区,如果有必须自旋。如果没有,则可以进入临界区。
2: ldaxr %w0, %2
读取锁的值,赋值给tmp变量。
add %w0, %w0, #1
将tmp的值加1, 然后将结果放入tmp中。
tbnz %w0, #31, 1b
判断tmp[31]是否等于0,不等于0也就是说write进程在临界区,需要自旋等待,跳到标号1继续。
stxr %w1, %w0, %2
将tmp的值复制给lock,然后将结果放入tmp2中。
cbnz %w1, 2b
判断tmp2是否等于0,不等于0就跳到标号2继续。
可以看到read操作需要先判断临界区是否有write进程存在,如果有就需要自旋。
读取者解锁操作:
static inline void arch_read_unlock(arch_rwlock_t *rw)
{
unsigned int tmp, tmp2;
asm volatile(
"1: ldxr %w0, %2\n"
" sub %w0, %w0, #1\n"
" stlxr %w1, %w0, %2\n"
" cbnz %w1, 1b\n"
: "=&r" (tmp), "=&r" (tmp2), "+Q" (rw->lock)
:
: "memory");
}
读取者退出临界区只需要将锁的值减1即可。
1: ldxr %w0, %2
读取锁的值,复制给tmp
sub %w0, %w0, #1
将tmp的值减去1,同时将结果放入到tmp中
stlxr %w1, %w0, %2
将tmp的值复制给lock,然后将结果存放到tmp2
cbnz %w1, 1b
如果tmp2的值不为0,就跳转到标号1继续执行。
小节
从概率上将,当一个进程试图去写时,成功获得锁的几率要远小于读进程概率。所以在一个读写相互依赖的系统中,这种设计会导致读取者饥饿,也就是没有数据可读。所以读写锁使用的系统就是读操作占用绝大多数,这样读写操作就比以前的spin lock大大提升效率和性能。