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