Linux内核同步之RCU机制基础

2023-11-11

Why RCU

1. 中断与抢占

当一个进程被时钟中断打断后,kernel运行tick中断处理程序(一般是top half),中断处理程序运行结束后,有两种情况:

  1. 之前的进程获得CPU继续运行。

  2. 另一个进程获得了CPU开始运行,而之前的进程则被抢占(preempted)了。

所以,抢占只会发生在tick时钟中断之后,但时钟中断并不是每次都会产生进程抢占

这里一定是时钟中断sheduler_tick()么?还是指所有的interrupt中断都可能产生抢占?

1.1 进程调度与抢占关系

进程调度是由kernel中scheduler来完成,但是也分为抢占性调度和非抢占性调度。如果内核没有开启抢占,那么OS一旦将CPU分配给某个进程开始运行后,除非该进程执行完毕或因事件主动放弃CPU,否则该进程会一直执行下去,这种情况下因该进程主动放弃CPU或执行结束而放弃CPU导致的进程调度,我理解为非抢占调度,是scheduler被动调度,然后发生进程切换。

而如果内核开启了抢占,那么当某个进程在执行时,来了更高优先级的进程或事件,则可以抢占当前正在运行的进程,scheduler_tick()时钟中断处理中scheduler会去判断,然后安排进程调度,将CPU让给更需要优先运行的任务,进而发生进程切换,这是scheduler主动调度,我理解为抢占调度。

2. 内核同步

2.1 内核抢占

2.1.1 理解内核

内核如同辛勤劳动的你,对,没错,你就是内核。你每天的工作运行至少能满足两类人的需求,老板和客户,所以你每天采取的策略是:

  1. 老板(中断)发起要求时,如果你(内核)手头空闲,则为老板(中断)服务。

  2. 如果老板(中断)发起要求时,你(内核)正在为客户(异常包括系统调用)服务,则你(内核)停止为客户(异常)服务,开始为老板(中断)服务。

  3. 如果老板1(中断1)发起要求时,你(内核)正在为老板2(中断2)服务,那么你(内核)停止为老板2(中断2)服务,开始为老板1(中断1)服务。

  4. 老板1(中断1)发起要求,你(内核)原本在为客户1(异常1)服务,然后停止,去为老板1(中断1)服务完成,服务完成后,你(内核)没有选择原来的客户1(异常1)继续服务,而是挑选了一个新的客户2(异常2)开始服务。

你的服务就对应于CPU处于内核态时所执行的代码。老板们的请求就是中断,客户们的请求就是异常(这里的异常是用户态进程系统调用和普通的异常的统称)。

2.1.2 抢占

内核分为抢占式和非抢占式,Linux kernel 2.6之后就可以配置选择抢占还是非抢占。

请添加图片描述

抢占模式 理解
No
Forced Preemption
上下文切换发生在系统调用返回用户空间的点,不会在内核空间主动抢占
Voluntary
Kernel Preemption
与“no
forced”模式类似,但内核开发者可以在进行复杂操作时时不时检查下是否可以reschedule,比如cond_resched()来检查系统是否有更高优先级的任务被唤醒
Preemptible
Kernel
内核里面也可以抢占,系统会有更多的上下文切换,实时性更好,对软实时系统,该选项最佳 对服务器系统,CPU会有一定时间做上下文切换“无用功”,此时就不如no
forced
Fully
Preemptible Kernel
硬实时系统,除了少数选定的关键部分之外,所有内核代码都是可抢占的

请添加图片描述

Linux内核中抢占注意点

  1. 无论是抢占模式还是非抢占模式,运行中的内核态进程都可以主动放弃CPU,比如进程需要等待资源而不得不转入睡眠状态,因为主动放弃CPU而产生的进程切换叫做计划性进程切换。而抢占模式下,内核在响应引起进程切换的异常事件(比如唤醒高优先级进程的中断处理程序)的方式上与非抢占模式下的进程切换有区别,叫做强制性进程切换

  2. 所有的进程切换都有宏switch_to()来完成。非抢占模式下,当前正在运行的内核态进程除非它自己放弃CPU,否则不会发生进程切换(就没有所谓的时间片概念了)。

2.1.3 何时同步必需

进入临界区内,必须采取保护措施。

2.1.4 何时同步非必需

  • 中断处理程序和tasklet不必编写成可重入的函数。

  • 仅被软中断和tasklet访问的per cpu变量不需要同步。

  • 仅被一种tasklet访问的数据结构不需要同步。

3. RCU介绍

3.1 是什么

Linux kernel document中解释:

RCU背后的基本思想可以分为removal(删除)和reclamation(回收)来理解。

removal:删除对旧数据的指针引用,同时还允许readers读访问旧数据。

reclamation:释放旧数据,必须是在没有读者还指向该旧数据之后才能释放回收。

