Linux学习 内核同步(文章搬运)

自旋锁相关,本文第一章节

1 Linux当中的自旋锁

1.1 Spinlock

自旋锁的产生,是因为内核中会发生访问资源冲突。此时有两种锁的解决方案进行选择:(1)原地等待,直到资源释放;(2)将当前进程挂起,调度其他进程执行,进入睡眠状态。而自旋锁就是一种原地等待的机制。

一个线程获取了一个自旋锁后,另外一个线程期望获取该自旋锁,获取不到,只能够原地“打转”(忙等待)。由于自旋锁的这个忙等待的特性,注定了它使用场景上的限制 —— 自旋锁不应该被长时间的持有(消耗 CPU 资源)。

1.2 使用自旋锁

使用场景:共享数据被中断上下文和进程上下文访问,该如何保护呢?如果只有进程上下文的访问,那么可以考虑使用semaphore或者mutex的锁机制,但是现在中断上下文也参和进来,那些可以导致睡眠的lock就不能使用了,这时候,可以考虑使用spin lock。

定义锁的两种方式:

//动态方式
spinlock_t lock;
spin_lock_init (&lock);
//静态方式
DEFINE_SPINLOCK(lock);

1.2.1 自旋锁把自己锁死了怎么办

是什么原因会导致自旋锁把自己锁死呢?
一个线程获取了一个锁,但是被中断处理程序打断,中断处理程序也获取了这个锁(但是之前已经被锁住了,无法获取到,只能自旋),中断无法退出,导致线程中后面释放锁的代码无法被执行,导致死锁。(如果确认中断中不会访问和线程中同一个锁,其实无所谓)

因此不允许在递归过程中调用自旋锁

场景1 内核抢占场景

(1)进程A在某个系统调用过程中访问了共享资源 R
(2)进程B在某个系统调用过程中也访问了共享资源 R

会不会造成冲突呢?假设在A访问共享资源R的过程中发生了中断,中断唤醒了沉睡中的,优先级更高的B,在中断返回现场的时候,发生进程切换,B启动执行,并通过系统调用访问了R,如果没有锁保护,则会出现两个thread进入临界区,导致程序执行不正确。

加上spin lock看看如何:A在进入临界区之前获取了spin lock,同样的,在A访问共享资源R的过程中发生了中断,中断唤醒了沉睡中的,优先级更高的B,B在访问临界区之前仍然会试图获取spin lock,这时候由于A进程持有spinlock而导致B进程进入了永久的spin……怎么破?

linux的kernel很简单,在A进程获取spin lock的时候,禁止本CPU上的抢占(上面的永久spin的场合仅仅在本CPU的进程抢占本CPU的当前进程这样的场景中发生)。如果A和B运行在不同的CPU上,那么情况会简单一些:A进程虽然持有spin lock而导致B进程进入spin状态,不过由于运行在不同的CPU上,A进程会持续执行并会很快释放spin lock,解除B进程的spin状态。

场景2 中断上下文场景

(1)运行在CPU0上的进程A在某个系统调用过程中访问了共享资源 R
(2)运行在CPU1上的进程B在某个系统调用过程中也访问了共享资源 R
(3)外设P的中断handler中也会访问共享资源 R

在这样的场景下,使用spin lock可以保护访问共享资源R的临界区吗?我们假设CPU0上的进程A持有spin lock进入临界区,这时候,外设P发生了中断事件,并且调度到了CPU1上执行,看起来没有什么问题,执行在CPU1上的handler会稍微等待一会CPU0上的进程A,等它立刻临界区就会释放spinlock

但是,如果外设P的中断事件被调度到了CPU0上执行会怎么样?CPU0上的进程A在持有spinlock的状态下被中断上下文抢占,而抢占它的CPU0上的handler在进入临界区之前仍然会试图获取spinlock,悲剧发生了,CPU0上的P外设的中断handler永远的进入spin状态,这时候,CPU1上的进程B也不可避免在试图持有spinlock的时候失败而导致进入spin状态。

为了解决这样的问题,linux kernel采用了这样的办法:如果涉及到中断上下文的访问,spinlock需要和禁止本 CPU 上的中断联合使用。

场景3 底半部场景

linux kernel中提供了丰富的bottom half的机制,虽然同属中断上下文,不过还是稍有不同。我们可以把上面的场景简单修改一下:外设P不是中断handler中访问共享资源R,而是在的bottom half中访问。

使用spin lock+禁止本地中断当然是可以达到保护共享资源的效果,但是使用牛刀来杀鸡似乎有点小题大做,这时候disable bottom half就OK了

场景4 中断上下文之间的竞争

同一种中断handler之间在uni core和multi core上都不会并行执行,这是linux kernel的特性。

如果不同中断handler需要使用spin lock保护共享资源,对于新的内核(不区分fast handler和slow handler),所有handler都是关闭中断的,因此使用spin lock不需要关闭中断的配合。

bottom half又分成softirqtasklet,同一种softirq会在不同的CPU上并发执行,因此如果某个驱动中的softirq的handler中会访问某个全局变量,对该全局变量是需要使用spin lock保护的,不用配合disable CPU中断或者bottom half。

tasklet更简单,因为同一种tasklet不会多个CPU上并发。

1.2.2 实现自旋锁

1、文件整理

和体系结构无关的代码如下:

(1) include/linux/spinlock_types.h
这个头文件定义了通用spin lock的基本的数据结构(例如spinlock_t)和如何初始化的接口(DEFINE_SPINLOCK)。这里的“通用”是指不论SMP还是UP都通用的那些定义。

(2)include/linux/spinlock_types_up.h

这个头文件不应该直接include,include/linux/spinlock_types.h文件会根据系统的配置(是否SMP)include相关的头文件,如果UP则会include该头文件。这个头文定义UP系统中和spin lock的基本的数据结构和如何初始化的接口。当然,对于non-debug版本而言,大部分struct都是empty的。

(3)include/linux/spinlock.h

这个头文件定义了通用spin lock的接口函数声明,例如spin_lock、spin_unlock等,使用spin lock模块接口API的驱动模块或者其他内核模块都需要include这个头文件。

