让我们来看看下面这个简单的代码:

class Foo {
    std::mutex m_mutex;
    std::unordered_map<std::string, int> m_map;

public:
    void pass_by_reference(const std::string &key, int value) {
        std::lock_guard<std::mutex> lock(m_mutex);
        m_map.emplace(key, value);
    }

    void pass_by_value(const std::string key, int value) {
        std::lock_guard<std::mutex> lock(m_mutex);
        m_map.emplace(key, value);
    }

    void useless_copy(const std::string &key, int value) {
        std::string copy(key);
        std::lock_guard<std::mutex> lock(m_mutex);
        m_map.emplace(copy, value);
    }

    void useless_hash(const std::string &key, int value) {
        std::hash<std::string> hash_fn;
        (void) hash_fn(key);
        std::lock_guard<std::mutex> lock(m_mutex);
        m_map.emplace(key, value);
    }
};

前面两个函数唯一的区别就是传引用和传值的区别。我们都知道,传引用对于大的对象来说应该更快,因为这样可以防止无用的拷贝。 Effective C++ 的条款20就是:宁以 pass-by-reference-to-const 替换 pass-by-value。这是一个非常普遍使用的最佳实践,所以说每当你开始写一个函数的时候你都会不由自主地传引用。

useless_copy 传引用但是创建了个在栈上的副本。useless_hash 计算了这个字符串的 Hash 但是也没有使用它。我的直觉告诉我这四个函数应该有几乎相同的性能,pass_by_reference 甚至应该略快一点。

Benchmark

我写了个 Benchmark 程序,八个线程同时更新这个对象,每个线程操作1000000次,一共有1000000个长度为32的字符串。你可以在这里获取这个程序:https://gist.github.com/abcdabcd987/f0d99b2f21567654276ca14d7d1338db。 结果如下:

pass by reference: 7.360 ± 0.077 seconds
pass by value    : 5.601 ± 0.040 seconds
useless copy     : 5.657 ± 0.051 seconds
useless hash     : 5.704 ± 0.065 seconds

结果显示,后面的三个函数比 pass_by_reference 快了25%,非常反直觉。

Cache

每当奇怪的事情发生的时候,就该开始怀疑是不是处理器 Cache 的问题了。

在 C++ 里面,引用就如同一个指针。pass_by_reference 实际上是传递了一个指向字符串的指针。这个字符串的内容在 std::unordered_map::emplace 之前从来没有被访问过。当字符串的内容第一次被访问的时候,一定会产生一次 Cache Miss,因为我们的程序里面有太多的字符串了。所以说,处理器就需要停下来去内存中读取数据。相比于 Cache,从内存读数据还是一个很慢的过程。

更糟糕的是,这个线程停在了 Critical Section 里面。其他所有的线程都等在锁上面,而这个拿了锁的线程却在发呆,等待数据从内存传过来。这个线程真的是占着茅坑不拉屎!

pass_by_valueuseless_copy 没有这个问题,因为在创建栈上的副本的时候处理器已经把字符串抓到了 Cache 里面了,而这又发生在 Critical Section 之前,所以说其他的线程就不会被卡住。

useless_hash 也没有这个问题,因为计算字符串的 Hash 也需要读取一遍字符串的内容,所以说在进入 Critical Section 之前这个字符串也已经在 Cache 里面了。

__builtin_prefetch

骆铮同学在我发表了这篇文章之后提到了 __builtin_prefetch,于是我又跑了一次 Benchmark(上面的 gist 已经更新):

builtin prefetch : 5.853 ± 0.081 seconds

这一招看来也很好用。不过有个让我很困惑的事情:__builtin_prefetch 会把多少数据抓到 Cache 里面?GCC 文档没有说清楚。我发现 __builtin_prefetch 被翻译成了 prefetcht0 指令。

 42:cache-prefetching.cc ****         std::lock_guard<std::mutex> lock(m_mutex);
5900                   .loc 17 42 0
5901 0016 488B06           movq    (%rsi), %rax
 41:cache-prefetching.cc ****         __builtin_prefetch(key.data());
5902                   .loc 17 41 0
5903 0019 8955EC           movl    %edx, -20(%rbp)
 42:cache-prefetching.cc ****         std::lock_guard<std::mutex> lock(m_mutex);
5904                   .loc 17 42 0
5905 001c 0F1808           prefetcht0  (%rax)

于是我去查了查 Intel® 64 and IA-32 Architectures Software Developer Manuals:

The implementation of prefetch locality hints is implementation-dependent, and can be overloaded or ignored by a processor implementation. The amount of data prefetched is also processor implementation-dependent. It will, however, be a minimum of 32 bytes.

Section 4.3 Instructions (M-U), PREFETCHh.

也就是说,每次抓多少是有具体的CPU实现决定的,但是至少会抓32字节。在我的 Benchmark 程序里面每个字符串长度正好是32字节,那如果字符串更长一点呢?

cache-prefetching-size

从图中可以看出,随着字符串长度变长,__builtin_prefetch 的效果会下降,最终就没有效果了。从4KB开始性能显著下降,这是因为我的处理器每个超线程有32KB、8路组关联L1 Cache,每一路正好有32/8=4KB。__builtin_prefetch 有个好处是,在数据量小的时候它很有用,当数据量大的时候它虽然不起作用但也不会有负面影响。useless_copyuseless_hash 可能造成了更多的 Cache Miss,所以在数据量大的时候跑得更慢。

总结

  • “过早的优化是万恶之源。”不要就因为看了这篇文章所以就去优化你程序的Cache控制。大多数情况下你可以相信硬件本身的优化。
  • 如果你真的想要优化 Cache
    • 检查在进入 Critical Section 之前,数据是不是已经被抓到 Cache 里面了
    • 也可以试试看 __builtin_prefetch

后记

或许你一读完上面的代码就已经想到了原因,但是当我在写上一篇文章分片:简单粗暴细化锁的粒度的通用方法的时候,我觉得并不是那么显然。当我在写那篇文章的时候,我遇到了一个非常怪异的现象:只有一个分片的 KVSharded 竟然跑得比 KVBigLock 快。我拉来了刘家昌同志帮我想想这是为什么。我们尝试了若干种办法,但没有一个起了作用:

昌喵肝到了深夜最终给出了这个非常合理的解释。在此特别感谢昌喵!