文章

C/C++ std::memory_order(内存定序)

C/C++ std::memory_order(内存定序)

文中的性能测试程序都作为我 MyCpp C/C++模板项目 的内置应用程序上传到 GitHub 了,随时可以下载复现实验。

我今天终于把 std::memory_order 给彻底搞懂了,其实我之前已经看了好几遍了,一直都感觉云里雾里的 —— 你问我怎么今天突然就看懂了?答:因为今天我改看 en.cppreference.com 英文原版了……

zh.cppreference.com 上这篇文章的主要内容是 Fruderica2017 年 2 月 3 日 翻译完成的。虽然我很感谢这位翻译者的杰出贡献,但是实话实说,他翻译的实在是差强人意。我不明白为什么原来英文版本里使用日常用语表达得简单直白的文字,怎么翻译成中文就变得佶屈聱牙、晦涩难懂了,突然变成一大堆生造词和怪异句式的融合,好像生怕有人看懂了一般,真不知道是不是国人翻译技术文章都喜欢这么干……

写这篇博客的目的有两个,一是我决定如果过段时间有时间把这篇文章重新翻译一遍(但不是现在),二是记录在我彻底理解了之后的实验结果(我发现不同内存序对性能的影响非常大)。

不同内存定序实现自旋锁的比较

之前,我基于标准库 <atomic> 的自旋锁代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SpinMutex
{
public:
  void lock() noexcept
  {
    while (mLocked.test_and_set(std::memory_order_seq_cst))
      ;
  }

  bool try_lock() noexcept
  {
    return !mLocked.test_and_set(std::memory_order_seq_cst);
  }

  void unlock() noexcept { mLocked.clear(std::memory_order_seq_cst); }

private:
  std::atomic_flag mLocked{ ATOMIC_FLAG_INIT };
};

都是使用默认的“循序一致”定序(std::memory_order_seq_cst),因为我之前不确定各处用什么定序是对的,所以选择了这个最保守也最慢的定序。

今天我搞懂了之后就明白了,这里应该用“获取-释放”(std::memory_order_acquirestd::memory_order_release)定序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SpinMutex
{
public:
  void lock() noexcept
  {
    while (mLocked.test_and_set(std::memory_order_acquire))
      ;
  }

  bool try_lock() noexcept
  {
    return !mLocked.test_and_set(std::memory_order_acquire);
  }

  void unlock() noexcept { mLocked.clear(std::memory_order_release); }

private:
  std::atomic_flag mLocked{ ATOMIC_FLAG_INIT };
};