(4)include/linux/spinlock_up.h

这个头文件不应该直接include,在include/linux/spinlock.h文件会根据系统的配置(是否SMP)include相关的头文件。这个头文件是debug版本的spin lock需要的。

(5)include/linux/spinlock_api_up.h

同上,只不过这个头文件是non-debug版本的spin lock需要的

(6)linux/spinlock_api_smp.h

SMP上的spin lock模块的接口声明

(7)kernel/locking/spinlock.c

SMP上的spin lock实现。

在这里插入图片描述

2 数据结构
首先定义一个 spinlock_t 的数据类型,其本质上是一个整数值(对该数值的操作需要保证原子性),该数值表示spin lock是否可用。初始化的时候被设定为1。当thread想要持有锁的时候调用spin_lock函数,该函数将spin lock那个整数值减去1,然后进行判断,如果等于0,表示可以获取spin lock,如果是负数,则说明其他thread的持有该锁,本thread需要spin。

内核中的spinlock_t的数据类型定义如下:

typedef struct spinlock { 
        struct raw_spinlock rlock;  
} spinlock_t;
 
typedef struct raw_spinlock { 
    arch_spinlock_t raw_lock; 
} raw_spinlock_t;

spin lock的命名规范定义如下:
(1)spinlock,在rt linux(配置了PREEMPT_RT)的时候可能会被抢占(实际底层可能是使用支持PI(优先级翻转)的mutext)。
(2)raw_spinlock,即便是配置了PREEMPT_RT也要顽强的spin
(3)arch_spinlock,spin lock是和architecture相关的,arch_spinlock是architecture相关的实现

对于UP平台,所有的arch_spinlock_t都是一样的,定义如下:typedef struct { } arch_spinlock_t;

spin_lock的代码如下:

static inline void spin_lock(spinlock_t *lock) 
{ 
    raw_spin_lock(&lock->rlock); 
}

raw_spin_lock,代码如下:

#define raw_spin_lock(lock)    _raw_spin_lock(lock)

(to be continued…)

2 读写自旋锁

一个内核链表元素,很多进程(或者线程)都会对其进行读写,但是使用 spinlock 的话,多个读之间无法并发,只能被 spin,为了提高系统的整体性能,内核定义了一种锁:

  1. 允许多个处理器进程(或者线程或者中断上下文)并发的进行读操作(SMP 上),这样是安全的,并且提高了 SMP 系统的性能。

  2. 在写的时候,保证临界区的完全互斥

所以,当某种内核数据结构被分为:读-写,或者生产-消费,这种类型的时候,类似这种 读-写自旋锁就起到了作用。对读者是共享的,对写者完全互斥。

读/写自旋锁是在保护SMP体系下的共享数据结构而引入的,它的引入是为了增加内核的并发能力。只要内核控制路径没有对数据结构进行修改,读/写自旋锁就允许多个内核控制路径同时读同一数据结构。如果一个内核控制路径想对这个结构进行写操作,那么它必须首先获取读/写锁的写锁,写锁授权独占访问这个资源。这样设计的目的,即允许对数据结构并发读可以提高系统性能。

解锁的逻辑:

(1)在write thread离开临界区的时候,由于write thread是排他的,因此临界区有且只有一个write thread,这时候,如果write thread执行unlock操作,释放掉锁,那些处于spin的各个thread(read或者write)可以竞争上岗。

(2)在read thread离开临界区的时候,需要根据情况来决定是否让其他处于spin的write thread们参与竞争。如果临界区仍然有read thread,那么write thread还是需要spin(注意:这时候read thread可以进入临界区,听起来也是不公平的)直到所有的read thread释放锁(离开临界区),这时候write thread们可以参与到临界区的竞争中,如果获取到锁,那么该write thread可以进入。

读-写自旋锁的使用

与 spinlock 的使用方式几乎一致,读-写自旋锁初始化方式也分为两种:

动态的:rwlock_t rw_lock; rwlock_init (&rw_lock);
静态的:DEFINE_RWLOCK(rwlock);
初始化完成后就可以使用读-写自旋锁了,内核提供了一组 APIs 来操作读写自旋锁,最简单的比如:

rwlock_t rw_lock;
rwlock_init (&rw_lock);
 
read_lock(rw_lock);
------------- 读临界区 -------------
read_unlock(rw_lock);

rwlock_t rw_lock;
rwlock_init (&rw_lock);
 
write_lock(rw_lock);
------------- 写临界区 -------------
write_unlock(rw_lock);

这样会导致死锁,因为读写锁的本质还是自旋锁。写锁不断的等待读锁的释放,导致死锁。如果读-写不能清晰的分开的话,使用一般的自旋锁,就别使用读写锁了。

注意:由于读写自旋锁的这种特性(允许多个读者),使得即便是递归的获取同一个读锁也是允许的。更比如,在中断服务程序中,如果确定对数据只有读操作的话(没有写操作),那么甚至可以使用 read_lock 而不是 read_lock_irqsave,但是对于写来说,还是需要调用 write_lock_irqsave 来保证不被中断打断,否则如果在中断中去获取了锁,就会导致死锁。

读写锁内核API

接口API描述Read/Write Spinlock API
定义rw spin lock并初始化DEFINE_RWLOCK
动态初始化rw spin lockrwlock_init
获取指定的rw spin lockread_lock write_lock
获取指定的rw spin lock同时disable本CPU中断read_lock_irq write_lock_irq
保存本CPU当前的irq状态,disable本CPU中断并获取指定的rw spin lockread_lock_irqsave write_lock_irqsave
获取指定的rw spin lock同时disable本CPU的bottom halfread_lock_bh write_lock_bh
释放指定的spin lockread_unlock write_unlock
释放指定的rw spin lock同时enable本CPU中断read_unlock_irq write_unlock_irq
释放指定的rw spin lock同时恢复本CPU的中断状态read_unlock_irqrestore write_unlock_irqrestore
获取指定的rw spin lock同时enable本CPU的bottom halfread_unlock_bh write_unlock_bh
尝试去获取rw spin lock,如果失败,不会spin,而是返回非零值read_trylock write_trylock

