linux中的RCU机制

Linux中的RCU机制(read-copy-updata)

RCU(read-copy-updata)是Linux中比较重要的一种同步机制。RCU是一种极端的非对称读/写同步机制,**允许读操作在没有任何锁及原子操作的情况下进行。**这意味着在更新操作进行的时候读操作也可能在运行。读者临界代码不需要承担任何同步开销,不需要锁,不需要原子指令。在除alpha外的平台,还不需要内存屏障指令。

写者临界代码必须承担住主要的同步开销,为了适应读者的临界代码的要求,写着必须延迟销毁数据结构和维护数据结构的多个版本,必须使用同步机制–加锁,提供排序的更新。

读者必须提供信号,让写者决定何时完成数据结构销毁操作。(因为RCU需要保证每个读者读取到数据在修改前是一致的,所以数据的更新操作会延迟,延迟到什么时候,需要由读者来决定,当读者读取完数据退出临界区时,告知写者,这时写者就能够开始写操作)但是该信号可能被延迟,允许一个单信号操作服务多个读者RCU临界代码。RCU通常用非原子操作地增加一个本地计数器的方法发信号给写者,该方法开销很小(因为没有原子操作,所以可能会有一定的延迟)。

一种特殊的立即回收器用于观察读者发给写者的信号,一旦所有读者已发信号表示可以安全销毁时,垃圾回收器才开始执行销毁操作。垃圾回收器通常以类似于屏障计算的方式实现,或者在NUMA系统用一个联合树的形式实现。

**RCU通过延迟写操作来提供同步性能。**系统中数据读取操作远多于写操作,而RWlock(读写锁)机制在SMP对称多处理器环境下随着处理器增多性能会迅速下降,RCU机制改善了这种情况,**RCU机制的核心是写操作分为 “写” 和 “更新” 两步,**允许读操作在任何时候无阻碍访问,当系统有写操作是,更新动作一直延迟到对该数据的所有读操作完成为止。

RCU机制相关的基本概念

  • Live Variable:一个变量在它被修改前可能被访问到,因此它的当前值可能影响到将来运行状态。

  • Dead Variable:一个变量在它下一次被访问前将被修改,因此它的当前值对将来的运行状态没有影响。

  • Temporary Variable:仅生存在临界部分的变量。

  • Permanent Variable:一个在临界部分外的变量。

  • Read-Side Critical Section:保护禁止其他CPU修改的代码区域,但允许多个CPU同时读。

  • Quiescent State:一个线程的执行状态,在此期间,线程(执行写操作的线程)不持有对任何RCU保护的数据结构的引用。

  • Candidate Quiescent State:它是代码中的一个点,在这个点上,所有在此线程以前使用的Temporary Variable都是Dead Variable在所有RCURead-Side Critical Section外面任何点都是该状态。

  • Observed Quiescent State:尽管不可能观察所有的候选静止状态,但是还是可以很容易地观察部分候选静止状态,因为它们与一个永久操作系统状态的改变相关。

    (这个概念不是很懂,给大家看看gpt的回答)

    gpt:RCU的 observed quiesecent state 是指在 RCU 中,读操作可以观察到的一种状态。当一个读操作开始时,它会首先检查是否存在一个观察到的静默状态(quiesecent state)。如果存在,那么读操作可以继续执行,否则需要等待直到观察到了一个静默状态。 观察到的静默状态(observed quiesecent state)是指在该状态下,没有任何正在进行的写操作对读操作产生影响。也就是说,读操作可以安全地访问数据,而不会受到并发写操作的干扰。 RCU通过一种被称为“延迟回收”的技术来实现 观察到的静默状态。延迟回收允许在没有引用的情况下延迟释放资源,以避免对正在进行的对操作造成影响。

  • Grace Period宽限期:**这是RCU最重要的一个概念。**RCU提供了一个宽限期给并发访问(由RCU提供,但是宽限期的长短是有读者决定的),宽限期是指所有CPU当前正执行的RCU读操作的临界部分完成的时间(可能现在单纯看概念还不是很懂,没关系,后面会画个图来直观地呈现)。

RCU的组成