RCU中updater可以立即实施removal操作,然后等到在removal操作期间的active readers都完成了读操作再来实施reclamation操作,等待的方法一般由两种:1. 阻塞直至所有readers完成;2. 设置callback函数来等待readers完成然后通知。这里只需要考虑removal过程中依然还是active状态的readers,因为在removal操作之后再来进行读操作的readers已经不能访问到旧数据,也就不能进行reclamation释放操作。

所以经典的RCU更新时序是:

  • 删除指向数据的指针,这样后续readers就不能再指引到它。

  • 等待所有之前还指引到数据的reader完成RCU的read-side操作。

  • 在没有reader还指向数据后,释放到该数据。

通俗的理解:RCU机制中记录了指向共享数据的指针的所有使用者,在该数据需要改变时,先创建一个副本,在副本中修改。等待所有读者使用者结束对旧数据的读取之后,指针替换为指向新的、修改后的副本的指针,释放旧数据。 RCU机制允许读写并发。

3.1.1 RCU约束

RCU性能好,但也有一些约束:

  1. 对共享资源的访问主要是只读,写访问相对较少。

  2. RCU保护的代码范围内,内核不能进入睡眠状态。

  3. RCU保护的资源必须通过指针访问。

3.2 解决什么

是一种同步问题解决方案。

在内核代码编程中,要时刻有一个意识:任意一条执行流,都有可能在任意一条指令之后被中断,然后再执行时是不确定的时间之后

这就会引出一个问题:被中断之后,再回到断点开始执行时,前后的依赖环境是否发生了变化?

进一步简化该问题:指令执行所依赖的环境是独享还是共享?,独享则安全,共享则可能被意外修改而引发同步问题。

那么,遇到同步问题,怎么办?

你可能直接说出来了,那就是加锁,对共享资源上锁。

3.2.1 同步问题的本质

同步问题的产生本质是共享与同时,共享顾名思义就是数据会被共同访问,不管是读还是写。同时并不是指同一个时间点,而是说A在某项工作还未做完的情况下(这可能是一段时间,因为代码执行中间可能会被中断然后切走),B也需要去参与进来访问共享资源,这就视为同时。

只要破坏共享或者同时,就可解决同步问题。

内核中的同步技术有

技术 说明 example code
per CPU变量 在CPU之间复制数据结构 per_cpu(name, cpu)
原子操作 对一个计数器原子地“读-修改-写”的指令 atomic_add(i, v)
内存屏障 避免指令重新排序 wmb()
自旋锁 加锁时忙等 spin_lock()
信号量 加锁时阻塞等待(睡眠) struct semaphore内核信号量对象
顺序锁 基于访问计数器的锁 write_seqlock()
禁止本地中断 禁止本地CPU上的中断处理 local_irq_disable()
禁止本地软中断 禁止本地CPU上的可延迟函数处理 local_bh_disable()给本地CPU的软中断计数器加1,这样do_softirq()就不会执行软中断
RCU 通过指针而不是锁来访问共享数据 rcu_read_lock()

3.2.2 经典的spinlock

spinlock在无法获取锁的时候会自旋等待,其有特征:

  • spinlock持锁是关闭抢占的,但不一定关闭中断,也就是说在spinlock临界区中,不会出现进程调度,但是可能出现进程环境的切换比如中断、软中断

如何深入思考spinlock的实现原理,发现一个矛盾点:spinlock的作用是对共享数据进行互斥保护,当一个访问者进入临界区后其他访问者只能等待。但是要做到当一个访问真进入临界区后其他访问者等待需要依赖线程之间的通信,这就是矛盾点:

spinlock中,等待着要知道锁已经被占用,也必须访问某个共享的资源才能获取这个信息,实现spinlock就要依赖对另一个共享资源的同步问题,谁来实现?

软件无法实现,就得靠硬件实现,对于每个不同的硬件架构至少需要实现对单字长变量的原子操作执行,比如64位平台,硬件必须支持一类或一组指令能保证对一个变量执行如++操作时是原子操作。

int main(int argc, char *argv[])
{
  400536:       55                      push   %rbp
  400537:       48 89 e5                mov    %rsp,%rbp
  40053a:       89 7d ec                mov    %edi,-0x14(%rbp)
  40053d:       48 89 75 e0             mov    %rsi,-0x20(%rbp)
    int i = 0;
  400541:       c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
    i++;
  400548:       83 45 fc 01             addl   $0x1,-0x4(%rbp)
    return 0;
  40054c:       b8 00 00 00 00          mov    $0x0,%eax
}

如果是变量i是volatile(每次从内存重新读取,而不是使用寄存器中的值)类型呢?

int main(int argc, char *argv[])
{
  400536:       55                      push   %rbp
  400537:       48 89 e5                mov    %rsp,%rbp
  40053a:       89 7d ec                mov    %edi,-0x14(%rbp)
  40053d:       48 89 75 e0             mov    %rsi,-0x20(%rbp)
    volatile int i = 0;
  400541:       c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
    i++;
  400548:       8b 45 fc                mov    -0x4(%rbp),%eax
  40054b:       83 c0 01                add    $0x1,%eax
  40054e:       89 45 fc                mov    %eax,-0x4(%rbp)
    return 0;
  400551:       b8 00 00 00 00          mov    $0x0,%eax
}

