天天看點

關于 線程模型中經常使用的 __sync_fetch_and_add 原子操作的性能

最近從 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。
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;
}