RCU的全称是Read-Copy-Update,意即读丐一复制一更新,在Linux提供的所有内核互斥设施当中属于一种免锁机制·同前面讨论过的读取者与写入者自旋锁rwlock、读取者与写入者信号量rwsem以及顺序锁seqlock一样,RCU的适用模型也是读取者与写入者共存的系统。与rwlock、rwsem和seqlock不同的是,RCU中的读取和写入操作无须考虑两者之间的互斥问题·通过前面的讨论我们知道,加锁与解锁都要涉及内存操作,同时还什有内存屏障方法的引入,这些都使得锁櫟作的系统开销变得很大·在此基础上,Linux内核加入了对RCU这种免锁的互斥访问机制的支持·虽然在设备驱动程序中使用RCU的机会很少,但是通过对RCtJ的讨论以及与其他加锁机制的对比,可以更深入理解Linux内核为设备驱动程序提供的这些内核设施各自的利弊。
RCU并不是很新的概念,但是Linux内核直到2,5版本才开始引入这种机制·其核心思想讲起来也许并不复杂,但是从Linux内核中相关源码的设计看来,还是很晦涩难懂的·限于篇幅的原因,本书并不打算在源码的层面上详细分析其实现过程。
RCU的原理简单地说.是将读取者和写入者要访问的共享数据放在一个指针p中,读取者通过p来访问其中的数据,而写入者则通过修改p来更新数据·在具体的实现上,读取者一方并没有太多的事要做,大量的工作集中在写入者一方·免锁的实现必定要通过双方恪守一定的规则才可达成。
1 读取者的RCU临界区
对于读取者来说,如果要访问共享数据,所要做的工作首先是调用rcu_read_lock和rcu_read_unlock函数构建自己所谓的读取者側的临界区,然后在临界区中获得指向共享数据区的指针,实际的读取操作就是对该指针的引用·这里对于读取者的一个明确的规则是,对指针的引用必须在临界区中完成·离开临界区之后不应该出现任何形式的对该指针的引用·在临界区中,关闭内核的可抢占性意味着在临界区中不会因为中断的发生导致进程的切换·而且作为确定的规则,临界区中的代码不能发生睡眠。简言之,临界区中的代码不应该导致任何形式的进程切换,虽然函数的名称中含有lock字样,但是rcu_read_lock和rcu_read_unlock实际要做的工作仅仅是分别关闭和打开内核的可抢占性而己。
2 写入者的RCU操作
RCU操作中写入者要完成的工作是重新分配一个被保护的共享数据区,〈视具体情况决定是否)将老数据区的数据复制到新数据区,然后再根据需要修改新数据区,最后用新数据区指针替换掉老的指针,替换指针的操作是一个原子操作,不需要与读取名进行互斥操作。在写入者做完这些工作之后·后续的所有RCU的读取操作都将访问到这个新的共享数据区,但是写入者在用新指针替换掉老指针之后还不能马上释放老指针指向的数据区所占用的内存空间,这是因为系统中还可能存在对老拒针的引用。这主要发生在如下两种情况:一是在单处理器的范围看,假设读取者在进入RCU临界区后,刚获得共享区的指针之后发生了一个中断(因为rcu_read_lock只是关闭了内核可抢占性,并没有关闭本地的中断),如果写入者恰好是中断处理函数中的行为,那么当中断返回后,被中断进程在RCU临界区中继续执行时,将会继续引用老指针:另一个可能是在多处理器系统.当处理器A上的个读取者进入RCU临界区并获得共享数据区中的指针后,在其还没来得及引用该指针时,处理器B上的一个写入者更新了指向共享数据区的指针,这样处理器A上的读取者也将引用到老指针。
因此·写入者在替换掉共享区的指针后,老指针所指向的共享数据区所在的空间还不能马上释放·写入者需要和内核共同协作,在确定所有对老指针的引用都结束后才可以释放老指针指向的内存空间·为此,写入者在用新指针替换掉老指针之后需要的噪作是,调用call数向内核注册一个回调函数,内核在确定所有对老指针的引用都结束时会调用该回调函数,回调函数的功能则主要是释放老指针指向的内存空间·下面是call_rcu的原型:
void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *rcu)) { __call_rcu(head, func, &rcu_preempt_state, -1, 0); }
RCU的写入者负责在替换掉老指针之后调用call_rcu向内核注册一回调函数,回调函数负责实现释放老指针指向的内存空间,call_rcu中的参数№就是指向该回调函数的指针·函数中的head是内核在调用func时传递到中的参数。实际的使用中,会把struct rcu_head内嵌到共享数据所在的结构体中,这样在回调函数中可以通过传进来的struct rcu_head指针,使用container_of宏获得指向旧的共享数据区的指针,然后调用kfree释放旧的数据区。
关于回调函数被调用的时机,内核必须确保没有对老指针的引用时才能调用回调函数释放老指针·内核确保没有读取者对老指针的引用是基.于以下规则:所有可能的对共享数据区指针的不一致引用一定是发生在读取者的RCU临界区(RCU的一条明确的規则是,离开临界区之后不应该出现任何形式的对该指针的引用),因为临界区由rcu_read_lock和rcu_read_unlock界定,所以就单处理器范围曲言·在临界区中一定不会发生进程的切换(rcu_read_lock将会关闭内核的可抢占性,这也是读取者在其临界区中的代码一定不会出现进程切换的原因),所以如果在某一CPU上发生了一次进程切换,那么所有对老指针的引用都会结束,之后的读取者再进入RCU临界区都将看到新指针。因此,内核确定没有对老指针的引用的条件是:系统中所有处理器上都至少发生了一次进程切换。
3 RCU使用的特点
通过前面对RCU读取者与写入者操作的讨论,可以看到RCU实质上是对读取者与写入者自旋锁rwlock的一种优化:RCU的读取者在读取数据时除了关闭内核可抢占性外,与普通数据的读取操作没有任何区别,读取者也不关心当前有没有写入者正在对共享数据区进行操作:而对于rwlock,在读取者打算工作时,必须确保没有写入者正在工作,否则读取进程将进入自旋状态·所以RCU可以让多个读取者与写入者同时工作·相对于读取者,RCU写入者的开销比较大,它需要申请新的内存空间,正常的数据更新操作,向内核注册回调函数,同时也要考虑与其他写入者之间的互斥问题,但是与rwlock不一样的是,写入者不需要考虑与读取者的互斥问题。
可见,RCU读取者性能的提升是在增加写入者负担的前提下完成的·因此在一个读取者与写入者共存的系统中,按照设计者的说法·如果写入者的操作比例在10%。以上,那么就应该考虑其他的互斥方法,反之采用RCU的实现可以获得更高的性能·另外,RCU的设计思想决定了必须要以指针的方式来访问被保护資源。为了在代码中使用RCU,所有RCU相关的操作都应该使用内核提供的RCU API数,以确保RCU机制的正确使用·这些API主要集中在指针和链表的操作。
下面是一个RCU的典型用法范例:
//假设struct shared_data是一个再读取者和写入者之间共享的受保护的资源 struct shared_data{ int a; int b; struct rcu_head rcu; }; //读取者的代码,读取者调用rcu_read_lock和rcu_read_unlock构建它的读取临界区 //所有对指向被保护资源指针的引用都因该只在灵界去中出现,而且临界区中代码不能睡眠 static void demo_reader(struct shared_data *ptr){ struct shared_data *p=NULL; rcu_read_lock(); //调用rcu_dereference获得ptr指针 p=rcu_dereference(&ptr); if(p){ do_something_with(p); } rcu_read_unlock; } //写入者的代码 //写入者提供的回调函数,用于释放老指针 static void demo_del_oldptr(struct shared_data *rh){ struct shared_data *p=container_of(rh,struct shared_data,rcu); kfree(p); } static void demo_writer(struct shared_data *ptr){ struct shared_data *new_ptr=kmalloc(...); .... new_ptr->a=10; new_ptr->b=20; //用新指针更新老指针 rcu_assign_pointer(ptr,new_ptr); //用call_rcu让内核在确保所有对老指针ptr的引用都结束后面回调demo_del_oldptr //释放老指针 call_rcu(ptr->rcu,demo_del_oldptr); }
上面的例子中.写入者在调用rcu_assign_pointer更新了老指针之后,为了在所有对老指针的引用都消失后释放老指针指向的空间,使用call_rcu向系统注册了一个回调函数demo_del_oldptr,系统将在确定没有对老指针的引用之后调用该函数。另一个类似的函数是synchronize_rcu这个函数可能会阻塞,因为它要等待所有对老指针的引用都结束时才返回,函数返回意味着系统中所有对老指针的引用都消失了,此时再释放老指针的空间是安全的。如果在中断上下文中执行写入者的操作,那么就不能使用synchronize_rcu,而应该使用call_rcu。