5.1 libstdc++ 对共享指针原子操作的支持

引言

这篇笔记是承接 CppCon 2023 Lock-free Atomic Shared Pointers Without a Split Reference Count内存模型基础、标准原子类型、自旋锁 来写的。

C++20 有 std::atomic<std::shared_ptr>std::atomic<std::weak_ptr> 的偏特化,之前连这两个偏特化都没有,因而会编译错误(std::atomic requires a trivially copyable type),只能使用对共享指针提供的原子操作自由函数std::atomic_*)。但是这样的类型并不是无锁的,可以通过 is_lock_free() 的返回值看出来,见 https://godbolt.org/z/b5P84jM9f 。根据 Daniel1 的幻灯片,MSVC 和 libstdc++ 中这两个类型都是有锁;根据我的查证,libc++ 截至 2025 年 1 月 5 日还没有实现这两个偏特化。

既然知道有锁了,libstdc++ 是怎么实现共享指针原子变量的线程安全访问的呢?

原子操作自由函数

C++20 之前原子操作自由函数对 std::shared_ptr<T> 的支持可见 atomic_load_explicit_Sp_locker它对指针值做 hash,得到互斥量池里的一个下标,从而获取一个 __gnu_cxx::__mutex 的引用。可以参考 以下代码

// shared_ptr.cc
#include "mutex_pool.h"

namespace __gnu_internal _GLIBCXX_VISIBILITY(hidden)
{
  /* Returns different instances of __mutex depending on the passed index
   * in order to limit contention.
   */
  __gnu_cxx::__mutex&
  get_mutex(unsigned char i)
  {
#ifdef _GLIBCXX_CAN_ALIGNAS_DESTRUCTIVE_SIZE
    // Increase alignment to put each lock on a separate cache line.
    struct alignas(__GCC_DESTRUCTIVE_SIZE) M : __gnu_cxx::__mutex { };
#else
    using M = __gnu_cxx::__mutex;
#endif
    // Use a static buffer, so that the mutexes are not destructed
    // before potential users (or at all)
    static __attribute__ ((aligned(__alignof__(M))))
      char buffer[(sizeof (M)) * (mask + 1)];
    static M *m = new (buffer) M[mask + 1];
    return m[i];
  }
}
// mutex_pool.h
namespace __gnu_internal _GLIBCXX_VISIBILITY(hidden)
{
  const unsigned char mask = 0xf;
  const unsigned char invalid = mask + 1;

  /* Returns different instances of __mutex depending on the passed index
   * in order to limit contention.
   */
  __gnu_cxx::__mutex& get_mutex(unsigned char i);
}

在这段代码里面,unsigned char i 限制了互斥量池的最大大小为 256。而实际上 mask0xf,说明互斥量池的大小为 16。哈希 + 池化的模式还可见于 libstdc++ 原子变量 waitnotify 的实现

原子模板类对共享指针的偏特化

C++20 std::atomic<std::shared_ptr<T>> 的实现参考 shared_ptr_atomic.h

template<typename _Tp>
struct _Sp_atomic { // 假设 64 位环境,16 字节
    // ...
    // An atomic version of __shared_count<> and __weak_count<>.
    // Stores a _Sp_counted_base<>* but uses the LSB as a lock.
    struct _Atomic_count // 8 字节
      {
      private:
        // 里面实际上是存储了一个指针,并且最低位有特殊含义。
        // 因为 atomic 模板类对指针类型有偏特化,而这里需要直接操作位表示,
        // 所以存储选择 uintptr_t 类型。
    	mutable __atomic_base<uintptr_t> _M_val{0}; // 8 字节
    	static constexpr uintptr_t _S_lock_bit{1};  // 注意是静态,在实例中不占空间
      };
private:
    typename _Tp::element_type* _M_ptr = nullptr; // 8 字节
    _Atomic_count _M_refcount;                    // 8 字节
};

