多处理器环境和线程同步的高级话题

白杨

baiy.cn

 

作为《C++编码规范与指导》中的一个小节,本文主要阐述与 C/C++ 语言相关性比较紧密的,SMP 环境下的多线程同步问题。之所以称之为《高级话题》是因为本文预期的读者已经具备了信号量、互斥量、条件变量、原子操作和竞态条件等方面的背景知识。我们将以此为基础开始讨论三个相对高级一点的问题:

 

原子操作和 volatile 关键字

volatile 关键字确实与原子操作有预定关联,但他们之间的关系并不像很多人想象的那么单纯:

C/C++ 中的 volatile 关键字提供了以下保证:

  1. 对声明为 volatile 的变量进行的任何操作都不会被优化器去除,即使它看起来没有意义(例如:连续多次对某个变量赋相同的值),因为它可能被某个在编译时未知的外部设备或线程访问。
  2. 被声明为 volatile 的变量不会被编译器优化到寄存器中,每次读写操作都保证在内存(详见下文)中完成。
  3. 在不同表达式内的多个 volatile 变量间的操作顺序不会被优化器调换(即:编译器保证多个 volatile 变量在 sequence point 之间的访问顺序不会被优化和调整)。

volatile *不* 提供如下保证

  1. volatile 声明不保证读写和运算操作的原子性。
  2. volatile 声明不保证对其进行的读写操作直接发生在主内存。相反,CPU 会尽可能让这些读写操作发生在 L1/L2 等 cache 上。除非:
    1. 发生了一个未命中的读请求。
    2. 所有级别的 cache 均已被配置为通过式写(write through)。
    3. 目标地址为 non-cacheable 区(主要是其它设备映射到内存地址空间的通信接口。例如:网卡的板载缓冲区、显卡板载显存、WatchDog 寄存器等等)。
  3. 编译器仅保证在生成目标码时不调整 volatile 变量的访问顺序,但通常并不保证该变量不受处理器的 out-of-order 特性影响。目前唯一一个已知的特例是安腾(IA64)处理器版的 VC:在生成 IA64 Target 时,VC 会自动在所有 volatile 访问之间添加内存屏障(详见下文)以保证访问顺序。但 ISO 标准并未要求编译器实现类似机制。实际上,其它编译器(或是面向其它平台的 VC)也都没有类似保证。也就是说,通常认为 volatile 并不保证代码在处理器上的执行顺序,如果需要类似的保证,程序员应当自己使用内存屏障操作。

而原子操作要求做到以下保证:

  • 对原子量的 '读出-计算-写入' 操作序列是原子的,在以上动作完成之前,任何其它处理器和线程均无法访问该原子量。
  • 原子操作必须保证缓存一致性。即:在多处理器环境中,原子操作要同时保持所有处理器上的各级 cache 之间、以及它们与主存间的访问一致性。

可见,使用 volitale 关键字并不足以保证操作的原子语义。volitale 关键字的主要设计目的是支持 C/C++ 程序与内存映射设备间的通信。但这并不是说 volitale 关键字对原子操作没有任何帮助:

  • 对于一个被声明为 volitale 类型的原子量来说,如果所有写操作都是原子的,并且已提供了必要的 cache 一致性保证,那么读取该原子量时就不必再次对总线上锁。

    这是因为 volitale 关键字保证了对该变量的读操作起码是在当前 CPU cache 中完成的(即:该变量不会被优化到寄存器中)。与此同时,只要当前环境能够保证多个处理器的 cache 之间,以及 cache 与主存之间的访问一致性。那么该读操作不管在主存还是当前系统中任意一个处理器的 cache 上发生,读到的都是一致的数据。

    在这里,volitale 关键字配合原子量的写入操作一起实现了一个典型的读者/写者同步模型:所有写操作和 cache 同步都保证被原子、互斥地完成;读操作则可以被并发地实现。这也是 Windows API 中没有提供类似 'AtomicLoad' 式语义操作的原因

    当然,这里还有一个隐含的附加条件,就是 CPU 必须能够在一个操作中读入这个原子量。这个条件通常可以忽略,因为超出 CPU 位宽的数据类型通常无法被实现成真正的原子量类型。

