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


‍‍‍‍‍‍‍ 9d27de21-1a04-495d-9b72-68fbe8b4a1ce.jpg




背景

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


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


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


c332cb87-7d74-4982-be36-fa6ff8e6e276.png 8042ebc3-c748-41b2-b5d9-6e1d88993e8c.png

备注: 本文中讲解的代码以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() 消费valuepg_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


c332cb87-7d74-4982-be36-fa6ff8e6e276.png 8042ebc3-c748-41b2-b5d9-6e1d88993e8c.png 陷阱: 不能想当然的把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,见下}voidSetLatch(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中文资源。


695f9928-dffd-41fd-955f-0cf400fc0cf0.gif

c19ba984-7ae1-41de-a1df-91952d5b8b6a.png 来一波 “在看”、“分享” “赞” 吧!


本文分享自微信公众号 - Greenplum中文社区(GreenplumCommunity)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“ OSC源创计划 ”,欢迎正在阅读的你也加入,一起分享。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值