RCU由三个基本机制组成:

  • 订阅发布机制 – 用于插入
  • 等待已有的RCU读者完成 – 用于删除
  • 维护多个最近更新的对象的版本 – 为读者维护

发布订阅机制

RCU的发布订阅机制是通过内存屏障(Memory Barrier)来实现的。

发布订阅机制两个关键的api

  • rcu_assign_pointer()
  • rcu_dereference()
1 struct foo {
2 int a;
3 int b;
4 int c;
5 };
6 struct foo *gp = NULL;
7
8 /* . . . */
9
10 p = kmalloc(sizeof(*p), GFP_KERNEL);
11 p->a = 1;
12 p->b = 2;
13 p->c = 3;
14 gp = p;

在上面的例子中,可以很明显的发现一些代码之间的依赖关系:p->a = 1 p->b = 2 p->c = 3这三条指令都依赖于p = kmalloc(sizeof(*p), GFP_KERNEL);的执行,所以在cpu的指令流水线上,后者一定会比前三者先进入流水线中;从可见性的角度上看就是,后面的指令对于前三者是可见的,后者对前三者是顺序执行。但是p->a = 1 p->b = 2 p->c = 3这三条指令之间并没有依赖关系,包括后面的 gp = p,所以,cpu执行这三条指令的时候(可能这三条指令还会被拆成多个微指令)是 乱序执行 的。

那它们乱序执行会出什么问题呢?假设现在先执行了 gp = p; 此时,在 p 的成员属性并没有被赋值,如果这个时候这个线程访问了 gp 的成员属性,后果很明显。所以我们需要保证 gp = p; 一定要发生在最后,这就需要 内存屏障 了,RCU将相关的内存屏障的操作封装在 具有发布语义的rcu_assign_pointer()原语中。所以我们可以这样做

p->a = 1;
p->b = 2;
p->c = 3;
rcu_assign_pointer(gp, p);

rcu_assign_pointer() 的作用很简单,只是将第二个参数赋值给第一个参数。这个宏函数的关键是第三行,它在这里设置了一个内存屏障,保证了赋值之前,p一定是有效的。这里怎么理解发布订阅呢?我的理解是:rcu_assign_pointer(describer, publisher),第一个参数订阅第二个参数,describer需要等待publisher被发布之后才能进行访问,(发布订阅机制在很多网络中间件都有支持,mq,redis…),那publisher什么时候才能发布呢?当然是在所有对 publisher 进行修改的操作执行完之后在发布。

下面贴出 rcu_assign_pointer()的主要实现:

#define __rcu_assign_pointer(p, v, space) \
    ({ \
        smp_wmb(); \
        (p) = (typeof(*v) __force space *)(v); \
    })

下面来看看比较有意思的,在linux kernel中的rculist的插入

#define list_next_rcu(list)     (*((struct list_head __rcu **)(&(list)->next)))

static inline void __list_add_rcu(struct list_head *new,
                struct list_head *prev, struct list_head *next)
{
    	// 复制写
        new->next = next;
        new->prev = prev;
        // 更新
    	rcu_assign_pointer(list_next_rcu(prev), new);
        next->prev = new;
}

需要注意的是:RCU的发布订阅机制只能保证写操作不影响读操作,读操作与读操作之间需要一些其他的同步机制进行同步。

在看下面的例子

p = gp;
if (p != NULL) {
    do_something_with(p->a, p->b, p->c);
}

尽管这段代码看起来不会受到顺序错乱的影响,不过十分不幸,DEC Alpha CPU 和投机性编译器优化可能会引发问题,不论你是否相信,这的确有可能会导致 p->a p->b p->c 的读取会在读取 p 之前!这种情况在投机性编译器优化的情况中最有可能会出现,编译器会揣测p的值,取出 p->a p->bp->c,之后取出 p 的真实值来检查拽侧的正确性。(这是很正常的,在cpu中通常会通过 乱序执行预测 来提高流水线的执行效率,笔者对计算机组成原理理解的十分浅显,所以只能想到这句话了)。

这时候, rcu_dereference()就能避免这种情况的发生

// ruc_read_lock() 和 rcu_read_unlock() 定义了读临界区的范围
rcu_read_lock();
p = rcu_dereferecne(gp);
if (p != NULL) {
    do_something_with(p->a, p->b, p->c);
}
rcu_read_unlock();

