白杨
作为《C++编码规范与指导》中的一个小节,本文主要阐述与 C/C++ 语言相关性比较紧密的,SMP 环境下的多线程同步问题。之所以称之为《高级话题》是因为本文预期的读者已经具备了信号量、互斥量、条件变量、原子操作和竞态条件等方面的背景知识。我们将以此为基础开始讨论三个相对高级一点的问题:
volatile 关键字确实与原子操作有预定关联,但他们之间的关系并不像很多人想象的那么单纯:C/C++ 中的 volatile 关键字提供了以下保证:
volatile *不* 提供如下保证
而原子操作要求做到以下保证:
可见,使用 volitale 关键字并不足以保证操作的原子语义。volitale 关键字的主要设计目的是支持 C/C++ 程序与内存映射设备间的通信。但这并不是说 volitale 关键字对原子操作没有任何帮助:
内存屏障和 Acquire、Release 语义
|
C++ 保证全局变量在进程启动的时候被依次(按照编译单元内的定义顺序)地初始化。C++
保证每个全局对象在进程加载时被构造一次,在进程结束时按照与构造相反的顺序被析构一次。但是,C++
并不保证在不同编译单元内定义的全局量会按照程序员预期的顺序初始化。 并且,如果用户定义了某些在构造时会创建和运行新线程的全局对象,那么这样做也是不安全的。无论如何,在全局量构造时创建新线程是一个很不好的编程习惯。因为此时用户代码和第三方库中可能还有很多全局变量没有或正在被初始化(C++ 不保证不同编译单元内的全局量初始化顺序),新线程很可能会直接或间接地访问到未初始化或未完全初始化的全局变量。 此外,想要确保在不同编译单元内定义的全局量,以预期的顺序初始化是一件麻烦的事情。有 VC、SUN CC 等编译器提供了 init segment 预编译选项的支持,此时在优先级较高的段内定义的全局量会比其他全局量先初始化。但是 init segment 仅提供了一种初始化顺序的粗略区分方式。而且 GCC 等很多编译器尚不支持该选项。 如果想要更精细地控制全局量的依赖关系,或是需要在不支持 init segment 指令的编译环境中控制全局量初始化顺序,就需要使用一些特别的技巧。比如:利用在同一编译单元内定义的全局量总是按照其定义顺序初始化的行为。我们可以在声明了某个全局量 'G' 的头文件 "G.h" 中定义一个静态全局量 'S' (使用 static 关键字或无名空间)。这样就为每个包含了 "G.h" 的编译单元都定义了一个静态全局量 'S'。由于 'G' 在 #include "G.h" 之后才可见,所以所有对全局量 'G' 的访问都发生在 'S' 被定义之后。也就是说,静态全局量 'S' 一定会在 'G' 被访问前构造。现在只要在 'S' 的构造函数中显式地完成对 'G' 的初始化即可确保初始化顺序间的依赖性关系被正确地建立。 更完善一些的 'S' 应该在其构造函数中维护一个整形静态变量作为引用计数,确保仅在第一个 'S' 对象被构造时完成初始化就可以了。 不过,使用这种技巧时,仍然需要注意以下几个问题:
|
C++ 标准保证局部静态变量会在第一次使用时被初始化,并按照与初始化相反的顺序在进程结束时销毁。但是,C++
不保证局部静态变量初始化的多线程安全性。实际上,大多数编译器是使用类似如下的技巧实现本地静态变量初始化的:
由以上例子可知,如果 func 需要工作在多线程并发的环境中,则可能会产生以下几种竞态条件:
对于非 POD 类型,问题则更加复杂:
可以看出,此时除了前文提到的各种竞态条件外,还可能出现 s_iMyObj 的构造函数和析构函数被多次调用的问题。 如果并发性对程序不重要,我们就可以使用一种简单的方式来保证局部静态对象初始化时的多线程安全性:
其中 fmxLock 可以看做是一个互斥量对象,而 CFastSessionLock 则是一个满足 RAII 语义的 Sentry 类(在构造时加锁,析构时解锁)。 或者,如果初始化不必非要延迟到用户第一次调用此函数时才进行。我们就可以直接将局部静态变量定义成全局变量(静态或非静态均可)。如果需要控制变量的作用域,可以使用一个全局哨位来完成进程启动时的初始化动作:
前文已经提过,与局部静态变量不同,C++ 保证全局变量在进程启动的时候,在同一个编译单元内定义的全局量会按照其定义顺序被依次地初始化。 此外,使用字面常量初始化一个本地静态 POD 数据是线程安全的。实际上,这类初始化并不是在程序第一次执行到该变量所在语句块时才进行的,而是在程序启动时就直接从映像文件内的数据段中加载了。但对于一个非 POD 对象,无论是否使用编译时已知的常量对其进行初始化,编译器都需要为其生成调用构造、析构函数的代码和初始化标志变量。例如:
对于 POD 类型,一种更好的解决方法是:充分利用操作系统加载进程时,对数据段做全零初始化的特性:C++ 标准中明确规定了,所有静态成员(即进程数据段)在进程加载时都必须 "zero-initialized"。由于数据段清零动作是在操作系统加载进程映像时就完成的,此时连主线程都还没有被创建,任何用户代码都没有开始执行,所以不存在多线程安全性问题。例如:
实际上,前文提到的,编译器自动生成的 "bCompilerInitFlag" 标记变量就是利用这个特性来完成初始化的。 利用进程数据段在映像加载时清零的特性,配合使用一个互斥量,我们就可以在几乎不损失并发性的前提下保证任意本地静态 POD 变量初始化时的线程安全性:
以上例子保证了 s_nMyVar 初始化时的多线程安全性,同时只在 s_nMyVar 尚未完整初始化时发生了并发调用才会上互斥锁,最大限度地保证了并发效率。 非 POD 类型的情况则相对复杂。 因为编译器总是会生成调用构造函数并将析构函数压入进程退出节(atexit)的代码,所以想要在维持并发性的同时保证其初始化时的线程安全性,这个非 POD 对象就必须满足以下条件:
遗憾的是,大部分有意义的类都无法同时满足以上 4 个条件。为此,我们可以使用一个句柄类来进行辅助,例如:
在这里可以简单的认为 CTmpHandle 是一个类似 "std::auto_ptr" 的智能指针模板类,它有一个指针型成员 'ptr'。"DontInit" 占位符表示构造时不做任何动作。这样,构造函数的多次并发调用仅仅相当于调用了空函数,不会产生任何不良影响。而 CTmpHandle 的析构函数看起来像这样: "delete ptr; ptr = NULL;" (为了突出主题,这里没有忽略了 delete 时析构函数抛出异常的情况)。销毁后将 'ptr' 置为 'NULL' 保证了在程序退出节序列地多次调用析构函数不会产生任何副作用。而真正的初始化代码则被一个互斥锁保护,由于只在 s_thMyObj 尚未完整初始化时发生了并发调用才会上互斥锁,所以最大限度地保证了并发效率。 顺便提一下,上例中的 's_thMyObj = new CMyClass;' 语句其实就是一个指针赋值操作('s_thMyObj.ptr = new CMyClass'),而语句 '!s_thMyObj' 则与 'NULL != s_thMyObj.ptr' 完全等效。所以上例也满足以上第四条所描述的“其初始化动作相对于检测初始化是否完成的操作来说,是一个原子操作”。应当注意到,此处的“原子操作”与本文第一节所描述的硬件级别的并不是一个概念。 |
Copyright (C) 2004 - 2019, Bai Yang (baiy.cn). All Rights
Reserved.