一文搞懂 , Linux内核—— 同步管理(下)

127 阅读4分钟

上面讲的自旋锁,信号量和互斥锁的实现,都是使用了原子操作指令。由于原子操作会 lock,当线程在多个 CPU 上争抢进入临界区的时候,都会操作那个在多个 CPU 之间共享的数据 lock。CPU 0 操作了 lock,为了数据的一致性,CPU 0 的操作会导致其他 CPU 的 L1 中的 lock 变成 invalid,在随后的来自其他 CPU 对 lock 的访问会导致 L1 cache miss(更准确的说是communication cache miss),必须从下一个 level 的 cache 中获取。

这就会使缓存一致性变得很糟,导致性能下降。所以内核提供一种新的同步方式:RCU(读-复制-更新)。

RCU 解决了什么

RCU 是读写锁的高性能版本,它的核心理念是读者访问的同时,写者可以更新访问对象的副本,但写者需要等待所有读者完成访问之后,才能删除老对象。读者没有任何同步开销,而写者的同步开销则取决于使用的写者间同步机制。

RCU 适用于需要频繁的读取数据,而相应修改数据并不多的情景,例如在文件系统中,经常需要查找定位目录,而对目录的修改相对来说并不多,这就是 RCU 发挥作用的最佳场景。

RCU 例子

RCU 常用的接口如下图所示:

为了更好的理解,在剖析 RCU 之前先看一个例子:

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
#include <linux/slab.h>
#include <linux/spinlock.h>
#include <linux/rcupdate.h>
#include <linux/kthread.h>
#include <linux/delay.h>
 
struct foo {
        int a;
        struct rcu_head rcu;
};
 
static struct foo *g_ptr;
 
static int myrcu_reader_thread1(void *data) //读者线程1
{
        struct foo *p1 = NULL;
 
        while (1) {
                if(kthread_should_stop())
                        break;
                msleep(20);
                rcu_read_lock();
                mdelay(200);
                p1 = rcu_dereference(g_ptr);
                if (p1) 
                        printk("%s: read a=%d\n", __func__, p1->a);
                rcu_read_unlock();
        }
 
        return 0;
}
 
static int myrcu_reader_thread2(void *data) //读者线程2
{
        struct foo *p2 = NULL;
 
        while (1) {
                if(kthread_should_stop())
                        break;
                msleep(30);
                rcu_read_lock();
                mdelay(100);
                p2 = rcu_dereference(g_ptr);
                if (p2)
                        printk("%s: read a=%d\n", __func__, p2->a);
         
                rcu_read_unlock();
        }
 
        return 0;
}
 
static void myrcu_del(struct rcu_head *rh) //回收处理操作
{
        struct foo *p = container_of(rh, struct foo, rcu);
        printk("%s: a=%d\n", __func__, p->a);
        kfree(p);
}
 
static int myrcu_writer_thread(void *p) //写者线程
{
        struct foo *old;
        struct foo *new_ptr;
        int value = (unsigned long)p;
 
        while (1) {
                if(kthread_should_stop())
                        break;
                msleep(250);
                new_ptr = kmalloc(sizeof (struct foo), GFP_KERNEL);
                old = g_ptr;
                *new_ptr = *old;
                new_ptr->a = value;
                rcu_assign_pointer(g_ptr, new_ptr);
                call_rcu(&old->rcu, myrcu_del);
                printk("%s: write to new %d\n", __func__, value);
                value++;
        }
 
        return 0;
}
 
static struct task_struct *reader_thread1;
static struct task_struct *reader_thread2;
static struct task_struct *writer_thread;
 
static int __init my_test_init(void)
{
        int value = 5;
 
        printk("figo: my module init\n");
        g_ptr = kzalloc(sizeof (struct foo), GFP_KERNEL);
 
        reader_thread1 = kthread_run(myrcu_reader_thread1, NULL, "rcu_reader1");
        reader_thread2 = kthread_run(myrcu_reader_thread2, NULL, "rcu_reader2");
        writer_thread = kthread_run(myrcu_writer_thread, (void *)(unsigned long)value, "rcu_writer");
 
        return 0;
}
static void __exit my_test_exit(void)
{
        printk("goodbye\n");
        kthread_stop(reader_thread1);
        kthread_stop(reader_thread2);
        kthread_stop(writer_thread);
        if (g_ptr)
                kfree(g_ptr);
}
MODULE_LICENSE("GPL");
module_init(my_test_init);
module_exit(my_test_exit);

执行结果是:

myrcu_reader_thread2: read a=0
myrcu_reader_thread1: read a=0
myrcu_reader_thread2: read a=0
myrcu_writer_thread: write to new 5
myrcu_reader_thread2: read a=5
myrcu_reader_thread1: read a=5
myrcu_del: a=0

RCU 原理

可以用下面一张图来总结,当写线程 myrcu_writer_thread 写完后,会更新到另外两个读线程 myrcu_reader_thread1 和 myrcu_reader_thread2。读线程像是订阅者,一旦写线程对临界区有更新,写线程就像发布者一样通知到订阅者那里,如下图所示。

写者在拷贝副本修改后进行 update 时,首先把旧的临界资源数据移除(Removal);然后把旧的数据进行回收(Reclamation)。结合 API 实现就是,首先使用 rcu_assign_pointer 来移除旧的指针指向,指向更新后的临界资源;然后使用 synchronize_rcu 或 call_rcu 来启动 Reclaimer,对旧的临界资源进行回收(其中 synchronize_rcu 表示同步等待回收,call_rcu 表示异步回收)。

为了确保没有读者正在访问要回收的临界资源,Reclaimer 需要等待所有的读者退出临界区,这个等待的时间叫做宽限期(Grace Period)。

Grace Period

中间的黄色部分代表的就是 Grace Period,中文叫做宽限期,从 Removal 到 Reclamation,中间就隔了一个宽限期,只有当宽限期结束后,才会触发回收的工作。宽限期的结束代表着 Reader 都已经退出了临界区,因此回收工作也就是安全的操作了。

宽限期是否结束,与 CPU 的执行状态检测有关,也就是检测静止状态 Quiescent Status。

Quiescent Status

Quiescent Status,用于描述 CPU 的执行状态。当某个 CPU 正在访问 RCU 保护的临界区时,认为是活动的状态,而当它离开了临界区后,则认为它是静止的状态。当所有的 CPU 都至少经历过一次 Quiescent Status 后,宽限期将结束并触发回收工作。

因为 rcu_read_lock 和 rcu_read_unlock 分别是关闭抢占和打开抢占,如下所示:

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

\

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

所以发生抢占,就说明不在 rcu_read_lock 和 rcu_read_unlock 之间,即已经完成访问或者还未开始访问。

Linux 同步方式的总结