现在我们来看看 rcu_derefence()的主要实现:

#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)); \
    })

不难看出,rcu_derefence()作用的过程:先拿到参数的地址,设置一个内存屏障,再将拿到的地址复制给调用者。

等待已经存在的RCU读者完成

  • rcu_read_lcok()
  • rcu_read_unlock()
  • synchronize_rcu()

RCU最基本的功能是等待一些事情的完成。RCU最大的好处在于它可以等待所有不同点事件,而无需显式地跟踪它们中的每一个,也不需要担心性能的下降,可伸缩性限制、复杂度死锁场景、以及内存泄漏等所有这些显式跟踪手法固有的问题。

  • 为什么RCU可以等待所有不同点事件,而无需显式地跟踪它们?

    RCU的等待所有不同点事件的机制是通过使用内存屏障来实现的。内存屏障可以确保在某个事件点之后,所有对共享数据的读操作都使用了新的数据副本。当一个写操作完成后,它会插入一个屏障,然后等待所有已经开始的读操作完成。这样,写操作就可以等待所有不同点事件,而无需显式跟踪每一个读操作。

    通过使用屏障和延迟内存资源的释放,RCU实现了高效的并发读写。它适用于读操作频繁,写操作相对较少的场景,可以提供系统的并发性能和吞吐量。

读临界区:一个RCU读临界区始于rcu_read_lock(),止于rcu_read_unlock()。还有一种特殊的SRCU允许在它的读临界区中睡眠或阻塞。

现在我们分析一下 rcu_read_lock()rcu_read_unlock():这两个宏函数用来规划出一个读临界区,多个读临界区并不互斥,(这个跟 rwlock 有点像)但是一块内存数据一旦能够在读临界区被获取到的指针引用,这块内存数据的释放必须等待到读临界区结束,通过使用 linux kernel api 的 synchronize_rcu()。读临界区的检查是全局的,系统中有任何代码处于读临界区,synchronize_rcu()都会阻塞。什么意思呢?可以这样理解:synchronize_rcu()底层就是设置了一个屏障,它需要等待在设置这个屏障前的所有读操作都执行完成了,才能执行屏障之后的操作。所以为什么说RCU不需要显式地跟踪每一个不同点事件,(开始我还以为是通过引用计数的方式来实现的,但是,又不符合这一点)。

举个例子吧

/* p 指向一块受 RCU 保护的共享数据 */
/* read */
rcu_read_lock();
p1 = rcu_dereference(p);
if (p1 != NULL) {
    printf("%d\n", p1->field);
}
rcu_read_unlock();

/* free the memory */
p2 = p;
if (p2 != NULL) {
    p = NULL;
    synchronize_rcu();
    kfree(p2);
}

多个读者和内存释放线程的时序关系:

img

  • 上图中,每个读者的方块表示获得 p 的引用(第5行代码)到读端临界区结束的时间周期;t1 表示 p = NULL 的时间;t2 表示 synchronize_rcu() 调用开始的时间;t3 表示 synchronize_rcu() 返回的时间。我们先看 Reader1,2,3,虽然这 3 个读者的结束时间不一样,但都在 t1 前获得了 p 地址的引用。t2 时调用 synchronize_rcu(),这时 Reader1 的读端临界区已结束,但 Reader2,3 还处于读端临界区,因此必须等到 Reader2,3 的读端临界区都结束,也就是 t3,t3 之后,就可以执行 kfree(p2) 释放内存。synchronize_rcu() 阻塞的这一段时间,有个名字,叫做 Grace period。而 Reader4,5,6,无论与 Grace period 的时间关系如何,由于获取引用的时间在 t1 之后,都无法获得 p 指针的引用,因此不会进入 p1 != NULL 的分支。