template<typename _Tp>
struct atomic<shared_ptr<_Tp>> {
    // ...
    // 将实现转发到 _M_impl 上
private:
    _Sp_atomic<shared_ptr<_Tp>> _M_impl;
};

每次对 _Sp_atomic 操作都要调用 _M_refcount.lock() 来上锁。上锁的过程首先是循环等待锁空闲,然后是在循环中调用 compare_exchange_strong可以看出是自旋锁。对引用计数块上锁之后才能得到其指针,此时 __atomic_base<uintptr_t> 的锁标志已被去除,以指针的形式从 lock() 中返回。

Benchmark 实验

两种实现的性能比较

现在来比较一下原子操作自由函数(互斥量)和原子变量偏特化(自旋锁)两种方案的性能。先创建多个线程,每个线程都会进行若干次先读后写的操作,测量两种方案下的执行时间。

在 quick-bench 下进行测试,在单线程或者循环次数比较少的时候,都是互斥量的性能更优。好像这个网站没有给多核?而且 benchmark 的 CPU 时间应该不能和程序实际运行时间等价。虽然结果没多大意义,但既然做过实验了还是放一下结果。(代码也在链接里面,就不单独列代码了。)

线程数循环次数原子操作自由函数 CPU 时间原子变量偏特化 CPU 时间链接
1100047,210.51648,898.659https://quick-bench.com/q/-GlB8zwmpVm0wxUHqMD1flX8S80
2100090,009.048981,656.665https://quick-bench.com/q/jtVQkUQCDV9ZqkJpTHdqJZ1X-fY
41000230,958.147208,060.813https://quick-bench.com/q/Zq3g98LNhSXXFcK_162RNjb-FtM
81000672,488.446664,635.097https://quick-bench.com/q/r07L7P2jwAJxU8CPXb81gV-uoJo
101620,372.601638,603.319https://quick-bench.com/q/AnEMoJYRCGnTZyzmiIRB8n6zpCE
1010752,338.154719,752.91https://quick-bench.com/q/8Ee42qG9eRaT6p5Ykj-FuBM-WPQ
10100646,976.937637,434.736https://quick-bench.com/q/we2LIuMsreMqNRAAE0BzxDp_3ZI
101000682,937.478652,523.314https://quick-bench.com/q/xNdy-7OXeRTvoBh82i7LmUMk8cw
1010000🚀 844,765.9361,098,030.509https://quick-bench.com/q/5Gjos6rN3K2H94PFde5tc_VFmO8

为了验证多核环境下的情况,我在笔记本上重新了试了一下。CPU 是 6 + 8 + 2 的第一代 Ultra 9,环境是 WSL2 + Debian 12,GCC 版本为 12.2.0。编译使用 CMake 的 Release 配置,即 -O3。安装 benchmark 库或者将其作为 CMake 的第三方依赖都可以。

线程数循环次数原子操作自由函数实际时间原子变量偏特化实际时间
1100090960 ns🚀 79325 ns
21000313173 ns🚀 218804 ns
41000🚀 553431 ns930307 ns
81000🚀 1382092 ns3317335 ns
101305089 ns316985 ns
1010326326 ns342686 ns
10100314011 ns311332 ns
101000🚀 1974462 ns4842926 ns
1010000🚀 22828557 ns60812315 ns

竞争稍微增多一点就是互斥量实现性能更优,基本印证了 要不要使用自旋锁 中的说法:在用户空间实现自旋锁还不如使用 std::mutex

互斥量池对性能的影响

之前共享指针的原子操作自由函数还有一点令人在意,就是我看到的 libstdc++ 代码里面用了互斥量池。所有共享指针使用同一个互斥量池,而不是每个共享指针实例独占一个互斥量,是否会对性能产生影响?

代码如下:

#include <benchmark/benchmark.h>
#include <memory>
#include <atomic>
#include <thread>
#include <vector>
#include <mutex>

