SRCU的简单实现

引子

  • Read-copy update (RCU)是一种在读多写少场景下可替代读写锁的高性能同步机制,RCU的读端不加锁,因此开销很低,不会被阻塞,执行的时间确定。这种设计决定了RCU写端不能阻塞读端,因此RCU写端的开销很高,因为它必须保留临界区数据直到没有读者访问,然后回收临界区数据
  • RCU要求访问临界区的读者不能睡眠或者被阻塞,原因是睡眠意味者上下文切换,进程的cpu被抢占,上下文切换在RCU宽限期检查中被认为是静默态的标志,是不允许出现在处于临界区的读者身上的。这个会影响宽限期的检查
  • 在许多场景下我们又要求进程是可睡眠的,比如实时系统,高优先级的进程可以抢占低优先级进程的cpu,因此低优先级的进程必须让出cpu,低优先级进程如果拿了RCU的读锁,此时就会睡眠,会破坏了RCU宽限期的检查。一个可以睡眠RCU同步进制被提了出来

设计原则

  • Sleepable Read-copy update (SRCU)要解决这个问题:RCU提供了宽限期异步等待的接口call_rcu,读者在临界区中睡眠,会使得宽限期被无限延长,这段时间内可能会有新的写者更新临界区,注册回调,然后调用call_rcu直接返回,如果读者睡眠时间无限延长,那被注册的回调就会无限多,消耗的内存会无限增多,最后导致系统崩溃。为了解决这个问题,SRCU在实现中提出了两个设计原则:
  1. 不提供宽限期等待的异步接口,这样就可以杜绝进程不断注册宽限期回调的现象,限制内存的无限消耗
  2. 将宽限期检查的隔离到一个子系统中,这样即使一个读者的睡眠时间无限延长,那么也只有处于这个子系统中的写者受到影响

