以下文章来源于BOTManJL ,作者BOT Man
What you don't use you don't pay for. (zero-overhead principle)
—— Bjarne Stroustrup
背景阅读
在学习了 Chromium/base 库(笔记)后,我体会到了一般人和 优秀工程师 的差距 —— 拥有较高的个人素质固然重要,但更重要的是能 降低开发门槛,让其他人更快的融入团队,一起协作(尤其像 Chromium 开源项目 由社区维护,开发者水平参差不齐)。
没吃过猪肉,但见过猪跑。?
项目中,降低开发门槛的方法有很多:除了 制定 代码规范、划分 功能模块、完善 单元测试 (unit test)、推行 代码审查 (code review)、整理 相关文档 之外,针对强类型的编译语言 C++,Chromium/base 库加入了大量的 检查 (check)。
为什么代码中需要各种检查?在 C++ 中调用一个函数、使用一个类、实例化一个模板时,对传入的参数、使用的时机,往往会有很多 限制 (constraint/restriction)(例如,数值参数不能传入负数、对象的访问不是线程安全的、函数调用不能重入);而处理限制的方法有很多:
- 口口相传:在 代码审查 时,有经验的开发者 向 新手开发者 传授经验(很容易失传)
- 文档说明:在 相关文档 中,提示使用者 功能模块的各种隐含限制(很容易被忽略)
- 检查限制:在合理划分 功能模块 的前提下,对模块的隐含限制 进行检查,并加入针对检查的 单元测试(最安全的保障,单元测试即文档)
本文主要分享 Chromium/base 库中使用的一些限制检查。
漫谈 C++ 的各种检查
1 编译时检查
编译时静态检查,主要依靠 C++ 语言提供的 语法支持/静态断言 和 编译器扩展 实现 —— 在检查失败的情况下,编译失败。
1.1 测试设施
如何确保代码中添加的检查有效呢?最高效的方法是:为 “检查” 添加单元测试。但对于 编译时检查 遇到了一个 难点 —— 如果检查失败,那么编译就无法通过。
为此,Chromium 支持 编译失败测试 (no-compile test):
- 单元测试文件中,每个用例通过
#ifdef
切割 - 每个用例中,标明 编译失败后期望的 报错细节
- 通过
#define
运行各个用例 - 在编译失败后,检查 报错细节 是否和预期一致
对应的单元测试文件后缀为 *_unittest.nc
,通过 nocompile.gni
加入单元测试工程。
1.2 可拷贝性检查
C++ 语言本身有很多编译时检查(例如 类的成员访问控制 (member access control)、const
关键字 在编译成汇编语言后,不能反编译还原),但 C++ 对象默认是可拷贝的,从而带来了许多问题(参考 资源管理小记)。
尤其是 多态 (polymorphic) 类的默认拷贝行为,一般都不符合预期:
- C.67: A polymorphic class should suppress copying
- C.130: For making deep copies of polymorphic classes prefer a virtual clone function instead of copy construction/assignment
为此,Chromium 提供了两个 常用的宏:
DISALLOW_COPY_AND_ASSIGN
用于禁用类的 拷贝构造函数 和 拷贝赋值函数DISALLOW_IMPLICIT_CONSTRUCTORS
用于禁用类的 默认构造函数 和 拷贝行为
由于 Chromium 大量使用了 C++ 的多态特性,这些宏随处可见。
1.3 参数类型检查
Chromium 还基于 现代 C++ 元编程 技术,通过 static_assert
进行静态断言。
在之前写的 深入 C++ 回调 中分析了:
Chromium 的base::Callback <>
+
base::Bind()
回调机制,提到了相关的静态断言检查。
base::Bind
为了 处理失效的(弱引用)上下文,针对弱引用指针base::WeakPtr
扩展了base::IsWeakReceiver
检查,判断弱引用的上下文是否有效;并通过静态断言检查传入参数,强制要求使用者遵循 弱引用检查的规范:
base::Bind
不允许直接将this
指针 绑定到 类的成员函数 上,因为this
裸指针可能失效 变成野指针base::Bind
不允许绑定 lambda 表达式,因为base::Bind
无法检查 lambda 表达式捕获的 弱引用 的 有效性base::Bind
只允许将base::WeakPtr
指针绑定到 没有返回值的(返回void
)类的成员函数 上,因为 当弱引用失效时不调用回调,也没有返回值
base::Callback
区分回调只能执行一次还是可以多次,通过引用限定符 (reference qualifier) &&
/ const &
,区分在对象处于 非 const 右值 / 其他状态时的 Run
成员函数,只允许一次回调 base::OnceCallback
在非 const 右值状态下调用 Run
函数,保证严谨的 资源管理语义:
base::OnceClosure cb; std::move(cb).Run(); // OK
base::OnceClosure cb; cb.Run(); // not compile
const base::OnceClosure cb; cb.Run(); // not compile
const base::OnceClosure cb; std::move(cb).Run(); // not compile
另外,静态断言检查还广泛应用在 Chromium/base 的容器、智能指针 模板的实现中,用于生成可读性更好的实例化错误信息。
1.4 线程标记检查
最新的 Chromium 使用了 Clang 编译,通过扩展 线程标记 (thread annotation),静态分析线程安全问题。(参考:Thread Safety Annotations for Clang - DeLesley Hutchins)
Chromium/base 的单元测试文件 :
thread_annotations_unittest.nc
描述了一些 锁的错误使用场景(假设数据 data 被锁 lock 保护,定义标记为 Type data GUARDED_BY(lock);):
- 访问 data 之前,忘记获取 lock
- 获取 lock 之后,忘记释放 lock
这些错误能在编译时被 Clang 检查到,从而编译失败。
2 运行时检查
运行时动态检查,主要基于 Chromium/base 库提供的 断言 DCHECK
/CHECK
实现 —— 如果断言失败,运行着的程序会立即终止。
其中,DCHECK
只对调试版 (debug) 有效,而 CHECK
也可用于发布版 (release) —— 从而避免在发布版进行无用的检查。
2.1 测试设施
检查的方法很直观 —— 构造一个检查失败的场景,期望断言失败。
Chromium/base 基础设施中的EXPECT_DCHECK_DEATH
提供了这个功能,对应的单元测试文件后缀为 *_unittest.cc
。
2.2 数值溢出检查
C++ 的数值类型,都是固定大小的标量类型 —— 如果存储数值超出范围,会导致溢出 (overflow)。
例如,尝试通过 使用无符号数 避免出现负数,往往是一个典型的徒劳之举。(比如 unsigned(0) - unsigned(1) == UINT_MAX
,参考 ES.106: Don’t try to avoid negative values by using unsigned
)
为此,Chromium 的 base/numerics 提供了一个 无依赖 (dependency-free)、仅头文件 (header-only) 的模板库,处理数值溢出问题:
base::StrictNumeric
/base::strict_cast<>()
编译时 阻止溢出 —— 如果 类型转换 有溢出的可能性,通过静态断言报错base::CheckedNumeric
/base::checked_cast<>()
运行时 检查溢出 —— 如果 数值运算/类型转换 出现溢出,立即终止程序base::ClampedNumeric
/base::saturated_cast<>()
运行时 截断运算 —— 如果 数值运算/类型转换 出现溢出,对计算结果 截断 (non-sticky saturating) 处理
2.3 线程相关检查
最新的 Chromium/base 线程模型引入了线程池,并支持了序列 (sequence) 的概念 —— 相对于线程池中的普通任务乱序调度,同一序列的任务 能保证被顺序调度 —— 因此,推荐使用逻辑序列 而不是物理线程:
- 同一物理线程 只能同时运行 一个逻辑序列,使得 序列模型 等效于 单线程模型
- 同一物理线程 可以用于运行 多个逻辑序列,提高 物理线程 的利用率
线程/序列 相关的检查主要依赖于 线程/序列 本地存储:
- 每个线程有独立的
base::ThreadLocalStorage
线程本地存储 (thread local storage, TLS) - 每个序列有独立的
base::SequenceLocalStorageSlot
序列本地存储 (sequence local storage, SLS) - 当 逻辑序列 被放到 物理线程 上执行时,把当前序列的 SLS 关联到 执行线程的 TLS
2.3.1 线程安全检查
很多时候,某个对象只会在 同一线程/序列 中 创建/访问/销毁:
- 正常情况下,无竞争 (contention-free) 模型没必要保证 线程安全 (thread-safety),因为 线程同步操作/原子操作 会带来 不必要的开销
- 异常情况下,一旦被 多线程同时使用,访问冲突导致 数据竞争 (data race),可能出现 未定义行为
为此,Chromium 借助:
base::ThreadChecker
/base::SequenceChecker
检查对象是否只在 同一线程/序列 中使用:
[THREAD|SEQUENCE]_CHECKER(checker)
创建并关联 线程/序列checker
DCHECK_CALLED_ON_VALID_
THREAD|SEQUENCE
检查或关联checker
和 当前执行环境的 线程/序列DETACH_FROM_
THREAD|SEQUENCE
解除checker
和 线程/序列 的关联- 另外,发布版的检查实现为 空对象,即总能通过检查
实现的 核心思想 非常简单:
- 线程/序列 创建时,通过 TLS/SLS 记录 当前线程/序列的 ID(例如 线程 ID、序列 ID)
checker
构造时,记录 当前线程/序列的 IDchecker
检查时,读取 当前线程/序列的 ID,和checker
记录的 ID 比较checker
析构时,先执行检查(可以提前 解除关联)- 另外,
checker
读写 数据成员时,需要进行互斥的 线程同步操作(锁)
在[sec|通知迭代检查] 提到,base::ObserverList
借助 iteration_sequence_checker_
在迭代时检查 对象操作 是否线程/序列安全。
2.3.2 线程限制检查
程序中常常会有一些 特殊用途的线程(例如 客户端 UI 主线程),而这些线程往往有着 特殊的限制(例如,UI 线程要求保持 响应性 (responsive),实时响应用户输入)。
为此,Chromium 借助 :
base::ThreadRestrictions
检查可能涉及线程限制的函数在当前执行的线程上是否允许:
- 阻塞 (blocking) 操作
- 主要包括文件 I/O 操作(有可能被系统缓存,从而不阻塞)
- 可能导致线程 交出 CPU 执行机会,进入 wait 状态
- 同步原语 (sync primitive)
- 执行 线程同步操作
- 可能导致程序 死锁 (deadlock)/卡顿 (jank)
- CPU 密集工作 (CPU intensive work)
- 超过 100ms CPU 时间的操作
- 可能导致程序 卡顿 (jank)
- 单例 (singleton) 操作
- 对于 非泄露型
base::Singleton
,会在base::AtExitManager
注册 “退出时销毁单例对象” - 如果主线程先退出,在
base::AtExitManager
中销毁单例,导致仍在运行的 non-joinable 线程再访问单例时,出现野指针崩溃
- 对于 非泄露型
实现的 核心思想 也很简单:
- 通过 TLS 记录 当前线程的限制情况(每种限制用一个 TLS
bool
存储) - 对于 可能涉及限制的函数,调用前先检查 当前线程 是否允许某个限制
在最新的Chromium/base 中,线程限制检查被进一步封装为:
base::ScopedBlockingCall
,并应用于大量文件 I/O 相关函数中。
2.3.3 死锁检查
Chromium 通过 base::internal::CheckedLock
检查 死锁 (deadlock)。
实现的 核心思想 非常简单 —— 检查等待链是否成环:
- 维护一个 全局的 <从每个 lock 到其 predecessor lock> 映射表(创建时添加,销毁时移除)
- 维护一个 当前线程的 <已获取 lock> 列表(TLS 存储;获取时记录,释放时移除)
- 创建时,断言 predecessor 已创建(如果 predecessor 不存在,可能顺序错误)
- 获取时,断言 predecessor 是当前线程最近获取的 lock(若不是,可能顺序错误)
2.4 观察者模式检查
在之前写的 令人抓狂的观察者模式 中,介绍了如何通过 :
Chromium/base 提供的base::ObserverList
,检查观察者模式的一些潜在问题。
2.4.1 生命周期检查
由于观察者和被观察者的生命周期往往是解耦的,所以总会出现一些阴差阳错的问题:
- 观察者先销毁
- 问题:若
base::ObserverList
通知时不检查 观察者是否有效,可能导致 野指针崩溃 - 解决:观察者继承于
base::CheckedObserver
在通知前base::ObserverList
检查观察者弱引用base::WeakPtr
的有效性
- 问题:若
- 被观察者先销毁
- 问题:若
base::ObserverList
销毁时不检查 观察者列表是否为空,可能导致 被观察者销毁后,观察者不能再移除(野指针崩溃) - 解决:模板参数
check_empty
若为true
,在析构时断言 “观察者已被全部移除”
- 问题:若
2.4.2 通知迭代检查
观察者可能在 base::ObserverList
通知时,再访问同一个 base::ObserverList
对象:
- 添加观察者
- 问题:是否需要在 本次迭代中,继续通知 新加入的观察者
- 解决:被观察者参数
base::ObserverListPolicy
决定迭代过程中,是否通知 新加入的观察者
- 移除观察者
- 问题:循环内(间接)删除节点,导致迭代器失效(崩溃)
for(auto it = c.begin(); it != c.end(); ++it) c.erase(it);
- 解决:观察者节点
MarkForRemoval()
标记为 “待移除”,然后等迭代结束后移除
- 问题:循环内(间接)删除节点,导致迭代器失效(崩溃)
- 通知迭代重入
- 问题:许多情况下,若不考虑 重入情况,可能会导致 死循环问题
- 解决:模板参数
allow_reentrancy
若为false
,在迭代时断言 “正在通知迭代时 不允许重入”
- 销毁被观察者
- 问题:需要立即停止 迭代过程,让所有迭代器 全部失效
- 解决:通过特殊的
base::internal::WeakLinkNode
+
双向链表base::LinkedList
存储base::ObserverList
所有的迭代器;在base::ObserverList
析构时,将迭代器 标记为无效(自动停止迭代),并 移除、销毁
- 线程安全问题
- 问题:由于
base::ObserverList
不是线程安全的,在通知迭代时,需要保证其他操作在 同一线程/序列 - 解决:被观察者成员
iteration_sequence_checker_
在迭代开始时关联序列,在结束时解除关联,在迭代过程中检查 移除观察者/通知重入/销毁被观察者 操作是否序列安全(参考 [sec|线程安全检查])
- 问题:由于
和 base::Singleton
一样,Chromium/base 的设计模式实现 堪称 C++ 里的典范 —— 无论是功能上,还是性能上,均为 “人无我有,人有我优”。
写在最后
站在巨人的肩膀上。—— 艾萨克·牛顿
Chromium/base 库一直在 迭代、优化,学习、借鉴 许多其他优秀的开源项目。例如,[sec|线程标记检查] 使用的标记就来源于 abseil
。
由于 Chromium/base 改动频繁,本文某些细节 可能会过期。如果有什么新发现,欢迎补充~ ?