一、Linux为何会引入读写锁?
除了mutex,在linux内核中,还有一个经常用到的睡眠锁就是rw semaphore(后文简称为rwsem),它到底和mutex有什么不同呢?为何会有rw semaphore?无他,仅仅是为了增加内核的并发,从而增加性能而已。Mutex严格的限制只有一个thread可以进入临界区,但是实际应用中,有些场景对共享资源的访问可以严格区分读和写的,并且是读多写少,这时候,其实多个读的thread同时进入临界区是OK的,使用mutex则限制一个线程进入临界区,从而导致性能的下降。
本文会描述linux5.15.81中读写锁的数据结构和逻辑过程。
二、如何抽象读写锁的数据结构?
下图可以抽象rwsem相关的数据结构:
一个rwsem对象需要记录两种数据:
- 读写锁的状态信息
- 和该读写锁相关的任务信息
我们先看看读写锁的状态。读写锁状态字需要分别记录读锁和写锁的状态:由于多个reader可以同时处于临界区,所以对于reader-owned的场景,读锁状态变成了一个counter,来记录临界区内reader的数量,counter等于0表示读锁为空锁状态。对于writer,其行为和互斥锁一致,因此其写锁状态和mutex一样,仍然使用一个bit表示。
和读写相关的任务有两类,一类是已经持锁的线程(即在临界区的线程),另外一类是无法持锁而需要等待的任务。对于writer持锁情况,由于排他性,我们很清楚的知道是哪个task持锁,那么一个task struct指针就足够了记录owner了。然而对于读侧可以多个reader进入临界区,那么owner们需要组成一个队列才可以记录每一个临界区的reader。不过在实际的rwsem实现中,由于跟踪owner们开销比较大,因此也是用一个task struct指针指向其一。具体linux代码是这样处理的:reader进入的时候会设置owner task,但是离开读临界区并不会清除task指针。这样,实际上对于读,owner task应该表示该任务曾经拥有该锁,并不表示是目前持锁的owner task,也有可能已经离开临界区,甚至该任务已经销毁。
如果持锁失败,无法进入临界区,我们有两种选择:
- 乐观自旋
- 挂入等待队列
两种选择各有优点和缺点,总结如下:
在5.15的内核中,只有在write持锁路径上有乐观自旋的操作,reader路径没有,只有偷锁的操作。当乐观自旋失败后就会挂入等待队列,阻塞当前线程。(乐观自旋功能有一个很有意思的发展过程,从开始支持writer的乐观自旋,到支持全场景的乐观自旋,然后又回到最初,有兴趣可以查阅内核的patch了解详情)
在了解了rwsem的基本概念之后,我们一起来看看struct rw_semaphore数据结构,其成员描述如下:
由于是sleep lock,我们需要把等待的任务挂入队列。在内核中,struct rwsem_waiter用来抽象等待rwsem的任务,其成员描述如下:
三、Rwsem外部接口API为何?
Rwsem模块的外部接口API如下:
资料直通车:Linux内核源码技术学习路线+视频教程内核源码
四、尝试获取读锁
和down_read不一样,down_read_trylock只是尝试获取读锁,如果成功,那么自然是好的,直接返回1,如果失败,也不会阻塞,只是返回0就可以了。代码主逻辑在__down_read_trylock函数中,如下:
- tmp的初始值设定为RWSEM_UNLOCKED_VALUE(0值),因此第一次循环是为当前是空锁而做的优化:如果当前的sem->count等于0,那么给sem->count赋值RWSEM_READER_BIAS,标记持锁成功,然后设定owner返回1即可。
- 如果快速获取空锁不成功,这时候tmp已经赋值(等于sem->count),不再是0值了。通过对当前sem->count的值可以判断是否是可以进入临界区。持读锁失败的情况包括:
如果判断可以进入读临界区(临界区仅有reader并且没有writer等待的场景),那么重新进入循环,如果sem->count保持不变,那么可以持锁成功,给进入临界区的reader数目加一,并设置owner task和reader持锁标记(non-spinnable比特保持不变)。如果这期间有其他线程插入修改了count值,那么需要再次判断是否能持读锁,重复上面的循环。如果判断不可以进入临界区,退出循环,持锁失败。