基本术语

  • 静默态quiescent state
    静默态用来描述一个进程或者cpu没有拿锁的状态,这样的进程或者cpu,不能引用或者修改被锁保护的临界区数据。一个进程或cpu处于静默态的意思,就是它从临界区出来了,或者根本没有进入临界区。这种状态就是静默态。
  • 世代数 generation number
    当一个写者writer1等待的宽限期还未结束,随后一个新的写者writer2更新了数据,它也要等待一个宽限期,这两个写者等待宽限期消耗的时间是否一样呢?
    答案是不一样,如果一样,两个写者等待的宽限期可以同时结束,但这对writer1其实不公平,因为它本可以早结束的
    假设writer1将临界区数据从data0更新到data1writer2writer1之后访问临界区数据,它将data1更新为data2
    writer1只需要等访问data0的读者出临界区,就可以完成宽限期等待了,而不必等到访问data2的读者出临界区
    因此描述一个宽限期等待还没有结束时,另一个宽限期等待已经开始的写者场景,定义了世代数,它用来标识宽限期,方便区分每个写者所等待的宽限期,提高效率
    看下图,对cpu0上的写者来说,在t1时刻,cpu已经经历了静默态,此时需要等待其它三个cpu经历静默态,然后宽限期结束。如果在此时,有另外一个cpu0上的写者更新了数据,它同样在等待宽限期结束,它是从t2时刻开始等,还是从t1时刻开始等,当然是t1时刻,因为这样可以更快速的等到宽限期,那么cpu0上就有两个写者在等待宽限期,它们等到的宽限期是一样的吗?答案是不一样,后来的写者,等待的临界区数据要新一点。所以同一个时间窗口有两个宽限期发生,因此需要一个世代数来区别这两个宽限期,显然后一个宽限期所等的数据要新一点儿,因此它的世代数要大一些。
    在这里插入图片描述
  • 临界区锁计数lock counter
    读者访问临界区数据前,要先执行srcu_read_lock表明自己将要进入临界区,怎么表明?
    通过增加临界区锁计数lock counter来表明,而且临界区的数据随时间更新,前一个进入临界区的读者,和后一个进入临界区的读者,可能看到的数据都不一样了,对应的宽限期也不同,因而有不同的临界区锁计数。增加锁计数分两步:获取当前宽限期的世代数,找到该世代数对应的锁计数然后增加
    读者退出临界区后,要执行srcu_read_unlock表明自己已经离开临界区,怎么表明?
    通过减少临界区锁计数lock counter来表明,srcu_read_unlock会传入世代数作为入参,因此可以顺利找到时减少哪个宽限期的锁计数
    写者在更新完临界区数据后,进入宽限期,首先会拿到当前宽限期的世代数,根据这个世代数统计所有cpu上的锁计数,统计方法是将所有cpu上的锁计数加起来,加和为0,表示没有读者访问临界区了。宽限期已经结束,写者删除旧数据。加和不为0,写者就睡眠,隔段时间再起来,再此统计锁计数
    另一种锁计数的实现:进临界区的锁计数和出临界区的锁计数用两个变量表示,进入时增加enter lock counter,退出时增加exit lock counter,写者在统计所有cpu锁计数时,方法变成将所有enter lock counter加起来,所有exit lock counter加起来,两者相等,表示没有读者访问临界区了。宽限期结束,写者删除旧数据。如果enter lock counter加和大于exit lock counter,表示有读者没有出临界区
  • 宽限期回调
    写者在更新完临界区数据后,进入宽限期等待阶段,等待宽限期结束,这个实现有三种:
  1. 同步轮询:每隔一段时间,写者所在的cpu就主动统计一遍所有cpu上的锁计数,检查是否满足宽限期结束条件,满足宽限期结束,不满足继续轮询
  2. 异步调用:将宽限期结束的检查工作交给其它cpu来做,写者进程只注册一个回调,就直接返回去干别的事情。每个cpu会定时检查宽限期是否结束,当cpu挨个进入静默态,最后一个进入静默态的cpu,就会检查到宽限期结束条件满足,然后触发写者注册到的回调函数,通知写者处理后续的工作,比如删除旧的数据
  3. 异步调用,同步睡眠等待:同实现2相同,将检查宽限期的工作交给其它cpu来做,但写者不会返回区干别的事情,而是原地睡眠,这样可以防止写者因为别的操作再次更新临界区数据,再次注册回调,再次进入宽限期等待,以此往复增加开销

源码实现

数据结构

struct srcu_struct_array {
	int c[2];		// c[0]和c[1]分别实现两个宽限期的标记:当前宽限期和下一个宽限期
};
struct srcu_struct {
	int completed;	// 宽限期个数记录
	struct srcu_struct_array *per_cpu_ref;	// 读者进出临界区标记,进入+1,退出-1,每个CPU都有这个变量,per-cpu变量。
	struct mutex mutex;	// 保护completed的锁
};

读者

  • 进入临界区
int srcu_read_lock(struct srcu_struct *sp)
{
	int idx;
		
	preempt_disable();			// 关抢占,禁止运行这段代码的CPU被抢占用于运行其它进程的代码
 	idx = sp->completed & 0x1;	// 获取当前的宽限期
	barrier();	
	/* 在对应的宽限期增加锁计数。cpu变量per_cpu_ref->c[idx]加1 */
	per_cpu_ptr(sp->per_cpu_ref, smp_processor_id())->c[idx]++;		
	srcu_barrier();
	preempt_enable();	// 开启抢占
	return idx;
}
  • 退出临界区
void srcu_read_unlock(struct srcu_struct *sp, int idx)
{
	preempt_disable();
	srcu_barrier();
	/* cpu变量per_cpu_ref->c[idx]减1 */
	per_cpu_ptr(sp->per_cpu_ref,smp_processor_id())->c[idx]--;
	preempt_enable();
}

写者

