互斥锁 mutex:
在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。
加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。
如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被编程就绪状态,
第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。
在这种方式下,只有一个线程能够访问被互斥锁保护的资源。
自旋锁spinlock:
自旋锁的使用模式和互斥锁很类似。 只是在加锁后,有线程试图再次执行加锁操作的时候,该线程不会阻塞,而处于循环等待的忙等状态(CPU不能够做其他事情)。
所以自旋锁适用的情况是:锁被持有的时间较短,而且进程并不希望在重新调度上花费太多的成本。
读写锁 rwlock(也叫作共享互斥锁:读模式共享,写模式互斥):
读写锁有三种状态:读加锁状态、写加锁状态和不加锁状态
一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。(这也是它能够实现高并发的一种手段)
当读写锁在写加锁模式下,任何试图对这个锁进行加锁的线程都会被阻塞,直到写进程对其解锁。
当读写锁在读加锁模式先,任何线程都可以对其进行读加锁操作,但是所有试图进行写加锁操作的线程都会被阻塞,直到所有的读线程都解锁。
所以读写锁非常适合对数据结构读的次数远远大于写的情况。
如果严格按照上述读写锁的操作进行的话,那么当读者源源不断到来的时候,写者总是得不到读写锁,就会造成不公平的状态。
一种避免这种不公平状态的方法是:
当处于读模式的读写锁接收到一个试图对其进行写模式加锁操作时,便会阻塞后面对其进行读模式加锁操作的线程。
这样等到已经加读模式的锁解锁后,写进程能够访问此锁保护的资源。
RCU锁(Read-Copy Update):读-复制 更新
实际上是对读写锁的一种改进,同样是对读者线程和写者线程进行区别对待,只不过对待的方式是不同的。
读写锁中只允许多个读者同时访问被保护的数据,但是**在RCU中允许多个读者和多个写者同时访问被保护的资源。**写者的同步开销则取决于使用的写者间同步机制,RCU并不对此进行支持。
RCU中,读者不需要使用锁,要访问资源尽管访问就好了。
RCU中,写者的同步开销比较大,要等到所有的读者都访问完成了才能够对被保护的资源进行更新。
写者修改数据前首先拷贝一个被修改元素的副本,然后在副本上进行修改,修改完毕后它向垃圾回收器注册一个回调函数以便在适当的时机执行真正的修改操作。
读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。
写者要从链表中删除元素 B,它首先遍历该链表得到指向元素 B 的指针,然后修改元素 B 的前一个元素的 next 指针指向元素 B 的 next 指针指向的元素C,修改元素 B 的 next 指针指向的元素 C 的 prep 指针指向元素 B 的 prep指针指向的元素 A,在这期间可能有读者访问该链表,修改指针指向的操作是原子的,所以不需要同步,而元素 B 的指针并没有去修改,因为读者可能正在使用 B 元素来得到下一个或前一个元素。写者完成这些操作后注册一个回调函数以便在 grace period 之后删除元素 B,然后就认为已经完成删除操作。垃圾收集器在检测到所有的CPU不在引用该链表后,即所有的 CPU 已经经历了 quiescent state(上下文切换),grace period(所有的CPU都经历了一次上下文切换) 已经过去后,就调用刚才写者注册的回调函数删除了元素 B。
适用于网络路由表的查询更新、设备状态表的维护、数据结构的延迟释放以及多径I/O设备的维护
深入理解RCU实现
首先,上一节提到,写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。而这个“适当的时机”就是所有CPU经历了一次进程切换(也就是一个grace period宽限期)。为什么这么设计?因为RCU读者的实现就是抢占执行读取,读完了当然就可以进程切换了,也就等于是写者可以操作临界区了。那么就自然可以想到,内核会设计两个元素,来分别表示写者被挂起的起始点,以及每cpu变量,来表示该cpu是否经过了一次进程切换(quies state)。就是说,当写者被挂起后,
1)重置每个cpu变量,值为0。
2)当某个cpu经历一次进程切换后,就将自己的变量设为1。
3)当所有的cpu变量都为1后,就可以唤醒写者了。
下面我们来分别看linux里是如何完成这三步的。
我们从一个例子入手,这个例子来源于linux kernel文档中的whatisRCU.txt。这个例子使用RCU的核心API来保护一个指向动态分配内存的全局指针。
struct foo {
int a;
char b;
long c;
};
DEFINE_SPINLOCK(foo_mutex);
struct foo *gbl_foo;
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 = gbl_foo;
*new_fp = *old_fp;
new_fp->a = new_a;
rcu_assign_pointer(gbl_foo, new_fp);
spin_unlock(&foo_mutex);
synchronize_rcu();
kfree(old_fp);
}
int foo_get_a(void)
{
int retval;
rcu_read_lock();
retval = rcu_dereference(gbl_foo)->a;
rcu_read_unlock();
return retval;
}
如上代码所示,RCU被用来保护全局指针struct foo *gbl_foo。foo_get_a()用来从RCU保护的结构中取得gbl_foo的值。而foo_update_a()用来更新被RCU保护的gbl_foo的值(更新其a成员)。
首先,我们思考一下,为什么要在foo_update_a()中使用自旋锁foo_mutex呢? 假设中间没有使用自旋锁.那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);
old_fp = gbl_foo;
1:-------------------------
*new_fp = *old_fp;
new_fp->a = new_a;
rcu_assign_pointer(gbl_foo, new_fp);
synchronize_rcu();
kfree(old_fp);
}
假设A进程在上图----标识处被B进程抢点.B进程也执行了goo_ipdate_a().等B执行完后,再切换回A进程.此时,A进程所持的old_fd实际上已经被B进程给释放掉了.此后A进程对old_fd的操作都是非法的。所以在此我们得到一个重要结论:RCU允许多个读者同时访问被保护的数据,也允许多个读者在有写者时访问被保护的数据。但是注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制。
说明:本文中说的进程不是用户态的进程,而是内核的调用路径,也可能是内核线程或软中断等。
另外,我们在上面也看到了几个有关RCU的核心API。它们为别是:
- rcu_read_lock()
- rcu_read_unlock()
- synchronize_rcu()
- rcu_assign_pointer()
- rcu_dereference()
其中,rcu_read_lock()和rcu_read_unlock()这两个函数用来标记一个RCU读过程的开始和结束。其实作用就是帮助检测宽限期(Grace period)是否结束。
synchronize_rcu():这是RCU的核心所在,调用该函数意味着一个宽限期的开始,而直到宽限期结束,该函数才会返回。它挂起写者,等待读者都退出后释放老的数据。
rcu_dereference():读者调用它来获得一个被RCU保护的指针。
rcu_assign_pointer():写者使用该函数来为被RCU保护的指针分配一个新的值。
总结
在多线程场景下,我们经常需要并发访问一个数据结构,为了保证线程安全我们会考虑使用锁来进行同步:
-
互斥锁 mutex:同一时刻,只有一个线程能够访问被互斥锁保护的资源。
-
自旋锁spinlock:与互斥锁类似,只是自旋锁不会引起调用者睡眠。有线程试图再次执行加锁操作的时候,处于循环等待的忙等状态
-
读写锁 rwlock:读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,同一时间可以多线程对同一共享资源的访问,但是不能同时对该资源数据进行修改。
-
rcu(read-copy-update)
实际上是对读写锁的一种改进,同样是对读者线程和写者线程进行区别对待,只不过对待的方式是不同的。 读的时候正常读,写的时候,先加锁拷贝一份,然后进行写,写完就原子的更新回去。写操作不需要加锁,避免了频繁加读写锁本身的性能开销。
Linux 内核RCU 参考QSBR算法设计一套无锁同步机制
- 多个读者可以并发访问共享数据,而不需要加锁;
- 写者更新共享数据时候,需要先copy副本,在副本上修改,最终,读者只访问原始数据,因此他们可以安全地访问数据,多个写者之间是需要用锁互斥访问的(比如用自旋锁);
- 修改资源后,需要更新共享资源,让后面读者可以访问最新的数据;
- 等旧资源上所有的读者都访问完毕后,就可以回收旧资源了
实现思路—— 读写回收实现思路
RCU 的关键思想有两个:
1)复制后更新;
2)延迟回收内存。
1、对于读操作,可以直接对共享资源进行访问,但是前提是需要CPU支持访存操作的原子化,现代CPU对这一点都做了保证。但是RCU的读操作上下文是不可抢占的(这一点在下面解释),所以读访问共享资源时可以采用read_rcu_lock(),该函数的工作是停止抢占。
2、对于写操作,其需要将原来的老数据作一次备份(copy),然后对备份数据进行修改,修改完毕之后再用新数据更新老数据,更新老数据时采用了rcu_assign_pointer()宏,在该函数中首先屏障一下memory,然后修改老数据。这个操作完成之后,需要进行老数据资源的回收。操作线程向系统注册回收方法,等待回收。采用数据备份的方法可以实现读者与写者之间的并发操作,但是不能解决多个写者之间的同步,所以当存在多个写者时,需要通过锁机制对其进行互斥,也就是在同一时刻只能存在一个写者。
3、在RCU机制中存在一个垃圾回收的daemon,当共享资源被update之后,可以采用该daemon实现老数据资源的回收。回收时间点就是在update之前的所有的读者全部退出。由此可见写者在update之后是需要睡眠等待的,需要等待读者完成操作,如果在这个时刻读者被抢占或者睡眠,那么很可能会导致系统死锁。因为此时写者在等待读者,读者被抢占或者睡眠,如果正在运行的线程需要访问读者和写者已经占用的资源,那么死锁的条件就很有可能形成了。