读-写锁内核实现

说明:使用读写内核锁需要包含的头文件和 spinlock 一样,只需要包含:include/linux/spinlock.h 就可以了

这里仅看 和体系架构相关的部分,在 ARM 体系架构上:

arch_rwlock_t 的定义:

typedef struct { 
    u32 lock; 
} arch_rwlock_t;

arch_write_lock 的实现

static inline void arch_write_lock(arch_rwlock_t *rw)
{
	unsigned long tmp;
 
	prefetchw(&rw->lock);------------------------(0)
	__asm__ __volatile__(
"1:	ldrex	%0, [%1]\n"--------------------------(1)
"	teq	%0, #0\n"--------------------------------(2)
	WFE("ne")------------------------------------(3)
"	strexeq	%0, %2, [%1]\n"----------------------(4)
"	teq	%0, #0\n"--------------------------------(5)
"	bne	1b"--------------------------------------(6)
	: "=&r" (tmp)
	: "r" (&rw->lock), "r" (0x80000000)
	: "cc");
 
	smp_mb();------------------------------------(7)
}

(0) : 先通知 hw 进行preloading cache

(1): 标记独占,获取 rw->lock 的值并保存在 tmp 中

(2) : 判断 tmp 是否等于 0

(3) : 如果 tmp 不等于0,那么说明有read 或者write的thread持有锁,那么还是静静的等待吧。其他thread会在unlock的时候Send Event来唤醒该CPU的

(4) : 如果 tmp 等于0,将 0x80000000 这个值赋给 rw->lock

(5) : 是否 str 成功,如果有其他 thread 在上面的过程插入进来就会失败

(6) : 如果不成功,那么需要重新来过跳转到标号为 1 的地方,即开始的地方,否则持有锁,进入临界区

(7) : 内存屏障,保证执行顺序

arch_write_unlock 的实现:

static inline void arch_write_unlock(arch_rwlock_t *rw) 
{ 
    smp_mb(); ---------------------------0)
 
    __asm__ __volatile__( 
    "str    %1, [%0]\n" -----------------1: 
    : "r" (&rw->lock), "r" (0)
    : "cc");
 
    dsb_sev(); --------------------------2}

(0) : 内存屏障

(1) : rw->lock 赋值为 0

(2) :唤醒处于 WFE 的 thread

arch_read_lock 的实现:

static inline void arch_read_lock(arch_rwlock_t *rw)
{
	unsigned long tmp, tmp2;
 
	prefetchw(&rw->lock);
	__asm__ __volatile__(
"1:	ldrex	%0, [%2]\n" -----------0"	adds	%0, %0, #1\n" ---------1"	strexpl	%1, %0, [%2]\n" -------2WFE("mi") ---------------------3"	rsbpls	%0, %1, #0\n" ---------4"	bmi	1b" -----------------------5: "=&r" (tmp), "=&r" (tmp2)
	: "r" (&rw->lock)
	: "cc");
 
	smp_mb();
}

(0) : 标记独占,获取 rw->lock 的值并保存在 tmp 中

(1) : tmp = tmp + 1

(2) : 如果 tmp 结果非负值,那么就执行该指令,将 tmp 值存入rw->lock

(3) : 如果 tmp 是负值,说明有 write thread,那么就进入 wait for event 状态

(4) : 判断strexpl指令是否成功执行

(5) : 如果不成功,那么需要重新来过,否则持有锁,进入临界区

arch_read_unlock 的实现:

static inline void arch_read_unlock(arch_rwlock_t *rw)
{
	unsigned long tmp, tmp2;
 
	smp_mb();
 
	prefetchw(&rw->lock);
	__asm__ __volatile__(
"1:	ldrex	%0, [%2]\n" -----------0"	sub	%0, %0, #1\n" -------------1"	strex	%1, %0, [%2]\n" -------2"	teq	%1, #0\n" -----------------3"	bne	1b" -----------------------4: "=&r" (tmp), "=&r" (tmp2)
	: "r" (&rw->lock)
	: "cc");
 
	if (tmp == 0)
		dsb_sev(); ----------------5}

(0) : 标记独占,获取 rw->lock 的值并保存在 tmp 中

(1) : read 退出临界区,所以,tmp = tmp + 1

(2) : 将tmp值存入 rw->lock 中

(3) :是否str成功,如果有其他thread在上面的过程插入进来就会失败

(4) : 如果不成功,那么需要重新来过,否则离开临界区

(5) : 如果read thread已经等于0,说明是最后一个离开临界区的 Reader,那么调用 sev 去唤醒 WF E的 CPU Core(配合 Writer 线程)

读-写锁使用了一个 32bits 的数来存储当前的状态,最高位代表着是否有写线程占用了锁,而低 31 位代表可以同时并发的读的数量,看起来现在至少是绰绰有余了。

3 顺序锁(seqlock)

内核提供了更加偏向于写者的锁 —— seqlock;这种锁提供了一种简单的读写共享的机制,他的设计偏向于写者,无论是什么情况(没有多个写者竞争的情况),写者都有直接写入的权利(霸道),而读者呢?这里提供了一个序列值,当写者进入的时候,这个序列值会加 1,而读者去在读出数值的前后分别来 check 这个值,便知道是否在读的过程中(奇数还偶数),被写者“篡改”过数据,如果有的话,则再次 spin 的去读,一直到数据被完全的篡改完毕。

顺序锁临界区只允许一个writer thread进入(在多个写者之间是互斥的),临界区只允许一个writer thread进入,在没有writer thread的情况下,reader thread可以随意进入,也就是说reader不会阻挡reader。在临界区只有有reader thread的情况下,writer thread可以立刻执行,不会等待

Writer thread的操作:

对于writer thread,获取seqlock操作如下:

(1)获取锁(例如spin lock),该锁确保临界区只有一个writer进入。

(2)sequence counter加一

释放seqlock操作如下:

(1)释放锁,允许其他writer thread进入临界区。

(2)sequence counter加一(注意:不是减一哦,sequence counter是一个不断累加的counter)

