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

发布于 2022-8-3 17:46
浏览
0收藏

 

背景
在并发编程时,对于互斥区我们一般通过锁来保护。在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中看一下示例程序中使用到的原子函数的注释(留意加粗的部分)

带你入门Greenplum源码中的原子操作-开源基础软件社区

注释中提到的”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种内存屏障:

带你入门Greenplum源码中的原子操作-开源基础软件社区

以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中文社区
 

分类
已于2022-8-3 17:46:38修改
收藏
回复
举报
回复
添加资源
添加资源将有机会获得更多曝光,你也可以直接关联已上传资源 去关联
    相关推荐