前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >RAII技术:在Rust中实现带有守卫的自旋锁,支持一定程度上的编译期并发安全检查

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

原创
作者头像
灯珑LoGin
发布2023-01-16 19:46:40
6220
发布2023-01-16 19:46:40
举报
文章被收录于专栏:龙进的专栏龙进的专栏

摘要

本文介绍了一种使用了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 IInitialization),它是在一些面向对象语言中的一种习惯用法。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
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()。如下图所示:

http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/libs/spinlock.rs?r=ec53d23e#155
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
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
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包裹需要细致加锁的成员变量,比如这样:

代码语言:javascript
复制
pub struct a {
  pub data: SpinLock<data_struct>,
}

那么,对data_struct类型的data字段的访问,必须先加锁,否则是无法访问它的。

当然,我们也可以对整个结构体进行加锁

代码语言:javascript
复制
struct MyStruct {
  pub data: data_struct,
}
/// 被全局加锁的结构体
pub struct LockedMyStruct(SpinLock<MyStruct>);

总结

本文介绍的自旋锁,使用了RAII技术,结合Rust的生命周期及所有权机制。将锁与被其保护的数据进行了绑定,使其能够支持编译期检查。减少了BUG的产生,也减轻了程序员手动维护“锁——被锁保护的数据”关系的负担。

附录

转载请注明来源:https://longjin666.cn/?p=1678

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 什么是RAII技术?
  • 思路
  • 实现
    • 结构体定义
      • SpinLock实现
        • SpinLockGuard实现
        • 如何使用这样的自旋锁?
        • 总结
        • 附录
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档