这段时间看底层代码经常看到volatile这个类型修饰符,本身这个修饰符作用比较简单,但今天在看Unreal源码ThreadingBase底层线程池部分看到了volatile配合多线程相关的优化,涉及到的概念挺多,也蛮有意思的,这里跟大家分享一下。

编译器指令优化

很多时候我们在使用高级编程语言提供的函数、条件语句和循环这样的抽象编程构造进行编程,来提高我们的编程效率,然后不可避免地会带来性能下降的问题。所以,如今的编译器都已经深谙此道,会尝试自动优化代码以提高性能。编译器可以转化循环、条件语句和递归函数、消除整块代码和利用目标指令集的优势让代码变得高效而简洁。所以我个人也是坚持这样的编程原则,写出可读性高的代码要比因为手动优化而使代码变得晦涩难懂且难以维护要更加重要,有时候手动优化的代码反而可能会让编译器难以进行额外和更加有效的优化。实际的编译器优化内容非常广泛,这里使用主要列举和volatile相关的几个优化。

byte[0] = 0x51;
byte[0] = 0x52;
byte[0] = 0x53;

上面这个3个赋值语句理论会执行3次赋值操作,然后实际上在编译的时候,因为3条语句的功能相同,编译器会忽略前2条语句,认为只有最后语句有效,只生成一条机器代码。

再比如以下的代码:

int a = 1;
b = a;
c = 2;

处理器在读取变量的时候为了保证高效,一般会先去寄存器中寻找变量的备份,如果没有命中再去内存中读取数据。比较从内存中加载数据会非常低效,需要花费较长的时钟周期。

上面的代码假设b、c都在寄存器中有备份,而a没有。这样的话,在顺序执行的时候,因为第二个赋值语句需要等待a从内存中读取出来才可以进行赋值,需要等待较长的时钟周期,所以编译器会进行指令乱序优化,通过分析代码逻辑,将第二个赋值语句和第三个赋值语句进行调换,并不影响执行结果,但是因为b、c都在寄存器中有备份,可以在等待a从内存中读取的同时执行完c变量的赋值操作,从而优化了代码的执行效率。

这种优化在单线程串行执行的时候没有问题,对单线程程序的语义并没有任何影响,但是在多线程的情况就会有问题。

#include <iostream>
#include <atomic>
#include <thread>

using namespace std;

atomic<int> b {0};
atomic<int> c {0};

int Thread1() {
    int a = 1;
    b = a;
    c = 2;
    return 0;
}

int Thread2() {
    while(c != 2) {}
    cout << b << "," << c << endl;
    return 0;
}

int main(int argc, char *argv[]) {
    thread t0(Thread1);
    thread t1(Thread2);
    t0.join();
    t1.join();

    return 0;
}

上面的代码运行结果是1,2,而如果编译器对代码执行了指令乱序优化则有可能出现0,2这种结果。事实上编译器是不会对这段代码执行优化的,因为b,c的类型声明为了原子类型,C++原子类型的变量需要在线程间共享,在线程中总是保持着顺序执行。

那么除了原子类型的变量,还有一种经常在线程间共享的变量——volatile类型变量。

volatile

这个类型修饰符被设计的目的就是用来修饰被不同线程访问和修改的变量,每次都是去内存中读取变量。作为指令关键字,可以确保本条指令不会因编译器的优化而忽略。

C++ 11的定义:

Every access (read or write operation, member function call, etc.) made through a glvalue expression of volatile-qualified type is treated as a visible side-effect for the purposes of optimization (that is, within a single thread of execution, volatile accesses cannot be optimized out or reordered with another visible side effect that is sequenced-before or sequenced-after the volatile access. This makes volatile objects suitable for communication with a signal handler, but not with another thread of execution, see std::memory_order).

定义当中提到了volatile类型的变量不能和Sequence point前和后Side effect进行重新排序。

Side effect: Accessing an object designated by a volatile glvalue, modifying an object, calling a library I/O function, or calling a function that does any of those operations are all side effects, which are changes in the state of the execution environment.

Sequence point: A sequence point is a point in the execution sequence where all side effects from the previous evaluations in the sequence are complete, and no side effects of the subsequent evaluations started.

简单地解释下Side effect就是副作用的意思,是指对数据对象或者文件的修改,例如var = 9,这个求值表达式的副作用就是var的值被修改成了9。而Sequence point是序列点的意思,是指程序在运行中的一个特殊的时间点,在该点之前的所有副作用已经结束,并且后续的副作用还没发生。C 语句结束标志——分号(;)是序列点。标准规定,在两个序列点之间,一个对象所保存的值最多只能被修改一次。

所以类似这种代码(int a = (++b) + (++b);),整个语句只有一个Sequence point,就是结束语句的分号,对右边表达式的计算顺序没有任何的规定,编译器会按照他们自己的优化规则来进行计算。这样的代码如果只是在特定的平台或者编译器运行,那么就只有可读性差的问题,如果是考虑跨平台或者编译器的话,那么就完全是错误的代码了。