由此可见,对于共享数据的竞争变成了对锁的竞争,对锁的竞争实际上是对锁变量的竞争,本质上市将复合数据的保护收敛成单字长变量的保护,通过硬件的原子指令来解决该问题。

但是,是药三分毒,锁是一剂解决同步问题的猛药,它就有它的"副作用":

  • 死锁、饿死问题可能导致系统直接挂掉;

  • 锁只是缓解竞争,不是根治,在锁激烈竞争条件下系统性能开销依旧不小;

  • 锁的实现越来越复杂,其本身也会消耗指令周期和cache空间,且这种复杂度让问题越来越难量化分析;

  • 不同锁机制的实现,可能有其自身的问题;比如spinlock会带来cpu空转,假设4个cpu同时竞争一个spinlock,因为关闭抢占,这4个cpu只能处理中断和空转,其他不能处理,浪费cpu资源;

3.2.3 场景方案

通用的锁方案无法更进一步时,一个创新方向就是分拆场景,寻找特定场景下的针对性解决方案。

3.2.3.1 锁的延续

继续沿用锁的形式,但区分读写,因为读写的性质完全不一样,从用户角度看,读和写都是访问者,从硬件角度看,读和写的操作有本质区别,通常操作才是带来同步问题的罪魁祸首,比如两个访问者都是仅读操作共享数据,没问题,但两个访问者都是写操作共享数据,那就会有同步问题。OS既然可以在多读少写的场景中,基于spinlock、mutex等衍生出rwlock、rwsem等机制。

3.2.3.2 无锁设计

放弃同步机制

某些场景下,干脆放弃同步机制或者最小化使用同步机制,对于共享资源不加锁也是能接受的。

  • 比如/proc/目录下的节点变量,即使存在同时读写,也不一定会有bug,该设置对应内核中的一个全局变量,而内核代码只会读取该变量,这种情况不加同步措施(或者之加编译器屏障),通常也只会造成短时间内的读者读到旧值的问题,通常不会产生严重的bug。

  • 再比如,对于下图中的同时写,特定场景中不加锁也没问题。线程A和线程B同时操作变量cnt,尽管A和B都执行了cnt++,但是B的操作被覆盖了,两次++最终结果只有一次++的结果,理论上这一定是有问题的。但是在诸如统计网络包的时候,这种情况的发生概率较小,和加锁带来的巨大性能损失相比,统计值上的微差也不是不能接受,可以只加一个WRITE_ONCE来限制编译器优化。

请添加图片描述

所以,能否放弃同步机制,在了解了特定场景下同步措施带来的性能损失与同步措施解决的问题等等情况后,可以做出权衡。

二次确认

如果共享数据的并发访问概率足够低,直接加锁可能没有必要,可以直接无锁操作,然后对操作结果检查,如果不符合预期,再重新执行一遍操作,以期正确的结果。

percpu机制

perCPU变量也是一种解决了同步问题的方案,它破坏的是"共享"这个条件,将原本CPU之间共享的数据分散到每个CPU一份,这样虽然会存在进程环境和中断环境的同步问题,但却极大降低了多CPU之间的同步问题,从多核问题收敛到单核问题

RCU机制

RCU机制也是特定场景下的一种无锁方案。内核中RCU有多种实现,比如tiny rcu和tree rcu两种,tiny rcu一般用于小型系统、嵌入式系统,tree rcu用于多核CPU、桌面系统、服务器以及安卓等。

3.2.4 RCU机制

3.2.4.1 grace period

RCU机制的宽限期(grace period)是什么?

请添加图片描述

RCU机制中被保护的指针动态数据的removal和reclamation之间是有一定的时间期限的,这个时间就叫grace period(宽限期)。看上图,updater必须等待所有在宽限期开始之前就已经进入RCU临界区的线程都退出后,才能进行reclamation操作,因为如果不等待这批线程退出直接就kfree()掉那这些线程就可能会访问已经被释放的指针而出错。

4. RCU使用

4.1 core API

RCU机制中有许多API,不过核心的API可以列举下面5个,其他的API可以由它们扩展而来。

void rcu_read_lock(void)
void rcu_read_unlock(void)
void synchronise_rcu(void)
rcu_assign_pointer(p, v)
rcu_dereference(p)
4.1.1 rcu_read_lock()
static __always_inline void rcu_read_lock(void)
{
    __rcu_read_lock();
    __acquire(RCU);
    rcu_lock_acquire(&rcu_lock_map);
    RCU_LOCKDEP_WARN(!rcu_is_watching(),
             "rcu_read_lock() used illegally while idle");
}

static inline void __rcu_read_lock(void)
{
    preempt_disable();
}

用于reader进入RCU读临界区,经典RCU中,在读临界区内,线程是不能睡眠的(原因和rcu_read_lock()的实现有关,其实现是通过关闭抢占,关闭抢占就不能发声抢占调度,所以如果进入睡眠就可能睡死了),尽管有RCU也有preemptible的实现,kernel编译时打开CONFIG_PREEMPT_RCU这样RCU读临界区内能被抢占,也是不允许在RCU读临界区内阻塞进入睡眠的。

