引子
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
在实现中提出了两个设计原则:
- 不提供宽限期等待的异步接口,这样就可以杜绝进程不断注册宽限期回调的现象,限制内存的无限消耗
- 将宽限期检查的隔离到一个子系统中,这样即使一个读者的睡眠时间无限延长,那么也只有处于这个子系统中的写者受到影响
基本术语
- 静默态
quiescent state
静默态用来描述一个进程或者cpu没有拿锁的状态,这样的进程或者cpu,不能引用或者修改被锁保护的临界区数据。一个进程或cpu处于静默态的意思,就是它从临界区出来了,或者根本没有进入临界区。这种状态就是静默态。 - 世代数
generation number
当一个写者writer1
等待的宽限期还未结束,随后一个新的写者writer2
更新了数据,它也要等待一个宽限期,这两个写者等待宽限期消耗的时间是否一样呢?
答案是不一样,如果一样,两个写者等待的宽限期可以同时结束,但这对writer1
其实不公平,因为它本可以早结束的
假设writer1
将临界区数据从data0
更新到data1
,writer2
在writer1
之后访问临界区数据,它将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
,表示有读者没有出临界区 - 宽限期回调
写者在更新完临界区数据后,进入宽限期等待阶段,等待宽限期结束,这个实现有三种:
- 同步轮询:每隔一段时间,写者所在的cpu就主动统计一遍所有cpu上的锁计数,检查是否满足宽限期结束条件,满足宽限期结束,不满足继续轮询
- 异步调用:将宽限期结束的检查工作交给其它cpu来做,写者进程只注册一个回调,就直接返回去干别的事情。每个cpu会定时检查宽限期是否结束,当cpu挨个进入静默态,最后一个进入静默态的cpu,就会检查到宽限期结束条件满足,然后触发写者注册到的回调函数,通知写者处理后续的工作,比如删除旧的数据
- 异步调用,同步睡眠等待:同实现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
执行读者进程,访问临界区数据data1
,cpu1
执行写者进程,写者更新临界区数据,将data1
更新为data2
- 过程步骤:
- 读者访问临界区前加锁,获取当前宽限期id(gen)为0,c[0]++标记自己进入临界区
- 读者访问临界区数据
data1
- 写者在读者访问
data1
期间想修改data1
,首先拷贝data1
为data1 copy
,将data1 copy
修改data2
,并将原来指向data1
的引用修改为指向data2
,之后的读者访问临界区数据,读到的就是data2
- 调用
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
执行读者进程,访问临界区数据data2
,cpu3
执行写者进程,更新临界区数据,将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
执行读者进程,访问临界区数据data2
,cpu3
执行写者进程,更新临界区数据,将data2
更新为data3
,cpu4
执行写者进程,更新临界区数据,将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_struct
的completed
的最低位的,写者对它的修改需要加锁,同时刻只能有一个cpu上的写者可以修改completed
。假如三个写者,第一个正经历宽限期0,第二,三个都在等锁,第二个拿到锁之后经历宽限期1,那么第三个写者在拿到锁之后,已然同第二个写者一样经历了宽限期1。总结下来,如果一个写者等待的宽限期没有结束,假设是宽限期0,后面无论再来多少个写者,它们等待的宽限都是宽限期1 -
srcu_struct
中的completed
,记录宽限期个数,单调增加,其最低位表示世代数。读者在进入临界区之前,通过srcu_read_lock
标记自己踏入临界区,不仅如此,还要标记在哪个时间段进入的临界区,好让写者可以推断当前临界区有哪些读者在哪个时间段进行了访问。世代数就代表时间段,c[2]代表锁计数
读者访问步骤
- 访问临界区前加锁
srcu_read_lock
- 关抢占
- 读取srcu子系统当前的宽限期
idx = sp->completed & 0x1
- 宽限期对应锁计数加1
c[idx]++
- 开抢占
srcu_read_lock
返回宽限期索引给读者,读者保存- 访问临界区数据
- 退出临界区释放锁
srcu_read_unlock
,传入宽限期索引- 关抢占
- 宽限期对应锁计数减1
c[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
上的锁计数,计算其总和,由此判断宽限期是否结束。 - 上述实现有几个可以优化的点:
- 轮询检查宽限期是,可以改为休眠通知的方式。
- 宽限期检查不一定要由写者实现,也可以由第三方(系统)来实现,从而节约了计算资源。