RAII技术:在Rust中实现带有守卫的自旋锁,支持一定程度上的编译期并发安全检查

摘要

本文介绍了一种使用了RAII技术的自旋锁,配合Rust的生命周期及所有权机制,能够在减少代码量的同时,很好的解决自旋锁的“忘记放锁”、“双重释放”、“未加锁就访问”的并发安全问题。并且这种自旋锁能够支持编译期的检查,任何不符合以上安全要求的代码,将无法通过编译。

前言

对于许多编程语言默认提供的锁,加锁、放锁需要手动进行。手动加锁可以理解(这不废话嘛),但是,手动放锁的时机,总是难以控制。比如:在临界区内,执行过程中,如果程序出错了,在异常处理的过程中,忘记放锁,那么就会造成其他进程无法获得这个锁。传统的做法就是,人工寻找所有可能的异常处理路径,添加放锁的代码。这样做的话,能解决问题,但非常的繁琐,尤其是有多个锁的时候,更加如此。

并且,对于传统的语言,还可能存在**锁的“双重释放”**的问题,也就是:一个锁被进程A释放后,进程B对其加锁,接着,进程A的错误代码,执行了放锁操作,导致进程B的锁被过早地释放。这样的问题,当我们发现的时候,可能已经不是第一现场了,debug很困难。

并且,对于大部分的语言,锁与它所要保护的数据,并没有一种机制,告诉编译器/解释器:“这个锁,保护的就是这个数据对象”。因此,编译器很难检查出“未加锁就访问”的bug,程序员会经常犯这种错误(尤其是对于新手程序员,很难处理好锁的问题)。这样的代码,编译器无法保证其并发安全。

对于Rust,借助其生命周期、所有权机制,我们能够与RAII技术进行结合,能实现一种新的自旋锁,从而轻松解决以上的问题。

在**DragonOS**中,实现了具有守卫的自旋锁,能够解决以上的问题,让新手程序员也能很容易的管理自旋锁。**这样写出来的代码只要能够通过编译器的检查(就是能够编译通过),那么就不用担心以上提到的并发安全问题。**本文将基于DragonOS中实现的自旋锁进行讲解。

具体的代码链接:spinlock.rs (revision ec53d23e) - OpenGrok cross reference for /DragonOS/kernel/src/libs/spinlock.rs

什么是RAII技术?

RAII,全称资源获取即初始化(英语:Resource Acquisition Is Initialization),它是在一些面向对象语言中的一种习惯用法。RAII源于C++,在许多的编程语言中都有应用。

RAII要求,资源的有效期与持有资源的对象的生命周期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄漏问题。

思路

由于Rust在语言层面就实现了生命周期与所有权机制,因此,能够很好的实现RAII,并且能够支持编译期检查,不符合安全要求的代码,将无法通过编译。我们的思路是:把要保护的数据的所有权,交给对应的锁来管理,不再需要程序员来手动管理“锁——被锁保护的数据”的关系。也就是说,这个自旋锁,拥有要保护的数据的所有权,其他的地方需要访问被保护的数据,都需要从自旋锁申请借用这个变量,获得可变引用/不可变引用。这个访问的权限,不是直接给到要用到数据的函数内的局部变量的,而是由一个叫做“守卫”的对象负责持有权限。访问数据时,都要经过这个守卫(请注意,得益于Rust的“零成本抽象”,这是没有运行时开销的)。当守卫变量的生命周期结束,其析构函数就执行“放锁”的动作。

自旋锁出借自己保护的数据的访问权限时,会执行加锁的动作,然后返回一个守卫。请注意,守卫只会在“自旋锁加锁成功”后被初始化。因此,对于一个自旋锁,最多存在1个守卫。并且,只要守卫的生命周期没有结束,我们都能通过这个守卫,来访问被保护的数据。

那么,我们来小结一下,基于RAII+所有权+生命周期机制的自旋锁,解决以上问题的途径:

  • 忘记放锁/出现异常退出时,未放锁: 一旦守卫的生命周期结束,就会在析构函数中进行放锁。
  • “双重释放“问题: 所有放锁操作只能由守卫对象的析构函数进行。由于守卫对象最多同时刻只有1个,并且,由于守卫对象只要生命周期没有结束,那么锁一定是被获取到的。因此避免了“双重释放”的问题。
  • “未加锁就访问被保护的数据“的问题: 由于被保护的数据,其所有权属于自旋锁,并且是一个私有的字段。进程只能通过守卫来访问被保护的数据。而要获得守卫的方式只有1种:成功加锁。因此,它能解决“未加锁就访问”的问题。任何想要“不加锁就访问”的代码,都无法通过编译器的检查。

实现

上面说了思路,那么我们接下来就结合具体的代码,来介绍一下它的实现:

结构体定义

下图是SpinLock及其守卫的定义

http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/libs/spinlock.rs?r=ec53d23e#137

对于SpinLock,其内部包含两个私有的成员变量:

  • **lock:**这是一个RawSpinlock,具体功能与其他语言的自旋锁一致,需要手动加锁、放锁,具有自旋锁的最基本功能。不具备编译期的并发安全检查的特性。
  • **data:**这个字段是自旋锁保护的数据。在自旋锁被初始化时,要被保护的数据,会被放到这个UnsafeCell中。请注意,UnsafeCell支持内部可变性,也就是说,被保护的数据的值可以被修改。

对于SpinLockGuard这个守卫,它只有1个成员变量,也就是SpinLock的不可变引用。并且,SpinLockGuard没有构造器,它只能通过SpinLock的lock()方法,在加锁后产生。

SpinLock实现

SpinLock只具有两个成员方法:new()和lock()。如下图所示:

image

http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/libs/spinlock.rs?r=ec53d23e#155

  • new()方法: 初始化lock字段,并且将数据放入data字段。请注意,由于传入的value不是引用,因此,value的所有权,在new()函数结束后,被移动到了data字段中。程序的其他部分,不再拥有这个value的所有权。在外部的其他函数中,任何尝试访问value的行为,都会被编译器阻止
  • lock()方法: 本方法先对自旋锁进行加锁,然后返回一个守卫。请注意,lock()函数是唯一的获得守卫的途径。

同时,我们为SpinLock实现Sync这个Trait,这样,编译器就知道,SpinLock是线程安全的,它能在几个线程之间共享。(当然,我们要求T是实现了Send Trait的,因为只有这样,才意味着它能从一个进程发送给另一个进程)

http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/libs/spinlock.rs?r=ec53d23e#153

SpinLockGuard实现

SpinLockGuard的实现也很简单,我们为它实现了3个trait: Deref、DerefMut、Drop。

http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/libs/spinlock.rs?r=ec53d23e#172

  • 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的产生,也减轻了程序员手动维护“锁——被锁保护的数据”关系的负担。

附录

转载请注明来源:RAII技术:在Rust中实现带有守卫的自旋锁,支持一定程度上的编译期并发安全检查 – 龙进的技术笔记