内存屏障和 Acquire、Release 语义

内存屏障(Memory barrier, membar) 用于消除 CPU 乱序执行优化对内存访问顺序的影响,通常用于保证多个变量交叉访问的逻辑顺序。内存屏障分为以下几种:
  • 全屏障语义:全屏障逻辑上的操作序列为:'操作&双向同步',即:所有在该操作执行前的内存访问指令不得乱序调换到该指令后执行;所有在该操作执行后的内存访问指令也不得被乱序调换到该指令完成前执行。全屏障语义存在两种变体:读屏障和写屏障。这两种变体都是双向屏障,只不过读屏障仅限制 load 操作的执行顺序,而对于 store 操作的顺序则不做任何限制。相反,写屏障则仅限制 store 操作的执行顺序。
     
  • Acquire 语义:Acquire 屏障逻辑上的操作序列为 '操作-向后同步'。Acquire 屏障要求所有后续内存访问都不得被乱序调换到该操作前执行。Acquire 屏障经常被用于实现互斥量上锁,通常 Acquire 屏障之后的区域为临界区,向后的方向性同步保证了临界区内的内存访问操作不得提前到 Acquire 屏障之前进行,但不限制 Acquire 屏障之前的内存访问操作被乱序推迟到临界区内执行。
     
  • Release 语义:Release 屏障逻辑上的操作序列为 '向前同步-操作'。Release 屏障要求所有前导内存访问都不得被乱序调换到该操作后执行。Release 屏障经常被用于实现互斥量解锁,通常 Release 屏障之前的区域为临界区,向前的方向性同步保证了临界区内的内存访问操作不得推迟到 Release 屏障之后进行,但不限制 Release 屏障之后的内存访问操作被乱序提前到临界区内执行。
     
  • 无屏障语义:不为操作增加内存屏障,逻辑上的操作序列为:'操作'。

全屏障语义适用范围最广,任何使用其它三种屏障的场合都可以安全替换为全屏障操作。但是由于全屏障在最大程度上禁用了处理器的乱序执行能力, 因此它是效率最低的内存屏障操作。Acquire 屏障保证后续内存访问一定在当前操作完毕后进行,主要用于实现互斥量、信号量的上锁操作。Release 屏障保证前导内存访问一定在操作开始前执行完毕。主要用于实现互斥量、信号量的解锁操作。无屏蔽语义完全不禁用 CPU 的乱序执行优化,效率最高, 可广泛适用于引用计数等大部分需要使用原子量操作的场合。

参考资料:

 

全局对象初始化时的线程安全性和相互依赖性问题

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' 对象被构造时完成初始化就可以了。

不过,使用这种技巧时,仍然需要注意以下几个问题:

  • 会为每一个包含了 "G.h" 的编译单元增加一个静态对象 'S',虽然 'S' 通常不会有任何数据成员,但也在一定程度上增加了进程加载时的开销。特别是在资源受限的环境(如:嵌入式应用)或编译单元很多的场合。
     
  • 无法解决多重依赖关系,例如:全局对象 A 初始化时依赖在另一个编译单元中定义的全局量 B,而 B 在初始化时又依赖了全局量 C。那么这种技巧只能保证初始化顺序部分正确,例如:可以保证 B 在 A 之前初始化,但无法保证 C 在 B 之前初始化。因为这一技巧实际上打乱了 CRT 对不同编译单元的初始化顺序。因此在使用此技巧强制初始化 B 时,在 B 之前定义(同一编译单元内的)全局量可能尚未被初始化。
     
  • 无法解决局部静态对象与全局量之间的依赖关系。例如某函数 'func()' 中定义了一个局部静态对象 's_obj',该对象的初始化依赖与 'func()' 所在编译单元内的某个全局变量。由于编译器并不保证局部静态变量与全局变量之间的初始化顺序(即使在同一编译单元内),所以如果 'func()' 在其所在的编译单元中的全局量完成初始化之前就被调用,那么将产生不可预测的结果。解决这个问题的方法通常是将 's_obj' 所依赖的全局量也放入一个函数中,使其成为“按需加载”的局部静态对象。这就引入了下一个小节:

 

局部静态对象初始化时的线程安全性问题

