Code Snippets—Unreal Memory Barrier

发表于2017-06-21
评论0 2.8k浏览

前几天在改Unreal底层线程池代码的时候发现这样一句代码:

代码位置在ThreadingBase.cpp,类FQueuedThread的实现中。这句代码在多线程相关代码中会频繁出现。


“MemoryBarrier”翻译成中文有称作内存屏障的,也有称作内存栅栏的。我更喜欢内存栅栏这个词,所以后面相关的内容我都称作内存栅栏


内存栅栏不仅仅是使用C++编程会涉及到,使用C#、Java等其他的主流编程语言也一样会涉及到。因为这不是语言层面的概念,而且计算机硬件体系结构相关的概念。

这在多线程编程中是一个非常重要的概念,但是想必很多人还不是很了解,于是我决定写这篇文章,给大家简单介绍一下。


我们看一下FPlatformMisc::MemoryBarrier的定义会发现不同平台关于MemoryBarrier的定义不同。


Android和Linux平台(详见AndroidMisc.h和LinuxPlatformMisc.h)定义如下:

    FORCEINLINE static void MemoryBarrier()

    {

        __sync_synchronize();

    }


IOS和Mac平台(详见IOSPlatformMisc.h和MacPlatformMisc.h)定义如下:

    FORCEINLINE static void MemoryBarrier()

    {

        OSMemoryBarrier();

    }


而Windows平台(详见WindowsPlatformMisc.h)继承FGenericPlatformMisc的定义,函数内实现为空:

    void FGenericPlatformMisc::MemoryBarrier()

    {

    }


首先透露一下,__sync_synchronize();OSMemoryBarrier(); 这两句代码的效果其实是相同的。这句代码的作用是什么?为什么Windows上的实现不同于其他平台呢?


为了把这个内存栅栏搞懂,大家需要先了解一下,编译器指令优化、C++标准定义的Sequence point及Side effect、超标量流水线技术、处理器内存模型等等等等概念 。。。


是不是有些蒙??? 没关系,读完了下面的内容也许你就明白了。


编译器指令优化

看下面的一段代码:

    int t = 1;

    a = t;

    b = 2;


我们知道处理器读一个不在cache中的变量,则需要先从内存中加载变量到cache。从内存加载数据是比较低效的行为(这也是cache这种硬件结构产生的原因),会花费较长的时钟周期。


我们假设,从内存加载数据需要花费的时钟周期为2,赋值计算需要花费的时钟周期为1。在上面的代码执行的位置,我们假设此时变量a和b都在cache中,t在内存中。

如果代码按顺序执行花费的时钟周期如下:


load instruction for t    (cycle 0)

wait for t's loading      (cycle 1)

wait for t's loading      (cycle 2)

copy value from t to a    (cycle 3)

assign b with 2        (cycle 4)


分析代码可以发现,a = t和b = 2这两句代码他们的执行顺序和执行结果无关,也就是说如果我调换了这两句代码的执行顺序为下面这样:

    int t = 1;

    b = 2;  

    a t;


最终的执行结果a的值和b的值不会发生改变。但是此时处理器消耗的时钟周期可就变化了:

load instruction for t      (cycle 0)

assign b with 2             (cycle 1) --> padding wait for t's loading

wait for t's loading        (cycle 2)

copy value from t to a      (cycle 3)


我们可以看到调换了代码的执行顺序之后节省了一个时钟周期。


这也就是编译器执行指令乱序优化的原因之一。注意这里的“之一”,因为编译器执行指令乱序的原因还有很多很多,这里就不一一列举了。编译器在代码编译阶段能够通过分析代码逻辑,通过将指令乱序来进行大范围的代码优化,以更好地适应后续处理器的执行,提高执行效率。


目前为止我们很嗨皮地看到编译器通过指令乱序帮我们做了代码的优化。这种优化在单线程串行执行的时候没有问题,因为优化的前提要保证对单线程程序的语义是没有影响的。

否则优化岂不是变成了乱改代码。。。


我们再来看一下编译器指令乱序优化发生在多线程环境中的情况。