由上面的操作可知,如果临界区没有任何的writer thread,那么sequence counter是偶数(sequence counter初始化为0),如果临界区有一个writer thread(当然,也只能有一个),那么sequence counter是奇数。

Reader thread的操作如下:

(1)获取sequence counter的值,如果是偶数,可以进入临界区,如果是奇数,那么等待writer离开临界区(sequence counter变成偶数)。进入临界区时候的sequence counter的值我们称之old sequence counter。

(2)进入临界区,读取数据

(3)获取sequence counter的值,如果等于old sequence counter,说明一切OK,否则回到step(1)

适用场景:

一般而言,seqlock适用于:
(1)read操作比较频繁
(2)write操作较少,但是性能要求高,不希望被reader thread阻挡(之所以要求write操作较少主要是考虑read side的性能)
(3)数据类型比较简单,但是数据的访问又无法利用原子操作来保护。我们举一个简单的例子来描述:假设需要保护的数据是一个链表,header—>A node—>B node—>C node—>null。**reader thread遍历链表的过程中,将B node的指针赋给了临时变量x,这时候,中断发生了,reader thread被preempt(注意,对于seqlock,reader并没有禁止抢占)。**这样在其他cpu上执行的writer thread有充足的时间释放B node的memory(注意:reader thread中的临时变量x还指向这段内存)。当read thread恢复执行,并通过x这个指针进行内存访问(例如试图通过next找到C node),悲剧发生了……

顺序锁的使用

定义
定义一个顺序锁有两种方式:seqlock_t seqlock,seqlock_init(&seqlock); DEFINE_SEQLOCK(seqlock)
写临界区:

write_seqlock(&seqlock);
/* -------- 写临界区 ---------*/
write_sequnlock(&seqlock);

读临界区:

unsigned long seq;
 
do { 
     seq = read_seqbegin(&seqlock); 
/* ---------- 这里读临界区数据 ----------*/
} while (read_seqretry(&seqlock, seq)); 

例子:在 kernel 中,jiffies_64 保存了从系统启动以来的 tick 数目,对该数据的访问(以及其他jiffies相关数据)需要持有jiffies_lock 这个 seq lock:

读当前的 tick :

u64 get_jiffies_64(void) 
{
 
    do { 
        seq = read_seqbegin(&jiffies_lock); 
        ret = jiffies_64; 
    } while (read_seqretry(&jiffies_lock, seq)); 
}

内核更新当前的 tick :

static void tick_do_update_jiffies64(ktime_t now) 
{ 
    write_seqlock(&jiffies_lock);
 
    /* 临界区会修改jiffies_64等相关变量 */
    write_sequnlock(&jiffies_lock); 
}

顺序锁的实现

  1. seqlock_t 结构:
typedef struct {
	struct seqcount seqcount;
	spinlock_t lock;
} seqlock_t;
  1. write_seqlock/write_sequnlock
static inline void write_seqlock(seqlock_t *sl) 
{ 
    spin_lock(&sl->lock);
    sl->sequence++; 
    smp_wmb(); 
}
 
static inline void write_sequnlock(seqlock_t *sl)
{
    smp_wmb();
    s->sequence++;
    spin_unlock(&sl->lock);
}

可以看到 seqlock 其实也是基于 spinlock 的。smp_wmb 是写内存屏障,由于seq lock 是基于 sequence counter 的,所以必须保证这个操作。

  1. read_seqbegin:
static inline unsigned read_seqbegin(const seqlock_t *sl) 
{  
    unsigned ret;
 
repeat: 
    ret = ACCESS_ONCE(sl->sequence); ---进入临界区之前,先要获取sequenc counter的快照 
    if (unlikely(ret & 1)) {        -----如果是奇数,说明有writer thread 
        cpu_relax(); 
        goto repeat;  ----如果有writer,那么先不要进入临界区,不断的polling sequenc counter 
    }
 
    smp_rmb();  ---确保sequenc counter和临界区的内存访问顺序 
    return ret; 
}

如果有writer thread,read_seqbegin函数中会有一个不断polling sequenc counter,直到其变成偶数的过程,在这个过程中,如果不加以控制,那么整体系统的性能会有损失(这里的性能指的是功耗和速度)。因此,在polling过程中,有一个cpu_relax的调用,对于ARM64,其代码是:

static inline void cpu_relax(void) 
{ 
    asm volatile("yield" ::: "memory"); 
}

yield 指令用来告知硬件系统,本cpu上执行的指令是polling操作,没有那么急迫,如果有任何的资源冲突,本cpu可以让出控制权。

  1. read_seqretry
static inline unsigned read_seqretry(const seqlock_t *sl, unsigned start) 
{ 
    smp_rmb();---确保sequenc counter和临界区的内存访问顺序 
    return unlikely(sl->sequence != start); 
}

start参数就是进入临界区时候的sequenc counter的快照,比对当前退出临界区的sequenc counter,如果相等,说明没有writer进入打搅reader thread,那么可以愉快的离开临界区。

还有一个比较有意思的逻辑问题:read_seqbegin为何要进行奇偶判断?把一切都推到read_seqretry中进行判断不可以吗?也就是说,为何read_seqbegin要等到没有writer thread的情况下才进入临界区?其实有writer thread也可以进入,反正在read_seqretry中可以进行奇偶以及相等判断,从而保证逻辑的正确性。当然,这样想也是对的,不过在performance上有欠缺,reader在检测到有writer thread在临界区后,仍然放reader thread进入,可能会导致writer thread的一些额外的开销(cache miss),因此,最好的方法是在read_seqbegin中拦截。

4 信号量

Linux Kernel 除了提供了自旋锁,还提供了睡眠锁,信号量就是一种睡眠锁。信号量的特点是,如果一个任务试图获取一个已经被占用的信号量,他会被推入等待队列,让其进入睡眠。此刻处理器重获自由,去执行其他的代码。当持有的信号量被释放,处于等待队列的任务将被唤醒,并获取到该信号量。