C++ 标准保证局部静态变量会在第一次使用时被初始化,并按照与初始化相反的顺序在进程结束时销毁。但是,C++ 不保证局部静态变量初始化的多线程安全性。实际上,大多数编译器是使用类似如下的技巧实现本地静态变量初始化的:
 
用户代码
void func(void)
{
    static int s_nMyVar = MyCalcAlgorithm();
    // ...
}

编译器生成的代码
void func(void)
{
    static int bCompilerInitFlag;  // 初始化标记
    static int s_nMyVar;
    if (FALSE == bCompilerInitFlag)
    {
        bCompilerInitFlag = TRUE;
        s_nMyVar = MyCalcAlgorithm();
    }
    // ...
}

由以上例子可知,如果 func 需要工作在多线程并发的环境中,则可能会产生以下几种竞态条件:

  1. 使用未被初始化的 s_nMyVar(线程 A 执行 bCompilerInitFlag = TRUE,但调用 MyCalcAlgorithm 尚未返回;此时线程 B 判断 FALSE == bCompilerInitFlag 为 false 所以认为 s_nMyVar 已初始化完毕)。

  2. s_nMyVar 被重复初始化(线程 B 在线程 A 执行 bCompilerInitFlag = TRUE 前进行了 FALSE == bCompilerInitFlag 判断)。

  3. 如果 s_nMyVar 的初始化行为不是一个原子操作(例如,s_nMyVar 是一个结构体),还有可能出现未完全初始化的 s_nMyVar 被使用的情况(与第一条类似)。

对于非 POD 类型,问题则更加复杂:

用户代码
void func(void)
{
    static CMyClass s_iMyObj;
    // ...
}

