Linux RCU机制+内存屏障

发布时间 2023-04-26 22:19:39作者: 流水灯

1. RCU

1.1 RCU 背景(读写锁的问题)

1.1.1 读写锁(写独占,读共享,写锁优先级高)

读写锁是另一种实现线程间同步的方式。

读写锁的特性为:写独占,读共享;写锁优先级高

读写锁是“写模式加锁”时, 解锁前,所有尝试对该锁进行加锁(不管是读锁还是写锁)的线程都会被阻塞;–> 写独占
读写锁是“读模式加锁”时, 如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。–> 读共享
读写锁是“读模式加锁”时, 既有试图以写模式加锁的线程,也有试图以读模式加锁的线程。
那么读写锁会阻塞随后的读模式锁请求,优先满足写模式锁。–> 写锁优先级高

1.1.2 读写锁的缺点(写独占时不可读)

写独占时,不可读

1.1.3 RCU 是对读写锁的优化/替换,解决读写互斥问题(随时读,写互斥)

为了彻底的解决读与写互斥问题,引入rcu。

随时可以拿到读锁,即对临界区的读操作随时都可以得到满足
某一时刻只能有一个人拿到写锁,多个写锁需要互斥。
写的动作包括 拷贝–修改–宽限窗口到期后删除原值

1.2 RCU 定义(读,拷贝更新: 随时读,写互斥)

RCU :(Read-Copy Update) ,是 Linux 中比较重要的一种同步机制。

“读,拷贝更新”
通俗理解:“随意读,但更新数据的时候,需要先复制一份副本,在副本上完成修改,再一次性地替换旧数据”

这是 Linux 内核实现的一种针对“读多写少”的共享数据的同步机制。

1.3 RCU 应用场景(链表)

1.3.1 多读少写的情况:典型应用场景 - 链表

RCU 的一个典型的应用场景是链表,目的是提高遍历读取数据的效率。

为了达到目的使用RCU机制读取数据的时候不对链表进行耗时的加锁操作。
这样在同一时间可以有多个线程同时读取该链表,并且允许一个线程对链表进行修改(修改的时候,需要加锁)。

1.4. 宽限期

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

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

1.4.1 例子说明宽限期

本例子:多个线程(读取线程foo_read,写线程foo_update)对全局对象 gbl_foo 进行操作,宽限期如何保证数据同步。

struct foo {  
       int a;  
       char b;  
       long c;  
};  

DEFINE_SPINLOCK(foo_mutex);  //互斥锁

struct foo *gbl_foo;  //全局对象

//读线程操作,读取
void foo_read(void)  
{  
    rcu_read_lock();  //标记RCU读过程的开始
    foo *fp = gbl_foo;  
    if ( fp != NULL )  
            dosomething(fp->a,fp->b,fp->c);  
    rcu_read_unlock();  //标记RCU读过程的结束
}  

//写线程操作,修改
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),极有可能造成coredump
        
        总结:必须等待所有在宽限期开始前已经开始的读线程结束,才可以进行销毁操作
        */
    kfee(old_fp);  
}    

1.5 RCU 优缺点

RCU的优点:性能、不会死锁,以及良好的实时延迟。

RCU的缺点:

对于写者而言,它的开销很比读写锁大,他需要延迟数据结构的释放以及复制被修改的数据结构
写者与写者之前还需要采取额外的锁机制来互斥其他写者的并行操作(一般都是 spinlock)

1.6 RCU 机制实现的一个基本前提:不能被阻塞

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

2. 设计思想(新老指针替换,避免加锁)

通过新老指针替换的方式来实现免锁方式的共享保护

2.1 设计思想具体实现:

1、rcu_read_lock 关闭内核可抢占性,允许中断

RCU读取侧进入临界区的标志是调用rcu_read_lock,实质性的工作由第一个函数__rcu_read_lock()来完成,__rcu_read_lock()通过调用 preempt_disable()关闭内核可抢占性。但是中断是允许的。

2、读过程发生中断

假设读取者正处于rcu临界区中且刚读取了一个共享数据区的指针p(但是还没有访问p中的数据成员),发生了一个中断,而该中断处理例程ISR恰好需要修改p所指向的数据区。

3、RCU的设计原则:拷贝数据

按照RCU的设计原则,ISR会新分配一个同样大小的数据区new_p,再把老数据区p中的数据拷贝到新数据区,接着是在new_p的基础上做数据修改的工作(因为是在new_p空间中修改,所以不存在对p的并发访问,因此说RCU是一种免锁机制,原因就在这里)

