摘要
本文介绍了一种使用了RAII技术的自旋锁,配合Rust的生命周期及所有权机制,能够在减少代码量的同时,很好的解决自旋锁的“忘记放锁”、“双重释放”、“未加锁就访问”的并发安全问题。并且这种自旋锁能够支持编译期的检查,任何不符合以上安全要求的代码,将无法通过编译。
前言
对于许多编程语言默认提供的锁,加锁、放锁需要手动进行。手动加锁可以理解(这不废话嘛),但是,手动放锁的时机,总是难以控制。比如:在临界区内,执行过程中,如果程序出错了,在异常处理的过程中,忘记放锁,那么就会造成其他进程无法获得这个锁。传统的做法就是,人工寻找所有可能的异常处理路径,添加放锁的代码。这样做的话,能解决问题,但非常的繁琐,尤其是有多个锁的时候,更加如此。
并且,对于传统的语言,还可能存在锁的“双重释放”的问题,也就是:一个锁被进程A释放后,进程B对其加锁,接着,进程A的错误代码,执行了放锁操作,导致进程B的锁被过早地释放。这样的问题,当我们发现的时候,可能已经不是第一现场了,debug很困难。
并且,对于大部分的语言,锁与它所要保护的数据,并没有一种机制,告诉编译器/解释器:“这个锁,保护的就是这个数据对象”。因此,编译器很难检查出“未加锁就访问”的bug,程序员会经常犯这种错误(尤其是对于新手程序员,很难处理好锁的问题)。这样的代码,编译器无法保证其并发安全。
对于Rust,借助其生命周期、所有权机制,我们能够与RAII技术进行结合,能实现一种新的自旋锁,从而轻松解决以上的问题。
在DragonOS中,实现了具有守卫的自旋锁,能够解决以上的问题,让新手程序员也能很容易的管理自旋锁。这样写出来的代码只要能够通过编译器的检查(就是能够编译通过),那么就不用担心以上提到的并发安全问题。本文将基于DragonOS中实现的自旋锁进行讲解。
具体的代码链接:http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/libs/spinlock.rs?r=ec53d23e#137
什么是RAII技术?
RAII,全称资源获取即初始化(英语:Resource Acquisition Is Initialization),它是在一些面向对象语言中的一种习惯用法。RAII源于C++,在许多的编程语言中都有应用。
RAII要求,资源的有效期与持有资源的对象的生命周期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄漏问题。
思路
由于Rust在语言层面就实现了生命周期与所有权机制,因此,能够很好的实现RAII,并且能够支持编译期检查,不符合安全要求的代码,将无法通过编译。我们的思路是:把要保护的数据的所有权,交给对应的锁来管理,不再需要程序员来手动管理“锁——被锁保护的数据”的关系。也就是说,这个自旋锁,拥有要保护的数据的所有权,其他的地方需要访问被保护的数据,都需要从自旋锁申请借用这个变量,获得可变引用/不可变引用。这个访问的权限,不是直接给到要用到数据的函数内的局部变量的,而是由一个叫做“守卫”的对象负责持有权限。访问数据时,都要经过这个守卫(请注意,得益于Rust的“零成本抽象”,这是没有运行时开销的)。当守卫变量的生命周期结束,其析构函数就执行“放锁”的动作。
自旋锁出借自己保护的数据的访问权限时,会执行加锁的动作,然后返回一个守卫。请注意,守卫只会在“自旋锁加锁成功”后被初始化。因此,对于一个自旋锁,最多存在1个守卫。并且,只要守卫的生命周期没有结束,我们都能通过这个守卫,来访问被保护的数据。
那么,我们来小结一下,基于RAII+所有权+生命周期机制的自旋锁,解决以上问题的途径:
- 忘记放锁/出现异常退出时,未放锁:一旦守卫的生命周期结束,就会在析构函数中进行放锁。
- “双重释放“问题:所有放锁操作只能由守卫对象的析构函数进行。由于守卫对象最多同时刻只有1个,并且,由于守卫对象只要生命周期没有结束,那么锁一定是被获取到的。因此避免了“双重释放”的问题。
- “未加锁就访问被保护的数据“的问题:由于被保护的数据,其所有权属于自旋锁,并且是一个私有的字段。进程只能通过守卫来访问被保护的数据。而要获得守卫的方式只有1种:成功加锁。因此,它能解决“未加锁就访问”的问题。任何想要“不加锁就访问”的代码,都无法通过编译器的检查。
实现
上面说了思路,那么我们接下来就结合具体的代码,来介绍一下它的实现:
结构体定义
下图是SpinLock及其守卫的定义:
对于SpinLock,其内部包含两个私有的成员变量:
- lock:这是一个RawSpinlock,具体功能与其他语言的自旋锁一致,需要手动加锁、放锁,具有自旋锁的最基本功能。不具备编译期的并发安全检查的特性。
- data:这个字段是自旋锁保护的数据。在自旋锁被初始化时,要被保护的数据,会被放到这个UnsafeCell中。请注意,UnsafeCell支持内部可变性,也就是说,被保护的数据的值可以被修改。
对于SpinLockGuard这个守卫,它只有1个成员变量,也就是SpinLock的不可变引用。并且,SpinLockGuard没有构造器,它只能通过SpinLock的lock()方法,在加锁后产生。
SpinLock实现
SpinLock只具有两个成员方法:new()和lock()。如下图所示:
- new()方法:初始化lock字段,并且将数据放入data字段。请注意,由于传入的value不是引用,因此,value的所有权,在new()函数结束后,被移动到了data字段中。程序的其他部分,不再拥有这个value的所有权。在外部的其他函数中,任何尝试访问value的行为,都会被编译器阻止。
- lock()方法:本方法先对自旋锁进行加锁,然后返回一个守卫。请注意,lock()函数是唯一的获得守卫的途径。
同时,我们为SpinLock实现Sync这个Trait,这样,编译器就知道,SpinLock是线程安全的,它能在几个线程之间共享。(当然,我们要求T是实现了Send Trait的,因为只有这样,才意味着它能从一个进程发送给另一个进程)
SpinLockGuard实现
SpinLockGuard的实现也很简单,我们为它实现了3个trait: Deref、DerefMut、Drop。
- Deref:当我们访问SpinLockGuard时,相当于访问被自旋锁保护的变量(不可变引用)
- DerefMut:当我们访问SpinLockGuard时,相当于访问被自旋锁保护的变量(可变引用)
- Drop:当SpinLockGuard的生命周期结束时,将会自动释放锁。
如何使用这样的自旋锁?
与传统的SpinLock需要反复确认变量在锁的保护之下相比,SpinLock的使用非常简单,只需要这样做:
在上面这个例子中,我们声明了一个SpinLock,并且把要保护的数据:一个Vec数组,传了进去。然后,我们在第3行,获取了锁。在接下来的几行中,我们通过这个守卫,来向Vec内部插入数据。当离开内部的闭包(由“{}”包裹)之后,在最后一行,我们通过打印,能发现,锁被自动的释放了。
对于结构体内部的变量,我们可以使用SpinLock进行细粒度的加锁,也就是使用SpinLock包裹需要细致加锁的成员变量,比如这样:
pub struct a {
pub data: SpinLock<data_struct>,
}
那么,对data_struct类型的data字段的访问,必须先加锁,否则是无法访问它的。
当然,我们也可以对整个结构体进行加锁:
struct MyStruct {
pub data: data_struct,
}
/// 被全局加锁的结构体
pub struct LockedMyStruct(SpinLock<MyStruct>);
总结
本文介绍的自旋锁,使用了RAII技术,结合Rust的生命周期及所有权机制。将锁与被其保护的数据进行了绑定,使其能够支持编译期检查。减少了BUG的产生,也减轻了程序员手动维护“锁——被锁保护的数据”关系的负担。
附录
- DragonOS中,SpinLock的实现:http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/libs/spinlock.rs?r=ec53d23e#137
- DragonOS代码仓库:https://github.com/fslongjin/DragonOS
转载请注明来源:https://longjin666.cn/?p=1678
欢迎关注我的公众号“灯珑”,让我们一起了解更多的事物~