编译器生成的代码
void DestroyMyObj(void) // 调用析构函数
{
    `void func(void)::'s_iMyObj.~CMyClass();
}

void func(void)
{
    static int bCompilerInitFlag;  // 初始化标记
    static CMyClass s_iMyObj;
    if (FALSE == bCompilerInitFlag)
    {
        bCompilerInitFlag = TRUE;
        s_iMyObj.CMyClass(&s_iMyObj); // 调用构造函数
        atexit(&DestroyMyObj);        // 在退出进程时调用析构函数
    }
    // ...
}

可以看出,此时除了前文提到的各种竞态条件外,还可能出现 s_iMyObj 的构造函数和析构函数被多次调用的问题。

如果并发性对程序不重要,我们就可以使用一种简单的方式来保证局部静态对象初始化时的多线程安全性:

void func(void)
{
  CFastSessionLock flk(fmxLock); // 每次访问函数都上锁

    static int s_nMyVar1 = MyCalcAlgorithm1();
    static int s_nMyVar2 = MyCalcAlgorithm2();
    // ...
}

其中 fmxLock 可以看做是一个互斥量对象,而 CFastSessionLock 则是一个满足 RAII 语义的 Sentry 类(在构造时加锁,析构时解锁)。

或者,如果初始化不必非要延迟到用户第一次调用此函数时才进行。我们就可以直接将局部静态变量定义成全局变量(静态或非静态均可)。如果需要控制变量的作用域,可以使用一个全局哨位来完成进程启动时的初始化动作:

namespace {
    class CLocalStaticInitializer
    {
    public:
        CLocalStaticInitializer()
        {
            func(); // 确保在进程启动时完成 func 函数中本地静态变量的初始化
        }
    };
    const CLocalStaticInitializer sg_initLocalStatics;
}

前文已经提过,与局部静态变量不同,C++ 保证全局变量在进程启动的时候,在同一个编译单元内定义的全局量会按照其定义顺序被依次地初始化。

此外,使用字面常量初始化一个本地静态 POD 数据是线程安全的。实际上,这类初始化并不是在程序第一次执行到该变量所在语句块时才进行的,而是在程序启动时就直接从映像文件内的数据段中加载了。但对于一个非 POD 对象,无论是否使用编译时已知的常量对其进行初始化,编译器都需要为其生成调用构造、析构函数的代码和初始化标志变量。例如:

void func(void)
{
    static int s_nMyVar1 = 15; // OK
    static int s_nMyVar2 = MyCalcAlgorithm(); // 不行,不是编译时已知的常量

    static BYTE s_gbMyArray[3] = {1, 2, 3}; // OK

    struct MY_POD_TYPE
    {
        int a, b;
        const char* c;
    };
    static MY_POD_TYPE iMyPodData = { 10, 20, "30" }; // OK

    static std::string s_str = "123"; // 不行!即使使用字面常量初始化一个非 POD 变
                                      // 量, 编译器仍然需要生成初始化代码和相应标
                                      // 志变量
    // ...
}

对于 POD 类型,一种更好的解决方法是:充分利用操作系统加载进程时,对数据段做全零初始化的特性:C++ 标准中明确规定了,所有静态成员(即进程数据段)在进程加载时都必须 "zero-initialized"。由于数据段清零动作是在操作系统加载进程映像时就完成的,此时连主线程都还没有被创建,任何用户代码都没有开始执行,所以不存在多线程安全性问题。例如:

int func(void)
{
    static int s_nMyVar; // s_nMyVar 在进程映像加载时已被初始化为 0, 编译器
                         // 不需要为其生成任何封装代码和标志变量
    // ...
    return s_nMyVar;
}

实际上,前文提到的,编译器自动生成的 "bCompilerInitFlag" 标记变量就是利用这个特性来完成初始化的。

利用进程数据段在映像加载时清零的特性,配合使用一个互斥量,我们就可以在几乎不损失并发性的前提下保证任意本地静态 POD 变量初始化时的线程安全性

int func(void)
{
    static int s_nMyVar;
    if (0 == s_nMyVar)
    { CFastSessionLock flk(sg_fmxLocalStaticVarInit); // 上互斥锁

        if (0 == s_nMyVar) // 判断在等待互斥锁时,其他线程是否已完成初始化
        {
            s_nMyVar = MyCalcAlgorithm();
        }
    }
    return s_nMyVar;
}

以上例子保证了 s_nMyVar 初始化时的多线程安全性,同时只在 s_nMyVar 尚未完整初始化时发生了并发调用才会上互斥锁,最大限度地保证了并发效率。

非 POD 类型的情况则相对复杂。 因为编译器总是会生成调用构造函数并将析构函数压入进程退出节(atexit)的代码,所以想要在维持并发性的同时保证其初始化时的线程安全性,这个非 POD 对象就必须满足以下条件:

  1. 能够利用进程加载时的数据段清零特性(即:这个类和他的所有基类都能够正确地识别和处理所有数据成员均为全 0 值时的情形);

  2. 保证其构造函数被编译器生成的初始化代码并发地重复调用时不会产生任何副作用(通常需要依赖第一条实现);

  3. 保证其析构函数在进程退出节被顺序(非并发)地重复调用时不会产生任何副作用;

  4. 其初始化动作相对于检测初始化是否完成的操作来说,是一个原子操作。意即:在下例中的第一个 if 语句返回 false 时,s_thMyVar 必须保证已经初始化完毕,而不能是正初始化到一半的状态。

遗憾的是,大部分有意义的类都无法同时满足以上 4 个条件。为此,我们可以使用一个句柄类来进行辅助,例如:

const CMyClass& func(void)
{
    typedef CTmpHandle< CMyClass > THMYCLASS;

    static THMYCLASS s_thMyObj(DontInit); // DontInit 占位符:不初始化成员
    if (!s_thMyObj)
    { CFastSessionLock flk(sg_fmxLocalStaticVarInit); // 上互斥锁

        if (!s_thMyVar) // 判断在等待互斥锁时,其他线程是否已完成初始化
        {
            s_thMyObj = new CMyClass;
        }
    }
    return *s_thMyObj;
}

在这里可以简单的认为 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' 完全等效。所以上例也满足以上第四条所描述的“其初始化动作相对于检测初始化是否完成的操作来说,是一个原子操作”。应当注意到,此处的“原子操作”与本文第一节所描述的硬件级别的并不是一个概念。

 

 

版权所有 (C) 2004 - 2019, 白杨 (baiy.cn). 保留所有权利.
Copyright (C) 2004 - 2019, Bai Yang (baiy.cn). All Rights Reserved.