从信号量的睡眠特性得出一些结论:

  • 由于竞争信号量的时候,未能拿到信号的进程会进入睡眠,所以信号量可以适用于长时间持有。
  • 而且信号量不适合短时间的持有,因为会导致睡眠的原因,维护队列,唤醒,等各种开销,在短时间的锁定某对象,反而比忙等锁的效率低。
  • 由于睡眠的特性,只能在进程上下文进行调用,无法再中断上下文中使用信号量。
  • 一个进程可以在持有信号量的情况下去睡眠(可能并不需要,这里只是假如),另外的进程尝试获取该信号量时候,不会死锁。
  • 期望去占用一个信号量的同时,不允许持有自旋锁,因为企图去获取信号量的时候,可能导致睡眠,而自旋锁不允许睡眠。

在有一些特定的场景,自旋锁和信号量没得选,比如中断上下文,只能用自旋锁,比如需要要和用户空间做同步的时候,代码需要睡眠,信号量是唯一选择。**如果有的地方,既可以选择信号量,又可以选自旋锁,则需要根据持有锁的时间长短来进行选择。**理想情况下是,越短的时间持有,选择自旋锁,长时间的适合信号量。与此同时,信号量不会关闭调度,他不会对调度造成影响。

信号量允许多个锁持有者,而自旋锁在一个时刻,最多允许一个任务持有。信号量同时允许的持有者数量可以在声明信号的时候指定。绝大多数情况下,信号量允许一个锁的持有者,这种类型的信号量称之为二值信号量,也就是互斥信号量。

一个任务要想访问共享资源,首先必须得到信号量,获取信号量的操作将把信号量的值减1,若当前信号量的值为负数,表明无法获得信号量,该任务必须挂起在该信号量的等待队列等待该信号量可用;若当前信号量的值为非负数,表示可以获得信号量,因而可以立刻访问被该信号量保护的共享资源。

**当任务访问完被信号量保护的共享资源后,必须释放信号量,释放信号量通过把信号量的值加1实现,**如果信号量的值为非正数,表明有任务等待当前信号量,因此它也唤醒所有等待该信号量的任务。

信号量的操作

信号量相关的东西放置到了: include/linux/semaphore.h 文件。初始化一个信号量有两种方式:

struct semaphore sem;
sema_init(&sem, val);
——————————————————————————————
DEFINE_SEMAPHORE(sem)

内核针对信号量提供了一组操作接口:

函数定义功能说明
sema_init(struct semaphore *sem, int val)初始化信号量,将信号量计数器值设置val。
down(struct semaphore *sem)获取信号量,不建议使用此函数,因为是 UNINTERRUPTABLE 的睡眠。
down_interruptible(struct semaphore *sem)可被中断地获取信号量,如果睡眠被信号中断,返回错误-EINTR。
down_killable (struct semaphore *sem)可被杀死地获取信号量。如果睡眠被致命信号中断,返回错误-EINTR。
down_trylock(struct semaphore *sem)尝试原子地获取信号量,如果成功获取,返回0,不能获取,返回1。
down_timeout(struct semaphore *sem, long jiffies)在指定的时间jiffies内获取信号量,若超时未获取,返回错误-ETIME。
up(struct semaphore *sem)释放信号量sem。

注意:down_interruptible 接口,在获取不到信号量的时候,该任务会进入 INTERRUPTABLE 的睡眠,但是 down() 接口会导致进入 UNINTERRUPTABLE 的睡眠,down 用的较少。

5 互斥体

互斥体是一种睡眠锁,他是一种简单的睡眠锁,其行为和 count 为 1 的信号量类似。互斥体简洁高效,但是相比信号量,有更多的限制,因此对于互斥体的使用条件更加严格:

  • 任何时刻,只有一个指定的任务允许持有 mutex,也就是说,mutex 的计数永远是 1;
  • 给 mutex 上锁这,必须负责给他解锁,也就是不允许在一个上下文中上锁,在另外一个上下文中解锁。这个限制注定了 mutex 无法承担内核和用户空间同步的复杂场景。常用的方式是在一个上下文中进行上锁/解锁。
  • 递归的调用上锁和解锁是不允许的。也就是说,不能递归的去持有同一个锁,也不能够递归的解开一个已经解开的锁。
  • 当持有 mutex 的进程,不允许退出
  • mutex 不允许在中断上下文和软中断上下文中使用过,即便是mutex_trylock 也不行
  • mutex 只能使用内核提供的 APIs操作,不允许拷贝,手动初始化和重复初始化

信号量和互斥体

他们两者很相似,除非是 mutex 的限制妨碍到逻辑,否则这两者之间,首选 mutex

自旋锁和互斥体

多数情况,很好区分。中断中只能考虑自旋锁,任务睡眠使用互斥体。如果都可以的的情况下,低开销或者短时间的锁,选择自旋锁,长期加锁的话,使用互斥体。

互斥体的使用

函数定义功能说明
mutex_lock(struct mutex *lock)加锁,如果不可用,则睡眠(UNINTERRUPTIBLE)
mutex_lock_interruptible(struct mutex *lock);加锁,如果不可用,则睡眠(TASK_INTERRUPTIBLE)
mutex_unlock(struct mutex *lock)解锁
mutex_trylock(struct mutex *lock)试图获取指定的 mutex,或得到返回1,否则返回 0
mutex_is_locked(struct mutex *lock)如果 mutex 被占用返回1,否则返回 0

6 RCU

RCU 的全称是(Read-Copy-Update),意在读写-复制-更新,在 Linux 提供的所有内核互斥的设施当中属于一种免锁机制。在之前讨论过的读写自旋锁(rwlock)、顺序锁(seqlock)一样,RCU 的适用模型也是读写共存的系统。

  • 读写自旋锁:读者和写者互斥,读者和读者共存,写者和写者互斥。(偏向读者)
  • 顺序锁:写者和写者互斥,写者直接打断读者(偏向写者)

上述两种都是基于 spinlock 的一种用于特定场景的锁机制。RCU 与他们不同,它的读取和写入操作无需考虑两者之间的互斥问题。

