暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

带你入门Greenplum源码中的原子操作

Greenplum中文社区 2022-05-07
961

‍‍‍‍‍‍‍

背景

在并发编程时,对于互斥区我们一般通过锁来保护。在Greenplum中也是如此,所以大家在源码中可以看到相应的锁操作,比如我们已经熟悉的spinlock,lwlock等等。

但是在有些场景中,互斥区非常小(比如只访问一个变量的场景),为了提升性能,更希望使用无锁方式来进行操作,因此希望对应的操作可以原子化。针对这类场景,在大部分编程语言中都内置了相应的基础库,比如C++中的std::atomic, Java中的java.util.concurrent.atomic等等。

但是Greenplum/postgres是使用C语言开发的,并没有现成的标准库可以使用,因此自行实现了这部分原子操作功能,但是功能相对比较简易,需要小心细致的使用。本文将会对它们进行简要介绍,并分享作者的一些使用经验。

备注:本文中讲解的代码以Greenplum的master分支(还未发布的 Greenplum

7)为准,另外区别于常见的多线程并发模型,Greenplum/Postgres是多进程并发模型,进程之间的共享变量都位于共享内存之中。

基础

Greenplum中的原子操作函数主要位于src/include/port/atomics.h中,这些函数基本可以分为如下三类:
  • barrier内存屏障(后文中会进行介绍)宏;
    例如:pg_memory_barrier
  • 32bit数原子操作;
    例如:pg_atomic_read_u32(), pg_atomic_fetch_add_u32() 
  • 64bit数原子操作;
    例如:pg_atomic_write_u64(), pg_atomic_fetch_sub_u64() 
接下来我们通过一个示例程序来演示这些原子操作的使用方法。

示例程序