// 可调整的常量
const int N_THREADS = 8;       // 线程数
const int N_SHARED_PTRS = 4;   // 共享指针数
const int N_OPERATIONS = 1000; // 每个线程的操作次数

// 共享指针数组
std::vector<std::shared_ptr<int>> shared_ptrs(N_SHARED_PTRS);

// 用于保护共享指针的互斥锁数组
std::vector<std::mutex> mutexes(N_SHARED_PTRS);

// 初始化共享指针(只执行一次)
void initialize_shared_ptrs() {
    for (int i = 0; i < N_SHARED_PTRS; ++i) {
        shared_ptrs[i] = std::make_shared<int>(0);
    }
}

// 使用 std::atomic_load 和 std::atomic_store
static void BM_AtomicLoadStore(benchmark::State& state) {
    // 初始化共享指针(只执行一次)
    initialize_shared_ptrs();

    for (auto _ : state) {
        std::vector<std::thread> threads;

        // 创建线程
        for (int i = 0; i < N_THREADS; ++i) {
            threads.emplace_back([i]() {
                int ptr_index = i % N_SHARED_PTRS; // 分配给线程的共享指针索引
                for (int j = 0; j < N_OPERATIONS; ++j) {
                    auto local_ptr = std::atomic_load(&shared_ptrs[ptr_index]);
                    std::atomic_store(&shared_ptrs[ptr_index], 
                        std::make_shared<int>(*local_ptr + 1));
                }
            });
        }

        // 等待所有线程完成
        for (auto& t : threads) {
            t.join();
        }
    }
}
BENCHMARK(BM_AtomicLoadStore);

// 使用 std::mutex 保护共享指针
static void BM_MutexProtected(benchmark::State& state) {
    // 初始化共享指针(只执行一次)
    initialize_shared_ptrs();

    for (auto _ : state) {
        std::vector<std::thread> threads;

        // 创建线程
        for (int i = 0; i < N_THREADS; ++i) {
            threads.emplace_back([i]() {
                int ptr_index = i % N_SHARED_PTRS; // 分配给线程的共享指针索引
                for (int j = 0; j < N_OPERATIONS; ++j) {
                    // Not exactly equivalent to std::atomic_load + std::atomic_store
                    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    // std::lock_guard<std::mutex> lock(mutexes[ptr_index]);
                    // auto local_ptr = shared_ptrs[ptr_index];
                    // shared_ptrs[ptr_index] = std::make_shared<int>(*local_ptr + 1);
                    std::shared_ptr<int> local_ptr;
                    {
                        std::lock_guard<std::mutex> lock(mutexes[ptr_index]);
                        local_ptr = shared_ptrs[ptr_index];
                    }
                    {
                        std::lock_guard<std::mutex> lock(mutexes[ptr_index]);
                        shared_ptrs[ptr_index] = std::make_shared<int>(*local_ptr + 1);
                    }
                }
            });
        }

        // 等待所有线程完成
        for (auto& t : threads) {
            t.join();
        }
    }
}
BENCHMARK(BM_MutexProtected);

BENCHMARK_MAIN();

结果:

线程数共享指针总数循环次数原子操作自由函数耗时std::mutex 耗时
14100089058 ns80306 ns
241000🚀 105798 ns228047 ns
441000🚀 268838 ns387156 ns
841000🚀 588483 ns656299 ns
1641000🚀 1189470 ns1379560 ns
3241000🚀 2693715 ns3517673 ns
6441000🚀 6599134 ns8734254 ns
12841000🚀 14365890 ns20750675 ns

在共享指针总数为 4 时,自行维护互斥量的性能更差!这可能是因为我们自行对互斥量上锁,会有共享指针赋值操作符中原子操作的开销,而共享指针原子操作的读写将上锁的过程合并到了一起,效率更高

考虑到共享指针为 4 时互斥量池哈希冲突可能不够大,为了体现使用互斥量池的劣势,接下来增大共享指针总数,测试结果反过来了🤯:

