linux源码之自旋锁(spinlock)/读写自旋锁分析

本文kernel代码分析基于以下
1.linux-4.14.159
2.64bit代码处理逻辑

我们在上面学习semaphore的时候知道其不能在有中断的场景下使用,这节我们看下自旋锁spinlock,这个主要用在有中断的场景下,其最大特点就是获取不了锁则会自旋忙等待既不让出cpu,在临界区入口死等,这主要用于SMP(多核)下的同步问题。

先概述下spinlock的特点

1.spinlock不可递归,具有不可重入性,如果释放锁之前又调用了接口去获取这个锁,肯定获取不到从而引起cpu死锁
2. 是一种cpu忙等待的锁机制,即不会让出cpu调用其它进程,而是一直死等
3. 因为如上这些特性,其使用场景主要针对那些临界区资源执行比较短的场景,否则的话会造成cpu资源严重浪费

spinlock不足及改进

1.老的的kernel版本的spinlock不能保证资源访问公平及有序性,Linux 2.6.25引入了排队策略(FIFO Ticket Spinlock)的概念,采取先访问者先获取锁即先来先到的原则保证了资源访问的公平及有序性,这在后面再重点介绍这个策略

2.有些共享资源的访问如共享内存时,对于并发读操作的spinlock保护是多余的或者说对于读写需要进一步细分的场景spinlock没有区别对待,spinlock又引入了读写 spinlock,这个后面会详细介绍。

一. spinlock

1.1 首先看下spinlock的数据结构:

//取掉了一些debug相关的code
 typedef struct spinlock {
      //@1
	union {
     
		struct raw_spinlock rlock;
	};
} spinlock_t;

typedef struct raw_spinlock {
     
	arch_spinlock_t raw_lock;
} raw_spinlock_t;

typedef struct {
      //@2
#ifdef __AARCH64EB__  //@3 
	u16 next;
	u16 owner;
#else
	u16 owner;
	u16 next;
#endif
} __aligned(4) arch_spinlock_t;
@1. 可以看到spinlock 封装了raw_spinlock,而后者又封装了和体系相关的arch_spinlock_t,
    
@2. 我们在开头提到了spinlock的排队策略,这里我们重点来解释下
  a.我们看到arch_spinlock_t定义两个两个字段next和owner,
  next表示下一个进程来获取锁时将给其分配的数值,owner表示存在这个数值的进程才配持有锁
  b.开始时都为0,而只有当next=owner的进程才能获取锁;
  c.每个进程获取锁时会next++,当释放锁时owner++	
   
  举例描述下这个过程:
  a.第一个进程获取锁,next=owner=0,可以获取锁,然后(next++)=1
  b.第一个释放锁前,无后续进程来申请锁,第一个进程释放锁owner++,此时next=owner=1,过程结束;
    如果第一个释放锁前,此时陆续有三个进程来申请锁,goto step c
  c.第二个进程申请锁next=1,owner=0;然后 (next++)=2 (先记录lock中next,再把next++写入到lock中,下同)
  d.第三个进程申请锁next=2,owner=0;然后 (next++)=3 
  e.第四个进程申请锁next=3,owner=0;然后 (next++)=4
  f.若第一个进程释放锁后(owner++)=1;而此时只有第二个进程获取时记录的next=1,   
    此时owner=next=1(这里next是申请时记录的next非next++后的),因此只有第二个进程能获取到锁,而其它进程继忙等待
  g.这样先到的先给锁后到后给锁,保证了公平性和有序性,但其实这里没有考虑到优先级问题 
  
@3 大小端的区别

1.2 初始化后我们看使用场景

spin_lock(lock);
...临界区资源...
spin_unlock(lock);

spin_lock

spin_lock->raw_spin_lock->_raw_spin_lock->__raw_spin_lock
static inline void __raw_spin_lock(raw_spinlock_t *lock)//@1
{
     
	preempt_disable();
	spin_acquire(&lock->dep_map, 0, 0, _RET_IP_); 
	LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock); 
}
static inline void do_raw_spin_lock(raw_spinlock_t *lock) __acquires(lock)
{
     
	__acquire(lock); //@2
	arch_spin_lock(&lock->raw_lock);//@3
}
@1. 关闭内核抢占,spin_acquire是和锁有关的debug,不用关注,最后通过宏最终调用do_raw_spin_lock

@2. 和静态代码检查分析有关,可忽略