我们再来看下面代码:

    std::atomic<int> a{ 0 };

    std::atomic<int> b{ 0 };

    int Thread0(int)

    {

        int t = 1;

        a = t;

        b = 2;

        return 0;

    }

    int Thread1(int)

    {

        while (b != 2) {};

        std::cout << a << "," << b << std::endl;

        return 0;

    }

    int main()

    {

        std::thread t0(Thread0, 0);

        std::thread t1(Thread1, 0);

        t0.join();

        t1.join();

        return 0;

    }


执行结果应该是:

1,2


但是如果根据前面介绍的内容,编译器对指令进行乱序优化的话,Thread1中是否会打印出0,2这样的结果呢?


如果出现了这种执行结果,那说明上面的代码同样被执行了指令乱序优化,这样的话我们就无法保证多线程代码的执行正确了。。。


事实上编译器并不会对这段代码做指令乱序优化。C++ 0x中原子类型的变量在线程中总是保持着顺序执行,因为a和b都是原子类型的变量,原子类型的变量需要在线程间进行共享,所以如果编译器按照之前单线程情况下的优化方式进行优化,那上面的代码恐怕就永远无法正确执行了。


多线程环境下非原子类型的变量由于通常不需要在线程之间进行同步,顾没有必要保持顺序执行,仍然会被优化。


除了原子类型的变量,还有一种经常在线程间共享的变量,那就是volatile类型的变量。对于这类变量编译器是怎么处理的呢?

我们看一下C++ 0x标准是怎么说的:


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和Sequence point的定义我估计很多人也是不熟悉的,下面同样贴出标准的定义:


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.


上面说的很清楚,也没有出现什么陌生的概念,所以我就不过多介绍了。

总之,标准规定了,对于volatile类型的变量编译器是不能执行指令乱序优化的。


代码分析

说了这么多,我们回头来看Unreal的这段代码。为了方面阅读,我将代码做了简化,只保留我们关注的代码部分:


    /** The work this thread is doing. */

    IQueuedWork* volatile QueuedWork;

    virtual uint32 Run() override

    {

        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;

    }

    void DoWork(IQueuedWork* InQueuedWork)

    {

        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类型,根据前面介绍的知识我们知道QueuedWork前后的代码在编译阶段不会被编译器执行指令乱序优化。

但是事实上这段代码如果没有加“FPlatformMisc::MemoryBarrier();”话在某些平台上执行的结果仍然可能不正确,“QueuedWork = InQueuedWork;”DoWorkEvent->Trigger();仍然会被乱序执行。


这是为什么呢?


处理器内存模型

我们通过设置变量类型为volatile禁止了编译器的指令乱序,但是其实指令在处理器中执行的时候由于各种原因仍然可能是乱序的。具体地说指令在处理器中由于各种优化的原因,就从没有严格按照顺序执行过。。。


具体的各种乱序原因我们这里就不介绍了,否者这篇文章就可以写本书了。。。


这里我们要了解一下内存模型超标量流水线技术

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

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


由于这种情况下指令的执行顺序并不是顺序执行的,所以处理器的内存模型分为强顺序模型(Strong ordered) 和弱顺序模型(Weak ordered )两种。


我们熟知的x86架构的处理器采用强顺序内存模型,而移动终端设备大量使用的ARMv7处理器则采用弱顺序内存模型。


这样问题就出来了,我们在移动平台上QueuedWork = InQueuedWork; 和DoWorkEvent->Trigger();的执行顺序又变得无法保证顺序执行了。


不过幸好,相应的平台提供了实现内存栅栏的技术手段,来保证内存栅栏前后的代码的执行顺序一定按照代码编写的先后顺序。在基于x86架构运行的Windows平台上则不需要担心这个问题,这也就解释了为什么FPlatformMisc::MemoryBarrier()的Windows实现为空的问题。


现在我们知道了ARMv7上面个使用了更加高效的硬件架构设计,但是给我们程序狗们带来了编程复杂度的提高。。。


说到这里可能有人会问,是不是ARMv7架构要比x86架构更加高效?


这个问题比较复杂,又涉及到了复杂指令集(CISC)和精简指令集(RISC)等一系列新的概念。


如果有人感兴趣可以在下面留言,后续再给大家介绍。


不知不觉又写了这么多,内容有些晦涩,但却是多线程编程极为重要的基础。


我发布在这里的文章都是转至我的微信订阅号,如果你想及时获得最新发布的文章,可以关注我的微信订阅号。

Code Snippets——Unreal代码缺陷之浮点误差

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引