最近从 kvell 这篇论文中看到一些单机存储引擎的优秀设计,底层存储硬件性能在不远的未来可能不再是主要的性能瓶颈,反而高并发下的CPU可能是软件性能的主要限制。像BPS/AEP/Optane-SSD 等Intel 推出的硬件存储栈已经能够在延时上接近DRAM的量级,吞吐在较低的队列深度下更是能够超越当前主流NVMe-ssd 数倍甚至一个量级;同时结合 SPDK/io_uring/ZNS 等新型底层软件栈,更是能够在操作系统层级完全发挥硬件性能。
这个时候,我们的软件设计模型需要适配硬件的发展。这里KVell 提出的单机引擎软件栈就是 shard-nothing。 即引擎层调度I/O的时候是单线程的,每个调度线程绑定一个 CPU-core 来完整调度整个IO的处理,这个调度方式的优劣会在后面介绍KVell 的时候详细描述(NUMA架构下对cpu的访存非常友好)。总之,多线程独立处理请求的模型 也是 ceph 最新的 crimson osd 正在进行重构的主体架构。
本文主要讨论的是在 KVell 的实现中看到 一个线程间同步数据的调度函数的使用__sync_fetch_and_add
,它是GCC 提供的针对一个变量的原子操作。
有点好奇为什么KVell 会使用这个函数原子操作这个变量,按照我们的编程习惯,我们为了防止多线程对同一个变量的修改不是原子的,可能会考虑使用排他锁或者内核的 atomic_* 系列操作,但是它这里使用了这个函数,那这个选择肯定是有原因的(当然可能C语言没有atomic 库,所以没法直接用)。
所以就简单做了一个性能测试:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <mutex>
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
#include <sys/time.h>
int g_iFlagAtom = 1;
#define WORK_SIZE 5000000
#define WORKER_COUNT 10
std::vector<std::thread> g_tWorkerID;
std::mutex mu;
#ifdef ATOMIC
std::atomic<int> g_iSum;
#else
int g_iSum = 0;
#endif
uint64_t NowMicros() {
struct timeval tv;
gettimeofday(&tv, nullptr);
return static_cast<uint64_t>(tv.tv_sec) * 1000000 + tv.tv_usec;
}
void * thr_worker(int tid) {
printf ("WORKER THREAD %d STARTUP\n", tid);
int i=0;
for (i=0; i<WORK_SIZE; ++i) {
if (g_iFlagAtom) {
#ifdef ATOMIC
#else
__sync_fetch_and_add(&g_iSum, 1);
#endif
} else {
#ifdef ATOMIC
g_iSum ++;
#else
mu.lock();
g_iSum ++;
mu.unlock();
#endif
}
}
return NULL;
}
int main(int argc, char* argv[]) {
if (argc < 2) {
printf("args < 2");
return -1;
}
g_iFlagAtom = atoi(argv[1]);
int i;
for (i=0;i<WORKER_COUNT;++i) {
g_tWorkerID.push_back(std::thread(thr_worker, i));
}
uint64_t start = NowMicros();
for (i=0;i<g_tWorkerID.size();++i) {
g_tWorkerID[i].join();
}
printf ("CREATED %d WORKER THREADS\n", i);
std::cout << "THE SUM :" << g_iSum
<< " TIME:" << NowMicros() - start
<< "us"
<< std::endl;
return 0;
}
- 对比
__sync_fetch_and_add
和普通排他锁之间的性能差异: g++ -std=c++11 test_atomic.cc -o test_atomic
# 普通锁 性能
$ ./test_atomic 0
WORKER THREAD 1 STARTUP
WORKER THREAD 0 STARTUP
WORKER THREAD 2 STARTUP
WORKER THREAD 3 STARTUP
WORKER THREAD 5 STARTUP
WORKER THREAD 4 STARTUP
WORKER THREAD 7 STARTUP
WORKER THREAD 6 STARTUP
WORKER THREAD 8 STARTUP
WORKER THREAD 9 STARTUP
CREATED 10 WORKER THREADS
THE SUM :50000000 TIME:2870679us
# __sync_fetch_and_add 性能
$ ./test_atomic 1
WORKER THREAD 0 STARTUP
WORKER THREAD 1 STARTUP
WORKER THREAD 3 STARTUP
WORKER THREAD 4 STARTUP
WORKER THREAD 2 STARTUP
WORKER THREAD 5 STARTUP
WORKER THREAD 6 STARTUP
WORKER THREAD 7 STARTUP
WORKER THREAD 8 STARTUP
WORKER THREAD 9 STARTUP
CREATED 10 WORKER THREADS
THE SUM :50000000 TIME:1138828us
- 对比
atomic
原子变量的性能:g++ -std=c++11 test_atomic.cc -o test_atomic -DATOMIC
$ ./test_atomic 0
WORKER THREAD 0 STARTUP
WORKER THREAD 5 STARTUP
WORKER THREAD 6 STARTUP
WORKER THREAD 3 STARTUP
WORKER THREAD 4 STARTUP
WORKER THREAD 9 STARTUP
WORKER THREAD 2 STARTUP
WORKER THREAD 1 STARTUP
WORKER THREAD 7 STARTUP
WORKER THREAD 8 STARTUP
CREATED 10 WORKER THREADS
THE SUM :50000000 TIME:1180191us
从上面的测试数据可以整体看到__sync_fetch_and_add
的性能是比互斥锁性能好数倍,而和atomic的性能差不多。
为什么__sync_fetch_and_add
性能比互斥锁好呢?
我们来看一下如下代码的汇编实现。
#include <iostream>
int main() {
int a;
__sync_fetch_and_add(&a, 1);
return 0;
}
编译: g++ -S test_sync_fetch_and_add.cc -o t.s
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 11, 0 sdk_version 11, 1
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
xorl %eax, %eax
movl $0, -4(%rbp)
movl $1, -8(%rbp)
movl $1, %ecx
# lock前缀,这里是这个函数性能的关键。
lock xaddl %ecx, -8(%rbp)
movl %ecx, -12(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
其中汇编代码中有一个lock
前缀, 这个lock 前缀后面跟的是一个xaddl
的指令。
这里万分感谢一位同事对lock
前缀实现上的指正,现如今博客上的内容很多都是几年前甚至十几年前的技术,如今随着硬件的高速发展,这一些信息如果不能及时跟进最新的技术动态,往往会误导后续学习的同学,在最新技术迭代方面,以后一定会持续求证,保证总结的信息是准确的,并且后续持续更新之前的一些技术博客,以防误导他人。
关于lock
前缀的实现,在 Intel486 和 Pentium processors 以及之前的处理器上面确实会在指令执行期间对内存总线进行加锁。
但是在 intel P6 和 更新的处理器上面,锁前缀已经不再是对内存总线进行加锁了,而是通过缓存一致性原理加锁当前处理器的cache,即当前的cpu-cache 所访问的内存,防止其他的cpu访问或者修改当前的cpu-cache中对应内存的内容,这样的加锁粒度更小,也更高效。更细节的内容可以参考intel 官方文档 中的8.1.4部分。
下面是原回答:
在x86 平台上, CPU 提供了指令执行期间加锁内存总线的手段。也就是通过这个
lock
前缀,标识后续的一个指令的执行之前会加锁内存总线,而同处于当前内存总线的其他CPU的指令在此期间无法修改内存,等到lock 后面的一个指令执行完毕才会释放内存总线的锁。用这个指令前缀能够实现 CAS 以及 spinlock。
以下是__sync_fetch_and_add
函数的简单实现:
inline unsigned int __sync_fetch_and_sub(volatile unsigned int* p,
unsigned int decr)
{
unsigned int result;
__asm__ __volatile__ ("lock; xadd %0, %1"
:"=r"(result), "=m"(*p)
:"0"(-decr), "m"(*p)
:"memory");
return result;
}
同样的,我们在C++的 标准库的 atomic 代码的汇编中 中也能看到带有lock 前缀的指令。那这种 lock 前缀加锁内存总线相比于我们的排他锁的实现的性能差异体现在哪呢?
mu.lock()
或者 pthread_mutex_lock()
底层都是会调用操作系统的futex
系统调用,这个是操作系统层级调度线程的原子操作时的调度方式。它的粒度是操作系统级别的,让其他想要访问当前内存地址的线程挂起,等待增在访问内存的线程执行完毕再调度其他的线程。这样的调度粒度往往是线程的大量上下文信息在CPU cache中的load 和 overload。相比于__sync_fetch_and_sub
函数中的lock
前缀 锁内存总线来说效率高多了。