对多核处理器进行编程以利用其强大功能意味着编写多线程代码。C 和 C++ 不是为并发而设计的,因此开发人员必须为这些语言使用诸如 pthreads 之类的库。由于全新类别的编程缺陷带来的风险,多线程代码比单线程代码更难正确处理。
在流氓的并发错误库中,竞争条件是臭名昭著的屡犯者。竞争条件发生在程序检查资源属性并假设该属性没有更改的情况下执行操作,即使外部参与者已经介入并更改了该属性。
数据竞争是一种特殊类型的竞争条件,它涉及对多线程程序中内存位置的并发访问。当有两个或多个执行线程访问共享内存位置,至少一个线程正在更改该位置的数据,并且没有明确的协调访问机制时,就会出现此缺陷。如果发生数据竞争,它会使程序处于不一致的状态。
数据竞争的阴险本质
人们普遍认为,一些数据竞争是无害的,可以安全地忽略。不幸的是,这仅在极少数情况下是正确的。最好通过举例说明原因。
单例模式是一种常见的习惯用法,其中程序维护对单个底层对象的引用,如果已初始化,则布尔变量对其进行编码。这种模式也称为延迟初始化。以下代码是该模式的示例:
if (!initialized) {
object = create();
initialized = true;
}
。。. 目的 。。.
这段代码完全适合单线程程序,但它不是线程安全的,因为它在名为initialized的变量上存在数据竞争。如果由两个不同的线程调用,则存在两个线程几乎同时观察到初始化为 false 的风险,并且都将调用create(),从而违反了单例属性。
为了使这个线程安全,自然的方法是用锁保护整个if语句。然而,获取和释放锁的成本可能很高,因此程序员试图通过使用双重检查锁定习惯用法来避免这种成本——在锁范围之外进行检查,在锁范围内进行检查。内部检查用于确认在获得锁后第一个检查仍然有效:
if (!initialized) {
lock();
if (!initialized) {
object = create();
initialized = true;
}
unlock();
}
。。. 目的 。。.
从表面上看,这看起来就足够了,实际上,只要保证语句按该顺序执行就足够了。但是,优化编译器可能会生成实质上切换object = create()和initialized = true顺序的代码。毕竟,这两个语句之间没有明确的依赖关系。在这种情况下,如果第二个线程在分配给initialized之后的任何时间进入此代码,则该线程将在object被初始化之前使用它的值。
优化编译器是不可思议的野兽。那些优化速度的人会考虑许多深奥的考虑,其中很少有对程序员来说是显而易见的。他们通常会生成明显无序的指令,因为这样做可能会导致更少的高速缓存未命中,或者因为需要更少的指令。
假设因为重新排序在前面的示例中引入了竞争条件,所以认为编译器有问题是错误的。编译器正在做它被允许做的事情。语言规范对此非常清楚和明确:允许编译器假设程序中没有数据竞争。
实际上,规范更广泛:允许编译器在存在未定义行为的情况下做任何事情。这有时被开玩笑地称为着火语义;如果程序具有未定义的行为,该规范允许编译器将计算机置于火上。除了数据竞争之外,缓冲区溢出、无效地址的取消引用等许多传统错误也构成了未定义的行为。因为编译器可以自由地做任何事情,而不是烧毁建筑物,他们通常会做明智的事情,即假设未定义的行为永远不会发生并相应地进行优化。
即使对于并发和编译器方面的专家来说,这样做的后果有时也会令人惊讶。很难让程序员相信看起来完全正确的代码可以编译成有严重错误的代码。
另一个例子是值得描述的。假设有两个线程,一个读取共享变量,另一个写入共享变量。让我们假设读者在写入者更改之前或之后看到该值并不重要(这不是一种不常见的模式)。如果这些访问不受锁保护,那么显然存在数据竞争。然而,尽管着火规则,大多数程序员会得出结论,这是完全良性的。
事实证明,至少有两种合理的方式可以编译这段代码,读者会看到错误的值。第一种方法很容易解释:假设该值是一个只能读取 32 位字的架构上的 64 位数量。那么读者和作者都需要两条指令,不幸的交错可能意味着读者看到旧值的前 32 位和新值的后 32 位,当它们组合时可能不是旧值也不是新的。
生成错误代码的第二种方式更为微妙。假设读者做了以下事情,其中数据竞争在名为global的变量上:
int local = global; // Take a copy of
// the global
if (local == something) {
。..
}
。.. // Some non-trivial code that does
// not change global or local
if (local == something) {
。..
}
在这里,读者正在制作 racy 变量的本地副本并引用该值两次。可以合理地期望两个地方的值相同,但同样,优化编译器可以生成未满足期望的代码。如果将local分配给一个寄存器,那么它将有一个值用于第一次比较,但如果两个条件之间的代码足够重要,那么该寄存器可能会溢出——换句话说,为了不同的目的而重用。在这种情况下,在第二个条件下,local的值将从全局变量重新加载到寄存器中,此时编写器可能已将其更改为不同的值。
程序员应该非常怀疑某些数据竞争是可以接受的,并且应该努力从他们的代码中找到并删除它们。
发现风险缺陷的技术
在发现并发缺陷时,传统的动态测试技术可能不够用。一个通过一百次测试的程序并不能保证下一次通过,即使是相同的输入和相同的环境。这些错误是否出现对时间非常敏感,线程中的操作交错的顺序本质上是不确定的。
用于发现数据竞争的新动态测试技术正在出现。这些技术通过在应用程序执行时监视它们并观察每个线程持有的锁以及这些线程正在访问的内存位置来工作。如果发现异常,则发出诊断。其他工具有助于诊断可能导致故障的数据竞争。一些公司现在提供工具来促进数据竞争的诊断,从而允许重播导致异常的事件。
静态分析工具也可用于查找数据竞争和其他并发错误。动态测试工具会发现针对具有固定输入集的程序的特定执行出现的缺陷,而静态分析工具会检查所有可能的执行和所有可能的输入。出于性能原因,工具可能会限制进行多少探索,因此可能并不完全详尽;即便如此,它们可以涵盖的范围远远超过动态测试所能实现的范围。静态分析的优点是不需要测试用例,因为程序从未真正执行过。
相反,这些工具通过创建程序模型然后以各种方式探索模型以发现异常来工作。GrammaTech 的 CodeSonar 通过创建表示每个线程持有的锁集的模型并通过执行探索执行路径的程序的符号执行来发现数据竞争。它记录受锁保护的变量集,并使用此信息来查找可能导致共享变量在没有适当同步的情况下使用的交错。类似的技术可用于发现其他并发缺陷,例如死锁和锁管理不善。
一旦发现,数据竞争通常很容易修复,尽管这样做会导致性能损失。在某些情况下,可能会尝试使用 C 中的 volatile 关键字来纠正数据争用,但不建议这样做,因为 volatile 并非旨在解决并发问题,并且在任何情况下都是一个难以理解的构造,经常被错误编译。最新版本的 C 和 C++ 包含并发并支持原子操作。对这些操作的编译器支持正在慢慢出现,在它变得可用之前,最好的方法是使用锁。
为了实现多核处理器的高质量软件,建议对数据竞争采取零容忍政策。使用静态和动态技术的组合来查找它们,并注意不要过度依赖深奥的编译器技术来修复它们。这些缺陷是如此危险和不可预测,因此系统地消除它们是确保它们不会造成伤害的唯一安全方法。
审核编辑:郭婷
-
处理器
+关注
关注
68文章
19167浏览量
229153 -
C++
+关注
关注
22文章
2104浏览量
73497 -
编译器
+关注
关注
1文章
1618浏览量
49052
发布评论请先 登录
相关推荐
评论