/* 统计所有cpu上宽限期世代数为idx的锁计数总和 */
int srcu_readers_active_idx(struct srcu_struct *sp, int idx)
{
	int cpu;
  	int sum;
   	sum = 0;
  	for_each_possible_cpu(cpu)
  		sum += per_cpu_ptr(sp->per_cpu_ref, cpu)->c[idx];
  	return sum;
}
  
void synchronize_srcu(struct srcu_struct *sp)
{
	int idx;		

	idx = sp->completed;
	mutex_lock(&sp->mutex);				// 加锁,为修改completed做准备
	if ((sp->completed - idx) >= 2) {	// 如果加锁前后宽限期计数增加了2个或者以上
		mutex_unlock(&sp->mutex);		// 表明过了至少一个宽限期,直接返回
		return;
	}
	synchronize_sched();				// 等待所有CPU处于可抢占状态
	idx = sp->completed & 0x1;			// 获取当前宽限期的gen id
	sp->completed++;					// 宽限期计数+1
	synchronize_sched();				// 等待所有CPU处于可抢占状态
	while (srcu_readers_active_idx(sp, idx))	// 统计所有CPU上c[idx]的总和,为0表示没有读者访问临界区,退出循环,否则一直睡眠
		schedule_timeout_interruptible(1);
	synchronize_sched();
	mutex_unlock(&sp->mutex);
}

在这里插入图片描述

场景分析

场景1

  • cpu0执行读者进程,访问临界区数据data1cpu1执行写者进程,写者更新临界区数据,将data1更新为data2
  • 过程步骤:
  1. 读者访问临界区前加锁,获取当前宽限期id(gen)为0,c[0]++标记自己进入临界区
  2. 读者访问临界区数据data1
  3. 写者在读者访问data1期间想修改data1,首先拷贝data1data1 copy,将data1 copy修改data2,并将原来指向data1的引用修改为指向data2,之后的读者访问临界区数据,读到的就是data2
  4. 调用synchronize_srcu等待宽限期结束
  • synchronize_srcu执行过程:
	idx = sp->completed;			获取宽限期计数,idx == 0
	mutex_lock(&sp->mutex);			加锁,所有CPU都没有拿锁,运行下一步
	if ((sp->completed - idx) >= 2) {	加锁期间宽限期计数completed没有变化,运行下一步
		mutex_unlock(&sp->mutex);
		return;
	}
	synchronize_sched();			等待所有CPU处于可抢占状态,此时所有CPU都处于可抢占状态,运行下一步
	idx = sp->completed & 0x1;		获取当前宽限期id,用于检查宽限期是否结束,idx == 0
	sp->completed++;				宽限期计数加1,后面访问临界区的读者宽限期id会变化,completed == 1
	synchronize_sched();			等待所有CPU处于可抢占状态,此时所有CPU都处于可抢占状态,运行下一步
	while (srcu_readers_active_idx(sp, idx))		统计所有CPU上c[0]的总和,为0表示没有读者访问临界区,退出循环,否则一直睡眠
		schedule_timeout_interruptible(1);	
	在CPU0上的读者还没有离开临界区前,所有cpu上的访问计数如下:
	cpu0: c[0]:1
	cpu1: c[0]:0
	cpu2: c[0]:0
	cpu3: c[0]:0
	在CPU0上的读者离开临界区后,所有cpu上的访问计数如下:
	cpu0: c[0]:0
	cpu1: c[0]:0
	cpu2: c[0]:0
	cpu3: c[0]:0
	条件满足,执行下一步
	synchronize_sched();		
	mutex_unlock(&sp->mutex);