@3. arch_spin_lock和体系结构有关,此文如开头说明是基于arm64
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
     
	unsigned int tmp;
	arch_spinlock_t lockval, newval;

	asm volatile(   //@4
	/* Atomically increment the next ticket. */
	ARM64_LSE_ATOMIC_INSN(
	/* LL/SC */
"	prfm	pstl1strm, %3\n"  //@5
"1:	ldaxr	%w0, %3\n" //@6
"	add	%w1, %w0, %w5\n" //@7
"	stxr	%w2, %w1, %3\n" //@8
"	cbnz	%w2, 1b\n", //@9
	/* LSE atomics */
"	mov	%w2, %w5\n"
"	ldadda	%w2, %w0, %3\n"
	__nops(3)
	)

	/* Did we get the lock? */
"	eor	%w1, %w0, %w0, ror #16\n" //@11
"	cbz	%w1, 3f\n" //@12
	/*
	 * No: spin on the owner. Send a local event to avoid missing an
	 * unlock before the exclusive load.
	 */
"	sevl\n"  //@13
"2:	wfe\n" //@14
"	ldaxrh	%w2, %4\n" //@15
"	eor	%w1, %w2, %w0, lsr #16\n" //@16
"	cbnz	%w1, 2b\n" //@17
	/* We got the lock. Critical section starts here. */
"3:"
	: "=&r" (lockval), "=&r" (newval), "=&r" (tmp), "+Q" (*lock)
	: "Q" (lock->owner), "I" (1 << TICKET_SHIFT)
	: "memory");
}
@4. 这是内嵌汇编代码,不清楚的可以查资料了解下

@5. 将lock变量预存到一级cache中,为了提高访问速度

@6. ldaxr(独占加载指令) 把lock赋给lockval,lockval=lock

@7. %w5为1 << TICKET_SHIFT,TICKET_SHIFT为16,即lockval+(1<<16)赋给newval
    注意newval中的next字段储存的是next++后的值,而lockval仍然是之前的
    
@8. stxr(独占存储指令) 把newval的值写入到lock,并把指令执行成功与否的结果写入到tmp中

@9. 如果tmp的值不等于0,即独占写失败,则跳到标号1继续执行
    @5-@9 这个过程通过独占指令来原子修改next,结果时next++
    
@10. 另外一种修改next的方法,这里不再介绍

@11. lockval循环右移16位,再和lockval进行异或运算,把结果保存到newval
     其实就是判断next是否等于owner

@12. 如果newval等于0,即如果next==owner,则跳到标号3,即临界资源区域 

@13. sevl(Send Event Local)使event在本地发出信号,它可以启动一个以WFE指令开始的等待循环

@14. cpu进入低功耗状态,自旋等待

@15. 走到这里表示被唤醒,ldaxrh(类ldaxr,h为halfword)获取lock->owner存到tmp中 

@16. lockval逻辑右移16位,为next,再和tmp异或,把结果保存到newval,
     即判断next是否等于owner

@17. 如果newval不等于0,则跳到标号2继续自旋等待;否则进入临界区资源

再来看spin_unlock

我们直接看相关的核心代码

static inline void arch_spin_unlock(arch_spinlock_t *lock) //@1
{
     
	unsigned long tmp;

	asm volatile(ARM64_LSE_ATOMIC_INSN(
	/* LL/SC */
	"	ldrh	%w1, %0\n"
	"	add	%w1, %w1, #1\n"
	"	stlrh	%w1, %0",
	/* LSE atomics */
	"	mov	%w1, #1\n"
	"	staddlh	%w1, %0\n"
	__nops(1))
	: "=Q" (lock->owner), "=&r" (tmp)
	:
	: "memory");
}
@1. 这个比较简单,获取owner,并owner++及写回,过程是原子的

我们在spinlock的不足及改进中提到过读写spinlock,我们再来看下

二. 读写自旋锁
读写锁的基本原理,类似之前我看的rw_semaphore
1.同一时刻允许多个读者(reader)获得锁进入临界区
2.同一时刻只允许一个写者(writer)获得锁进入临界区,也就是写者与写者互斥
3.同一时刻不允许写着和读者同时获取锁进入临界区,也就是读者与写者互斥
4.此种锁机制照顾读者,使用时请对自己的场景评估是否适合,否则会引起性能问题

2.1. 看一下数据结构

//去除了一些debug相关的code
typedef struct {
     
	arch_rwlock_t raw_lock;
} rwlock_t;
typedef struct {
     
	volatile unsigned int lock; //@1
} arch_rwlock_t;
 
@1 这里我们可以看到和spinlock的差异实质上就是字段不一样;
   一个是next和owner;一个是lock

2.2 初始化后我们看使用场景

//读
read_lock(rwlock);
...临界区只读资源...
read_unlock(rwlock)

//写
write_lock(rwlock);
...临界区读写资源...
write_unlock(rwlock)

注意:read_lock和read_unlock成对,write_lock和write_unlock成对,不能read和write成对,即read_lock和write_unlock是非法的。