之前的锁分析中,可以知道,加锁、解锁都涉及内存操作,同时伴有内存屏障引入,这些都会导致锁操作的系统开销变大,在此基础之上, 内核在 Kernel 的 2.5 版本引入了 RCU 的免锁互斥访问机制。

什么叫免锁机制呢?对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它,但写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的操作。

因此RCU实际上是一种改进的 rwlock,读者几乎没有什么同步开销,它不需要锁,不使用原子指令。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。写者的同步开销比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其它写者的修改操作。读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。

RCU与rwlock的不同之处是:它既允许多个读者同时访问被保护的数据,又允许多个读者和多个写者同时访问被保护的数据(注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制),读者没有任何同步开销,而写者的同步开销则取决于使用的写者间同步机制。但RCU不能替代rwlock,因为如果写比较多时,对读者的性能提高不能弥补写者导致的损失。

读者在访问被RCU保护的共享数据期间不能被阻塞,这是RCU机制得以实现的一个基本前提,也就说当读者在引用被RCU保护的共享数据期间,读者所在的CPU不能发生上下文切换,spinlock和rwlock都需要这样的前提。

写者在访问被RCU保护的共享数据时不需要和读者竞争任何锁,只有在有多于一个写者的情况下需要获得某种锁以与其他写者同步。写者修改数据前首先拷贝一个被修改元素的副本,然后在副本上进行修改,修改完毕后它向垃圾回收器注册一个回调函数以便在适当的时机执行真正的修改操作。

等待适当时机的这一时期称为宽限期 grace period,而CPU发生了上下文切换称为经历一个quiescent state,grace period就是所有CPU都经历一次quiescent state所需要的等待的时间(读的时候禁止了内核抢占,也就是上下文切换,如果在某个 CPU 上发生了进程切换,那么所有对老指针的引用都会结束之后)。

垃圾收集器就是在grace period之后调用写者注册的回调函数(call_rcu 函数注册回调)来完成真正的数据修改或数据释放操作的。

总的来说,RCU的行为方式:

1、随时可以拿到读锁,即对临界区的读操作随时都可以得到满足

2、某一时刻只能有一个人拿到写锁,多个写锁需要互斥,写的动作包括 拷贝–修改–宽限窗口到期后删除原值

3、临界区的原始值为m1,如会有人拿到写锁修改了临界区为m2,则在写锁修改临界区之后拿到的读锁获取的临界区的值为m2,之前获取的为m1,这通过原子操作保证

对比发现RCU读操作随时都会得到满足,但写锁之后的写操作所耗费的系统资源就相对比较多了,并且只有在宽限期之后删除原资源。

针对对象

RCU 保护的对象是指针。这一点尤其重要.因为指针赋值是一条单指令.也就是说是一个原子操作.因它更改指针指向没必要考虑它的同步.只需要考虑cache的影响。

内核中所有关于 RCU 的操作都应该使用内核提供的 RCU 的 APIs 函数完成,这些 APIs 主要集中在指针和链表的操作。

使用场景

RCU 使用在读者多而写者少的情况.RCU和读写锁相似.但RCU的读者占锁没有任何的系统开销.写者与写写者之间必须要保持同步,且写者必须要等它之前的读者全部都退出之后才能释放之前的资源

读者是可以嵌套的.也就是说rcu_read_lock()可以嵌套调用

从 RCU 的特性可知,RCU 的读取性能的提升是在增加写入者负担的前提下完成的,因此在一个读者与写者共存的系统中,按照设计者的说法,如果写入者的操作比例在 10% 以上,那么久应该考虑其他的互斥方法,反正,使用 RCU 的话,能够获取较好的性能。

使用限制

读者在访问被RCU保护的共享数据期间不能被阻塞。在读的时候,会屏蔽掉内核抢占。

RCU 的实现原理

在RCU的实现过程中,我们主要解决以下问题:

1,在读取过程中,另外一个线程删除了一个节点。删除线程可以把这个节点从链表中移除,但它不能直接销毁这个节点,必须等到所有的读取线程读取完成以后,才进行销毁操作。RCU中把这个过程称为宽限期(Grace period)。

2,在读取过程中,另外一个线程插入了一个新节点,而读线程读到了这个节点,那么需要保证读到的这个节点是完整的。这里涉及到了发布-订阅机制(Publish-Subscribe Mechanism)。

3, 保证读取链表的完整性。新增或者删除一个节点,不至于导致遍历一个链表从中间断开。但是RCU并不保证一定能读到新增的节点或者不读到要被删除的节点。

1. 宽限期

通过例子,方便理解这个内容。以下例子修改于Paul的文章:

struct foo {
    int a;
    char b;
    long c;
 };
 
DEFINE_SPINLOCK(foo_mutex);
 
struct foo *gbl_foo;
 
void foo_read (void)
{
     foo *fp = gbl_foo;
     if ( fp != NULL )
        dosomething(fp->a, fp->b , fp->c );
}
 
void foo_update( foo* new_fp )
{
     spin_lock(&foo_mutex);
     foo *old_fp = gbl_foo;
     gbl_foo = new_fp;
     spin_unlock(&foo_mutex);
     kfee(old_fp);
}

如上的程序,是针对于全局变量 gbl_foo 的操作。假设以下场景。有两个线程同时运行 foo_ read 和 foo_update 的时候,当 foo_ read 执行完赋值操作后,线程发生切换;此时另一个线程开始执行 foo_update 并执行完成。当 foo_ read 运行的进程切换回来后,运行 dosomething 的时候,fp 已经被删除,这将对系统造成危害。为了防止此类事件的发生,RCU里增加了一个新的概念叫宽限期(Grace period)。如下图所示:
在这里插入图片描述
图中每行代表一个线程,最下面的一行是删除线程,当它执行完删除操作后,线程进入了宽限期。宽限期的意义是,在一个删除动作发生后,它必须等待所有在宽限期开始前已经开始的读线程结束,才可以进行销毁操作。

这样做的原因是这些线程有可能读到了要删除的元素。图中的宽限期必须等待1和2结束;而读线程5在宽限期开始前已经结束,不需要考虑;而3,4,6也不需要考虑,因为在宽限期结束后开始后的线程不可能读到已删除的元素。为此RCU机制提供了相应的API来实现这个功能。