这是因为 std::mutex 只要求 unlock() 操作同步于(synchronize-withlock() 操作

那么,在我改完代码后,一个问题自然就来了:这两种定序的性能差别有多大呢?我写了一个简单的测试程序来测试一下:

1
2
3
4
5
6
7
auto m = std::make_shared<My::SpinMutex>();
work = [n, m] {
  std::lock_guard<My::SpinMutex> lock(*m);
  noop();
};
for (std::uint32_t i = 0; i < n; ++i)
  work();

n 取了 10000000,下面是之前 std::memory_order_seq_cst 的 5 次测试结果:

1
2
3
4
5
6
lock spin for 10000000 * 1 times.
0       108.24ms (92592592.59 /s)
1       107.42ms (93457943.93 /s)
2       107.39ms (93457943.93 /s)
3       107.45ms (93457943.93 /s)
4       106.38ms (94339622.64 /s)

如果你对这个数字没有什么概念,我可以告诉你下面这是 std::mutex 的测试结果:

1
2
3
4
5
6
lock mutex for 10000000 * 1 times.
0       143.74ms (69930069.93 /s)
1       144.35ms (69444444.44 /s)
2       143.62ms (69930069.93 /s)
3       144.27ms (69444444.44 /s)
4       144.19ms (69444444.44 /s)

要知道 sizeof(std::mutex) 有 40 个字节,而我的 sizeof(My::SpinMutex) 只有 1 个字节,可是居然只快了 30% 而已!

然后,下面是使用 std::memory_order_acquirestd::memory_order_release 的 5 次测试结果:

1
2
3
4
5
6
lock spin for 10000000 * 1 times.
0       59.95ms (169491525.42 /s)
1       58.13ms (172413793.10 /s)
2       58.93ms (172413793.10 /s)
3       59.42ms (169491525.42 /s)
4       71.89ms (140845070.42 /s)

!!!!!这个结果直接比原来快了 80% 以上,比 std::mutex 快了 144% !!!!!

自旋锁和互斥锁(std::mutex)的比较

结论:自旋锁随争用程度的增加而衰减的厉害,而互斥锁要好很多。这是可以理解的,因为自旋锁会导致线程发送忙等。所以,应当在争用不频繁、资源比较稀疏的情况下使用自旋锁。

下面结果中的 SpinMutex 都是修订后的版本。

  • 2 线程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    lock spin for 10000000 * 2 times.
    0       513.81ms (38986354.78 /s, 19493177.39 /s*tn)
    1       506.01ms (39525691.70 /s, 19762845.85 /s*tn)
    2       529.49ms (37807183.36 /s, 18903591.68 /s*tn)
    3       572.83ms (34965034.97 /s, 17482517.48 /s*tn)
    4       592.60ms (33783783.78 /s, 16891891.89 /s*tn)
    
    lock mutex for 10000000 * 2 times.
    0       1072.28ms (18656716.42 /s, 9328358.21 /s*tn)
    1       935.82ms (21390374.33 /s, 10695187.17 /s*tn)
    2       1093.20ms (18298261.67 /s, 9149130.83 /s*tn)
    3       1051.97ms (19029495.72 /s, 9514747.86 /s*tn)
    4       1073.34ms (18639328.98 /s, 9319664.49 /s*tn)
    
  • 4 线程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    lock spin for 10000000 * 4 times.
    0       2573.85ms (15546055.19 /s, 3886513.80 /s*tn)
    1       2592.91ms (15432098.77 /s, 3858024.69 /s*tn)
    2       2557.52ms (15643332.03 /s, 3910833.01 /s*tn)
    3       2819.61ms (14189428.88 /s, 3547357.22 /s*tn)
    4       2492.65ms (16051364.37 /s, 4012841.09 /s*tn)
    
    lock mutex for 10000000 * 4 times.
    0       2169.99ms (18441678.19 /s, 4610419.55 /s*tn)
    1       2196.82ms (18214936.25 /s, 4553734.06 /s*tn)
    2       2254.28ms (17746228.93 /s, 4436557.23 /s*tn)
    3       2202.29ms (18165304.27 /s, 4541326.07 /s*tn)
    4       2260.01ms (17699115.04 /s, 4424778.76 /s*tn)
    
  • 8 线程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    lock spin for 10000000 * 8 times.
    0       9525.40ms (8398950.13 /s, 1049868.77 /s*tn)
    1       9276.79ms (8624407.07 /s, 1078050.88 /s*tn)
    2       9695.85ms (8251676.12 /s, 1031459.52 /s*tn)
    3       9453.18ms (8462921.82 /s, 1057865.23 /s*tn)
    4       8629.13ms (9271062.70 /s, 1158882.84 /s*tn)
    
    lock mutex for 10000000 * 8 times.
    0       5055.93ms (15825914.94 /s, 1978239.37 /s*tn)
    1       5071.63ms (15775981.07 /s, 1971997.63 /s*tn)
    2       5084.54ms (15735641.23 /s, 1966955.15 /s*tn)
    3       5129.48ms (15597582.37 /s, 1949697.80 /s*tn)
    4       5192.45ms (15408320.49 /s, 1926040.06 /s*tn)
    

可以看到,到了 8 个线程的时候,自旋锁的性能已经不如互斥锁了。

本文由作者按照 CC BY 4.0 进行授权