一个简单的单生产者多消费者程序(简化自gpdb中的ShareInputScan逻辑),可以写成如下伪代码:
    /* 主进程 */
    // 如下这些变量实际位于共享内存中
    const int N = 10;    // N个消费者
    int value = -1;    // 生产/消费的值
    pg_atomic_uint32 ready;  // 生产完毕
    pg_atomic_uint32 ndone;  // 消费完毕
    ConditionVariable cv;    // 用于通知的条件变量
    pg_atomic_init_u32(&ready, 0); //初始化
    pg_atomic_init_u32(&ndone, 0); 
    // XXX() 启动1个生产者和N个消费者进程

    /* 生产者进程 */
     value = 100;        // 生产value
     pg_atomic_exchange_u32(ready, 1);   // 1个问题:使用pg_atomic_write_u32()可以吗?
     ConditionVariableBroadcast(cv);  // 通知全部消费者
    // 等待消费结果
    for (;;)
    {
    int d = pg_atomic_read_u32(&ndone);
    if (d >= N) break; // 全部消费者消费完毕
    ConditionVariableSleep(cv);
    }

    /* 消费者进程 */
    // 等待生产就绪
    for (;;)
    {
    int r = pg_atomic_read_u32(&ready);
    if (r) break;
    ConditionVariableSleep(cv);
    }
    assert(value > 0);
    // XXX() 消费value
    pg_atomic_add_fetch_u32(&ndone, 1);  // 效果ndone++,消费完毕
    ConditionVariableBroadcast(cv);  // 通知生产者

    解读

    这段程序并不复杂,但是编写时需要注意一些陷阱,请思考一下这2个问题:

            

      问题1:变量ready能否直接使用int类型+普通的读取/赋值操作?既然只有一个生产者且读写int32是一个原子操作,看起来能更加简化程序;

      问题2:生产者中能否使用pg_atomic_write_u32()

      (而不是pg_atomic_exchange_u32)?这个函数看起来更自然;

    然而这2个问题的答案都是“不能”,解释如下。

    问题1

    先来看看pg_atomic_uint32的定义

      typedef struct pg_atomic_uint32
      {
      volatile uint32 value;
      } pg_atomic_uint32;


      原来就是int+volatile关键字,volatile的作用见下一节中详述,简单说就是阻止编译器的激进优化,从而保证程序在并发状态下的正确执行。

      那如果我们将ready定义为:volatile int,然后正常读取和赋值它可以吗?答案依然是不行的。这里又涉及到CPU上的2个要点:cache coherency和乱序执行(不是编译器乱序),为了解决他们就需要引出memory barrier了(即内存屏障,也在下一节中详细介绍)。

      陷阱:不同编程语言中volatile关键字的作用很可能是不一样的,比如Java语言的volatile关键字就默认带了内存屏障的效果(所以在Java中,问题1是没问题的,用volatile修饰就足够了)。

      这里还有一个小故事:很多年前作者刚工作时,C++领域(C++11之前)一直没有比较好的介绍并发的书籍,于是就一直参考经典书籍《Java concurrency in practice》中的各种并发模式并顺手参考了Java语言中的volatile的含义。然后在编写C代码时一直采用volatile int模式,我们当时还流传着一个口诀:『一写多读不加锁』,不过比较幸运,程序一直没出过问题。但是对于现代编译器和CPU,这样是有很大隐患的,出现问题后也非常难以调试。

      问题2

      先在atomics.h中看一下示例程序中使用到的原子函数的注释(留意加粗的部分)

      函数

      注释
      pg_atomic_read_u32()
      unlocked read from atomic variable
      The read is guaranteed to return a value as it has been written by this or another process at some point in the past. There's however no cache coherency interaction guaranteeing the value hasn't since been written to again.
      No barrier semantics.
      pg_atomic_write_u32()
      write to atomic variable
      The write is guaranteed to succeed as a whole, i.e. it's not possible to observe a partial write for any reader. 
      No barrier semantics.
      pg_atomic_exchange_u32()
      exchange newval with current value
      Returns the old value of 'ptr' before the swap.
      Full barrier semantics.
      pg_atomic_add_fetch_u32()
      atomically add to variable
      Returns the value of ptr after the arithmetic operation.
      Full barrier semantics.
      注释中提到的”barrier”就是内存屏障,这里如果采用pg_atomic_write_u32来写ndone,随后再用pg_atomic_read_u32进行读取,由于它们2个都是No barrier semantics,因此读取出来的可能是一个旧值(之前某个时间点写入的正确值,不会存在”半”写入状态)。
      所以需要采用pg_atomic_exchange_u32来写入,它具有Full barrier semantics,可以保证随后的原子操作都可以读到最新值。
      示例程序中的2个共享变量读写都是遵守着这个模式:读写之间通过内存屏障保证最新值可见
        • ready:  pg_atomic_exchange_u32 (Full barrier semantics) -> pg_atomic_read_u32
        • ndone: pg_atomic_add_fetch_u32 (Full barrier semantics) -> pg_atomic_read_u32

      陷阱:不能想当然的把gpdb中的atomics.h当成C++中的std::atomic<type>来使用,它们暴露了更多细节(barrier语义)给调用者,从而保证更好的性能。

      而在C++中的原子类型的定位是更普遍的场景,为了使调用者省心+透明,默认传入了严格的内存顺序:memory_order_seq_cst(内存顺序是C++内存模型中的概念,达到的效果和内存屏障类似),以赋值操作为例:

        __int_type operator=(__int_type __i) volatile noexcept
        { store(__i); return __i; }
        _GLIBCXX_ALWAYS_INLINE void store(__int_type __i,
        memory_order __m = memory_order_seq_cst) volatile noexcept
        {
        memory_order __b = __m & __memory_order_mask;
        __glibcxx_assert(__b != memory_order_acquire);
        __glibcxx_assert(__b != memory_order_acq_rel);
        __glibcxx_assert(__b != memory_order_consume);
        __atomic_store_n(&_M_i, __i, __m);
        }
        atomic_base.h source code [libstdc++-v3/include/bits/atomic_base.h] - Woboq Code Browser:https://code.woboq.org/gcc/libstdc++-v3/include/bits/atomic_base.h.html#std::__atomic_base
        这保证了内存的顺序一致,具体读者可以参考《C++ concurrency in action》一书,

        深入理解

        到此可能读者还是觉得有些模糊,这里简单总结一下:

        从根源上讲,保证原子操作正确执行,需要处理好编译器的不适当优化,CPU指令乱序执行(特别是多core之间),以及正确的内存可见性。

        它们主要涉及到3个知识点,如下将分别简单进行介绍。

        volatile  阻止编译器(过度)优化。

        它往往以编程语言中的一个(相对冷僻)关键字出现,C语言中的volatile主要体现为3个含义:
        • 易变性:跳过寄存器,直接从内存中读取变量
        • 不可优化性:阻止编译器对变量的各种激进优化
        • 顺序性:各个volatile变量之间的操作顺序和用户程序保持一致
        这个关键字的来龙去脉请参考 《C++ and the Perils of Double-Checked Locking》(https://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf) 一文中对它的介绍:主要来源于对I/O程序的支持。
        另外还需要留意不同语言中volatile的不同含义(Java中的就是C的一个内存屏障增强版)。

        cache coherency  保证合适的内存可见性。

        CPU中的每个core上都有自己独立的cache,读写数据时有可能访问的不是内存,而是core对应的cache。因此内存中的值不一定是最新的,多core并发访问它时需要引入cache coherency协议来解决一致性问题。

        这部分细节还是挺多的,很多和与CPU硬件协议有关系,作者也不专业,本文就不再进行详细介绍了。读者这里只需要知道memory barrier的主要目的之一就是解决这些问题。

        如果读者对cache coherency有兴趣,可以参考:
        • UCI大学OS课程中的这个课件:lecture13-memory-barriers.pdf (uci.edu)(https://www.ics.uci.edu/~aburtsev/cs5460/lectures/lecture13-memory-ordering/lecture13-memory-barriers.pdf
        • x84指令集中的sfence/lfence/mfence指令的介绍

        memory barrier  内存屏障

        最后我们终于来到了memory barrier(内存屏障),简单说:它阻止CPU指令乱序执行,并保证内存可见性。
        Greenplum 源码中提供了4种内存屏障:

        内存屏障

        作用
        pg_compiler_barrier()
        prevent the compiler from moving code across
        the compiler must not reorder loads or stores to main memory around the barrier.  However, the CPU may still reorder loads or stores at runtime.
        pg_memory_barrier()
        prevent the CPU from reordering memory access,即”Full barrier
        memory barrier must act as a compiler barrier, and in addition must guarantee that all loads and stores issued prior to the barrier are completed before any loads or stores issued after the barrier.
        pg_read_barrier()
        读优化版的memory_barrier:隔离2个读操作,保证第一个读操作必须在第二个读操作之前进行
        pg_write_barrier()
        写优化版的memory_barrier:隔离2个写操作,保证第一个写操作必须在第二个写操作之前进行

        以pg_memory_barrier的宏代码为例,它是通过汇编指令来实现:

          #define pg_memory_barrier() pg_memory_barrier_impl()
          #define pg_memory_barrier_impl() \
          __asm__ __volatile__ ("lock; addl $0,0(%%rsp)" : : : "memory", "cc")
          #endif

          其中 __asm__ __volatile__ 指示编译器不要做优化;lock; addl 是一种更高效的”mfence”指令,配合后边的 memory 达到内存屏障的作用:读取到正确的最新值。
          限于篇幅和作者的知识水平,也就介绍到这里为止了,读者还有兴趣的话,可以参考PERF book(https://github.com/philips/perfbook/blob/master/advsync/memorybarriers.tex)中的memory barrier一节。

          Greenplum中的代码示例

          之前的生产者消费者示例程序的相关代码尚处于PR Review状态:

          Resolve GPDB_12_MERGE_FIXMEs in nodeShareInputScan.c by interma

          (https://github.com/greenplum-db/gpdb/pull/13170),

          本文发布时可能尚未merge。

          因此我们再看Greenplum中已有的一个代码片段:

            volatile sig_atomic_t notifyInterruptPending = false;  sig_atomic_t一般就是int 
            // gpdb中的设置中断标记函数
            void HandleNotifyInterrupt(void)
            {
            notifyInterruptPending = true; 设置标记变量,并没有使用pg_atomic函数
            SetLatch(MyLatch); 因此手动加入barrier,见下
            }
            void
            SetLatch(Latch *latch)
            {

            /*
            * The memory barrier has to be placed here to ensure that any flag
            * variables possibly changed by this process have been flushed to main
            * memory, before we check/set is_set.
            */
            pg_memory_barrier(); 加入memory_barrier

            }

            位于SetLatch()中的pg_memory_barrier()保证了notifyInterruptPending变量(以及其他共享变量)对于任意一个并发进程都是正确的:它们观察到的是效果是顺序执行且最新值可见。

            心得

            这里分享一些作者的使用心得。

            其实第一条心得和大多数复杂技术的使用建议类似:先不采用原子操作,正如atomics.h中的头注释所述:


             * Use higher level functionality (lwlocks, spinlocks, heavyweight locks)

             * whenever possible. Writing correct code using these facilities is hard.

             * For an introduction to using memory barriers within the PostgreSQL backend,

             * see src/backend/storage/lmgr/README.barrier


            因此在编写 Greenplum 代码的互斥逻辑时,还是优先使用lwlock和spinlock等锁操作(它们已经内置好了合适的memory_barrier逻辑,感兴趣的读者可以参看源码)。待功能正确稳定后,再结合性能数据进行必要的优化。


            如果一定要使用原子操作,请仔细阅读各个函数的注释,然后尽量follow gpdb源码中已有的代码模式。

            本文到此就结束了,内容可能相对冷门且有些晦涩,但是它揭露了”冰山下边”的世界,探索一下还是非常有趣的。本文算是抛砖引玉一下,如果能引起读者的好奇心,那就达到了目的了。最后,作者对这些内容也是现学现卖,文中的不当之处请随时指正。


            参考资料

            • gpdb/atomics.h at master · greenplum-db/gpdb (github.com):https://github.com/greenplum-db/gpdb/blob/master/src/include/port/atomics.h

            • gpdb/README.barrier at master · greenplum-db/gpdb (github.com):https://github.com/greenplum-db/gpdb/blob/master/src/backend/storage/lmgr/README.barrier

            • volatile与内存屏障总结 - 知乎 (zhihu.com):https://zhuanlan.zhihu.com/p/43526907

            • 相关书籍(都有中文翻译)

              • 《Java concurrency in practice》
              • C++ concurrency in action》 2nd
            • 三个独立主题
              • C语言中的volatile: C++ and the Perils of Double-Checked Locking:https://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf
              • cache coherency: lecture13-memory-barriers.pdf (uci.edu)::https://www.ics.uci.edu/~aburtsev/cs5460/lectures/lecture13-memory-ordering/lecture13-memory-barriers.pdf
              • memory barrier: perfbook/memorybarriers.tex at master · philips/perfbook (github.com):https://github.com/philips/perfbook/blob/master/advsync/memorybarriers.tex

            作者简介

            马洪旭,Greenplum研发工程师


            Greenplum& Apache HAWQ committer,热爱基础架构和开源技术,在VMware从事Greenplum内核研发工作,目前专注于存储相关领域。



            点击文末“阅读原文”,获取Greenplum中文资源。



            来一波 “在看”、“分享”和 “赞” 吧!


            文章转载自Greenplum中文社区,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

            评论