4、修改旧指针

ISR在把数据更新的工作完成后,将new_p赋值给p(p=new_p),最后它会再注册一个回调函数用以在适当的时候释放老指针p。因此,只要对老指针p上的所有引用都结束了,释放p就不会有问题。

5、释放旧指针

当中断处理例程做完这些工作返回后,被中断的进程将依然访问到p空间上的数据,也就是老数据,这样的结果是RCU机制所允许的。

2.2 何时才能释放老指针

2.2.1 当系统中所有处理器上都发生了一次进程切换,释放老指针

原因:临界区不能发生进程切换

所有对老指针的引用只可能发生在rcu_read_lock与rcu_read_unlock所包括的临界区中,而在这个临界区中不可能发生进程切换,而一旦出了该临界区就不应该再有任何形式的对老指针p的引用。

rcu_read_lock关闭内核可抢占性

很明显,这个规则要求读取者在临界区中不能发生进程切换,因为一旦有进程切换,释放老指针的回调函数就有可能被调用,从而导致老指针被释放掉,当被切换掉的进程被重新调度运行时它就有可能引用到一个被释放掉的内存空间。

因为它使得即便在临界区中发生了中断,当前进程也不可能被切换除去

2.2.2 Linux内核提供两种方法供使用者使用,异步call_rcu,同步synchronize_rcu

在释放老指针方面,Linux内核提供两种方法供使用者使用,一个是调用call_rcu,另一个是调用synchronize_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知道它现在可以释放老指针了。

2.2.3 all_rcu 伪代码实现说明

以文件名的例子来说明:
1.文件节点 FILE_NODE_S 结构体:

typedef struct tagFileNode
{
    /* 新建节点时赋值 */
    CHAR szFileName[FILENAME_LEN_MAX]; /* 文件名 */ 
    CHAR* pcFilePath;                  /* 文件绝对路径 */
    ……
    RCU_REG_S   stRcuInfo;
    spinlock_t  stFileNodeLock;     /* 节点锁 */
    ……
}FILE_NODE_S;

2.rcu 节点 RCU_REG_S 结构体

typedef struct tagRCU_REG_S
{
    struct tagRCU_REG_S *stNode;
    RCU_CALLBACK_PF pfCallback;
}RCU_REG_S;

3.rcu 回调函数实现(就是free 节点)

/* rcu 回调函数实现 */
STATIC VOID _File_Node_RcuFree(INOUT RCU_REG_S* pstRegData)
{
    FILE_NODE_S *pstFileNode = NULL;

    pstFileNode = container_of(pstRegData, FILE_NODE_S, stRcuInfo);
    
    if (NULL != pstFileNode->pcFilePath)
    {
        kfree(pstFileNode->pcFilePath);
        pstFileNode->pcFilePath = NULL;
    }

    kfree(pstFileNode);

    return;
}

4.RCU_Call 封装调用(就是调用注册的回调函数)

VOID RCU_Call (RCU_REG_S* pstRegData)
{
    if (NULL != pstRegData->pfCallback)
    {
        pstRegData->pfCallback(pstRegData);
    }
    return;
}

5.rcu回调注册和调用场景

创建节点的时候注册rcu回调
FILE_NODE_S* pstFileNode = NULL;
/* 其他赋值操作 */
pstFileNode->stRcuInfo.pfCallback = _File_Node_RcuFree;

…… ……
…… ……

/* 使用结束删除节点成员的最后调用 rcu回调 */
/* 节点在系统中所有处理器上都发生了一次进程切换,才进行回调释放节点 */
RCU_Call(&pstFileNode->stRcuInfo);

3. 内存屏障

内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念

3.1 内存乱序访问主要发生在两个阶段:

编译时,编译器优化导致内存乱序访问(指令重排)
运行时,多 CPU 间交互引起内存乱序访问

3.2 内存屏障背景:编译器的优化和cpu乱序

处理器为提高运算速度而做出的违背代码原有顺序的优化。

例如:a=10,b=20,result=a+b,正常顺序先执行a,再执行b,最后执行a+b,但假如a不在缓存中,b在缓存中,因为a不在缓存中,需要从主内存读取,这样b=20的操作就需要等待a执行完,CPU为了提高效率,先执行b=20,再执行a=10,最后执行a+b,提高执行效率。

问题:
CPU乱序执行在多线程环境下,就会出现数据不一致的问题