没有rcu_write_lock(),这是RCU机制的特点,也是RCU性能提升的原因,如果读、写都有lock,那就使用spinlock就可以了。

4.1.2 rcu_read_unlock()

reader通知updater它退出了RCU读临界区

static inline void rcu_read_unlock(void)
{
    RCU_LOCKDEP_WARN(!rcu_is_watching(),
             "rcu_read_unlock() used illegally while idle");
    __release(RCU);
    __rcu_read_unlock();
    rcu_lock_release(&rcu_lock_map); /* Keep acq info for rls diags. */
}

static inline void __rcu_read_unlock(void)
{
    preempt_enable();
    if (IS_ENABLED(CONFIG_RCU_STRICT_GRACE_PERIOD))
        rcu_read_unlock_strict();
}
4.1.3 synchronize_rcu() / call_rcu()

用来判断宽限期是否已经到了,换句话说就是判断再进入grace period之前已经在RCU读临界区内的reader是否都退出了。可以参考grace period那张图。

看下面这个时序图,CPU1是updater,CPU0和CPU2是reader,CPU1进入grace period之前,只有CPU0已经进入RCU读临界区内了,所以CPU1只需要等待CPU0退出RCU读临界区,便可以从synchronize_rcu()返回,结束grace period,去进行reclamation旧数据操作,而不必管CPU2还没有退出RCU读临界区,因为CPU2是在CPU1已经进入grace period之后才进入RCU读临界区,已经读取的是新数据。

        CPU 0                  CPU 1                 CPU 2
    ----------------- ------------------------- ---------------
1.  rcu_read_lock()
2.                    enters synchronize_rcu()
3.                                               rcu_read_lock()
4.  rcu_read_unlock()
5.                     exits synchronize_rcu()
6.                                              rcu_read_unlock()

synchronize_rcu()是可以blocking的,call_rcu()是synchronize_rcu()的一种异步实现,其不阻塞,而是通过注册一个回调函数,在所有的需要被等待的reader退出读临界区后,被调用。

4.1.4 rcu_assign_pointer()

updater用来assign一个新的指针,替换原来的被RCU保护的旧指针。通过宏定义实现,为的是在updater和reader之间安全地交互。

#define rcu_assign_pointer(p, v)                          \
do {                                          \
    uintptr_t _r_a_p__v = (uintptr_t)(v);                      \
    rcu_check_sparse(p, __rcu);                          \
                                          \
    if (__builtin_constant_p(v) && (_r_a_p__v) == (uintptr_t)NULL)          \
        WRITE_ONCE((p), (typeof(p))(_r_a_p__v));              \
    else                                      \
        smp_store_release(&p, RCU_INITIALIZER((typeof(p))_r_a_p__v)); \
} while (0)
4.1.5 rcu_dereference()

与rcu_assign_pointer()一样必须通过宏定义实现。reader通过该API来获取一个被RCU保护的指针。

rcu_dereference()并不是真的去解引用一个指针,而是从复制RCU-protected pointer至本地变量,通过调用rcu_dereference()是为了方便后面真正的去dereference它

比如

p = rcu_dereference(head.next);
return p->data;

rcu_dereference()获取的RCU变量作用域必须是在一次RCU读临界区内使用,不能在临界区外使用,如

rcu_read_lock();
p = rcu_dereference(head.next);
rcu_read_unlock();
x = p->address; /* BUG!!! */
rcu_read_lock();
y = p->data;    /* BUG!!! */
rcu_read_unlock();

4.2 代码示例

4.2.1 updater阻塞
struct foo {
        int a;
        char b;
        long c;
};
DEFINE_SPINLOCK(foo_mutex);

struct foo __rcu *gbl_foo;

void foo_init()
{
    struct foo *p = NULL;
    p = kmalloc(sizeof(struct foo), GFP_KERNEL);
    p->a = 1;
    p->b = 2;
    p->c = 3;
    rcu_assign_pointer(gbl_foo, p);
}

void foo_update_a(int new_a)
{
    struct foo *new_fp;
    struct foo *old_fp;

    new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
    spin_lock(&foo_mutex);
    old_fp = rcu_dereference_protected(gbl_foo, lockdep_is_held(&foo_mutex));
    *new_fp = *old_fp;    //拷贝旧数据到新内存
    new_fp->a = new_a;    //写操作,更新数据
    rcu_assign_pointer(gbl_foo, new_fp);    //更新gbl_foo指向新内存
    spin_unlock(&foo_mutex);
    synchronize_rcu();    //阻塞等待宽限期
    kfree(old_fp);    //释放旧内存
}

int foo_read_a(void)
{
        int retval;

        rcu_read_lock();
        retval = rcu_dereference(gbl_foo)->a;
        rcu_read_unlock();
        return retval;
}