void foo_read(void)
{
    rcu_read_lock();
    foo *fp = gbl_foo;
    if ( fp != NULL )
        dosomething(fp->a,fp->b,fp->c);
    rcu_read_unlock();
}
 
void foo_update( foo* new_fp )
{
	spin_lock(&foo_mutex);
	foo *old_fp = gbl_foo;
	gbl_foo = new_fp;
	spin_unlock(&foo_mutex);
	synchronize_rcu();
	kfee(old_fp);
}

其中 foo_ read 中增加了 rcu_read_lock 和 rcu_read_unlock,这两个函数用来标记一个RCU读过程的开始和结束。其实作用就是帮助检测宽限期是否结束。foo_update 增加了一个函数 synchronize_rcu(),调用该函数意味着一个宽限期的开始,而直到宽限期结束,该函数才会返回。我们再对比着图看一看,线程1和2,在 synchronize_rcu 之前可能得到了旧的 gbl_foo,也就是 foo_update 中的 old_fp,如果不等它们运行结束,就调用 kfee(old_fp),极有可能造成系统崩溃。而3,4,6在synchronize_rcu 之后运行,此时它们已经不可能得到 old_fp,此次的kfee将不对它们产生影响。

宽限期是RCU实现中最复杂的部分,原因是在提高读数据性能的同时,删除数据的性能也不能太差。

2 订阅——发布机制

当前使用的编译器大多会对代码做一定程度的优化,CPU也会对执行指令做一些优化调整,目的是提高代码的执行效率,但这样的优化,有时候会带来不期望的结果。

void foo_update( foo* new_fp )
{
	spin_lock(&foo_mutex);
	foo *old_fp = gbl_foo;
	
	new_fp->a = 1;
	new_fp->b = ‘b’;
	new_fp->c = 100;
	
	gbl_foo = new_fp;
	spin_unlock(&foo_mutex);
	synchronize_rcu();
	kfee(old_fp);
}

这段代码中,我们期望的是6,7,8行的代码在第10行代码之前执行。但优化后的代码并不对执行顺序做出保证。在这种情形下,一个读线程很可能读到 new_fp,但new_fp的成员赋值还没执行完成。当读线程执行dosomething(fp->a, fp->b , fp->c ) 的 时候,就有不确定的参数传入到dosomething,极有可能造成不期望的结果,甚至程序崩溃。可以通过优化屏障来解决该问题,RCU机制对优化屏障做了包装,提供了专用的API来解决该问题。这时候,第十行不再是直接的指针赋值,而应该改为 :

rcu_assign_pointer(gbl_foo,new_fp);

<include/linux/rcupdate.h> 中 rcu_assign_pointer的实现比较简单,如下:

#define rcu_assign_pointer(p, v) \
         __rcu_assign_pointer((p), (v), __rcu)
 
#define __rcu_assign_pointer(p, v, space) \
         do { \
                 smp_wmb(); \
                 (p) = (typeof(*v) __force space *)(v); \
         } while (0)

我们可以看到它的实现只是在赋值之前加了优化屏障 smp_wmb来确保代码的执行顺序。另外就是宏中用到的__rcu,只是作为编译过程的检测条件来使用的。
<include/linux/rcupdate.h>

#define rcu_dereference(p) rcu_dereference_check(p, 0)
 
 
#define rcu_dereference_check(p, c) \
         __rcu_dereference_check((p), rcu_read_lock_held() || (c), __rcu)
 
#define __rcu_dereference_check(p, c, space) \
         ({ \
                 typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); \
                 rcu_lockdep_assert(c, "suspicious rcu_dereference_check()" \
                                       " usage"); \
                 rcu_dereference_sparse(p, space); \
                 smp_read_barrier_depends(); \
                 ((typeof(*p) __force __kernel *)(_________p1)); \
         })
 
static inline int rcu_read_lock_held(void)
{
         if (!debug_lockdep_rcu_enabled())
                 return 1;
         if (rcu_is_cpu_idle())
                 return 0;
         if (!rcu_lockdep_current_cpu_online())
                 return 0;
         return lock_is_held(&rcu_lock_map);
}
3. 数据读取的完整性

在这里插入图片描述
如图我们在原list中加入一个节点new到A之前,所要做的第一步是将new的指针指向A节点,第二步才是将Head的指针指向new。

这样做的目的是当插入操作完成第一步的时候,对于链表的读取并不产生影响,而执行完第二步的时候,读线程如果读到new节点,也可以继续遍历链表。

如果把这个过程反过来,第一步head指向new,而这时一个线程读到new,由于new的指针指向的是Null,这样将导致读线程无法读取到A,B等后续节点。

从以上过程中,可以看出RCU并不保证读线程读取到new节点。如果该节点对程序产生影响,那么就需要外部调用做相应的调整。如在文件系统中,通过RCU定位后,如果查找不到相应节点,就会进行其它形式的查找,相关内容等分析到文件系统的时候再进行叙述。

在这里插入图片描述
如图我们希望删除B,这时候要做的就是将A的指针指向C,保持B的指针,然后删除程序将进入宽限期检测。由于B的内容并没有变更,读到B的线程仍然可以继续读取B的后续节点。

B不能立即销毁,它必须等待宽限期结束后,才能进行相应销毁操作。由于A的节点已经指向了C,当宽限期开始之后所有的后续读操作通过A找到的是C,而B已经隐藏了,后续的读线程都不会读到它。这样就确保宽限期过后,删除B并不对系统造成影响。

如何使用 RCU

// 假设 struct shared_data 是一个在读者和写者之间共享的数据结构
struct shared_data {
    int a;
    int b;
    struct rcu_head rcu;
};
// 读者的代码。
// 读者调用 rcu_read_lock 和 rcu_read_unlock 来构建并访问临界区
// 所有对指向被保护资源指针的引用都应该只在临界区出现,而且临界区代码不能睡眠
static void demo_reader(struct shared_data *ptr)
{
    struct shared_data *p = NULL;
    rcu_read_lock();
    // call rcu_dereference to get the ptr pointer
    p = rcu_dereference(ptr);
    if (p)
        do_somethings(p);
    rcu_read_unlock();
}
// 写入侧的代码
// 写入者提供的回调函数,用于释放老的数据指针
static void demo_del_oldptr(struct rcu_head *rh)
{
    struct shared_data *p = container_of(rh, struct rcu_head, rcu);
    kfree(p);
}
 
