线程安全分析
介绍
Clang 的线程安全分析模块是 C++ 语言的一个扩展, 能对代码中潜在的竞争条件进行警告. 这种分析是完全静态的 (即编译时进行), 没有运行时的消耗. 当前这个功能还在开发中, 但它已经具备了足够的成熟度, 可以被部署到生产环境中. 它由 Google 开发, 同时受到 CERT(United States Computer Emergency Readiness Team, 美国互联网应急中心)/SEI(Software Engineering Institute, 软件工程中心) 的协助, 并在 Google 的内部代码中被广泛应用.
对于多线程的程序来说, 线程安全分析很像一个类型系统. 在一个多线程的环境中, 程序员除了可以声明一个数据的类型 (比如, int, float 等) 之外, 还可以声明对数据的访问是如何被控制的. 例如, 如果变量 foo 受到互斥锁 mu 的监控, 那么如果如果一段代码在读或者写 foo 之前没有加锁, 就会发出警告. 同样, 如果一段仅应被 GUI 线程访问的代码被其它线程访问了, 也会发出警告.
入门
- #include "mutex.h"
- class BankAccount {
- private:
- Mutex mu;
- int balance GUARDED_BY(mu);
- void depositImpl(int amount) {
- balance += amount; // WARNING! Cannot write balance without locking mu.
- }
- void withdrawImpl(int amount) REQUIRES(mu) {
- balance -= amount; // OK. Caller must have locked mu.
- }
- public:
- void withdraw(int amount) {
- mu.Lock();
- withdrawImpl(amount); // OK. We've locked mu.
- } // WARNING! Failed to unlock mu.
- void transferFrom(BankAccount& b, int amount) {
- mu.Lock();
- b.withdrawImpl(amount); // WARNING! Calling withdrawImpl() requires locking b.mu.
- depositImpl(amount); // OK. depositImpl() has no requirements.
- mu.Unlock();
- }
- };
这段代码说明了线程安全分析背后的基本概念. GUARDED_BY 属性声明, 一个线程在读或写 balance 变量之前, 必须先锁住 mu, 由此保证对 balance 的增加和降低操作都是原子的. 同样, REQUIRES 声明了在调用线程调用 withdrawImpl 方法之前, 必须先锁住 mu. 因为调用者已经在方法调用之前锁住了 mu, 因此在方法体内部修改 balance 就是安全的了.
depositeImpl 方法没有 REQUIRES 生命, 因此分析模块给出了一个警告. 线程安全分析模块并不是进程内部的, 因此对调用者的需求必须被显式的声明. 在 transferFrom 方法内部也有一个警告, 因为尽管方法锁住了 this->mu, 它没有锁住 b.mu, 分析模块知道这是两个不同的锁, 分属两个不同的对象.
最后, 在 withdraw 方法内部也有一个警告, 因为它没有解锁 mu. 每一个上锁操作必须有一个配对的解锁操作, 分析模块将检测成对的上锁和解锁操作. 一个函数可以仅上锁而不解锁(反之亦然), 但这必须被显式标注(使用 ACQUIRE/RELEASE).
运行分析
为了运行分析模块, 只需要加入编译选项 -Wthread-safety, 比如
clang -c -Wthread-safety example.cpp
注意, 这段代码假设已经有一个正确的标注文件 mutex.h 存在, 这个文件中声明了哪个方法执行了上锁, 解锁的操作.
基本概念: 监护权
线程安全分析提供了一种使用 "监护权" 保护资源的方法."资源" 可以是数据成员, 或者可以访问底层资源的过程或方法. 分析模块保证了, 除非调用者线程拥有了对于资源的监护权 (调用一个方法, 或者读 / 写一个数据), 否则它是无法访问到资源的. 监护权被绑定到一些具名的 C++ 对象上, 这些对象声明了专用的方法来获取和释放监护权. 这些对象的名称被用来识别监护权. 最常见的例子就是互斥锁. 例如, 如果 mu 是一个互斥锁, 那么调用 mu.Lock() 使得调用者线程拥有了 mu 所保护的数据的监护权. 同样的, 调用 mu.Unlock()释放监护权.
线程可以排他的或者共享的拥有监护权. 一个排他的监护权每次仅能被一个线程拥有, 而一个共享的监护权可以同时被多个线程拥有. 这个机制使得多读一写的模式成为可能. 写操作需要排他的监护权, 而读操作仅需要共享的监护权.
在程序执行的给定时刻, 每个线程拥有各自的监护权集合 (该线程锁住的互斥锁的集合). 它们类似于钥匙或者令牌, 允许线程访问这些资源. 跟物理上的安全钥匙一样, 线程不能复制, 也不能销毁监护权. 一个线程只能把监护权释放给另外一个线程, 或者从另外一个线程获得监护权. 安全起见, 分析模块的标识不清楚具体获取和释放监护权的机制, 它假设底层实现(例如, 互斥锁的实现) 能够恰当的完成这个任务.
在程序运行的某个具体时刻, 某个线程拥有的监护权集合是一个运行时的概念. 静态的任务是对这个集合 (也被称为监护权环境) 进行估计. 分析模块会通过静态分析描述程序任何执行节点的监护权环境. 这个估计, 是对实际运行时监护权环境的保守估计.
应用指导
线程安全分析模块使用属性来声明线程约束. 属性必须被绑定到具名的声明, 比如类, 方法, 数据成员. 我们强烈建议用户为这些不同的属性定义宏, 示例请参见以下的 mutex.h 文件. 接下来的说明将假设使用了宏.
由于历史原因, 线程安全分析模块的早期版本是用了以锁为中心的宏名称. 为了适应更普适的模型, 这些宏被更改了名称. 之前的名称仍然在使用, 在接下来的文档里会特别指明.
GUARDED_BY(c) 和 PT_GUARDED_BY(c)
GUARDED_BY 是一个应用在数据成员上的属性, 它声明了数据成员被给定的监护权保护. 对于数据的读操作需要共享的访问权限, 而写操作需要独占的访问权限.
PT_GUARDED_BY 与之类似, 只不过它是为指针和智能指针准备的. 对数据成员 (指针) 本身没有任何限制, 它保护的是指针指向的数据.
- Mutex mu;
- int *p1 GUARDED_BY(mu);
- int *p2 PT_GUARDED_BY(mu);
- unique_ptr<int> p3 PT_GUARDED_BY(mu);
- void test() {
- p1 = 0; // Warning!
- *p2 = 42; // Warning!
- p2 = new int; // OK.
- *p3 = 42; // Warning!
- p3.reset(new int); // OK.
- }
- REQUIRES(...),REQUIRES_SHARED(...)
早期的版本是 EXCLUSIVE_LOCKS_REQUIRED,SHARED_LOCKS_REQUIRED
REQUIRES 是作用于方法或者函数上的属性, 它表明了调用线程必须独享给定的监护权. 可以指定不止一个监护权. 监护权必须在函数的入口处, 出口处同时被声明.
REQUIRES_SHARED 与之类似, 只不过仅需要共享的访问权限.
- Mutex mu1, mu2;
- int a GUARDED_BY(mu1);
- int b GUARDED_BY(mu2);
- void foo() REQUIRES(mu1, mu2) {
- a = 0;
- b = 0;
- }
- void test() {
- mu1.Lock();
- foo(); // Warning! Requires mu2.
- mu1.Unlock();
- }
- (未完待续)
来源: https://www.cnblogs.com/jicanghai/p/9472001.html