线程数共享指针总数循环次数原子操作自由函数耗时std::mutex 耗时
1288100011025946 ns🚀 8755322 ns
1281610006816762 ns🚀 5489590 ns
1283210005502325 ns5297484 ns
1286410005625533 ns5377507 ns
102432100054262267 ns🚀 37587115 ns
102464100042344566 ns🚀 36543750 ns
1024128100042877169 ns🚀 36769052 ns
1024256100039072114 ns38813602 ns
1024512100037306563 ns36461196 ns
10241024100039054127 ns38676024 ns
80925121000270409075 ns🚀 238338752 ns
809220481000281548516 ns🚀 242187529 ns
809280921000275440750 ns🚀 236298876 ns

当线程总数超过线程池大小时,原子操作自由函数的耗时确实比外部的 std::mutex 多。

Note

多试几次可能会出现测出来的结果相差很大的情况(有时候耗时差不多,有时候耗时比接近 2),有可能是哈希结果随着共享指针地址变化,每次启动程序时共享指针分配互斥量的均匀程度不同。

画折线图看趋势

固定线程数为 1024,操作次数为 1000,#n 表示共享指针总数是 n。将实验数据整理成这样的格式:

#1
-------------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-------------------------------------------------------------
BM_AtomicLoadStore  248032779 ns     28470177 ns           13
BM_MutexProtected   237027562 ns     28843112 ns           25
BM_AtomicSharedPtr 1.8736e+10 ns     43095100 ns            1

#2
-------------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-------------------------------------------------------------
BM_AtomicLoadStore  168213275 ns     46402056 ns           16
BM_MutexProtected   320855445 ns     39238330 ns           10
BM_AtomicSharedPtr 8455017155 ns     32803500 ns            1

画图代码:

import matplotlib.pyplot as plt

# 读取文件并提取数据
with open('benchmark.log', 'r') as file:
    lines = file.readlines()

# 初始化存储数据的字典
data = {
    'BM_AtomicLoadStore': {'n': [], 'time': []},
    'BM_MutexProtected': {'n': [], 'time': []},
    'BM_AtomicSharedPtr': {'n': [], 'time': []}
}

# 解析数据
current_n = None
for line in lines:
    if line.startswith('#'):
        current_n = int(line.strip().replace('#', ''))
    elif 'BM_' in line:
        parts = line.split()
        benchmark_name = parts[0]
        time_ns = float(parts[1].replace('ns', ''))
        data[benchmark_name]['n'].append(current_n)
        data[benchmark_name]['time'].append(time_ns)

# 绘制图形
plt.figure(figsize=(10, 6))

# 极端大的数据影响画图,头两个数据就不要了
data['BM_AtomicSharedPtr']['time'][0] = float('nan')
data['BM_AtomicSharedPtr']['time'][1] = float('nan')

for benchmark_name, values in data.items():
    plt.plot(values['n'], values['time'], marker='o', markersize=5, label=benchmark_name)

plt.xlabel('n')
plt.ylabel('Time (ns)')
plt.title('Benchmark Time vs n')
plt.legend()
plt.grid(True)
plt.show()

结果:

自变量 n 即共享指针总数(即锁的数量),越大竞争越少。对上图做出以下观察:

  1. BM_AtomicSharedPtr 基本上总是不如 BM_MutexProtected 性能好。前者是自旋锁,后者使用了 pthread 的 mutex。
  2. BM_AtomicLoadStore 在 n 比较小的时候性能比 BM_MutexProtected 好。可能是因为它能在上锁后直接获取共享指针的控制块,在控制块上就只需要做平凡操作而不是原子操作。
  3. BM_AtomicLoadStore 在 n 增大时性能不如 BM_MutexProtected。这时其性能已经受到了互斥量池容量大小的限制。
  4. 在 n 非常大时,BM_AtomicLoadStore 尽管仍不如 BM_MutexProtected,但是也没有显著地更差。可能是因为个人电脑的核心数并不多,测试中互斥量池大小受限最多只允许 16 个线程同时操作,但我的笔记本加上超线程(不算超小核)也才 20 个线程。