场景2

  • cpu2执行读者进程,访问临界区数据data2cpu3执行写者进程,更新临界区数据,将data2更新为data3
  • cpu3 synchronize_srcu执行过程:
	idx = sp->completed;			获取宽限期计数,idx == 0,此时cpu1也在等待宽限期,但还没有将宽限期计数加1
	mutex_lock(&sp->mutex);			加锁,cpu1此时拿着锁,需要等到其结束
	分析:
	cpu1等待宽限期结束,cpu3获取到锁,completed此时为1,completed增加一个计数,表明过了一个宽限期
	但这个宽限期不是cpu3要等待的宽限期,是上一个宽限期
	从上图中可以看到:cpu2在等待宽限期0,而cpu3在等待宽限期1
	详细说:cpu1修改了data1,它在等data1的读者出临界区
	在这个期间,新的读者如果访问临界区,它读到的是data2,比如cpu2上的读者
	怎么界定写者要等待的是哪个宽限期?
	从上图看,cpu3上的写者在执行synchronize_srcu的时间点,临界区的数据是data2了,因此它等待就是data2对应的宽限期
	data1的宽限期id为0,因此data2的宽限期id是1
	下面的条件中之所以要求宽限期计数增加2才表示宽限期结束,就是这个原因
	因为宽限期计数加1,可以是因为上一个宽限期结束而加的1
	而当前临界区,可能还有读者访问数据,它拿到的宽限期id是当前的宽限期id,用于访问临界区的计数,比如这里的cpu2
	
	if ((sp->completed - idx) >= 2) {	
		mutex_unlock(&sp->mutex);
		return;
	}
	synchronize_sched();			
	idx = sp->completed & 0x1;		
	sp->completed++;				
	synchronize_sched();			
	while (srcu_readers_active_idx(sp, idx))		
		schedule_timeout_interruptible(1);	
	synchronize_sched();		
	mutex_unlock(&sp->mutex);

在这里插入图片描述

场景3

  • cpu2执行读者进程,访问临界区数据data2cpu3执行写者进程,更新临界区数据,将data2更新为data3cpu4执行写者进程,更新临界区数据,将data3更新为data4
  • cpu4 synchronize_srcu执行过程:
    由于cpu4后于cpu3更新临界区数据,因此cpu4更新时拷贝的数据是data3,加锁也在cpu3 synchronize_srcu之后
	idx = sp->completed;		获取宽限期计数,idx == 0,此时cpu1和cpu3都在等待宽限期,cpu1拿到了锁
	mutex_lock(&sp->mutex);		加锁,cpu1此时拿着锁,需要等到其结束
	分析:
	cpu4和cpu3都在拿锁,在竞争,假设cpu3竞争到,cpu4要等到cpu3释放锁后才能拿到
	在cpu4等锁的过程中,cpu1和cpu3都完成了宽限期同步,cpu1等待的宽限期0结束,completed被cpu1加1
	cpu3等待的宽限期1结束,completed被cpu3加1,completed增加了2,cpu4在拿锁的过程中,相当于经历了宽限期1的全过程。
	在cpu4拿到锁之后,没有读者在临界区,可以直接返回了
	实际上,cpu3帮cpu4完成了宽限期等待的过程,两个cpu都在等宽限期1
	
	if ((sp->completed - idx) >= 2) {	
		mutex_unlock(&sp->mutex);		
		return;
	}
	/* 等待所有CPU处于可抢占状态
	 * 如果有读者正在执行srcu_read_lock,等待它结束
	 */
	synchronize_sched();
	idx = sp->completed & 0x1;			
	sp->completed++;				
	synchronize_sched();				
	while (srcu_readers_active_idx(sp, idx))	
		schedule_timeout_interruptible(1); 
	synchronize_sched();		
	mutex_unlock(&sp->mutex);,

实现解析

数据结构