代码中展现了用RCU保护一个指向动态分配空间的全局指针。updater动态创建new_fp,与原来的全局变量指针gbl_foo所指的对象类型一样,然后设置new_fp->a=new_a,做了个写操作(通过rcu_dereference_protected()来获取的RCU保护变量gbl_foo),然后通过rcu_assign_pointer()来更新gbl_foo指向new_fp指向的对象,保证reader还能通过gbl_foo访问到数据,最后通过synchronize_rcu()来等待宽限期,等到宽限期之前所有的reader都退出读临界区后,synchronize_rcu()返回,然后来reclamation旧数据,kfree()释放。

可以看出,updater之间也有同步竞争问题,这里是通过spinlock来解决的,这里的RCU是解决reader与updater之间的同步竞争问题,使得reader不用管是否有人在写,直接读即可

问题:kfree()释放的是kmalloc()分配的内存,old_fp何时指向动态分配的内存的?

这里应该是示例代码不全,gbl_foo原本指向的内存空间应该是kmalloc()分配的,这里没写出来,因为是动态分配的,才可以用kfree(old_fp)来释放旧内存空间。

使用方法总结:

  • reader通过rcu_read_lock()和rcu_read_unlock()来保护读临界区。

  • reader在读临界区内,通过rcu_dereference()来获取RCU指针变量;updater在写临界区内,通过rcu_dereference_protected()来获取RCU指针变量。

  • updaters之间通过锁、信号量等来解决同步问题。

  • updater通过rcu_assign_pointer()来更新RCU指针变量,注意rcu_assign_pointer()只能保证reader月updater之前没有同步问题,但不能保证多个updaters之间的同步问题,所以该操作需要在临界区内操作,比如用锁来保护。

  • updater通过synchronize_rcu()来等待宽限期结束,然后释放旧数据。

从上面代码例子可以看出RCU机制的两点约束,RCU机制使用场景是读多写少;RCU保护的对象只能是指向动态分配内存,且通过指针访问

4.2.2 updater非阻塞

上面的例子中,synchronize_rcu()会block,直至grace period结束。但是,如果updater不想阻塞等待怎么办,还有其他事要办。可以使用call_rcu()来替换synchronize_rcu() 的使用。

void call_rcu(struct rcu_head *head, rcu_callback_t func);

call_rcu()函数会在grace period宽限期结束后回调func(head),需要注意的是func(head)要么是软中断要么是进程上下文中来调用,所以func(head)函数中不能阻塞blocking

如果使用call_rcu(),则需要做一些修改:

  1. 需要再struct foo中添加struct rcu_head rcu变量。
struct foo {
    int a;
    char b;
    long c;
    struct rcu_head rcu;
};
  1. foo_update_a()函数也需要稍加修改
void foo_update_a(int new_a)
{
        struct foo *new_fp;
        struct foo *old_fp;

        new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
        spin_lock(&foo_mutex);
        old_fp = rcu_dereference_protected(gbl_foo, lockdep_is_held(&foo_mutex));
        *new_fp = *old_fp;
        new_fp->a = new_a;
        rcu_assign_pointer(gbl_foo, new_fp);
        spin_unlock(&foo_mutex);
        call_rcu(&old_fp->rcu, foo_reclaim);
}

void foo_reclaim(struct rcu_head *rp)
{
        struct foo *fp = container_of(rp, struct foo, rcu);

        kfree(fp);
}
这里设置call_rcu()的回调函数为foo_reclaim()来做回收处理。

如果call_rcu()中只是调用kfree()来释放旧数据,也可以直接使用kfree_rcu()来代替call_rcu()

kfree_rcu(old_fp, rcu);

5. RCU核心特征

5.1 读临界区

5.1.1 grace period理解

RCU中通过rcu_read_lock()和rcu_read_unlock()之间来保护读临界区,其是如何实现的?

通过看代码,你会发现,经典RCU中rcu_read_lock()仅仅是关了一下抢占,preempt_disable(),会不会好奇,像以往的自旋锁之类,要保护临界区,怎么也得上个锁吧,怎么RCU只要关一下抢占就进入读临界区了?

请添加图片描述

上图中存在三个reader,updater(writer)先是从D1拷贝一份到D2,然后写操作,最后update更新D2,在这个过程中,reader3是一开始就读到了新数据,所以这里不用管,而reader1一直读的旧数据,它下次再读时就是读新的数据了,所以也不用管,reader2就麻烦,reader2是开始读的旧数据D1,然后updater更新到D2时(grace period开始),reader2还没读完,那么此时updater虽然更新完了D2新数据,但还不能立即删除旧数据D1,因为reader2还在读旧数据,所以updater必须等reader2读完之后,才能删除旧数据,这个等待时间就是grace period宽限期。

5.1.2 开关抢占

请添加图片描述