上面最终的实现和spinlock基本类似,因此我们只看核心的code

arch_read_lock

static inline void arch_read_lock(arch_rwlock_t *rw)
{
     
	unsigned int tmp, tmp2;

	asm volatile(
	"	sevl\n"
	ARM64_LSE_ATOMIC_INSN(
	/* LL/SC */
	"1:	wfe\n"
	"2:	ldaxr	%w0, %2\n" //@1
	"	add	%w0, %w0, #1\n"  //@2
	"	tbnz	%w0, #31, 1b\n" //@3
	"	stxr	%w1, %w0, %2\n" //@4
	"	cbnz	%w1, 2b\n"  //@5
	__nops(1),
	: "=&r" (tmp), "=&r" (tmp2), "+Q" (rw->lock)
	:
	: "cc", "memory");
}
@1. 把rw->lock值读到tmp中
@2. tmp++
@3. tmp[31]是否等于0,不等于0也就是说write进程在临界区,跳到标号1继续自旋等待
@4. 把tmp值写入到rw->lock,写入的返回值保存到tmp2
@5. tmp2不为0,独占写入失败,跳到标号2继续执行

arch_read_unlock

static inline void arch_read_unlock(arch_rwlock_t *rw)
{
     
	unsigned int tmp, tmp2;

	asm volatile(ARM64_LSE_ATOMIC_INSN(
	/* LL/SC */
	"1:	ldxr	%w0, %2\n" //@1
	"	sub	%w0, %w0, #1\n" //@2
	"	stlxr	%w1, %w0, %2\n" //@3
	"	cbnz	%w1, 1b",  //@4
	/* LSE atomics */
	"	movn	%w0, #0\n"
	"	staddl	%w0, %2\n"
	__nops(2))
	: "=&r" (tmp), "=&r" (tmp2), "+Q" (rw->lock)
	:
	: "memory");
}
@1. rw->lock获取写入到tmp
@2. tmp--
@3. 把tmp值写入到rw->lock,写入的返回值保存到tmp2
@4. tmp2不为0,独占写入失败跳到标号1继续执行

再看arch_write_lock

static inline void arch_write_lock(arch_rwlock_t *rw)
{
     
	unsigned int tmp;

	asm volatile(ARM64_LSE_ATOMIC_INSN(
	/* LL/SC */
	"	sevl\n"
	"1:	wfe\n"
	"2:	ldaxr	%w0, %1\n" //@1
	"	cbnz	%w0, 1b\n" //@2
	"	stxr	%w0, %w2, %1\n" //@3
	"	cbnz	%w0, 2b\n" //@4
	__nops(1),
	"3:")
	: "=&r" (tmp), "+Q" (rw->lock)
	: "r" (0x80000000)
	: "memory");
}
@1. rw->lock读取写入到tmp中
@2. 如果tmp不会0,则存在读者/写者,跳到标号1继续自旋等待
@3.0x80000000写入到rw->lock,即给lock的bit31位写1,并把结果保存到tmp
@4. 独占写入失败,跳到标号2继续执行

arch_write_unlock

static inline void arch_write_unlock(arch_rwlock_t *rw) 
{
     
	asm volatile(ARM64_LSE_ATOMIC_INSN(
	"	stlr	wzr, %0",      //@1
	"	swpl	wzr, wzr, %0") //@2
	: "=Q" (rw->lock) :: "memory");
}
@1. 清零lock变量
@2.0寄存器互换,保证清零lock 成功 

如上结构体即读写自旋锁的实现可以发现,读写自旋锁实现很简单,lock变量是一个unsigned int的32位数,而且bit 31位用来表示写者的持锁信息,bit 0-bit 30位用来表示读者的持锁信息。

通过上面的代码实现可以看到1bit就可以来表示写者的持锁状态,写者获取锁成功时bit 31写1(此时不存在读者),释放锁时lock直接清零;而读者获取锁成功时,写者肯定不会获取,lock++,读者释放锁时 lock–,lock的数量表示目前多少个读者持锁。

为甚么读写自旋锁设计比读写信号量的设计要简单很多啊?不涉及等待队列? 这个其实和他们使用场景差异(中断)较大有关

三.自旋锁其它接口

  spin_lock_irq // 获取自旋锁同时失效本地中断,
  spin_lock_irqsave //获取自旋锁同时失效本地中断,保存本CPU当前的irq状态
  spin_lock_bh //获取自旋锁同时失效本地软中断(bottom half)

上面这些其实核心实现是一样的,可以自己check下源码

参考:
https://www.ibm.com/developerworks/cn/linux/l-cn-spinlock_mips/index.html

你可能感兴趣的:(linux内核源码研究,linux,内核,锁,同步)