Unreal ThreadingBase

下面我们看下Unreal里面的ThreadingBase里面的线程池的代码里面神奇的地方。

/** The work this thread is doing. */
IQueuedWork* volatile QueuedWork;

/**
 * The real thread entry point. It waits for work events to be queued. Once
 * an event is queued, it executes it and goes back to waiting.
 */
virtual uint32 Run() override
{
    while (!TimeToDie)
    {
        // This will force sending the stats packet from the previous frame.
        SET_DWORD_STAT( STAT_ThreadPoolDummyCounter, 0 );
        // We need to wait for shorter amount of time
        bool bContinueWaiting = true;
        while( bContinueWaiting )
        {               
            DECLARE_SCOPE_CYCLE_COUNTER( TEXT( "FQueuedThread::Run.WaitForWork" ), STAT_FQueuedThread_Run_WaitForWork, STATGROUP_ThreadPoolAsyncTasks );
            // Wait for some work to do
            bContinueWaiting = !DoWorkEvent->Wait( 10 );
        }

        IQueuedWork* LocalQueuedWork = QueuedWork;
        QueuedWork = nullptr;
        FPlatformMisc::MemoryBarrier();
        check(LocalQueuedWork || TimeToDie); // well you woke me up, where is the job or termination request?
        while (LocalQueuedWork)
        {
            // Tell the object to do the work
            LocalQueuedWork->DoThreadedWork();
            // Let the object cleanup before we remove our ref to it
            LocalQueuedWork = OwningThreadPool->ReturnToPoolOrGetNextJob(this);
        } 
    }
    return 0;
}

/**
 * Tells the thread there is work to be done. Upon completion, the thread
 * is responsible for adding itself back into the available pool.
 *
 * @param InQueuedWork The queued work to perform
 */
void DoWork(IQueuedWork* InQueuedWork)
{
    DECLARE_SCOPE_CYCLE_COUNTER( TEXT( "FQueuedThread::DoWork" ), STAT_FQueuedThread_DoWork, STATGROUP_ThreadPoolAsyncTasks );

    check(QueuedWork == nullptr && "Can't do more than one task at a time");
    // Tell the thread the work to be done
    QueuedWork = InQueuedWork;
    FPlatformMisc::MemoryBarrier();
    // Tell the thread to wake up and do its job
    DoWorkEvent->Trigger();
}

首先DoWork函数会在触发异步任务的线程被调用,执行完QueuedWork = InQueuedWork;这句代码后调用DoWorkEvent->Trigger();触发工作线程开始处理任务,Run函数在工作线程执行。

因为这两句代码没有直接关联,所以编译器可能会执行指令乱序优化。但是一旦交换了这两句代码的执行顺序,我们在Run函数中就会遇到QueuedWork为空的情况,而错误地结束Run函数的执行。我们可以看到QueuedWork变量的类型为volatile,根据之前的介绍,正常情况下编译器不会执行乱序优化。然而事实上如果这段代码不加上FPlatformMisc::MemoryBarrier();在某些平台上执行结果仍然可能不正确。

处理器内存模型

之所以变量声明为volatile还是会乱序原因比较多,就不一一解释了,我们围绕MemoryBarrier来了解两个概念,“内存模型”和“超标量流水技术”。

内存模型是一个硬件概念,表示机器指令以什么样的顺序被处理器执行。

超标量流水线技术是指处理器中设置了一条以上的流水线,并且每时钟周期内可以并行完成多条指令的执行。

由于这种情况下指令的执行顺序并不是顺序执行的,所以处理器的内存模型分为“强顺序模型(Strong ordered) 和弱顺序模型(Weak ordered )”两种。常见的x86架构的处理器采用强顺序内存模型,而移动终端设备大量使用的ARMv7处理器则采用弱顺序内存模型。所以问题就来了,在移动平台上我们就没法保证QueuedWork = InQueuedWork;和DoWorkEvent->Trigger();这两句话的执行顺序了。好在对应的平台提供了“内存栅栏”的技术。

内存栅栏是一类同步指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。我们看下FPlatformMisc::MemoryBarrier()在不同平台下的实现:

Android和Linux:

FORCEINLINE static void MemoryBarrier()
{
    __sync_synchronize();
}

iOS和Mac:

FORCEINLINE static void MemoryBarrier()
{
    OSMemoryBarrier();
}

Windows:

void FGenericPlatformMisc::MemoryBarrier()
{
}

可以看出在ARMv7架构对应的平台都调用了对应的内存栅栏的接口,而基于x86的Windows平台就不需要担心这个问题,所以实现也为空。

如果对硬件架构感兴趣,可以对CISC和RISC做一些了解,计算机组成原理相关的知识,找一找相关的书籍阅读下。