参考上图,这个图中reader3一直读的新数据D2,不用管。reader1和reader2在updater更新新数据之前就开始读旧数据,所以updater必须等reader1和reader2退出才能释放旧数据。而reader的读临界区是通过关抢占进入的,退出读临界区是开抢占的,关闭抢占意味着在读临界区这段时间内,该CPU上不会发生抢占性进程调度,所以updater要判断reader1有没有读完数据退出读临界区,只要判断reader1所在CPU上有没有发生一次进程调度即可(即使是reader1主动退出发生非抢占性调度,那也说明reader1读完退出了),这样通过开关抢占就能实现保护读临界区的问题,而不用再额外通过锁机制来实现,最大化了读端的性能,并且读端不用管是否有写端在进行写操作,只管读就是

请添加图片描述

上图是一个简单的示例,updater在CPU1上更新数据,调用synchronize_rcu()进入grace period,开始等待所有reader退出后才能释放旧数据,而调用synchronize_rcu()会阻塞然后立即触发一次非抢占性进程调度,而此时CPU2上的reader还没有读完,等reader2读完后,CPU2上发生了一次进程调度,说明发生了进程切换,reader2在进入grace period之前的读操作完成了。再看CPU3上,reader3进退读临界区3次之后才发生了一次进程调度,updater便可以判断reader3在进入grace period之前的读操作绝对完成了。此时grace period结束,updater可以释放旧数据了。

从上面的例子可以看出,RCU也有几点特征:

  • RCU针对读多写少的场景。

  • RCU中写端有一定延迟,宽限期内,有的读端拿的新数据,有的还拿的旧数据,也是一种延迟。

  • RCU中updater删除旧数据有延迟,并不是更新完新数据后立即删除旧数据,而是要等到grace period结束才能删除旧数据。

  • RCU保护的对象不能是复合结构,只能是指针,如果是复合结构,新数据会直接覆盖旧数据,读端可能就发生错误。

  • RCU机制追求的是读端极致性能,这是内核中的场景创新。

  • 经典RCU中读临界区是通过开-关抢占来实现的,性能及多核扩展性非常好,但这也导致读临界区内不支持抢占和睡眠。

5.2 写临界区

5.2.1 updater如何感知进程调度

对于经典RCU中可以设计通过perCPU变量来时updater感知所有CPU是否完成了一次进程调度。

在较新的linux kernel代码中,为了减少grace period,减少延迟,加快释放旧数据内存,synchronize_rcu()可以通过IPI中断来触发CPU发生一次中断,其原理可以写成如下:

for_each_online_cpu(cpu)
    run_on(cpu);

这里的run_on(cpu)将当前线程绑定到指定CPU去执行,这样就能在该CPU上强制发生一次进程调度切换,这样就可以保证所有的grace period之前进入RCU read-side critical sections的reader都退出。该方法适用于RCU读临界区类禁止抢占的内核(非抢占内核和抢占内核都可以,因为是中断触发),但不适用于实时抢占内核,realtime rcu有另外的实现。