现在看看rcu_read_lock() rcu_read_unlock() synchronize_rcu()

  • rcu_read_lock()rcu_read_unlock()的实现都非常简单,分别是关闭抢占和打开抢占

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

    这时是否度过宽限期的判断就比较简单:每个CPU都经过一次抢占,就说明不在rcu_read_lock()rcu_read_unlock()之间,必然已经完成访问或者还未开始访问。

  • synchronize_rcu()

    void synchronize_rcu(void)
     {
       	struct rcu_synchronize rcu;
        
     	// 初始化为等待事件为完成
        init_completion(&rcu.completion);
        
        // 在当前cpu的私有数据中,注册回调函数
        // 其中回调函数的complete(&rcu->completion);用来唤醒等待队列的进程
    	/* Will wake me after RCU finished */
    	call_rcu(&rcu.head, wakeme_after_rcu);
     
    	/* Wait for it */
        // completion是linux kernel的另一种同步机制,找机会再深入了解一下
        // 简单了解了一下,发现是个无底洞,顶不住了
    	wait_for_completion(&rcu.completion);
     }
    
    struct rcu_synchronize {
        struct rcu_head head;
        struct completion completion;
    };
    
    struct rcu_head {
        struct rcu_head *next;					// 指向 rcu_head 的指针
        void (*func)(struct rcu_head *head);	// 回调函数 完成写操作最后的数据释放或修改操作
    }
    

因为RCU由rcu_read_lock()rcu_read_unlock()定义的临界区是不允许休眠和阻塞的,所以当一个给定的CPU要进行上下文切换的时候,我们可以确定任何已由的RCU读临界区都已经完成了。也就是说只要每一个CPU都至少进行了一次上下文切换,那么所有先前的RCU读临界区也就完成了,

所以从概念上说 synchronize_rcu()可以被简化成(但是在实际情况下是不可能的,这里只是方便理解)

for_each_online_cpu(cpu);
run_on(cpu);

run_on():将当前线程切换到指定CPU,来强制该cpu进行上下文切换

for_each_online_cpu():循环强制对每个CPU进行一次上下文切换。

维护多个版本的近期更新的对象

我们直接看看在 kernel 中是怎么操作的

  1. 删除链表项

    // 常见的代码模式:
    p = seach_the_entry_to_delete();
    list_del_rcu(p->list);
    synchronize_rcu();
    kfree(p);
    
    //其中 list_del_rcu() 的源码如下,把某一项移出链表:
    /* list.h */
    static inline void __list_del(struct list_head * prev, struct list_head * next)
    {
        next->prev = prev;
        prev->next = next;
    }
    
    /* rculist.h */
    static inline void list_del_rcu(struct list_head *entry)
    {
        __list_del(entry->prev, entry->next);
        entry->prev = LIST_POISON2;
    }
    
  2. 更新链表项

    // 常见的代码模式
    // copy
    p = search_the_entry_to_update();
    q = kmalloc(sizeof(*p), GFP_KERNEL);
    *q = *p;
    // write
    q->field = new_value;
    // updata
    list_replace_rcu(&p->list, &q->list);
    synchronize_rcu();
    kfree(p);
    

    解释一下最后三行为什么不是先调用 synchronize_rcu(),再调用list_replace_rcu(&p->list, &q->list)kfree(p)?其实也很简单,画个图就行了。

    
    data_1  ->  data_2 ->  data_3 -> data_4
        
    这里用 data_new 替换 data_3
        
        
        					
    data_1  ->  data_2      data_3 -> data_4
        			|				  |
        			--->  data_new --->|
        
    
    

    然后是看看源码,也挺简单的

    static inline void list_replace_rcu(struct list_head *old,
                    struct list_head *new)
    {
        new->next = old->next;
        new->prev = old->prev;
        rcu_assign_pointer(list_next_rcu(new->prev), new);
        new->next->prev = new;
        old->prev = LIST_POISON2;
    }
    

RCU 和 copy-on write 的区别

最后,就简单聊聊 RCU 和 copy-on write 的区别吧。

先看看 copy-on write。笔者对 copy-on wirte 的理解是基于 mit6.s086 的xv6 kernel 的,和linux kernel 中的copy-on write 难免会有些出入。

其实 copy-on write 属于 lazy copy 的一种。比如说:现在有一个进程 process_1 他调用了 fork() 生成了一个子进程 process_2,我们直到,每个进程都有自己的内存空间,在 kernel 中称为 页 (page),每个进程都有自己的页。那理所当然的,当调用 fork() 后,产生的子进程也应该有自己的 page。如果 kernel 采用的是 copy-on write 机制的话,kernel 并不会马上为子进程申请一个 page,而是让该子进程与父进程公用一个 page,但是 对应的父子进程的 PTE 上的 可写位 会被置为零,也就是说不允许执行写操作。当父进程或子进程执行写操作 之后就会产生一个 page fault(页错误),这时候, cpu就会从用户态陷入到内核态,正真地分配一个页,在 copy ,然后执行写操作。