如: 开发中出现
由于编译器优化导致多核多线程中有的使用还未初始化的自旋锁导致死锁或者踩空指针。

3.2.1 内存屏障作用:解决编译器的优化和CPU的乱序执行

内存屏障主要解决的问题是编译器的优化和CPU的乱序执行。

内存屏障的作用:
1.在有内存屏障的地方,会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序。
2.在有内存屏障的地方,线程修改完共享变量以后会马上把该变量从本地内存写回到主内存,并且让其他线程本地内存中该变量副本失效(使用MESI协议)

编译器在优化的时候,生成的汇编指令可能和c语言程序的执行顺序不一样,在需要程序严格按照c语言顺序执行时,需要显式的告诉编译不需要优化,这在linux下是通过barrier()宏完成的,它依靠volidate关键字和memory关键字,前者告诉编译barrier()周围的指令不要被优化,后者作用是告诉编译器汇编代码会使内存里面的值更改,编译器应使用内存里的新值而非寄存器里保存的老值。

3.3 内存屏障

CPU乱序执行在多线程环境下,就会出现数据不一致的问题,因此就可以通过内存屏障这个机制来处理这个问题。

smp_rmb(): 读内存屏障。在invalid queue的数据被刷完之后再执行屏障后的读操作。
smp_wmb(): 写内存屏障。在store buffer的数据被刷完之后再执行屏障后的写操作。
smp_mb(): 同时具有读屏障和写屏障功能

3.3.1 写内存屏障store(smp_wmb) 和 读内存屏障load(smp_rmb)

1.写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,能让写入缓存中最新数据更新写入主内存中,让其他线程可见。强制写入主内存,这种显示调用,不会让CPU去进行指令重排序

2.读内存屏障(Load Memory Barrier):在指令后插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存中加载数据。也是不会让CPU去进行指令重排。

Linux操作系统将写屏障指令封装成了smp_wmb()函数

3.3.2 smp_wmb 实现

cpu执行smp_mb()的思路是,会先把当前store buffer中的数据刷到cache之后,再执行屏障后的“写入操作”

3.3.3 例子说明:

    ……
    spin_lock_init(stLock);
     /* 加屏障防止被编译器优化导致加入未初始化指针 */
    smp_wmb();
    ……

3.4 rcu锁多线程中必要时加内存屏障处理

3.5 内存屏障API

方法    描述
rmb()    读内存屏障,阻止跨越屏障的载入动作发生重排序
wmb()    写内存屏障,阻止跨越屏障的存储动作发生重排序
mb()    读写内存屏障,阻止跨越屏障的载入和存储动作重新排序
smp_rmb()    在SMP上提供rmb()功能,在UP上提供barrier()功能
smp_wmb()    在SMP上提供wmb()功能,在UP上提供barrier()功能
smp_mb()    在SMP上提供mb()功能,在UP上提供barrier()功能
barrier()    阻止编译器跨越屏障对载入或存储操作进行优化
read_barrier_depends()    阻止跨越屏障的具有数据依赖关系的载入动作重排序
smp_read_barrier_depends()    在SMP上提供read_barrier_depends()功能,在UP上提供barrier()功能

4. 使用rcu注意事项

4.1 rcu锁中不能睡眠操作

如果睡眠那么RCU的设计规则就遭到了破坏,系统将进入一种不稳定的状态。

5.内存屏障在RCU上的应用:__list_add_rcu

static inline void list_add_rcu(struct list_head *new, 
                                struct list_head *head)
{
    __list_add_rcu(new, head, head->next);
}

static inline void __list_add_rcu(struct list_head * new,
                                  struct list_head * prev, 
                                  struct list_head * next)
{
    new->next = next; //a
    new->prev = prev;
    smp_wmb();    /* 保证上面的指针操作a一定在线面的指针操作b之前执行
                   目的是防止优化后,下面的执行b,导致new指NULL或链表断开
                   导致最后踩空/异常 */
                   
    next->prev = new; //b
    prev->next = new;
}

5.1 smp_wmb() : 就是保证此行前面的代码先执行,后面的后执行。今次而已

说明:
能保证处于内存屏障两边的内存操作满足部分有序(通俗说就是前面先于后面的执行,按序执行不能优化)
译注: 这里"部分有序"的意思是, 内存屏障之前的操作都会先于屏障之后的操作, 但是如果几个操作出现在屏障的同一边, 则不保证它们的顺序

5.2 rcu和内存屏障关系:一般rcu单独使用或者和内存屏障一起使用