6. 参考文献

  1. Linux kernel document – what is RCU

  2. 内核工匠 – RCU前传

  3. LWN – the design of preemptible RCU

  4. Linux kernel 6.0.9 source code

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Linux内核同步之RCU机制基础 的相关文章

  • 即使 makefile 和源代码存在,为什么“Build Project”在 Eclipse Helios CDT 中显示为灰色?

    我无法构建我的项目 我在 Eclipse Helios 中创建了一个新的 CDT 项目 并告诉它使用现有的源代码和 makefile 这两者都正确显示在 Package 和 Project 视图中 然而 项目 菜单中的 构建全部 和 构建项
  • 在单个命令中使用前缀重命名文件夹中的所有文件

    重命名带有前缀的文件夹中的所有文件 Unix 假设一个文件夹有两个文件 a txt b pdf 那么它们都应该从一个命令重命名为 Unix a txt Unix b pdf 如果您的文件名包含没有空格并且你没有任何子目录 你可以使用一个简单
  • 跟踪 pthread 调度

    我想做的是创建某种图表 详细说明 Linux 中 两个 线程的执行情况 我不需要查看线程的作用 只需查看它们何时被安排以及持续多长时间 基本上是一条时间线 在过去的几个小时里 我一直在互联网上搜索跟踪 pthread 调度的方法 不幸的是
  • 使用netcat将unix套接字传输到tcp套接字

    我正在尝试使用以下命令将 unix 套接字公开为 tcp 套接字 nc lkv 44444 nc Uv var run docker sock 当我尝试访问时localhost 44444 containers json从浏览器中 它不会加
  • /proc/PID 文件格式

    我想从中检索一些流程信息 proc目录 我的问题如下 中的文件是否有标准格式 proc PID 例如 有这个proc PID status文件与Name t ProcName在第一行 我可以在其他地方用空格代替这个文件吗 t或者类似的东西
  • Docker DNS 设置

    我尝试使用自定义网络和 dos 设置创建 docker 容器 docker网络创建 driver bridge opt com docker network bridge enable ip masquerade true opt com
  • 如何使用ffmpeg重叠和合并多个音频文件?

    我正在尝试将多个音频文件合并到一个文件中 但我可以使用以下命令来连接 而不是连接 ffmpeg v debug i file1 wav i file2 wav i file3 wav filter complex 0 0 concat n
  • Xenomai 中的周期性线程实时失败

    我正在创建一个周期性线程 它在模拟输出上输出方波信号 我正在使用 Xenomai API 中的 Posix Skin 和 Analogy 我使用示波器测试了代码的实时性能 并查看了方波信号 频率为 1kHz 的延迟 我应该实现 250us
  • Windows 与 Linux 文本文件读取

    问题是 我最近从 Windows 切换到 Ubuntu 我的一些用于分析数据文件的 python 脚本给了我错误 我不确定如何正确解决 我当前仪器的数据文件输出如下 Header 有关仪器等的各种信息 Data 状态 代码 温度 字段等 0
  • 为什么 ld 无法从 /etc/ld.so.conf 中的路径找到库?

    我想添加 opt vertica lib64进入系统库路径 所以我执行以下步骤 1 添加 opt vertica lib64 into etc ld so conf 然后运行ldconfig 2 检查 bash ldconfig p gre
  • Bash 脚本 - 迭代 find 的输出

    我有一个 bash 脚本 其中需要迭代 find 命令输出的每一行 但似乎我正在迭代 find 命令中的每个单词 以空格分隔 到目前为止我的脚本看起来像这样 folders find maxdepth 1 type d for i in f
  • Linux、ARM:为什么仅当启动时存在 I2C GPIO 扩展器时才创建 gpiochip

    在 imx6sx 硬件平台 NXP 嵌入式 ARM 上使用 Linux 3 14 52 问题是设备树中指定的 PCF8575 I2C GPIO 扩展器不会实例化为 sys class gpio 结构中的设备 除非它们在内核启动期间存在 这些
  • 具有少量父设备属性的 udev 规则

    我需要复杂且通用的udev规则来确定插入任何 USB 集线器的特定端口的 USB 设备 所以 我必须结合设备树不同层的父属性 我有这个 udevadm info query all name dev ttyUSB0 attribute wa
  • 如何从类似于 eclipse 的命令行创建可运行的 jar 文件

    我知道 eclipse 会生成一个可运行的 jar 文件 其中提取并包含在该 jar 文件中的所有库 jar 文件 从命令提示符手动创建 jar 文件时如何执行类似的操作 我需要将所有 lib jar 解压到类文件夹中吗 目前我正在使用 j
  • “grep -q”的意义是什么

    我正在阅读 grep 手册页 并遇到了 q 选项 它告诉 grep 不向标准输出写入任何内容 如果发现任何匹配 即使检测到错误 也立即以零状态退出 我不明白为什么这可能是理想或有用的行为 在一个程序中 其原因似乎是从标准输入读取 处理 写入
  • 如何从 Linux 命令行获取视频文件的分辨率(宽度和高度)?

    我一直在挖掘 mplayer mencoder 和 ffmpeg 文档 但我似乎无法想出anything 我对输出格式不是特别挑剔 因为我可以使用正则表达式将其拉出来 我只是似乎无法首先获取数据 Use ffprobe https ffmp
  • 如何从linux命令行运行.exe可执行文件? [关闭]

    Closed 这个问题不符合堆栈溢出指南 help closed questions 目前不接受答案 我在 Windows 中有一个 abc exe 可执行文件 我可以使用 DOS 命令提示来执行此应用程序 并为其提供一些运行时变量 我想从
  • 无法仅在控制台中启动 androidstudio

    你好 我的问题是下一个 我下载了Android Studio如果我去 路径 android studio bin 我执行studio sh 我收到以下错误 No JDK found Please validate either STUDIO
  • 套接字:监听积压并接受

    listen sock backlog 在我看来 参数backlog限制连接数量 这是我的测试代码 server initialize the sockaddr of server server sin family AF INET ser
  • Scrapy FakeUserAgentError:获取浏览器时发生错误

    我使用 Scrapy FakeUserAgent 并在我的 Linux 服务器上不断收到此错误 Traceback most recent call last File usr local lib64 python2 7 site pack

