对于 C++ 多线程程序开发者来说,确保程序的正确性和稳定性是至关重要的。但是,多线程程序往往会面临复杂的并发问题,如数据竞争、死锁等,这些问题难以被发现和解决,容易导致程序崩溃或出现不可预期的错误。为了提高多线程程序的质量和性能,我们需要使用一些工具来检测和避免这些潜在问题。
在这方面,Clang Thread Safety Analysis 是一个非常有用的工具,它可以帮助我们在编译时静态地分析 C++ 代码,检测并发问题。Clang Thread Safety Analysis 是 LLVM/Clang 编译器的一部分,可以在编译时将分析结果输出到编译器的错误信息中,提供给开发者及时发现并解决并发问题。
Clang Thread Safety Analysis 已经在 Google 大规模部署,Google 的所有 C++ 代码现在都默认启用了线程安全分析。
根据官方文档:
Clang Thread Safety Analysis is a C++ language extension which warns about potential race conditions in code. The analysis is completely static (i.e. compile-time); there is no run-time overhead.
也就是说,Clang Thread Safety Analysis 是C++语言的扩展,它会对潜在的 race condition 提供警告。这种分析完全发生在编译时,没有运行时的开销。
使用 Clang Thread Safety Analysis,写代码的时候需要给一些类和变量加上 Attributes,编译器便能通过给定的信息给出警告。
什么是Race condition?Wikipedia上说:
它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。
这听起来就不是很直观,举个例子,按照下面的代码,我们期望 counter 的值为200000,因为我们有两个线程,每个线程都会将 counter 增加100000次。然而,由于 Race Condition 的存在,最后打印出的 counter 值可能会小于200000。这是因为两个线程可能同时读取 counter 的值,进行增加操作,然后将结果写回。这就导致了一些增加操作被覆盖,从而得到了一个错误的结果。这就是 Race Condition 的一个典型例子。
#include
#include
// 全局变量
int counter = 0;
void* increment_counter(void* arg) {
for (int i = 0; i < 100000; ++i) {
++counter;
}
return NULL;
}
int main() {
// 创建两个线程
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, increment_counter, NULL);
pthread_create(&thread2, NULL, increment_counter, NULL);
// 等待两个线程完成
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("%d\n", counter);
return 0;
}
解决 Race Condition 的一种常见方法是使用锁(Locks)或互斥量(Mutex)。以下是一个使用C语言和互斥量解决上述 Race Condition 的例子:
#include
#include
// 全局变量
int counter = 0;
// 创建一个互斥量
pthread_mutex_t lock;
void* increment_counter(void* arg) {
for (int i = 0; i < 100000; ++i) {
// 获取锁
pthread_mutex_lock(&lock);
++counter;
// 释放锁
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main() {
// 初始化互斥量
pthread_mutex_init(&lock, NULL);
// 创建两个线程
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, increment_counter, NULL);
pthread_create(&thread2, NULL, increment_counter, NULL);
// 等待两个线程完成
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("%d\n", counter);
// 销毁互斥量
pthread_mutex_destroy(&lock);
return 0;
}
现在,无论何时运行这个程序,counter 的值都会是200000,这就是我们期望的结果。
对于这种简单的情况,看出在哪里加锁、哪里释放是十分简单的。而在更大的代码库中、更多的锁的情况下,完成却不容易了。同时,毕竟代码是人写的,人也会犯错。在多线程同步的问题上犯错,会让调试的过程极为痛苦—— Race Condition 有时是臭名昭著的不好复现。为了解决这种问题,使用 Thread Safety Analysis 就是好的解决方案,它能提醒我们在编译时解决问题。
线程安全分析工具使用属性注解来声明依赖,这些属性注解是附加在类、方法、数据成员之上的。官方推荐使用宏(mutex.h)来使属性注解可读性更好、可理解。
clang++ -std=c++11 -Wthread-safety -O2 -g your_code.cpp
其中,-Wthread-safety 表示开启 Thread Safety 分析,用于检测线程安全问题。
以下是一个简单的示例:
#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();
}
};
(1)GUARDED_BY:该属性声明,线程在对该变量进行读写之前,一定要获得对应的锁,以确保对该变量的操作是线程安全的。
(2)PT_GUARDED_BY:和 GUARDED_BY 类似,它用于指针和智能指针上,对指针自身没有约束,但对它所指向的数据施加属性保护。
Mutex mu;
int *p1 GUARDED_BY(mu);
int *p2 PT_GUARDED_BY(mu);
unique_ptr
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.
}
(1)REQUIRES(mu):指示调用线程在调用该函数前,必须先获得mu锁。它假设调用者在调用该函数前,已经拥有mu锁,内部对共享变量的修改无需额外加锁。
(2)REQUIRES_SHARED():与 REQUIRES 类似,但仅要求共享读访问权限。
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();
}
(3)ACQUIRE():声明要求函数内部获得锁,直到退出该函数也无需释放它。调用者在调用该函数前,不能持有该锁。
(4)RELEASE():声明函数具备释放锁的能力,调用者在调用入口处无需持有该锁,函数退出前会主动释放该锁。
Mutex mu;
MyClass myObject GUARDED_BY(mu);
void lockAndInit() ACQUIRE(mu) {
mu.Lock();
myObject.init();
}
void cleanupAndUnlock() RELEASE(mu) {
myObject.cleanup();
} // Warning! Need to unlock mu.
void test() {
lockAndInit();
myObject.doSomething();
cleanupAndUnlock();
myObject.doSomething(); // Warning, mu is not locked.
}
如果没有参数传递给 ACQUIRE 或 RELEASE,则假定入参为 this ,分析工具不会检查函数内部实现。常用在隐藏抽象接口的内部锁的实现细节。
EXCLUDES(...):声明调用者不能持有给定 capabilities, 该注解用来预防死锁,对于不可重入的锁,当同一个函数重入时,会获得2次锁,造成死锁。
Mutex mu;
int a GUARDED_BY(mu);
void clear() EXCLUDES(mu) {
mu.Lock();
a = 0;
mu.Unlock();
}
void reset() {
mu.Lock();
clear(); // Warning! Caller cannot hold 'mu'.
mu.Unlock();
}
NO_THREAD_SAFETY_ANALYSIS:关闭线程安全检查,该属性不属于声明的一部分,使用时要放在 .cpp 文件中。
(1)CAPABILITY():指明该类的实例可被用作 capaility,string 参数用来表明该 capaility 的种类,在警告时会输出。
(2)SCOPED_CAPABILITY:指明该类用于 RAII 风格的资源管理。
还有其他详细的使用介绍可以参考 Clang 的官方文档(Thread Safety Analysis:Thread Safety Analysis — Clang 18.0.0git documentation)。
遍历控制流图(CFG):算法开始遍历程序的控制流图。
维护一组当前持有的锁:在遍历图时,维护一组当前持有的锁。
在函数调用时:当遇到函数调用时,算法检查它是否是锁函数或解锁函数。
如果它是锁函数,将锁添加到集合中并检查其顺序。
如果它是解锁函数,将锁从集合中移除。
如果它是受保护的函数,算法会检查所需的锁是否在集合中。
在加载或存储时:当遇到加载或存储操作时,算法检查涉及的变量是否受保护。如果它是受保护的变量,算法会检查所需的锁是否在集合中。
可以在 Clang 代码库中的 lib/Analysis/ThreadSafety.cpp 找到此算法的当前实现。
Clang Thread Safety Analysis 是一个强大的工具,可以帮助我们检测并发程序中的线程安全问题。它通过代码注解(annotations )告诉编译器哪些成员变量和成员函数是受哪个 mutex 保护,这样如果忘记了加锁,编译器会给警告。因为在后续维护别人的代码时候,往往不像原作者那样深刻理解设计意图,特别容易遗漏线程安全的假设。这个工具正是为了应对这种情况,让原作者能把设计意图用代码注解清楚地写出来并让编译器能自动检查,让后来的维护者就不容易犯低级错误了。
D. Hutchins, A. Ballman and D. Sutherland, "C/C++ Thread Safety Analysis," 2014 IEEE 14th International Working Conference on Source Code Analysis and Manipulation, Victoria, BC, Canada, 2014, pp. 41-46, doi: 10.1109/SCAM.2014.34.
"Clang thread safety analysis documentation", [online] Available: Thread Safety Analysis — Clang 18.0.0git documentation.
"Clang: A c-language front-end for llvm", [online] Available: Clang C Language Family Frontend for LLVM.
"Thread Safety Annotations for Clang", [online] Available:https://llvm.org/devmtg/2011-11/Hutchins_ThreadSafety.pdf