static void demo_writer(struct shared_data *ptr)
{
    struct shared_data *new_ptr = kmalloc(...);
    ....
    new_ptr->a = 10;
    new_ptr->b = 20;
    // 用新指针更新老指针
    rcu_assign_pointer(ptr, new_ptr);
    // 调用 call_rcu 让内核在确保所有对老指针 ptr 的引用都解锁后,回调到 demo_del_oldptr 释放老指针
    call_rcu(ptr->rcu, demo_del_oldptr);
}

在上面的例子,写者调用 rcu_assign_pointer 更新老指针后,使用 call_rcu 接口,向系统注册了一会回调函数,系统在确定没有对老指针引用之后,调用这个函数。另一个类似的函数是上一节遇到的 synchronize_rcu 调用,这个函数可能会阻塞,因为他要等待所有对老指针的引用全部结束才返回,函数返回的时候意味着系统所有对老指针的引用都消失,此时在释放老指针才是安全的。如果在中断上下文执行写入者的操作,那么就应该使用 call_rcu ,不能使用 synchronize_rcu。

对于这 call_rcu 和 synchronize_rcu 的分析如下:

在释放老指针方面,Linux内核提供两种方法供使用者使用,一个是调用call_rcu,另一个是调用synchronize_rcu。前者是一种异步 方式,call_rcu会将释放老指针的回调函数放入一个结点中,然后将该结点加入到当前正在运行call_rcu的处理器的本地链表中,在时钟中断的 softirq部分(RCU_SOFTIRQ), rcu软中断处理函数rcu_process_callbacks会检查当前处理器是否经历了一个休眠期(quiescent,此处涉及内核进程调度等方面的内容),rcu的内核代码实现在确定系统中所有的处理器都经历过了一个休眠期之后(意味着所有处理器上都发生了一次进程切换,因此老指针此时可以被安全释放掉了),将调用call_rcu提供的回调函数。

synchronize_rcu的实现则利用了等待队列,在它的实现过程中也会向call_rcu那样向当前处理器的本地链表中加入一个结点,与 call_rcu不同之处在于该结点中的回调函数是wakeme_after_rcu,然后synchronize_rcu将在一个等待队列中睡眠,直到系统中所有处理器都发生了一次进程切换,因而wakeme_after_rcu被rcu_process_callbacks所调用以唤醒睡眠的 synchronize_rcu,被唤醒之后,synchronize_rcu知道它现在可以释放老指针了。

所以我们看到,call_rcu返回后其注册的回调函数可能还没被调用,因而也就意味着老指针还未被释放,而synchronize_rcu返回后老指针肯定被释放了。所以,是调用call_rcu还是synchronize_rcu,要视特定需求与当前上下文而定,比如中断处理的上下文肯定不能使用 synchronize_rcu函数了。

基本RCU操作 APIs

对于reader,RCU的操作包括:

(1)rcu_read_lock:用来标识RCU read side临界区的开始。

(2)rcu_dereference:该接口用来获取RCU protected pointer。reader要访问RCU保护的共享数据,当然要获取RCU protected pointer,然后通过该指针进行dereference的操作。

(3)rcu_read_unlock:用来标识reader离开RCU read side临界区

对于writer,RCU的操作包括:

(1)rcu_assign_pointer:该接口被writer用来进行removal的操作,在witer完成新版本数据分配和更新之后,调用这个接口可以让RCU protected pointer指向RCU protected data。

(2)synchronize_rcu:writer端的操作可以是同步的,也就是说,完成更新操作之后,可以调用该接口函数等待所有在旧版本数据上的reader线程离开临界区,一旦从该函数返回,说明旧的共享数据没有任何引用了,可以直接进行reclaimation的操作。

(3)call_rcu:当然,某些情况下(例如在softirq context中),writer无法阻塞,这时候可以调用call_rcu接口函数,该函数仅仅是注册了callback就直接返回了,在适当的时机会调用callback函数,完成reclaimation的操作。这样的场景其实是分开removal和reclaimation的操作在两个不同的线程中:updater和reclaimer。

RCU 的链表操作:

在 Linux kernel 中还专门提供了一个头文件(include/linux/rculist.h),提供了利用 RCU 机制对链表进行增删查改操作的接口。

(1) list_add_rcu :该函数把链表项new插入到RCU保护的链表head的开头。使用内存栅保证了在引用这个新插入的链表项之前,新链表项的链接指针的修改对所有读者是可见的。

static inline void list_add_rcu(struct list_head *new, struct list_head *head)

(2) list_add_tail_rcu:该函数类似于list_add_rcu,它将把新的链表项new添加到被RCU保护的链表的末尾。

static inline void list_add_tail_rcu(struct list_head *new,
					struct list_head *head)

(3) list_del_rcu:该函数从RCU保护的链表中移走指定的链表项entry,并且把entry的prev指针设置为LIST_POISON2,但是并没有把entry的next指针设置为LIST_POISON1,因为该指针可能仍然在被读者用于便利该链表。

static inline void list_del_rcu(struct list_head *entry)

(4) list_replace_rcu:该函数是RCU新添加的函数,并不存在非RCU版本。它使用新的链表项new取代旧的链表项old,内存栅保证在引用新的链表项之前,它的链接指针的修正对所有读者可见

static inline void list_replace_rcu(struct list_head *old,struct list_head *new)

(5) list_for_each_entry_rcu :该宏用于遍历由RCU保护的链表head

#define list_for_each_entry_rcu(pos, head, member) \
	for (pos = list_entry_rcu((head)->next, typeof(*pos), member); \
		&pos->member != (head); \
		pos = list_entry_rcu(pos->member.next, typeof(*pos), member))
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值