随机推荐

  • less切换主题

    less切换主题 核心css变量 less变量 1 定义主题less文件 assets css theme less 默认的主题颜色 primaryColor var primaryColor 000 primaryTextColor va
  • 批处理文件常用命令

    批处理有许多命令 1 Echo 命令 打开回显或关闭请求回显功能 或显示消息 如果没有任何参数 echo 命令将显示当前回显设置 语法 echo on off message Sample echo off echo hello world
  • Cannot create container for service peer1.org2.example.com: Conflict. 解决方案

    I have a docker compose yaml file defining 5 services orderer example com peer0 org1 example com peer1 org1 example com
  • 60-100-020-使用-MySQL 的Show Profile命令

    文章目录 1 概述 2 开启Profile功能 3 常用参数 4 注意点 5 总结 1 概述 前言 Show Profile是mysql提供的可以用来分析当前会话中sql语句执行的资源消耗情况的工具 可用于sql调优的测量 默认情况下处于关
  • Vue3实现九宫格抽奖(思路+完整代码)

    目录 前言 一 实现思路 1 组件结构 2 数据结构 奖品列表 抽奖按钮 v for的抽奖列表 3 组件交互 抽奖顺序 奖品高亮 中奖 二 完整代码 前言 虽然有一些抽奖插件比如lucky canvas来帮助我们快速发开抽奖小活动 但一些高
  • 最全电力电子仿真matlab/simulink仿真 单相全桥/半桥整流仿真 单相半波全波仿真

    最全电力电子仿真matlab simulink仿真 单相全桥 半桥整流仿真 单相半波全波仿真 三相全桥 半桥整流仿真 三相半波全波仿真 三相桥式整流及其有源逆变仿真 单相桥式整流及其无源逆变仿真 升降压斩波电路 boost buck电路仿真
  • 9V电池百科

    通称 层积电池 因其内部构造为6枚微型片状电池堆叠而得名 又名PPP3电池 标称电压9V 实际出厂电压略高 随使用逐渐降低 尺寸规格为宽度26 5mm 厚度17 5mm 高度48 5mm 根据电解物质的不同 种类主要有锂电池 lithium
  • jQuery获取input值的方法

    获取input的checked值是否为true 第一种 if input name item value val attr checked true 判断是否已经打勾 注 name即控件name属性 value即控件value属性 第二种
  • linux文件权限和文件夹权限解读

    基本介绍 使用ll命令可以查看当前文件夹下所有文件的具体信息 输出结果的第一个字段就是描述文件和目录权限的编码 这个字段的第一个字符代表了对象的类型 代表文件 d 代表目录 l 代表链接 c 代表字符型设备 b 代表块设备 n 代表网络设备
  • 机器学习笔记(李宏毅 2021/2022)——第五节:Transformer

    视频 2021 Transformer 上 2021 Transformer 下 self attention变形 一 Transformer应用 Transformer就是一个Sequence to sequence的model 他的缩写
  • 什么是LAMP(LAMP概念详解)

    LAMP是 Linux Apache MySQL PHP Perl Python 的简称 1998年Michael Kunze在电脑杂志C T撰写的文章中首次使用了缩略语LAMP 它所组成的各组件都是开源软件 因此很方便自由的获取 导致这些
  • 百度地图3d效果和卫星图效果

    3D图 3d案例如上图 代码如下
  • LDAP客户端+Samba架设指南

    1 设定使用 LDAP Authenication vi etc ldap conf host 192 168 1 5 base dc ldap dc gmshi dc org URI ldap 192 168 1 5 rootbinddn
  • 106道Java并发和多线程基础面试题大集合(2w字),这波面试稳了~

    前言 Java多线程分类中写了21篇多线程的文章 21篇文章的内容很多 个人认为 学习 内容越多 越杂的知识 越需要进行深刻的总结 这样才能记忆深刻 将知识变成自己的 这篇文章主要是对多线程的问题进行总结的 因此罗列了108个多线程的问题
  • C/C++中递归的定义和调用(如何使用递归)

    目录 递归是什么 递归模板 实例分析 1 阶乘 2 斐波那契数列 3 汉诺塔 递归是什么 先看一下什么叫递归 递归 就是在运行的过程中不断调用自己 直到满足某个条件 构成递归需具备的条件 子问题须与原始问题干同样的事 且更为简洁明了 不能无
  • springboot+vue jwt校验token 单点登录

    SSO Single Sign On 模式 CAS单点登录 OAuth2 分布式 SSO single sign on 模式 单点登录英文全称Single Sign On 简称就是SSO 它的解释是 在多个应用系统中 只需要登录一次 就可以
  • 面试官:断网了,还能 ping 通 127.0.0.1 吗?

    你女神爱不爱你 你问她 她可能不会告诉你 但网通不通 你 ping 一下就知道了 可能看到标题 你就知道答案了 但是你了解背后的原因吗 那如果把 127 0 0 1 换成 0 0 0 0 或 localhost 会怎么样呢 你知道这几个IP
  • Python吴恩达深度学习作业22 -- Emoji表情情感分类器

    Emojify 此次你将使用单词向量表示来构建Emojifier表情符号 你是否曾经想过让短信更具表现力 你的emojifier应用程序将帮助你做到这一点 因此 与其写 恭喜晋升 有机会喝杯咖啡聊天吧 爱你 emojifier可以自动将其变
  • VMware(Ubuntu)共享文件夹设置

    VMware共享文件夹设置 安装完成ubuntu虚拟机后 需要建立共享文件夹来方便在Host主机和虚拟机ubuntu之间分享文件 在虚拟机设置中 在 选项 卡中找到 共享文件夹 项 在右侧添加共享文件夹 在虚拟机中 在ubuntu终端中使用
  • Linux内核同步之RCU机制基础

    Why RCU 1 中断与抢占 当一个进程被时钟中断打断后 kernel运行tick中断处理程序 一般是top half 中断处理程序运行结束后 有两种情况 之前的进程获得CPU继续运行 另一个进程获得了CPU开始运行 而之前的进程则被抢占