什么是PTE? 我忘了具体是什么意思,我们知道 page 是一个虚拟的概念,每个进程看到的都是虚拟内存,虚拟内存需要通过映射(mapping)到相应的物理内存上,kernel 代码中有对应的映射函数。但是只有单一映射的话,物理内存就跟虚拟内存大小,没什么意义,所以这时候就需要页表(page table)来建立一个多级映射的关系,大大增加虚拟内存的大小,而 page table 上的每一列 就称为 PTE。

所以,copy-on write 和 RCU 还是有很大的不同的,前者只是一种内存机制(我认为),而RCU是一种同步机制。(为什么会说这个?因为昨天在博客上看到有位兄弟说这两个差不多。但是仔细一看,感觉还真不一样。)

参考文献

  1. 一文带你深入解析Linux内核-RCU机制(超详细~) - 知乎 (zhihu.com)
  2. 深入理解 Linux 的 RCU 机制 - 腾讯云开发者 - 博客园 (cnblogs.com)
  3. 爆肝三天整理的RCU同步机制最全介绍 - 知乎 (zhihu.com)
  • 20
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Linux内核RCU(Read-Copy-Update)机制是一种用于实现高效并发访问共享数据的技术。在RCU机制,读操作不会被阻塞,而写操作则通过延迟更新来避免对读操作的影响。然而,当某些情况下读操作被长时间阻塞时,就会发生RCU stall。下面是Linux内核RCU stall的机制的详细介绍: 1. RCU的快速路径:在正常情况下,RCU的快速路径允许读操作不受写操作的影响,从而实现高效并发访问。当一个线程进行读操作时,它会在读取共享数据之前增加一个引用计数,并在读操作完成后减少引用计数。这样,即使有其他线程正在进行写操作,读操作仍然可以顺利进行。 2. 写操作的延迟更新:当一个线程进行写操作时,它会创建一个新的数据版本,并将更新后的数据写入新版本。然后,它将原来的数据版本标记为废弃,并等待所有正在进行读操作的线程完成后才会释放废弃版本的内存资源。这种延迟更新的方式可以避免对读操作的影响。 3. RCU stall的触发:RCU stall通常在以下情况下触发: - 长时间的写操作:当一个写操作需要很长时间才能完成时,所有正在进行读操作的线程需要等待写操作完成才能继续进行读操作,这可能导致RCU stall的发生。 - 频繁的写操作:如果写操作频繁地竞争同一资源,如数据结构或共享变量,读操作的等待时间会增加,可能导致RCU stall。 - 更新操作的阻塞:当一个更新操作阻塞了RCU的快速路径,使得其他读操作无法顺利进行时,也会引发RCU stall。 4. RCU stall的处理:当RCU stall发生时,内核会通过一些机制来尝试解决或减轻RCU stall的影响,包括: - RCU回调函数处理机制:当RCU stall发生时,内核会通过回调函数处理机制来延迟执行一些需要被延迟的任务,如内存回收等,以减轻RCU stall的影响。 - RCU GP(Grace Period)的处理:GP是一个时间间隔,在这个时间间隔内,所有已经开始进行读操作的线程都可以继续执行,而不会被阻塞。内核会通过GP来确保读操作的一致性,并在GP结束后释放废弃版本的内存资源。 - RCU stall检测机制:内核会定期检测是否发生了长时间的阻塞,如果检测到长时间的阻塞,会尝试唤醒被阻塞的CPU,以解决RCU stall的问题。 通过以上机制Linux内核的RCU机制可以在大多数情况下实现高效并发访问共享数据。然而,在某些特殊情况下,如长时间的写操作或频繁的写操作竞争,可能会引发RCU stall。内核通过回调函数处理、GP处理和RCU stall检测等机制来处理和减轻RCU stall的影响,以保证系统的性能和响应时间。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值