struct srcu_struct_array {
	int c[2];
};
struct srcu_struct {
	int completed;	// 宽限期个数记录
	struct srcu_struct_array *per_cpu_ref;	// 读者进出临界区标记,进入+1,退出-1,每个CPU都有这个变量,per-cpu变量。
	struct mutex mutex;	// 保护completed的锁
};
  • srcu_struct_array中的c[2]数组,用于记录进入临界区的锁计数,每个cpu有一个这样的变量,反映有多少读者进入临界区。读者进入临界区前将计数加1,表明自己进入临界区。正常情况下一个整形变量就可以了,但这里是个数组,有两个元素,这样设计是考虑如果当前宽限期没有结束,写者在等待。下一个写者又更新了数据等待宽限期,需要区分这两个宽限期,c[0]因此记录的是当前宽限期的锁计数,c[1]记录的是下一个宽限期的锁计数。其中的索引就是世代数。如果再来一个写者更新了数据,然后等待宽限期,会怎样?答案是它拿不到世代数,因为世代数存放在srcu_structcompleted的最低位的,写者对它的修改需要加锁,同时刻只能有一个cpu上的写者可以修改completed。假如三个写者,第一个正经历宽限期0,第二,三个都在等锁,第二个拿到锁之后经历宽限期1,那么第三个写者在拿到锁之后,已然同第二个写者一样经历了宽限期1。总结下来,如果一个写者等待的宽限期没有结束,假设是宽限期0,后面无论再来多少个写者,它们等待的宽限都是宽限期1

  • srcu_struct中的completed,记录宽限期个数,单调增加,其最低位表示世代数。读者在进入临界区之前,通过srcu_read_lock标记自己踏入临界区,不仅如此,还要标记在哪个时间段进入的临界区,好让写者可以推断当前临界区有哪些读者在哪个时间段进行了访问。世代数就代表时间段,c[2]代表锁计数

读者访问步骤

  • 访问临界区前加锁srcu_read_lock
    • 关抢占
    • 读取srcu子系统当前的宽限期idx = sp->completed & 0x1
    • 宽限期对应锁计数加1c[idx]++
    • 开抢占
  • srcu_read_lock返回宽限期索引给读者,读者保存
  • 访问临界区数据
  • 退出临界区释放锁srcu_read_unlock,传入宽限期索引
    • 关抢占
    • 宽限期对应锁计数减1c[idx]--
    • 开抢占

写者更新步骤

假设临界区数据如下图所示,线程1作为读者,在对链表进行遍历,此时正访问元素B,线程0作为写者,想要更新元素B
在这里插入图片描述
在这里插入图片描述

  • 拷贝一份临界区数据,在新的数据上进行修改,将所有指向原临界区数据的引用改为指向新的数据
    • cp B B’
    • modify B’
    • A指向新的元素B’,B‘指向C
    • 元素B’仍然指向C,线程1在线程0更新后访问的临界区数据仍然不变
  • 等待宽限期synchronize_srcu
    • 获取宽限期个数sp->completed
    • 修改宽限期个数前加锁等待mutex_lock(&sp->mutex) enter
    • 成功获得锁mutex_lock(&sp->mutex) exit
    • 查看宽限期个数sp->completed是否增加,如果增加一个,可能是上一个宽限期结束增加的计数,继续等待宽限期;如果增加两个,那么第二个一定是当前宽限期结束后增加的计数,说明宽限期已经等到,直接返回
    • 获取当前宽限期的世代数idx = sp->completed & 0x1,增加宽限期个数
    • 根据世代数统计所有cpu上的当前宽限期内读者访问临界区的锁计数,如果为0,表示没有读者了,返回,否则一直等待
  • 没有读者访问临界区旧数据了,删除旧数据B

宽限期检查机制

  • 上述SRCU源码实现中,临界区写者负责调用synchronize_srcu发起宽限期等待,之后检查宽限期是否结束,它每隔一段时间轮询各cpu上的锁计数,计算其总和,由此判断宽限期是否结束。
  • 上述实现有几个可以优化的点:
  1. 轮询检查宽限期是,可以改为休眠通知的方式。
  2. 宽限期检查不一定要由写者实现,也可以由第三方(系统)来实现,从而节约了计算资源。
  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

享乐主

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值