
背景
在并发编程时,对于互斥区我们一般通过锁来保护。在Greenplum中也是如此,所以大家在源码中可以看到相应的锁操作,比如我们已经熟悉的spinlock,lwlock等等。
但是在有些场景中,互斥区非常小(比如只访问一个变量的场景),为了提升性能,更希望使用无锁方式来进行操作,因此希望对应的操作可以原子化。针对这类场景,在大部分编程语言中都内置了相应的基础库,比如C++中的std::atomic, Java中的java.util.concurrent.atomic等等。
但是Greenplum/postgres是使用C语言开发的,并没有现成的标准库可以使用,因此自行实现了这部分原子操作功能,但是功能相对比较简易,需要小心细致的使用。本文将会对它们进行简要介绍,并分享作者的一些使用经验。


备注:本文中讲解的代码以Greenplum的master分支(还未发布的 Greenplum
7)为准,另外区别于常见的多线程并发模型,Greenplum/Postgres是多进程并发模型,进程之间的共享变量都位于共享内存之中。
基础
barrier内存屏障(后文中会进行介绍)宏; 例如:pg_memory_barrier 32bit数原子操作; 例如:pg_atomic_read_u32(), pg_atomic_fetch_add_u32() 64bit数原子操作; 例如:pg_atomic_write_u64(), pg_atomic_fetch_sub_u64()
示例程序
/* 主进程 */// 如下这些变量实际位于共享内存中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; // 生产valuepg_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
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


而在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);}
深入理解
到此可能读者还是觉得有些模糊,这里简单总结一下:
从根源上讲,保证原子操作正确执行,需要处理好编译器的不适当优化,CPU指令乱序执行(特别是多core之间),以及正确的内存可见性。
它们主要涉及到3个知识点,如下将分别简单进行介绍。
volatile 阻止编译器(过度)优化。
易变性:跳过寄存器,直接从内存中读取变量 不可优化性:阻止编译器对变量的各种激进优化 顺序性:各个volatile变量之间的操作顺序和用户程序保持一致
cache coherency 保证合适的内存可见性。
CPU中的每个core上都有自己独立的cache,读写数据时有可能访问的不是内存,而是core对应的cache。因此内存中的值不一定是最新的,多core并发访问它时需要引入cache coherency协议来解决一致性问题。
这部分细节还是挺多的,很多和与CPU硬件协议有关系,作者也不专业,本文就不再进行详细介绍了。读者这里只需要知道memory barrier的主要目的之一就是解决这些问题。
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 内存屏障
以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
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
参考资料
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

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