Perf 实验

再试试 perf(perf 在 WSL2 上的安装参考 WSL2 中安装 perf)。每次注释掉其他代码,只对单个测试跑 perf:

$ perf record ./build/main
...
$ perf report --demangle
... # 这里会在终端中显示不同函数运行占用的 CPU 时间

实际测试发现在有 perf 的情况下,维护外部互斥量的 benchmark 耗时会增加,说明 perf 也会影响 benchmark,那这个时候我们就只看 perf 结果不看 Google Benchmark 的结果了。实验选定的线程数为 1024,共享指针总数为 32,每个线程要执行的操作数为 1000。新增对共享指针用例的测试:

// 新增:atomic shared_ptr 数组
std::vector<std::atomic<std::shared_ptr<int>>> atomic_shared_ptrs(N_SHARED_PTRS);

static void BM_AtomicSharedPtr(benchmark::State& state) {
    // 初始化共享指针(只执行一次)
    initialize_shared_ptrs();

    for (auto _ : state) {
        std::vector<std::thread> threads;

        // 创建线程
        for (int i = 0; i < N_THREADS; ++i) {
            threads.emplace_back([i]() {
                int ptr_index = i % N_SHARED_PTRS; // 分配给线程的共享指针索引
                for (int j = 0; j < N_OPERATIONS; ++j) {
                    auto local_ptr = atomic_shared_ptrs[ptr_index].load(); // load
                    atomic_shared_ptrs[ptr_index].store(
                        std::make_shared<int>(*local_ptr + 1)); // store
                }
            });
        }

        // 等待所有线程完成
        for (auto& t : threads) {
            t.join();
        }
    }
}

BENCHMARK(BM_AtomicSharedPtr);

结果:

这个比 benchmark 的结果稳定一些,多试几次基本一样。耗时最多的 lambda 函数就是启动 thread 时创建的工作函数。

  1. 看起来 ExternalMutex 在工作函数内以及 mutex 操作耗费的 CPU 时间占比更多,可能表明其休眠比 AtomicLoadStore 少,从而表明其互斥量竞争不如 AtomicLoadStore 严重。
  2. AtomicSharedPtr 实验中超过 92% 的 CPU 时间都花在了工作函数里面,体现了其自旋性质。因为 -O3 将很多操作都内联了,所有原子操作耗时都加在了一个函数里面。
  3. 这组运行参数是自旋锁的甜点配置,虽然这里没有列出来,但实际性能是 ExternalMutex > AtomicSharedPtr » AtomicLoadStore,说明 1024 个线程均匀分配到 32 个锁上对于自旋锁来说竞争并不算激烈。不过考虑到自旋锁依然不如 std::mutex 的性能好,所以建议还是直接用 std::mutex

总结

C++20 之前可以用 std::atomic_* 这些自由函数对共享指针做原子操作,在 libstdc++ 中依靠的是一个互斥量池,有些共享指针可能会映射到同一个互斥量。C++20 之后这些自由函数被标记为过时,同时 std::atomic<> 对共享指针有了偏特化,在 libstdc++ 中实质是自旋锁。系统负载增大时,互斥量版的实现比自旋锁版的实现性能好。

std::atomic_* 对共享指针操作时使用互斥量池,其大小只有 16,效果竟惊人地好。在低负载的情况下,其性能比对每个共享指针手动创建一个互斥量好;在高负载的情况下,尽管互斥量池的冲突增加,其性能也只是略低于手动创建互斥量。只有在中等负载情况下,即 $线程数 / 共享指针总数$ 适中时,手动维护互斥量的收益更大,有时候能达到 1.7 倍的性能差距。原因可见前文的猜测( 画折线图看趋势)。